From dad31473d9f92c566d73ccfd5d2db76d3b926008 Mon Sep 17 00:00:00 2001 From: linglingye001 <143174321+linglingye001@users.noreply.github.com> Date: Wed, 23 Apr 2025 11:57:42 +0800 Subject: [PATCH 1/7] request tracing and AI config telemetry (#21) --- README.md | 4 + .../azureappconfiguration.go | 59 ++++-- .../azureappconfiguration_test.go | 112 +++++++++++ .../internal/tracing/tracing.go | 112 +++++++++++ .../internal/tracing/tracing_test.go | 186 ++++++++++++++++++ azureappconfiguration/settings_client.go | 11 +- azureappconfiguration/utils.go | 44 +++++ azureappconfiguration/utils_test.go | 128 ++++++++++++ 8 files changed, 639 insertions(+), 17 deletions(-) create mode 100644 azureappconfiguration/internal/tracing/tracing.go create mode 100644 azureappconfiguration/internal/tracing/tracing_test.go diff --git a/README.md b/README.md index 9c5c2e8..9797847 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,10 @@ go get github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration - [Console Application](./example/console-example/): Load settings from Azure App Configuration and use in a console application. - [Web Application](./example/gin-example/): Load settings from Azure App Configuration and use in a Gin web application. +## Data Collection + +The software may collect information about you and your use of the software and send it to Microsoft. Microsoft may use this information to provide services and improve our products and services. You may turn off the telemetry by setting the environment variable `AZURE_APP_CONFIGURATION_TRACING_DISABLED` to `TRUE`. There are also some features in the software that may enable you and Microsoft to collect data from users of your applications. If you use these features, you must comply with applicable law, including providing appropriate notices to users of your applications together with a copy of Microsoft’s privacy statement. Our privacy statement is located at https://go.microsoft.com/fwlink/?LinkID=824704. You can learn more about data collection and use in the help documentation and our privacy statement. Your use of the software operates as your consent to these practices. + ## Contributing This project welcomes contributions and suggestions. Most contributions require you to agree to a diff --git a/azureappconfiguration/azureappconfiguration.go b/azureappconfiguration/azureappconfiguration.go index 2cd4c14..c91f570 100644 --- a/azureappconfiguration/azureappconfiguration.go +++ b/azureappconfiguration/azureappconfiguration.go @@ -17,10 +17,12 @@ import ( "encoding/json" "fmt" "log" - "regexp" + "os" + "strconv" "strings" "sync" + "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/tracing" "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/tree" decoder "github.com/go-viper/mapstructure/v2" "golang.org/x/sync/errgroup" @@ -34,6 +36,8 @@ type AzureAppConfiguration struct { clientManager *configurationClientManager resolver *keyVaultReferenceResolver + + tracingOptions tracing.Options } // Load initializes a new AzureAppConfiguration instance and loads the configuration data from @@ -62,6 +66,7 @@ func Load(ctx context.Context, authentication AuthenticationOptions, options *Op } azappcfg := new(AzureAppConfiguration) + azappcfg.tracingOptions = configureTracingOptions(options) azappcfg.keyValues = make(map[string]any) azappcfg.kvSelectors = deduplicateSelectors(options.Selectors) azappcfg.trimPrefixes = options.TrimKeyPrefixes @@ -79,7 +84,7 @@ func Load(ctx context.Context, authentication AuthenticationOptions, options *Op return azappcfg, nil } -// Unmarshal parses the configuration and stores the result in the value pointed to v. It builds a hierarchical configuration structure based on key separators. +// 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. // // Fields in the target struct are matched with configuration keys using the field name by default. @@ -121,7 +126,7 @@ func (azappcfg *AzureAppConfiguration) Unmarshal(v any, options *ConstructionOpt return decoder.Decode(azappcfg.constructHierarchicalMap(options.Separator)) } -// GetBytes returns the configuration as a JSON byte array with hierarchical structure. +// GetBytes returns the configuration as a JSON byte array with hierarchical structure. // This method is particularly useful for integrating with "encoding/json" package or third-party configuration packages like Viper or Koanf. // // Parameters: @@ -147,8 +152,9 @@ func (azappcfg *AzureAppConfiguration) GetBytes(options *ConstructionOptions) ([ func (azappcfg *AzureAppConfiguration) load(ctx context.Context) error { keyValuesClient := &selectorSettingsClient{ - selectors: azappcfg.kvSelectors, - client: azappcfg.clientManager.staticClient.client, + selectors: azappcfg.kvSelectors, + client: azappcfg.clientManager.staticClient.client, + tracingOptions: azappcfg.tracingOptions, } return azappcfg.loadKeyValues(ctx, keyValuesClient) @@ -160,6 +166,7 @@ func (azappcfg *AzureAppConfiguration) loadKeyValues(ctx context.Context, settin return err } + var useAIConfiguration, useAIChatCompletionConfiguration bool kvSettings := make(map[string]any, len(settingsResponse.settings)) keyVaultRefs := make(map[string]string) for _, setting := range settingsResponse.settings { @@ -177,7 +184,7 @@ func (azappcfg *AzureAppConfiguration) loadKeyValues(ctx context.Context, settin continue } - switch *setting.ContentType { + switch strings.TrimSpace(strings.ToLower(*setting.ContentType)) { case featureFlagContentType: continue // ignore feature flag while getting key value settings case secretReferenceContentType: @@ -190,12 +197,21 @@ func (azappcfg *AzureAppConfiguration) loadKeyValues(ctx context.Context, settin continue } kvSettings[trimmedKey] = v + if isAIConfigurationContentType(setting.ContentType) { + useAIConfiguration = true + } + if isAIChatCompletionContentType(setting.ContentType) { + useAIChatCompletionConfiguration = true + } } else { kvSettings[trimmedKey] = setting.Value } } } + azappcfg.tracingOptions.UseAIConfiguration = useAIConfiguration + azappcfg.tracingOptions.UseAIChatCompletionConfiguration = useAIChatCompletionConfiguration + var eg errgroup.Group resolvedSecrets := sync.Map{} if len(keyVaultRefs) > 0 { @@ -242,15 +258,6 @@ func (azappcfg *AzureAppConfiguration) trimPrefix(key string) string { return result } -func isJsonContentType(contentType *string) bool { - if contentType == nil { - return false - } - contentTypeStr := strings.ToLower(strings.Trim(*contentType, " ")) - matched, _ := regexp.MatchString("^application\\/(?:[^\\/]+\\+)?json(;.*)?$", contentTypeStr) - return matched -} - func deduplicateSelectors(selectors []Selector) []Selector { // If no selectors provided, return the default selector if len(selectors) == 0 { @@ -295,3 +302,25 @@ func (azappcfg *AzureAppConfiguration) constructHierarchicalMap(separator string return tree.Build() } + +func configureTracingOptions(options *Options) tracing.Options { + tracingOption := tracing.Options{ + Enabled: true, + } + + if value, exist := os.LookupEnv(tracing.EnvVarTracingDisabled); exist { + tracingDisabled, _ := strconv.ParseBool(value) + if tracingDisabled { + tracingOption.Enabled = false + return tracingOption + } + } + + tracingOption.Host = tracing.GetHostType() + + if !(options.KeyVaultOptions.SecretResolver == nil && options.KeyVaultOptions.Credential == nil) { + tracingOption.KeyVaultConfigured = true + } + + return tracingOption +} diff --git a/azureappconfiguration/azureappconfiguration_test.go b/azureappconfiguration/azureappconfiguration_test.go index 12a8238..38feef2 100644 --- a/azureappconfiguration/azureappconfiguration_test.go +++ b/azureappconfiguration/azureappconfiguration_test.go @@ -5,11 +5,13 @@ package azureappconfiguration import ( "context" + "net/http" "net/url" "sync" "testing" "time" + "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/tracing" "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -734,3 +736,113 @@ func TestLoadKeyValues_WithConcurrentKeyVaultReferences(t *testing.T) { } } } + +// mockTracingClient is a mock client that captures the HTTP header containing the correlation context +type mockTracingClient struct { + mock.Mock + capturedHeader http.Header +} + +func (m *mockTracingClient) getSettings(ctx context.Context) (*settingsResponse, error) { + // Extract header from context + if header, ok := ctx.Value(tracing.CorrelationContextHeader).(http.Header); ok { + m.capturedHeader = header + } + + args := m.Called(ctx) + return args.Get(0).(*settingsResponse), args.Error(1) +} + +func TestLoadKeyValues_WithAIContentTypes(t *testing.T) { + ctx := context.Background() + mockClient := new(mockSettingsClient) + + // Create settings with different content types + value1 := "regular value" + value2 := `{"ai": "configuration"}` + value3 := `{"ai": "chat completion"}` + mockResponse := &settingsResponse{ + settings: []azappconfig.Setting{ + {Key: toPtr("key1"), Value: &value1, ContentType: toPtr("text/plain")}, + {Key: toPtr("key2"), Value: &value2, ContentType: toPtr("application/json; profile=\"https://azconfig.io/mime-profiles/ai\"")}, + {Key: toPtr("key3"), Value: &value3, ContentType: toPtr("application/json; profile=\"https://azconfig.io/mime-profiles/ai/chat-completion\"")}, + }, + } + mockClient.On("getSettings", ctx).Return(mockResponse, nil) + + // Create the app configuration with tracing enabled + azappcfg := &AzureAppConfiguration{ + clientManager: &configurationClientManager{ + staticClient: &configurationClientWrapper{client: &azappconfig.Client{}}, + }, + kvSelectors: deduplicateSelectors([]Selector{}), + keyValues: make(map[string]any), + tracingOptions: tracing.Options{ + Enabled: true, + }, + } + + // Load the key values + err := azappcfg.loadKeyValues(ctx, mockClient) + assert.NoError(t, err) + + // Verify the tracing options were updated correctly + assert.True(t, azappcfg.tracingOptions.UseAIConfiguration, "UseAIConfiguration flag should be set to true") + assert.True(t, azappcfg.tracingOptions.UseAIChatCompletionConfiguration, "UseAIChatCompletionConfiguration flag should be set to true") + + // Verify the data was loaded correctly + assert.Equal(t, &value1, azappcfg.keyValues["key1"]) + assert.Equal(t, map[string]interface{}{"ai": "configuration"}, azappcfg.keyValues["key2"]) + assert.Equal(t, map[string]interface{}{"ai": "chat completion"}, azappcfg.keyValues["key3"]) +} + +func TestCorrelationContextHeader(t *testing.T) { + ctx := context.Background() + mockClient := new(mockTracingClient) + + // Create settings with different content types + value1 := "regular value" + value2 := `{"ai": "configuration"}` + value3 := `{"ai": "chat completion"}` + mockResponse := &settingsResponse{ + settings: []azappconfig.Setting{ + {Key: toPtr("key1"), Value: &value1, ContentType: toPtr("text/plain")}, + {Key: toPtr("key2"), Value: &value2, ContentType: toPtr("application/json; profile=\"https://azconfig.io/mime-profiles/ai\"")}, + {Key: toPtr("key3"), Value: &value3, ContentType: toPtr("application/json; profile=\"https://azconfig.io/mime-profiles/ai/chat-completion\"")}, + }, + } + mockClient.On("getSettings", ctx).Return(mockResponse, nil) + + // Create app configuration with key vault configured + tracingOptions := tracing.Options{ + Enabled: true, + KeyVaultConfigured: true, + Host: tracing.HostTypeAzureWebApp, + } + + azappcfg := &AzureAppConfiguration{ + clientManager: &configurationClientManager{ + staticClient: &configurationClientWrapper{client: &azappconfig.Client{}}, + }, + kvSelectors: deduplicateSelectors([]Selector{}), + keyValues: make(map[string]any), + tracingOptions: tracingOptions, + } + + // Load the key values + err := azappcfg.loadKeyValues(ctx, mockClient) + assert.NoError(t, err) + + // Verify the header contains all expected values + header := tracing.CreateCorrelationContextHeader(ctx, azappcfg.tracingOptions) + correlationCtx := header.Get(tracing.CorrelationContextHeader) + + assert.Contains(t, correlationCtx, tracing.HostTypeKey+"="+string(tracing.HostTypeAzureWebApp)) + assert.Contains(t, correlationCtx, tracing.KeyVaultConfiguredTag) + + // Verify AI features are detected and included in the header + assert.True(t, azappcfg.tracingOptions.UseAIConfiguration) + assert.True(t, azappcfg.tracingOptions.UseAIChatCompletionConfiguration) + assert.Contains(t, correlationCtx, tracing.FeaturesKey+"="+ + tracing.AIConfigurationTag+tracing.DelimiterPlus+tracing.AIChatCompletionConfigurationTag) +} diff --git a/azureappconfiguration/internal/tracing/tracing.go b/azureappconfiguration/internal/tracing/tracing.go new file mode 100644 index 0000000..3b56fa5 --- /dev/null +++ b/azureappconfiguration/internal/tracing/tracing.go @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package tracing + +import ( + "context" + "net/http" + "os" + "strings" +) + +type RequestType string + +type HostType string + +const ( + RequestTypeStartUp RequestType = "StartUp" + RequestTypeWatch RequestType = "Watch" + + HostTypeAzureFunction HostType = "AzureFunction" + HostTypeAzureWebApp HostType = "AzureWebApp" + HostTypeContainerApp HostType = "ContainerApp" + HostTypeKubernetes HostType = "Kubernetes" + HostTypeServiceFabric HostType = "ServiceFabric" + + EnvVarTracingDisabled = "AZURE_APP_CONFIGURATION_TRACING_DISABLED" + EnvVarAzureFunction = "FUNCTIONS_EXTENSION_VERSION" + EnvVarAzureWebApp = "WEBSITE_SITE_NAME" + EnvVarContainerApp = "CONTAINER_APP_NAME" + EnvVarKubernetes = "KUBERNETES_PORT" + // Documentation : https://docs.microsoft.com/en-us/azure/service-fabric/service-fabric-environment-variables-reference + EnvVarServiceFabric = "Fabric_NodeName" + + RequestTracingKey = "Tracing" + RequestTypeKey = "RequestType" + HostTypeKey = "Host" + KeyVaultConfiguredTag = "UsesKeyVault" + FeaturesKey = "Features" + AIConfigurationTag = "AI" + AIChatCompletionConfigurationTag = "AICC" + + AIMimeProfile = "https://azconfig.io/mime-profiles/ai" + AIChatCompletionMimeProfile = "https://azconfig.io/mime-profiles/ai/chat-completion" + + DelimiterPlus = "+" + DelimiterComma = "," + CorrelationContextHeader = "Correlation-Context" +) + +type Options struct { + Enabled bool + Host HostType + KeyVaultConfigured bool + UseAIConfiguration bool + UseAIChatCompletionConfiguration bool +} + +func GetHostType() HostType { + if _, ok := os.LookupEnv(EnvVarAzureFunction); ok { + return HostTypeAzureFunction + } else if _, ok := os.LookupEnv(EnvVarAzureWebApp); ok { + return HostTypeAzureWebApp + } else if _, ok := os.LookupEnv(EnvVarContainerApp); ok { + return HostTypeContainerApp + } else if _, ok := os.LookupEnv(EnvVarKubernetes); ok { + return HostTypeKubernetes + } else if _, ok := os.LookupEnv(EnvVarServiceFabric); ok { + return HostTypeServiceFabric + } + return "" +} + +func CreateCorrelationContextHeader(ctx context.Context, options Options) http.Header { + header := http.Header{} + output := make([]string, 0) + + if tracing := ctx.Value(RequestTracingKey); tracing != nil { + if tracing.(RequestType) == RequestTypeStartUp { + output = append(output, RequestTypeKey+"="+string(RequestTypeStartUp)) + } else if tracing.(RequestType) == RequestTypeWatch { + output = append(output, RequestTypeKey+"="+string(RequestTypeWatch)) + } + } + + if options.Host != "" { + output = append(output, HostTypeKey+"="+string(options.Host)) + } + + if options.KeyVaultConfigured { + output = append(output, KeyVaultConfiguredTag) + + } + + features := make([]string, 0) + if options.UseAIConfiguration { + features = append(features, AIConfigurationTag) + } + + if options.UseAIChatCompletionConfiguration { + features = append(features, AIChatCompletionConfigurationTag) + } + + if len(features) > 0 { + featureStr := FeaturesKey + "=" + strings.Join(features, DelimiterPlus) + output = append(output, featureStr) + } + + header.Add(CorrelationContextHeader, strings.Join(output, DelimiterComma)) + + return header +} diff --git a/azureappconfiguration/internal/tracing/tracing_test.go b/azureappconfiguration/internal/tracing/tracing_test.go new file mode 100644 index 0000000..03ac057 --- /dev/null +++ b/azureappconfiguration/internal/tracing/tracing_test.go @@ -0,0 +1,186 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package tracing + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCreateCorrelationContextHeader(t *testing.T) { + t.Run("empty options", func(t *testing.T) { + ctx := context.Background() + options := Options{} + + header := CreateCorrelationContextHeader(ctx, options) + + // The header should be empty but exist + corrContext := header.Get(CorrelationContextHeader) + assert.Equal(t, "", corrContext) + }) + + t.Run("with RequestTypeStartUp", func(t *testing.T) { + ctx := context.WithValue(context.Background(), RequestTracingKey, RequestTypeStartUp) + options := Options{} + + header := CreateCorrelationContextHeader(ctx, options) + + // Should contain RequestTypeStartUp + corrContext := header.Get(CorrelationContextHeader) + assert.Contains(t, corrContext, RequestTypeKey+"="+string(RequestTypeStartUp)) + }) + + t.Run("with RequestTypeWatch", func(t *testing.T) { + ctx := context.WithValue(context.Background(), RequestTracingKey, RequestTypeWatch) + options := Options{} + + header := CreateCorrelationContextHeader(ctx, options) + + // Should contain RequestTypeWatch + corrContext := header.Get(CorrelationContextHeader) + assert.Contains(t, corrContext, RequestTypeKey+"="+string(RequestTypeWatch)) + }) + + t.Run("with Host", func(t *testing.T) { + ctx := context.Background() + options := Options{ + Host: HostTypeAzureWebApp, + } + + header := CreateCorrelationContextHeader(ctx, options) + + // Should contain Host + corrContext := header.Get(CorrelationContextHeader) + assert.Contains(t, corrContext, HostTypeKey+"="+string(HostTypeAzureWebApp)) + }) + + t.Run("with KeyVault configured", func(t *testing.T) { + ctx := context.Background() + options := Options{ + KeyVaultConfigured: true, + } + + header := CreateCorrelationContextHeader(ctx, options) + + // Should contain KeyVaultConfiguredTag + corrContext := header.Get(CorrelationContextHeader) + assert.Contains(t, corrContext, KeyVaultConfiguredTag) + }) + + t.Run("with AI configuration", func(t *testing.T) { + ctx := context.Background() + options := Options{ + UseAIConfiguration: true, + } + + header := CreateCorrelationContextHeader(ctx, options) + + // Should contain AIConfigurationTag + corrContext := header.Get(CorrelationContextHeader) + assert.Contains(t, corrContext, FeaturesKey+"="+AIConfigurationTag) + }) + + t.Run("with AI chat completion configuration", func(t *testing.T) { + ctx := context.Background() + options := Options{ + UseAIChatCompletionConfiguration: true, + } + + header := CreateCorrelationContextHeader(ctx, options) + + // Should contain AIChatCompletionConfigurationTag + corrContext := header.Get(CorrelationContextHeader) + assert.Contains(t, corrContext, FeaturesKey+"="+AIChatCompletionConfigurationTag) + }) + + t.Run("with both AI configurations", func(t *testing.T) { + ctx := context.Background() + options := Options{ + UseAIConfiguration: true, + UseAIChatCompletionConfiguration: true, + } + + header := CreateCorrelationContextHeader(ctx, options) + + // Should contain both AI configuration tags + corrContext := header.Get(CorrelationContextHeader) + assert.Contains(t, corrContext, FeaturesKey+"=") + + // Extract the Features part + parts := strings.Split(corrContext, DelimiterComma) + var featuresPart string + for _, part := range parts { + if strings.HasPrefix(part, FeaturesKey+"=") { + featuresPart = part + break + } + } + + // Check both tags are in the features part + assert.Contains(t, featuresPart, AIConfigurationTag) + assert.Contains(t, featuresPart, AIChatCompletionConfigurationTag) + + // Check the delimiter is correct + features := strings.Split(strings.TrimPrefix(featuresPart, FeaturesKey+"="), DelimiterPlus) + assert.Len(t, features, 2) + assert.Contains(t, features, AIConfigurationTag) + assert.Contains(t, features, AIChatCompletionConfigurationTag) + }) + + t.Run("with all options", func(t *testing.T) { + ctx := context.WithValue(context.Background(), RequestTracingKey, RequestTypeStartUp) + options := Options{ + Host: HostTypeAzureFunction, + KeyVaultConfigured: true, + UseAIConfiguration: true, + UseAIChatCompletionConfiguration: true, + } + + header := CreateCorrelationContextHeader(ctx, options) + + // Check the complete header + corrContext := header.Get(CorrelationContextHeader) + + assert.Contains(t, corrContext, RequestTypeKey+"="+string(RequestTypeStartUp)) + assert.Contains(t, corrContext, HostTypeKey+"="+string(HostTypeAzureFunction)) + assert.Contains(t, corrContext, KeyVaultConfiguredTag) + + // Extract the Features part + parts := strings.Split(corrContext, DelimiterComma) + var featuresPart string + for _, part := range parts { + if strings.HasPrefix(part, FeaturesKey+"=") { + featuresPart = part + break + } + } + + // Check both AI tags are in the features part + assert.Contains(t, featuresPart, AIConfigurationTag) + assert.Contains(t, featuresPart, AIChatCompletionConfigurationTag) + + // Verify the header format + assert.Equal(t, 4, strings.Count(corrContext, DelimiterComma)+1, "Should have 4 parts") + }) + + t.Run("delimiter handling", func(t *testing.T) { + ctx := context.WithValue(context.Background(), RequestTracingKey, RequestTypeStartUp) + options := Options{ + Host: HostTypeAzureWebApp, + KeyVaultConfigured: true, + } + + header := CreateCorrelationContextHeader(ctx, options) + + // Check the complete header + corrContext := header.Get(CorrelationContextHeader) + + // Verify there are exactly 3 parts separated by commas + parts := strings.Split(corrContext, DelimiterComma) + assert.Len(t, parts, 3, "Should have 3 parts separated by commas") + }) +} diff --git a/azureappconfiguration/settings_client.go b/azureappconfiguration/settings_client.go index 463340c..1889cfc 100644 --- a/azureappconfiguration/settings_client.go +++ b/azureappconfiguration/settings_client.go @@ -6,6 +6,8 @@ package azureappconfiguration import ( "context" + "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/tracing" + "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" ) @@ -16,8 +18,9 @@ type settingsResponse struct { } type selectorSettingsClient struct { - selectors []Selector - client *azappconfig.Client + selectors []Selector + client *azappconfig.Client + tracingOptions tracing.Options } type settingsClient interface { @@ -25,6 +28,10 @@ type settingsClient interface { } func (s *selectorSettingsClient) getSettings(ctx context.Context) (*settingsResponse, error) { + if s.tracingOptions.Enabled { + ctx = policy.WithHTTPHeader(ctx, tracing.CreateCorrelationContextHeader(ctx, s.tracingOptions)) + } + settings := make([]azappconfig.Setting, 0) for _, filter := range s.selectors { selector := azappconfig.SettingSelector{ diff --git a/azureappconfiguration/utils.go b/azureappconfiguration/utils.go index 36672b0..48cd274 100644 --- a/azureappconfiguration/utils.go +++ b/azureappconfiguration/utils.go @@ -5,7 +5,10 @@ package azureappconfiguration import ( "fmt" + "regexp" "strings" + + "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/tracing" ) func verifyAuthenticationOptions(authOptions AuthenticationOptions) error { @@ -65,3 +68,44 @@ func verifySeparator(separator string) error { return nil } + +func isJsonContentType(contentType *string) bool { + if contentType == nil { + return false + } + contentTypeStr := strings.ToLower(strings.Trim(*contentType, " ")) + matched, _ := regexp.MatchString("^application\\/(?:[^\\/]+\\+)?json(;.*)?$", contentTypeStr) + return matched +} + +func isAIConfigurationContentType(contentType *string) bool { + return hasProfile(*contentType, tracing.AIMimeProfile) +} + +func isAIChatCompletionContentType(contentType *string) bool { + return hasProfile(*contentType, tracing.AIChatCompletionMimeProfile) +} + +// hasProfile checks if a content type contains a specific profile parameter +func hasProfile(contentType, profileValue string) bool { + // Split by semicolons to get content type parts + parts := strings.Split(contentType, ";") + + // Check each part after the content type for profile parameter + for i := 1; i < len(parts); i++ { + part := strings.TrimSpace(parts[i]) + + // Look for profile="value" pattern + if strings.HasPrefix(part, "profile=") { + // Extract the profile value (handling quoted values) + profile := part[len("profile="):] + profile = strings.Trim(profile, "\"'") + + if profile == profileValue { + return true + } + } + } + + return false +} diff --git a/azureappconfiguration/utils_test.go b/azureappconfiguration/utils_test.go index ddc3791..7754640 100644 --- a/azureappconfiguration/utils_test.go +++ b/azureappconfiguration/utils_test.go @@ -231,3 +231,131 @@ func TestVerifySeparator(t *testing.T) { }) } } + +func TestIsAIConfigurationContentType(t *testing.T) { + tests := []struct { + name string + contentType *string + expected bool + }{ + { + name: "valid AI configuration content type", + contentType: strPtr("application/json; profile=\"https://azconfig.io/mime-profiles/ai\""), + expected: true, + }, + { + name: "valid AI configuration content type with extra parameters", + contentType: strPtr("application/json; charset=utf-8; profile=\"https://azconfig.io/mime-profiles/ai\"; param=value"), + expected: true, + }, + { + name: "invalid AI configuration content type - missing profile keyword", + contentType: strPtr("application/json; \"https://azconfig.io/mime-profiles/ai\""), + expected: false, + }, + { + name: "invalid content type - wrong profile", + contentType: strPtr("application/json; profile=\"https://azconfig.io/mime-profiles/other\""), + expected: false, + }, + { + name: "invalid content type - partial match", + contentType: strPtr("application/json; profile=\"prefix-https://azconfig.io/mime-profiles/ai\""), + expected: false, + }, + { + name: "invalid content type - not JSON", + contentType: strPtr("text/plain; profile=\"https://azconfig.io/mime-profiles/ai\""), + expected: false, + }, + { + name: "empty content type", + contentType: strPtr(""), + expected: false, + }, + { + name: "nil content type", + contentType: nil, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isJsonContentType(tt.contentType) && isAIConfigurationContentType(tt.contentType) + if result != tt.expected { + t.Errorf("isAIConfigurationContentType(%v) = %v, want %v", + tt.contentType, result, tt.expected) + } + }) + } +} + +func TestIsAIChatCompletionContentType(t *testing.T) { + tests := []struct { + name string + contentType *string + expected bool + }{ + { + name: "valid AI chat completion content type", + contentType: strPtr("application/json; profile=\"https://azconfig.io/mime-profiles/ai/chat-completion\""), + expected: true, + }, + { + name: "valid AI chat completion with multiple parameters", + contentType: strPtr("application/json; charset=utf-8; profile=\"https://azconfig.io/mime-profiles/ai/chat-completion\"; param=value"), + expected: true, + }, + { + name: "invalid content type - missing profile keyword", + contentType: strPtr("application/json; \"https://azconfig.io/mime-profiles/ai/chat-completion\""), + expected: false, + }, + { + name: "invalid content type - wrong profile", + contentType: strPtr("application/json; profile=\"https://azconfig.io/mime-profiles/other\""), + expected: false, + }, + { + name: "invalid content type - partial match", + contentType: strPtr("application/json; profile=\"prefix-https://azconfig.io/mime-profiles/ai/chat-completion\""), + expected: false, + }, + { + name: "invalid content type - not JSON", + contentType: strPtr("text/plain; profile=\"https://azconfig.io/mime-profiles/ai/chat-completion\""), + expected: false, + }, + { + name: "JSON content type without AI chat completion profile", + contentType: strPtr("application/json"), + expected: false, + }, + { + name: "empty content type", + contentType: strPtr(""), + expected: false, + }, + { + name: "nil content type", + contentType: nil, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isJsonContentType(tt.contentType) && isAIChatCompletionContentType(tt.contentType) + if result != tt.expected { + t.Errorf("isAIChatCompletionContentType(%v) = %v, want %v", + tt.contentType, result, tt.expected) + } + }) + } +} + +// Helper function to create string pointers for tests +func strPtr(s string) *string { + return &s +} From 88f5a604110b0a1649b9ae11f2235e2d4a477c69 Mon Sep 17 00:00:00 2001 From: linglingye001 <143174321+linglingye001@users.noreply.github.com> Date: Thu, 24 Apr 2025 15:33:09 +0800 Subject: [PATCH 2/7] WatchedSetting based refresh (#14) * resolve conflict * add tests * fix typo * update * rebase * update * update * update * update * update * update * update --- .../azureappconfiguration.go | 209 ++++++++- azureappconfiguration/constants.go | 8 + .../internal/refresh/refresh.go | 46 ++ .../internal/tracing/tracing.go | 14 +- .../internal/tracing/tracing_test.go | 18 +- azureappconfiguration/options.go | 32 +- azureappconfiguration/refresh_test.go | 405 ++++++++++++++++++ azureappconfiguration/settings_client.go | 79 +++- azureappconfiguration/utils.go | 26 ++ 9 files changed, 803 insertions(+), 34 deletions(-) create mode 100644 azureappconfiguration/internal/refresh/refresh.go create mode 100644 azureappconfiguration/refresh_test.go diff --git a/azureappconfiguration/azureappconfiguration.go b/azureappconfiguration/azureappconfiguration.go index c91f570..a25d941 100644 --- a/azureappconfiguration/azureappconfiguration.go +++ b/azureappconfiguration/azureappconfiguration.go @@ -21,23 +21,32 @@ import ( "strconv" "strings" "sync" + "sync/atomic" + "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/refresh" "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" decoder "github.com/go-viper/mapstructure/v2" "golang.org/x/sync/errgroup" ) // An AzureAppConfiguration is a configuration provider that stores and manages settings sourced from Azure App Configuration. type AzureAppConfiguration struct { - keyValues map[string]any - kvSelectors []Selector - trimPrefixes []string + keyValues map[string]any + kvSelectors []Selector + trimPrefixes []string + watchedSettings []WatchedSetting + + sentinelETags map[WatchedSetting]*azcore.ETag + kvRefreshTimer refresh.Condition + onRefreshSuccess []func() + tracingOptions tracing.Options clientManager *configurationClientManager resolver *keyVaultReferenceResolver - tracingOptions tracing.Options + refreshInProgress atomic.Bool } // Load initializes a new AzureAppConfiguration instance and loads the configuration data from @@ -56,6 +65,10 @@ func Load(ctx context.Context, authentication AuthenticationOptions, options *Op return nil, err } + if err := verifyOptions(options); err != nil { + return nil, err + } + if options == nil { options = &Options{} } @@ -77,9 +90,17 @@ func Load(ctx context.Context, authentication AuthenticationOptions, options *Op credential: options.KeyVaultOptions.Credential, } + if options.RefreshOptions.Enabled { + azappcfg.kvRefreshTimer = refresh.NewTimer(options.RefreshOptions.Interval) + azappcfg.watchedSettings = normalizedWatchedSettings(options.RefreshOptions.WatchedSettings) + azappcfg.sentinelETags = make(map[WatchedSetting]*azcore.ETag) + } + if err := azappcfg.load(ctx); err != nil { return nil, err } + // Set the initial load finished flag + azappcfg.tracingOptions.InitialLoadFinished = true return azappcfg, nil } @@ -150,14 +171,107 @@ func (azappcfg *AzureAppConfiguration) GetBytes(options *ConstructionOptions) ([ return json.Marshal(azappcfg.constructHierarchicalMap(options.Separator)) } +// Refresh manually triggers a refresh of the configuration from Azure App Configuration. +// It checks if any watched settings have changed, and if so, reloads all configuration data. +// +// The refresh only occurs if: +// - Refresh has been configured with RefreshOptions when the client was created +// - The configured refresh interval has elapsed since the last refresh +// - No other refresh operation is currently in progress +// +// If the configuration has changed, any callback functions registered with OnRefreshSuccess will be executed. +// +// Parameters: +// - ctx: The context for the operation. +// +// Returns: +// - An error if refresh is not configured, or if the refresh operation fails +func (azappcfg *AzureAppConfiguration) Refresh(ctx context.Context) error { + if azappcfg.kvRefreshTimer == nil { + return fmt.Errorf("refresh is not configured") + } + + // Try to set refreshInProgress to true, returning false if it was already true + if !azappcfg.refreshInProgress.CompareAndSwap(false, true) { + return nil // Another refresh is already in progress + } + + // Reset the flag when we're done + defer azappcfg.refreshInProgress.Store(false) + + // Check if it's time to perform a refresh based on the timer interval + if !azappcfg.kvRefreshTimer.ShouldRefresh() { + return nil + } + + // Attempt to refresh and check if any values were actually updated + refreshed, err := azappcfg.refreshKeyValues(ctx, azappcfg.newKeyValueRefreshClient()) + if err != nil { + return fmt.Errorf("failed to refresh configuration: %w", err) + } + + // Only execute callbacks if actual changes were applied + if refreshed { + for _, callback := range azappcfg.onRefreshSuccess { + if callback != nil { + callback() + } + } + } + + return nil +} + +// OnRefreshSuccess registers a callback function that will be executed whenever the configuration +// is successfully refreshed and actual changes were detected. +// +// Multiple callback functions can be registered, and they will be executed in the order they were added. +// Callbacks are only executed when configuration values actually change. They run synchronously +// in the thread that initiated the refresh. +// +// Parameters: +// - callback: A function with no parameters that will be called after a successful refresh +func (azappcfg *AzureAppConfiguration) OnRefreshSuccess(callback func()) { + azappcfg.onRefreshSuccess = append(azappcfg.onRefreshSuccess, callback) +} + func (azappcfg *AzureAppConfiguration) load(ctx context.Context) error { - keyValuesClient := &selectorSettingsClient{ - selectors: azappcfg.kvSelectors, - client: azappcfg.clientManager.staticClient.client, - tracingOptions: azappcfg.tracingOptions, + eg, egCtx := errgroup.WithContext(ctx) + eg.Go(func() error { + keyValuesClient := &selectorSettingsClient{ + selectors: azappcfg.kvSelectors, + client: azappcfg.clientManager.staticClient.client, + tracingOptions: azappcfg.tracingOptions, + } + return azappcfg.loadKeyValues(egCtx, keyValuesClient) + }) + + if azappcfg.kvRefreshTimer != nil && len(azappcfg.watchedSettings) > 0 { + eg.Go(func() error { + watchedClient := &watchedSettingClient{ + watchedSettings: azappcfg.watchedSettings, + client: azappcfg.clientManager.staticClient.client, + tracingOptions: azappcfg.tracingOptions, + } + return azappcfg.loadWatchedSettings(egCtx, watchedClient) + }) + } + + return eg.Wait() +} + +func (azappcfg *AzureAppConfiguration) loadWatchedSettings(ctx context.Context, settingsClient settingsClient) error { + settingsResponse, err := settingsClient.getSettings(ctx) + if err != nil { + return err } - return azappcfg.loadKeyValues(ctx, keyValuesClient) + // Store ETags for all watched settings + if settingsResponse != nil && settingsResponse.watchedETags != nil { + azappcfg.sentinelETags = settingsResponse.watchedETags + } + + return nil } func (azappcfg *AzureAppConfiguration) loadKeyValues(ctx context.Context, settingsClient settingsClient) error { @@ -246,6 +360,48 @@ func (azappcfg *AzureAppConfiguration) loadKeyValues(ctx context.Context, settin return nil } +// refreshKeyValues checks if any watched settings have changed and reloads configuration if needed +// Returns true if configuration was actually refreshed, false otherwise +func (azappcfg *AzureAppConfiguration) refreshKeyValues(ctx context.Context, refreshClient refreshClient) (bool, error) { + // 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) + } + + if !eTagChanged { + // No changes detected, reset timer and return + azappcfg.kvRefreshTimer.Reset() + return false, nil + } + + // Use an errgroup to reload key values and watched settings concurrently + eg, egCtx := errgroup.WithContext(ctx) + + // Reload key values in one goroutine + eg.Go(func() error { + settingsClient := refreshClient.loader + return azappcfg.loadKeyValues(egCtx, settingsClient) + }) + + if len(azappcfg.watchedSettings) > 0 { + eg.Go(func() error { + watchedClient := refreshClient.sentinels + return azappcfg.loadWatchedSettings(egCtx, watchedClient) + }) + } + + // Wait for all reloads to complete + if err := eg.Wait(); err != nil { + // Don't reset the timer if reload failed + return false, fmt.Errorf("failed to reload configuration: %w", err) + } + + // Reset the timer only after successful refresh + azappcfg.kvRefreshTimer.Reset() + return true, nil +} + func (azappcfg *AzureAppConfiguration) trimPrefix(key string) string { result := key for _, prefix := range azappcfg.trimPrefixes { @@ -324,3 +480,38 @@ func configureTracingOptions(options *Options) tracing.Options { return tracingOption } + +func normalizedWatchedSettings(s []WatchedSetting) []WatchedSetting { + result := make([]WatchedSetting, len(s)) + for i, setting := range s { + // Make a copy of the setting + normalizedSetting := setting + if normalizedSetting.Label == "" { + normalizedSetting.Label = defaultLabel + } + + result[i] = normalizedSetting + } + + return result +} + +func (azappcfg *AzureAppConfiguration) newKeyValueRefreshClient() refreshClient { + return refreshClient{ + loader: &selectorSettingsClient{ + selectors: azappcfg.kvSelectors, + client: azappcfg.clientManager.staticClient.client, + tracingOptions: azappcfg.tracingOptions, + }, + monitor: &watchedSettingClient{ + eTags: azappcfg.sentinelETags, + client: azappcfg.clientManager.staticClient.client, + tracingOptions: azappcfg.tracingOptions, + }, + sentinels: &watchedSettingClient{ + watchedSettings: azappcfg.watchedSettings, + client: azappcfg.clientManager.staticClient.client, + tracingOptions: azappcfg.tracingOptions, + }, + } +} diff --git a/azureappconfiguration/constants.go b/azureappconfiguration/constants.go index 608489f..028387d 100644 --- a/azureappconfiguration/constants.go +++ b/azureappconfiguration/constants.go @@ -3,6 +3,8 @@ package azureappconfiguration +import "time" + // Configuration client constants const ( endpointKey string = "Endpoint" @@ -18,3 +20,9 @@ const ( secretReferenceContentType string = "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8" featureFlagContentType string = "application/vnd.microsoft.appconfig.ff+json;charset=utf-8" ) + +// Refresh interval constants +const ( + // minimalRefreshInterval is the minimum allowed refresh interval for key-value settings + minimalRefreshInterval time.Duration = time.Second +) diff --git a/azureappconfiguration/internal/refresh/refresh.go b/azureappconfiguration/internal/refresh/refresh.go new file mode 100644 index 0000000..ba48ce3 --- /dev/null +++ b/azureappconfiguration/internal/refresh/refresh.go @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package refresh + +import "time" + +// Timer manages the timing for refresh operations +type Timer struct { + interval time.Duration // How often refreshes should occur + nextRefreshTime time.Time // When the next refresh should occur +} + +// Condition interface defines the methods a refresh timer should implement +type Condition interface { + ShouldRefresh() bool + Reset() +} + +const ( + DefaultRefreshInterval time.Duration = 30 * time.Second +) + +// NewTimer creates a new refresh timer with the specified interval +// If interval is zero or negative, it falls back to the DefaultRefreshInterval +func NewTimer(interval time.Duration) *Timer { + // Use default interval if not specified or invalid + if interval <= 0 { + interval = DefaultRefreshInterval + } + + return &Timer{ + interval: interval, + nextRefreshTime: time.Now().Add(interval), + } +} + +// ShouldRefresh checks whether it's time for a refresh +func (rt *Timer) ShouldRefresh() bool { + return !time.Now().Before(rt.nextRefreshTime) +} + +// Reset resets the timer for the next refresh cycle +func (rt *Timer) Reset() { + rt.nextRefreshTime = time.Now().Add(rt.interval) +} diff --git a/azureappconfiguration/internal/tracing/tracing.go b/azureappconfiguration/internal/tracing/tracing.go index 3b56fa5..fdecf93 100644 --- a/azureappconfiguration/internal/tracing/tracing.go +++ b/azureappconfiguration/internal/tracing/tracing.go @@ -11,7 +11,7 @@ import ( ) type RequestType string - +type RequestTracingKey string type HostType string const ( @@ -32,7 +32,6 @@ const ( // Documentation : https://docs.microsoft.com/en-us/azure/service-fabric/service-fabric-environment-variables-reference EnvVarServiceFabric = "Fabric_NodeName" - RequestTracingKey = "Tracing" RequestTypeKey = "RequestType" HostTypeKey = "Host" KeyVaultConfiguredTag = "UsesKeyVault" @@ -50,6 +49,7 @@ const ( type Options struct { Enabled bool + InitialLoadFinished bool Host HostType KeyVaultConfigured bool UseAIConfiguration bool @@ -75,12 +75,10 @@ func CreateCorrelationContextHeader(ctx context.Context, options Options) http.H header := http.Header{} output := make([]string, 0) - if tracing := ctx.Value(RequestTracingKey); tracing != nil { - if tracing.(RequestType) == RequestTypeStartUp { - output = append(output, RequestTypeKey+"="+string(RequestTypeStartUp)) - } else if tracing.(RequestType) == RequestTypeWatch { - output = append(output, RequestTypeKey+"="+string(RequestTypeWatch)) - } + if !options.InitialLoadFinished { + output = append(output, RequestTypeKey+"="+string(RequestTypeStartUp)) + } else { + output = append(output, RequestTypeKey+"="+string(RequestTypeWatch)) } if options.Host != "" { diff --git a/azureappconfiguration/internal/tracing/tracing_test.go b/azureappconfiguration/internal/tracing/tracing_test.go index 03ac057..86336b0 100644 --- a/azureappconfiguration/internal/tracing/tracing_test.go +++ b/azureappconfiguration/internal/tracing/tracing_test.go @@ -20,14 +20,13 @@ func TestCreateCorrelationContextHeader(t *testing.T) { // The header should be empty but exist corrContext := header.Get(CorrelationContextHeader) - assert.Equal(t, "", corrContext) + assert.Equal(t, "RequestType=StartUp", corrContext) }) t.Run("with RequestTypeStartUp", func(t *testing.T) { - ctx := context.WithValue(context.Background(), RequestTracingKey, RequestTypeStartUp) options := Options{} - header := CreateCorrelationContextHeader(ctx, options) + header := CreateCorrelationContextHeader(context.Background(), options) // Should contain RequestTypeStartUp corrContext := header.Get(CorrelationContextHeader) @@ -35,10 +34,11 @@ func TestCreateCorrelationContextHeader(t *testing.T) { }) t.Run("with RequestTypeWatch", func(t *testing.T) { - ctx := context.WithValue(context.Background(), RequestTracingKey, RequestTypeWatch) - options := Options{} + options := Options{ + InitialLoadFinished: true, + } - header := CreateCorrelationContextHeader(ctx, options) + header := CreateCorrelationContextHeader(context.Background(), options) // Should contain RequestTypeWatch corrContext := header.Get(CorrelationContextHeader) @@ -132,7 +132,6 @@ func TestCreateCorrelationContextHeader(t *testing.T) { }) t.Run("with all options", func(t *testing.T) { - ctx := context.WithValue(context.Background(), RequestTracingKey, RequestTypeStartUp) options := Options{ Host: HostTypeAzureFunction, KeyVaultConfigured: true, @@ -140,7 +139,7 @@ func TestCreateCorrelationContextHeader(t *testing.T) { UseAIChatCompletionConfiguration: true, } - header := CreateCorrelationContextHeader(ctx, options) + header := CreateCorrelationContextHeader(context.Background(), options) // Check the complete header corrContext := header.Get(CorrelationContextHeader) @@ -168,13 +167,12 @@ func TestCreateCorrelationContextHeader(t *testing.T) { }) t.Run("delimiter handling", func(t *testing.T) { - ctx := context.WithValue(context.Background(), RequestTracingKey, RequestTypeStartUp) options := Options{ Host: HostTypeAzureWebApp, KeyVaultConfigured: true, } - header := CreateCorrelationContextHeader(ctx, options) + header := CreateCorrelationContextHeader(context.Background(), options) // Check the complete header corrContext := header.Get(CorrelationContextHeader) diff --git a/azureappconfiguration/options.go b/azureappconfiguration/options.go index f3f5371..a8e0d81 100644 --- a/azureappconfiguration/options.go +++ b/azureappconfiguration/options.go @@ -6,6 +6,7 @@ package azureappconfiguration import ( "context" "net/url" + "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" @@ -21,13 +22,15 @@ type Options struct { // Selectors defines what key-values to load from Azure App Configuration // 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 + Selectors []Selector + // RefreshOptions contains optional parameters to configure the behavior of key-value settings refresh + RefreshOptions KeyValueRefreshOptions // KeyVaultOptions configures how Key Vault references are resolved. KeyVaultOptions KeyVaultOptions // ClientOptions provides options for configuring the underlying Azure App Configuration client. - ClientOptions *azappconfig.ClientOptions + ClientOptions *azappconfig.ClientOptions } // AuthenticationOptions contains parameters for authenticating with the Azure App Configuration service. @@ -35,11 +38,11 @@ type Options struct { type AuthenticationOptions struct { // Credential is a token credential for Azure EntraID Authenticaiton. // Required when Endpoint is provided. - Credential azcore.TokenCredential + Credential azcore.TokenCredential // Endpoint is the URL of the Azure App Configuration service. // Required when using token-based authentication with Credential. - Endpoint string + Endpoint string // ConnectionString is the connection string for the Azure App Configuration service. ConnectionString string @@ -49,7 +52,7 @@ type AuthenticationOptions struct { type Selector struct { // KeyFilter specifies which keys to retrieve from Azure App Configuration. // It can include wildcards, e.g. "app*" will match all keys starting with "app". - KeyFilter string + KeyFilter string // LabelFilter specifies which labels to retrieve from Azure App Configuration. // Empty string or omitted value will use the default no-label filter. @@ -57,6 +60,25 @@ type Selector struct { LabelFilter string } +// KeyValueRefreshOptions contains optional parameters to configure the behavior of key-value settings refresh +type KeyValueRefreshOptions struct { + // WatchedSettings specifies the key-value settings to watch for changes + WatchedSettings []WatchedSetting + + // Interval specifies the minimum time interval between consecutive refresh operations for the watched settings + // Must be greater than 1 second. If not provided, the default interval 30 seconds will be used + Interval time.Duration + + // Enabled specifies whether the provider should automatically refresh when the configuration is changed. + Enabled bool +} + +// WatchedSetting specifies the key and label of a key-value setting to watch for changes +type WatchedSetting struct { + Key string + Label string +} + // SecretResolver is an interface to resolve secrets from Key Vault references. // Implement this interface to provide custom secret resolution logic. type SecretResolver interface { diff --git a/azureappconfiguration/refresh_test.go b/azureappconfiguration/refresh_test.go new file mode 100644 index 0000000..c2ed49d --- /dev/null +++ b/azureappconfiguration/refresh_test.go @@ -0,0 +1,405 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package azureappconfiguration + +import ( + "context" + "fmt" + "testing" + "time" + + "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/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockETagsClient implements the eTagsClient interface for testing +type mockETagsClient struct { + changed bool + checkCallCount int + err error +} + +func (m *mockETagsClient) checkIfETagChanged(ctx context.Context) (bool, error) { + m.checkCallCount++ + if m.err != nil { + return false, m.err + } + return m.changed, nil +} + +// mockRefreshCondition implements the refreshtimer.RefreshCondition interface for testing +type mockRefreshCondition struct { + shouldRefresh bool + resetCalled bool +} + +func (m *mockRefreshCondition) ShouldRefresh() bool { + return m.shouldRefresh +} + +func (m *mockRefreshCondition) Reset() { + m.resetCalled = true +} + +func TestRefresh_NotConfigured(t *testing.T) { + // Setup a provider with no refresh configuration + azappcfg := &AzureAppConfiguration{} + + // Attempt to refresh + err := azappcfg.Refresh(context.Background()) + + // Verify that an error is returned + require.Error(t, err) + assert.Contains(t, err.Error(), "refresh is not configured") +} + +func TestRefresh_NotTimeToRefresh(t *testing.T) { + // Setup a provider with a timer that indicates it's not time to refresh + mockTimer := &mockRefreshCondition{shouldRefresh: false} + azappcfg := &AzureAppConfiguration{ + kvRefreshTimer: mockTimer, + } + + // Attempt to refresh + err := azappcfg.Refresh(context.Background()) + + // Verify no error and that we returned early + assert.NoError(t, err) + // Timer should not be reset if we're not refreshing + assert.False(t, mockTimer.resetCalled) +} + +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{ + RefreshOptions: KeyValueRefreshOptions{ + Enabled: true, + Interval: 500 * time.Millisecond, // Too short, should be at least minimalRefreshInterval + WatchedSettings: []WatchedSetting{ + {Key: "test-key", Label: "test-label"}, + }, + }, + } + + // Verify error + err := verifyOptions(options) + require.Error(t, err) + assert.Contains(t, err.Error(), "key value refresh interval cannot be less than") +} + +func TestRefreshEnabled_EmptyWatchedSettingKey(t *testing.T) { + // Test verifying validation when a watched setting has an empty key + options := &Options{ + RefreshOptions: KeyValueRefreshOptions{ + Enabled: true, + WatchedSettings: []WatchedSetting{ + {Key: "", Label: "test-label"}, // Empty key should be rejected + }, + }, + } + + // Verify error + err := verifyOptions(options) + require.Error(t, err) + assert.Contains(t, err.Error(), "watched setting key cannot be empty") +} + +func TestRefreshEnabled_InvalidWatchedSettingKey(t *testing.T) { + // Test verifying validation when watched setting keys contain invalid chars + options := &Options{ + RefreshOptions: KeyValueRefreshOptions{ + Enabled: true, + WatchedSettings: []WatchedSetting{ + {Key: "test*key", Label: "test-label"}, // Key contains wildcard, not allowed + }, + }, + } + + // Verify error + err := verifyOptions(options) + require.Error(t, err) + assert.Contains(t, err.Error(), "watched setting key cannot contain") +} + +func TestRefreshEnabled_InvalidWatchedSettingLabel(t *testing.T) { + // Test verifying validation when watched setting labels contain invalid chars + options := &Options{ + RefreshOptions: KeyValueRefreshOptions{ + Enabled: true, + WatchedSettings: []WatchedSetting{ + {Key: "test-key", Label: "test*label"}, // Label contains wildcard, not allowed + }, + }, + } + + // Verify error + err := verifyOptions(options) + require.Error(t, err) + assert.Contains(t, err.Error(), "watched setting label cannot contain") +} + +func TestRefreshEnabled_ValidSettings(t *testing.T) { + // Test verifying valid refresh options pass validation + options := &Options{ + RefreshOptions: KeyValueRefreshOptions{ + Enabled: true, + Interval: 5 * time.Second, // Valid interval + WatchedSettings: []WatchedSetting{ + {Key: "test-key-1", Label: "test-label-1"}, + {Key: "test-key-2", Label: ""}, // Empty label should be normalized later + }, + }, + } + + // Verify no error + err := verifyOptions(options) + assert.NoError(t, err) +} + +func TestNormalizedWatchedSettings(t *testing.T) { + // Test the normalizedWatchedSettings function + settings := []WatchedSetting{ + {Key: "key1", Label: "label1"}, + {Key: "key2", Label: ""}, // Empty label should be set to defaultLabel + } + + normalized := normalizedWatchedSettings(settings) + + // Verify results + assert.Len(t, normalized, 2) + assert.Equal(t, "key1", normalized[0].Key) + assert.Equal(t, "label1", normalized[0].Label) + assert.Equal(t, "key2", normalized[1].Key) + assert.Equal(t, defaultLabel, normalized[1].Label) +} + +// Additional test to verify real RefreshTimer behavior +func TestRealRefreshTimer(t *testing.T) { + // Create a real refresh timer with a short interval + timer := refresh.NewTimer(100 * time.Millisecond) + + // Initially it should not be time to refresh + assert.False(t, timer.ShouldRefresh(), "New timer should not immediately indicate refresh needed") + + // After the interval passes, it should indicate time to refresh + time.Sleep(110 * time.Millisecond) + assert.True(t, timer.ShouldRefresh(), "Timer should indicate refresh needed after interval") + + // After reset, it should not be time to refresh again + timer.Reset() + assert.False(t, timer.ShouldRefresh(), "Timer should not indicate refresh needed right after reset") +} + +// mockKvRefreshClient implements the settingsClient interface for testing +type mockKvRefreshClient struct { + settings []azappconfig.Setting + watchedETags map[WatchedSetting]*azcore.ETag + getCallCount int + err error +} + +func (m *mockKvRefreshClient) getSettings(ctx context.Context) (*settingsResponse, error) { + m.getCallCount++ + if m.err != nil { + return nil, m.err + } + return &settingsResponse{ + settings: m.settings, + watchedETags: m.watchedETags, + }, nil +} + +// TestRefreshKeyValues_NoChanges tests when no ETags change is detected +func TestRefreshKeyValues_NoChanges(t *testing.T) { + // Setup mocks + mockTimer := &mockRefreshCondition{} + mockMonitor := &mockETagsClient{changed: false} + mockLoader := &mockKvRefreshClient{} + mockSentinels := &mockKvRefreshClient{} + + mockClient := refreshClient{ + loader: mockLoader, + monitor: mockMonitor, + sentinels: mockSentinels, + } + + // Setup provider + azappcfg := &AzureAppConfiguration{ + kvRefreshTimer: mockTimer, + } + + // 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.Equal(t, 0, mockSentinels.getCallCount, "Sentinels should not be called when no changes") + assert.True(t, mockTimer.resetCalled, "Timer should be reset even when no changes") +} + +// TestRefreshKeyValues_ChangesDetected tests when ETags changed and reload succeeds +func TestRefreshKeyValues_ChangesDetected(t *testing.T) { + // Setup mocks for successful refresh + mockTimer := &mockRefreshCondition{} + mockMonitor := &mockETagsClient{changed: true} + mockLoader := &mockKvRefreshClient{} + mockSentinels := &mockKvRefreshClient{} + + mockClient := refreshClient{ + loader: mockLoader, + monitor: mockMonitor, + sentinels: mockSentinels, + } + + // Setup provider with watchedSettings + azappcfg := &AzureAppConfiguration{ + kvRefreshTimer: mockTimer, + watchedSettings: []WatchedSetting{{Key: "test", Label: "test"}}, + } + + // Call refreshKeyValues + refreshed, err := azappcfg.refreshKeyValues(context.Background(), mockClient) + + // Verify results + assert.NoError(t, err) + assert.True(t, refreshed, "Should return true when changes detected and applied") + assert.Equal(t, 1, mockMonitor.checkCallCount, "Monitor should be called exactly once") + assert.Equal(t, 1, mockLoader.getCallCount, "Loader should be called when changes detected") + assert.Equal(t, 1, mockSentinels.getCallCount, "Sentinels should be called when changes detected") + assert.True(t, mockTimer.resetCalled, "Timer should be reset after successful refresh") +} + +// TestRefreshKeyValues_LoaderError tests when loader client returns an error +func TestRefreshKeyValues_LoaderError(t *testing.T) { + // Setup mocks with loader error + mockTimer := &mockRefreshCondition{} + mockMonitor := &mockETagsClient{changed: true} + mockLoader := &mockKvRefreshClient{err: fmt.Errorf("loader error")} + mockSentinels := &mockKvRefreshClient{} + + mockClient := refreshClient{ + loader: mockLoader, + monitor: mockMonitor, + sentinels: mockSentinels, + } + + // Setup provider + azappcfg := &AzureAppConfiguration{ + kvRefreshTimer: mockTimer, + } + + // Call refreshKeyValues + refreshed, err := azappcfg.refreshKeyValues(context.Background(), mockClient) + + // Verify results + assert.Error(t, err) + assert.False(t, refreshed, "Should return false when error occurs") + assert.Contains(t, err.Error(), "loader error") + assert.Equal(t, 1, mockMonitor.checkCallCount, "Monitor should be called exactly once") + assert.Equal(t, 1, mockLoader.getCallCount, "Loader should be called when changes detected") + assert.False(t, mockTimer.resetCalled, "Timer should not be reset when error occurs") +} + +// TestRefreshKeyValues_SentinelError tests when sentinel client returns an error +func TestRefreshKeyValues_SentinelError(t *testing.T) { + // Setup mocks with sentinel error + mockTimer := &mockRefreshCondition{} + mockMonitor := &mockETagsClient{changed: true} + mockLoader := &mockKvRefreshClient{} + mockSentinels := &mockKvRefreshClient{err: fmt.Errorf("sentinel error")} + + mockClient := refreshClient{ + loader: mockLoader, + monitor: mockMonitor, + sentinels: mockSentinels, + } + + // Setup provider with watchedSettings to ensure sentinels are used + azappcfg := &AzureAppConfiguration{ + kvRefreshTimer: mockTimer, + watchedSettings: []WatchedSetting{{Key: "test", Label: "test"}}, + } + + // Call refreshKeyValues + refreshed, err := azappcfg.refreshKeyValues(context.Background(), mockClient) + + // Verify results + assert.Error(t, err) + assert.False(t, refreshed, "Should return false when error occurs") + assert.Contains(t, err.Error(), "sentinel error") + assert.Equal(t, 1, mockMonitor.checkCallCount, "Monitor should be called exactly once") + assert.Equal(t, 1, mockLoader.getCallCount, "Loader should be called when changes detected") + assert.Equal(t, 1, mockSentinels.getCallCount, "Sentinels should be called when changes detected") + assert.False(t, mockTimer.resetCalled, "Timer should not be reset when error occurs") +} + +// TestRefreshKeyValues_MonitorError tests when monitor client returns an error +func TestRefreshKeyValues_MonitorError(t *testing.T) { + // Setup mocks with monitor error + mockTimer := &mockRefreshCondition{} + mockMonitor := &mockETagsClient{err: fmt.Errorf("monitor error")} + mockLoader := &mockKvRefreshClient{} + mockSentinels := &mockKvRefreshClient{} + + mockClient := refreshClient{ + loader: mockLoader, + monitor: mockMonitor, + sentinels: mockSentinels, + } + + // Setup provider + azappcfg := &AzureAppConfiguration{ + kvRefreshTimer: mockTimer, + } + + // Call refreshKeyValues + refreshed, err := azappcfg.refreshKeyValues(context.Background(), mockClient) + + // Verify results + assert.Error(t, err) + assert.False(t, refreshed, "Should return false when error occurs") + assert.Contains(t, err.Error(), "monitor error") + assert.Equal(t, 1, mockMonitor.checkCallCount, "Monitor should be called exactly once") + assert.Equal(t, 0, mockLoader.getCallCount, "Loader should not be called when monitor fails") + assert.Equal(t, 0, mockSentinels.getCallCount, "Sentinels should not be called when monitor fails") + assert.False(t, mockTimer.resetCalled, "Timer should not be reset when error occurs") +} + +// TestRefresh_AlreadyInProgress tests the new atomic implementation of refresh status checking +func TestRefresh_AlreadyInProgress(t *testing.T) { + // Setup a provider with refresh already in progress + azappcfg := &AzureAppConfiguration{ + kvRefreshTimer: &mockRefreshCondition{}, + } + + // Manually set the refresh in progress flag + azappcfg.refreshInProgress.Store(true) + + // Attempt to refresh + err := azappcfg.Refresh(context.Background()) + + // Verify no error and that we returned early + assert.NoError(t, err) +} diff --git a/azureappconfiguration/settings_client.go b/azureappconfiguration/settings_client.go index 1889cfc..212177b 100644 --- a/azureappconfiguration/settings_client.go +++ b/azureappconfiguration/settings_client.go @@ -5,16 +5,19 @@ package azureappconfiguration import ( "context" + "errors" + "log" "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/azcore/policy" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" ) type settingsResponse struct { - settings []azappconfig.Setting - // TODO: pageETags + settings []azappconfig.Setting + watchedETags map[WatchedSetting]*azcore.ETag } type selectorSettingsClient struct { @@ -23,10 +26,27 @@ type selectorSettingsClient struct { tracingOptions tracing.Options } +type watchedSettingClient struct { + watchedSettings []WatchedSetting + eTags map[WatchedSetting]*azcore.ETag + client *azappconfig.Client + tracingOptions tracing.Options +} + type settingsClient interface { getSettings(ctx context.Context) (*settingsResponse, error) } +type eTagsClient interface { + checkIfETagChanged(ctx context.Context) (bool, error) +} + +type refreshClient struct { + loader settingsClient + monitor eTagsClient + sentinels settingsClient +} + func (s *selectorSettingsClient) getSettings(ctx context.Context) (*settingsResponse, error) { if s.tracingOptions.Enabled { ctx = policy.WithHTTPHeader(ctx, tracing.CreateCorrelationContextHeader(ctx, s.tracingOptions)) @@ -55,3 +75,58 @@ func (s *selectorSettingsClient) getSettings(ctx context.Context) (*settingsResp settings: settings, }, nil } + +func (c *watchedSettingClient) getSettings(ctx context.Context) (*settingsResponse, error) { + if c.tracingOptions.Enabled { + ctx = policy.WithHTTPHeader(ctx, tracing.CreateCorrelationContextHeader(ctx, c.tracingOptions)) + } + + settings := make([]azappconfig.Setting, 0, len(c.watchedSettings)) + watchedETags := make(map[WatchedSetting]*azcore.ETag) + for _, watchedSetting := range c.watchedSettings { + response, err := c.client.GetSetting(ctx, watchedSetting.Key, &azappconfig.GetSettingOptions{Label: to.Ptr(watchedSetting.Label)}) + if err != nil { + var respErr *azcore.ResponseError + if errors.As(err, &respErr) && respErr.StatusCode == 404 { + label := watchedSetting.Label + if label == "" || label == "\x00" { // NUL is escaped to \x00 in golang + label = "no" + } + // If the watched setting is not found, log and continue + log.Printf("Watched key '%s' with %s label does not exist", watchedSetting.Key, label) + continue + } + return nil, err + } + + settings = append(settings, response.Setting) + watchedETags[watchedSetting] = response.Setting.ETag + } + + return &settingsResponse{ + settings: settings, + watchedETags: watchedETags, + }, nil +} + +func (c *watchedSettingClient) checkIfETagChanged(ctx context.Context) (bool, error) { + if c.tracingOptions.Enabled { + ctx = policy.WithHTTPHeader(ctx, tracing.CreateCorrelationContextHeader(ctx, c.tracingOptions)) + } + + for watchedSetting, ETag := range c.eTags { + _, err := c.client.GetSetting(ctx, watchedSetting.Key, &azappconfig.GetSettingOptions{Label: to.Ptr(watchedSetting.Label), OnlyIfChanged: ETag}) + if err != nil { + var respErr *azcore.ResponseError + if errors.As(err, &respErr) && (respErr.StatusCode == 404 || respErr.StatusCode == 304) { + continue + } + + return false, err + } + + return true, nil + } + + return false, nil +} diff --git a/azureappconfiguration/utils.go b/azureappconfiguration/utils.go index 48cd274..f567360 100644 --- a/azureappconfiguration/utils.go +++ b/azureappconfiguration/utils.go @@ -29,6 +29,32 @@ func verifyOptions(options *Options) error { return err } + if options.RefreshOptions.Enabled { + if options.RefreshOptions.Interval != 0 && + options.RefreshOptions.Interval < minimalRefreshInterval { + 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") + } + + if strings.Contains(watchedSetting.Key, "*") || strings.Contains(watchedSetting.Key, ",") { + return fmt.Errorf("watched setting key cannot contain '*' or ','") + } + + if watchedSetting.Label != "" && + (strings.Contains(watchedSetting.Label, "*") || strings.Contains(watchedSetting.Label, ",")) { + return fmt.Errorf("watched setting label cannot contain '*' or ','") + } + } + } + return nil } From 1d5e502bb0700771178e530ffb213286ab329dca Mon Sep 17 00:00:00 2001 From: linglingye001 <143174321+linglingye001@users.noreply.github.com> Date: Thu, 8 May 2025 17:13:27 +0800 Subject: [PATCH 3/7] Secret refresh support (#20) * resolve conflict * add tests * rebase * update * update * update * secret refresh * update * update * update * de-deup settings * update * add request tracing for key vault refresh * minimal key vault refresh interval update * add tests * update --- .../azureappconfiguration.go | 157 ++++++++++---- azureappconfiguration/constants.go | 2 + .../internal/tracing/tracing.go | 5 + .../internal/tracing/tracing_test.go | 14 ++ azureappconfiguration/keyvault.go | 16 ++ azureappconfiguration/options.go | 13 ++ azureappconfiguration/refresh_test.go | 204 ++++++++++++++++-- azureappconfiguration/utils.go | 7 + 8 files changed, 355 insertions(+), 63 deletions(-) diff --git a/azureappconfiguration/azureappconfiguration.go b/azureappconfiguration/azureappconfiguration.go index a25d941..84f3ed0 100644 --- a/azureappconfiguration/azureappconfiguration.go +++ b/azureappconfiguration/azureappconfiguration.go @@ -17,6 +17,7 @@ import ( "encoding/json" "fmt" "log" + "maps" "os" "strconv" "strings" @@ -27,21 +28,25 @@ 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" decoder "github.com/go-viper/mapstructure/v2" "golang.org/x/sync/errgroup" ) // An AzureAppConfiguration is a configuration provider that stores and manages settings sourced from Azure App Configuration. type AzureAppConfiguration struct { - keyValues map[string]any + keyValues map[string]any + kvSelectors []Selector trimPrefixes []string watchedSettings []WatchedSetting - sentinelETags map[WatchedSetting]*azcore.ETag - kvRefreshTimer refresh.Condition - onRefreshSuccess []func() - tracingOptions tracing.Options + sentinelETags map[WatchedSetting]*azcore.ETag + keyVaultRefs map[string]string // unversioned Key Vault references + kvRefreshTimer refresh.Condition + secretRefreshTimer refresh.Condition + onRefreshSuccess []func() + tracingOptions tracing.Options clientManager *configurationClientManager resolver *keyVaultReferenceResolver @@ -96,6 +101,12 @@ func Load(ctx context.Context, authentication AuthenticationOptions, options *Op azappcfg.sentinelETags = make(map[WatchedSetting]*azcore.ETag) } + if options.KeyVaultOptions.RefreshOptions.Enabled { + azappcfg.secretRefreshTimer = refresh.NewTimer(options.KeyVaultOptions.RefreshOptions.Interval) + azappcfg.keyVaultRefs = make(map[string]string) + azappcfg.tracingOptions.KeyVaultRefreshConfigured = true + } + if err := azappcfg.load(ctx); err != nil { return nil, err } @@ -187,8 +198,8 @@ func (azappcfg *AzureAppConfiguration) GetBytes(options *ConstructionOptions) ([ // Returns: // - An error if refresh is not configured, or if the refresh operation fails func (azappcfg *AzureAppConfiguration) Refresh(ctx context.Context) error { - if azappcfg.kvRefreshTimer == nil { - return fmt.Errorf("refresh is not configured") + if azappcfg.kvRefreshTimer == nil && azappcfg.secretRefreshTimer == nil { + return fmt.Errorf("refresh is not enabled for either key values or Key Vault secrets") } // Try to set refreshInProgress to true, returning false if it was already true @@ -199,19 +210,24 @@ func (azappcfg *AzureAppConfiguration) Refresh(ctx context.Context) error { // Reset the flag when we're done defer azappcfg.refreshInProgress.Store(false) - // Check if it's time to perform a refresh based on the timer interval - if !azappcfg.kvRefreshTimer.ShouldRefresh() { - return nil - } - // Attempt to refresh and check if any values were actually updated - refreshed, err := azappcfg.refreshKeyValues(ctx, azappcfg.newKeyValueRefreshClient()) + keyValueRefreshed, err := azappcfg.refreshKeyValues(ctx, azappcfg.newKeyValueRefreshClient()) if err != nil { return fmt.Errorf("failed to refresh configuration: %w", err) } + // Attempt to reload Key Vault secrets and check if any values were actually updated + // No need to reload Key Vault secrets if key values are refreshed + secretRefreshed := false + if !keyValueRefreshed { + secretRefreshed, err = azappcfg.refreshKeyVaultSecrets(ctx) + if err != nil { + return fmt.Errorf("failed to reload Key Vault secrets: %w", err) + } + } + // Only execute callbacks if actual changes were applied - if refreshed { + if keyValueRefreshed || secretRefreshed { for _, callback := range azappcfg.onRefreshSuccess { if callback != nil { callback() @@ -280,9 +296,8 @@ func (azappcfg *AzureAppConfiguration) loadKeyValues(ctx context.Context, settin return err } - var useAIConfiguration, useAIChatCompletionConfiguration bool - kvSettings := make(map[string]any, len(settingsResponse.settings)) - keyVaultRefs := make(map[string]string) + // de-duplicate settings + rawSettings := make(map[string]azappconfig.Setting, len(settingsResponse.settings)) for _, setting := range settingsResponse.settings { if setting.Key == nil { continue @@ -292,7 +307,13 @@ func (azappcfg *AzureAppConfiguration) loadKeyValues(ctx context.Context, settin log.Printf("Key of the setting '%s' is trimmed to the empty string, just ignore it", *setting.Key) continue } + rawSettings[trimmedKey] = setting + } + var useAIConfiguration, useAIChatCompletionConfiguration bool + kvSettings := make(map[string]any, len(settingsResponse.settings)) + keyVaultRefs := make(map[string]string) + for trimmedKey, setting := range rawSettings { if setting.ContentType == nil || setting.Value == nil { kvSettings[trimmedKey] = setting.Value continue @@ -326,43 +347,63 @@ func (azappcfg *AzureAppConfiguration) loadKeyValues(ctx context.Context, settin azappcfg.tracingOptions.UseAIConfiguration = useAIConfiguration azappcfg.tracingOptions.UseAIChatCompletionConfiguration = useAIChatCompletionConfiguration - var eg errgroup.Group - resolvedSecrets := sync.Map{} - if len(keyVaultRefs) > 0 { - if azappcfg.resolver.credential == nil && azappcfg.resolver.secretResolver == nil { - return fmt.Errorf("no Key Vault credential or SecretResolver configured") - } + secrets, err := azappcfg.loadKeyVaultSecrets(ctx, keyVaultRefs) + if err != nil { + return fmt.Errorf("failed to load Key Vault secrets: %w", err) + } - for key, kvRef := range keyVaultRefs { - key, kvRef := key, kvRef - eg.Go(func() error { - resolvedSecret, err := azappcfg.resolver.resolveSecret(ctx, kvRef) - if err != nil { - return fmt.Errorf("fail to resolve the Key Vault reference '%s': %s", key, err.Error()) - } - resolvedSecrets.Store(key, resolvedSecret) - return nil - }) - } + maps.Copy(kvSettings, secrets) + azappcfg.keyValues = kvSettings + azappcfg.keyVaultRefs = getUnversionedKeyVaultRefs(keyVaultRefs) - if err := eg.Wait(); err != nil { - return err - } + return nil +} + +func (azappcfg *AzureAppConfiguration) loadKeyVaultSecrets(ctx context.Context, keyVaultRefs map[string]string) (map[string]any, error) { + secrets := make(map[string]any) + if len(keyVaultRefs) == 0 { + return secrets, nil + } + + if azappcfg.resolver.credential == nil && azappcfg.resolver.secretResolver == nil { + return secrets, fmt.Errorf("no Key Vault credential or SecretResolver was configured in KeyVaultOptions") + } + + resolvedSecrets := sync.Map{} + var eg errgroup.Group + for key, kvRef := range keyVaultRefs { + key, kvRef := key, kvRef + eg.Go(func() error { + resolvedSecret, err := azappcfg.resolver.resolveSecret(ctx, kvRef) + if err != nil { + return fmt.Errorf("fail to resolve the Key Vault reference '%s': %s", key, err.Error()) + } + resolvedSecrets.Store(key, resolvedSecret) + return nil + }) + } + + if err := eg.Wait(); err != nil { + return secrets, fmt.Errorf("failed to resolve Key Vault references: %w", err) } resolvedSecrets.Range(func(key, value interface{}) bool { - kvSettings[key.(string)] = value.(string) + secrets[key.(string)] = value.(string) return true }) - azappcfg.keyValues = kvSettings - - return nil + return secrets, nil } // refreshKeyValues checks if any watched settings have changed and reloads configuration if needed // Returns true if configuration was actually refreshed, false otherwise func (azappcfg *AzureAppConfiguration) refreshKeyValues(ctx context.Context, refreshClient refreshClient) (bool, error) { + if azappcfg.kvRefreshTimer == nil || + !azappcfg.kvRefreshTimer.ShouldRefresh() { + // Timer not expired, no need to refresh + return false, nil + } + // Check if any ETags have changed eTagChanged, err := refreshClient.monitor.checkIfETagChanged(ctx) if err != nil { @@ -402,6 +443,40 @@ func (azappcfg *AzureAppConfiguration) refreshKeyValues(ctx context.Context, ref return true, nil } +func (azappcfg *AzureAppConfiguration) refreshKeyVaultSecrets(ctx context.Context) (bool, error) { + if azappcfg.secretRefreshTimer == nil || + !azappcfg.secretRefreshTimer.ShouldRefresh() { + // Timer not expired, no need to refresh + return false, nil + } + + if len(azappcfg.keyVaultRefs) == 0 { + azappcfg.secretRefreshTimer.Reset() + return false, nil + } + + unversionedSecrets, err := azappcfg.loadKeyVaultSecrets(ctx, azappcfg.keyVaultRefs) + if err != nil { + return false, fmt.Errorf("failed to reload Key Vault secrets: %w", err) + } + + // Check if any secrets have changed + changed := false + keyValues := make(map[string]any) + maps.Copy(keyValues, azappcfg.keyValues) + for key, newSecret := range unversionedSecrets { + if oldSecret, exists := keyValues[key]; !exists || oldSecret != newSecret { + changed = true + keyValues[key] = newSecret + } + } + + // Reset the timer only after successful refresh + azappcfg.keyValues = keyValues + azappcfg.secretRefreshTimer.Reset() + return changed, nil +} + func (azappcfg *AzureAppConfiguration) trimPrefix(key string) string { result := key for _, prefix := range azappcfg.trimPrefixes { diff --git a/azureappconfiguration/constants.go b/azureappconfiguration/constants.go index 028387d..204be8a 100644 --- a/azureappconfiguration/constants.go +++ b/azureappconfiguration/constants.go @@ -25,4 +25,6 @@ const ( const ( // minimalRefreshInterval is the minimum allowed refresh interval for key-value settings minimalRefreshInterval time.Duration = time.Second + // minimalKeyVaultRefreshInterval is the minimum allowed refresh interval for Key Vault references + minimalKeyVaultRefreshInterval time.Duration = 1 * time.Minute ) diff --git a/azureappconfiguration/internal/tracing/tracing.go b/azureappconfiguration/internal/tracing/tracing.go index fdecf93..f677eb2 100644 --- a/azureappconfiguration/internal/tracing/tracing.go +++ b/azureappconfiguration/internal/tracing/tracing.go @@ -35,6 +35,7 @@ const ( RequestTypeKey = "RequestType" HostTypeKey = "Host" KeyVaultConfiguredTag = "UsesKeyVault" + KeyVaultRefreshConfiguredTag = "RefreshesKeyVault" FeaturesKey = "Features" AIConfigurationTag = "AI" AIChatCompletionConfigurationTag = "AICC" @@ -52,6 +53,7 @@ type Options struct { InitialLoadFinished bool Host HostType KeyVaultConfigured bool + KeyVaultRefreshConfigured bool UseAIConfiguration bool UseAIChatCompletionConfiguration bool } @@ -87,7 +89,10 @@ func CreateCorrelationContextHeader(ctx context.Context, options Options) http.H if options.KeyVaultConfigured { output = append(output, KeyVaultConfiguredTag) + } + if options.KeyVaultRefreshConfigured { + output = append(output, KeyVaultRefreshConfiguredTag) } features := make([]string, 0) diff --git a/azureappconfiguration/internal/tracing/tracing_test.go b/azureappconfiguration/internal/tracing/tracing_test.go index 86336b0..4729901 100644 --- a/azureappconfiguration/internal/tracing/tracing_test.go +++ b/azureappconfiguration/internal/tracing/tracing_test.go @@ -71,6 +71,20 @@ func TestCreateCorrelationContextHeader(t *testing.T) { assert.Contains(t, corrContext, KeyVaultConfiguredTag) }) + t.Run("with KeyVaultRefresh configured", func(t *testing.T) { + ctx := context.Background() + options := Options{ + KeyVaultConfigured: true, + KeyVaultRefreshConfigured: true, + } + + header := CreateCorrelationContextHeader(ctx, options) + + // Should contain KeyVaultRefreshConfiguredTag + corrContext := header.Get(CorrelationContextHeader) + assert.Contains(t, corrContext, KeyVaultRefreshConfiguredTag) + }) + t.Run("with AI configuration", func(t *testing.T) { ctx := context.Background() options := Options{ diff --git a/azureappconfiguration/keyvault.go b/azureappconfiguration/keyvault.go index 5e607ab..b224fed 100644 --- a/azureappconfiguration/keyvault.go +++ b/azureappconfiguration/keyvault.go @@ -140,3 +140,19 @@ func parse(reference string) (*secretMetadata, error) { version: secretVersion, }, nil } + +func getUnversionedKeyVaultRefs(refs map[string]string) map[string]string { + unversionedRefs := make(map[string]string) + for key, value := range refs { + var kvRef keyVaultReference + // If it is an invalid key vault reference, error will be returned when resolveSecret is called + json.Unmarshal([]byte(value), &kvRef) + + // Parse the URI to get metadata (host, secret name, version) + if secretMeta, _ := parse(kvRef.URI); secretMeta != nil && secretMeta.version == "" { + unversionedRefs[key] = value + } + } + + return unversionedRefs +} diff --git a/azureappconfiguration/options.go b/azureappconfiguration/options.go index a8e0d81..9716786 100644 --- a/azureappconfiguration/options.go +++ b/azureappconfiguration/options.go @@ -104,6 +104,19 @@ type KeyVaultOptions struct { // SecretResolver specifies a custom implementation for resolving Key Vault references. // When provided, this takes precedence over using the default resolver with Credential. SecretResolver SecretResolver + + // RefreshOptions specifies the behavior of Key Vault secrets refresh. + // Sets the refresh interval for periodically reloading secrets from Key Vault, must be greater than 1 minute. + RefreshOptions RefreshOptions +} + +// RefreshOptions contains optional parameters to configure the behavior of refresh +type RefreshOptions struct { + // Interval specifies the minimum time interval between consecutive refresh operations + Interval time.Duration + + // Enabled specifies whether the provider should automatically refresh when data is changed. + Enabled bool } // ConstructionOptions contains parameters for parsing keys with hierarchical structure. diff --git a/azureappconfiguration/refresh_test.go b/azureappconfiguration/refresh_test.go index c2ed49d..d8a3ef4 100644 --- a/azureappconfiguration/refresh_test.go +++ b/azureappconfiguration/refresh_test.go @@ -5,7 +5,10 @@ package azureappconfiguration import ( "context" + "encoding/json" "fmt" + "net/url" + "sync" "testing" "time" @@ -54,23 +57,7 @@ func TestRefresh_NotConfigured(t *testing.T) { // Verify that an error is returned require.Error(t, err) - assert.Contains(t, err.Error(), "refresh is not configured") -} - -func TestRefresh_NotTimeToRefresh(t *testing.T) { - // Setup a provider with a timer that indicates it's not time to refresh - mockTimer := &mockRefreshCondition{shouldRefresh: false} - azappcfg := &AzureAppConfiguration{ - kvRefreshTimer: mockTimer, - } - - // Attempt to refresh - err := azappcfg.Refresh(context.Background()) - - // Verify no error and that we returned early - assert.NoError(t, err) - // Timer should not be reset if we're not refreshing - assert.False(t, mockTimer.resetCalled) + assert.Contains(t, err.Error(), "refresh is not enabled for either key values or Key Vault secrets") } func TestRefreshEnabled_EmptyWatchedSettings(t *testing.T) { @@ -231,7 +218,7 @@ func (m *mockKvRefreshClient) getSettings(ctx context.Context) (*settingsRespons // TestRefreshKeyValues_NoChanges tests when no ETags change is detected func TestRefreshKeyValues_NoChanges(t *testing.T) { // Setup mocks - mockTimer := &mockRefreshCondition{} + mockTimer := &mockRefreshCondition{shouldRefresh: true} mockMonitor := &mockETagsClient{changed: false} mockLoader := &mockKvRefreshClient{} mockSentinels := &mockKvRefreshClient{} @@ -262,7 +249,7 @@ func TestRefreshKeyValues_NoChanges(t *testing.T) { // TestRefreshKeyValues_ChangesDetected tests when ETags changed and reload succeeds func TestRefreshKeyValues_ChangesDetected(t *testing.T) { // Setup mocks for successful refresh - mockTimer := &mockRefreshCondition{} + mockTimer := &mockRefreshCondition{shouldRefresh: true} mockMonitor := &mockETagsClient{changed: true} mockLoader := &mockKvRefreshClient{} mockSentinels := &mockKvRefreshClient{} @@ -294,7 +281,7 @@ func TestRefreshKeyValues_ChangesDetected(t *testing.T) { // TestRefreshKeyValues_LoaderError tests when loader client returns an error func TestRefreshKeyValues_LoaderError(t *testing.T) { // Setup mocks with loader error - mockTimer := &mockRefreshCondition{} + mockTimer := &mockRefreshCondition{shouldRefresh: true} mockMonitor := &mockETagsClient{changed: true} mockLoader := &mockKvRefreshClient{err: fmt.Errorf("loader error")} mockSentinels := &mockKvRefreshClient{} @@ -325,7 +312,7 @@ func TestRefreshKeyValues_LoaderError(t *testing.T) { // TestRefreshKeyValues_SentinelError tests when sentinel client returns an error func TestRefreshKeyValues_SentinelError(t *testing.T) { // Setup mocks with sentinel error - mockTimer := &mockRefreshCondition{} + mockTimer := &mockRefreshCondition{shouldRefresh: true} mockMonitor := &mockETagsClient{changed: true} mockLoader := &mockKvRefreshClient{} mockSentinels := &mockKvRefreshClient{err: fmt.Errorf("sentinel error")} @@ -358,7 +345,7 @@ func TestRefreshKeyValues_SentinelError(t *testing.T) { // TestRefreshKeyValues_MonitorError tests when monitor client returns an error func TestRefreshKeyValues_MonitorError(t *testing.T) { // Setup mocks with monitor error - mockTimer := &mockRefreshCondition{} + mockTimer := &mockRefreshCondition{shouldRefresh: true} mockMonitor := &mockETagsClient{err: fmt.Errorf("monitor error")} mockLoader := &mockKvRefreshClient{} mockSentinels := &mockKvRefreshClient{} @@ -403,3 +390,176 @@ func TestRefresh_AlreadyInProgress(t *testing.T) { // Verify no error and that we returned early assert.NoError(t, err) } + +func TestRefreshKeyVaultSecrets_WithMockResolver_Scenarios(t *testing.T) { + // resolutionInstruction defines how a specific Key Vault URI should be resolved by the mock. + type resolutionInstruction struct { + Value string + Err error + } + + tests := []struct { + name string + description string // Optional: for more clarity + + // Initial state for AzureAppConfiguration + initialTimer refresh.Condition + initialKeyVaultRefs map[string]string // map[appConfigKey]jsonURIString -> e.g., {"secretAppKey": `{"uri":"https://mykv.vault.azure.net/secrets/mysecret"}`} + initialKeyValues map[string]any // map[appConfigKey]currentValue + + // Configuration for the mockSecretResolver + // map[actualURIString]resolutionInstruction -> e.g., {"https://mykv.vault.azure.net/secrets/mysecret": {Value: "resolvedValue", Err: nil}} + secretResolutionConfig map[string]resolutionInstruction + + // Expected outcomes + expectedChanged bool + expectedErrSubstring string // Substring of the error expected from refreshKeyVaultSecrets + expectedTimerReset bool + expectedFinalKeyValues map[string]any + }{ + { + name: "Timer is nil", + initialTimer: nil, + initialKeyVaultRefs: map[string]string{"appSecret1": `{"uri":"https://kv.com/s/s1/"}`}, + initialKeyValues: map[string]any{"appSecret1": "oldVal1"}, + expectedChanged: false, + expectedTimerReset: false, + expectedFinalKeyValues: map[string]any{"appSecret1": "oldVal1"}, + }, + { + name: "Timer not expired", + initialTimer: &mockRefreshCondition{shouldRefresh: false}, + initialKeyVaultRefs: map[string]string{"appSecret1": `{"uri":"https://kv.com/s/s1/"}`}, + initialKeyValues: map[string]any{"appSecret1": "oldVal1"}, + expectedChanged: false, + expectedTimerReset: false, + expectedFinalKeyValues: map[string]any{"appSecret1": "oldVal1"}, + }, + { + name: "No keyVaultRefs, timer ready", + initialTimer: &mockRefreshCondition{shouldRefresh: true}, + initialKeyVaultRefs: map[string]string{}, + initialKeyValues: map[string]any{"appKey": "appVal"}, + expectedChanged: false, + expectedTimerReset: true, + expectedFinalKeyValues: map[string]any{"appKey": "appVal"}, + }, + { + name: "Secrets not changed, timer ready", + initialTimer: &mockRefreshCondition{shouldRefresh: true}, + initialKeyVaultRefs: map[string]string{"appSecret1": `{"uri":"https://myvault.vault.azure.net/secrets/s1"}`}, + initialKeyValues: map[string]any{"appSecret1": "currentVal", "appKey": "appVal"}, + secretResolutionConfig: map[string]resolutionInstruction{ + "https://myvault.vault.azure.net/secrets/s1": {Value: "currentVal"}, + }, + expectedChanged: false, + expectedTimerReset: true, + expectedFinalKeyValues: map[string]any{"appSecret1": "currentVal", "appKey": "appVal"}, + }, + { + name: "Secrets changed - existing secret updated, timer ready", + initialTimer: &mockRefreshCondition{shouldRefresh: true}, + initialKeyVaultRefs: map[string]string{"appSecret1": `{"uri":"https://myvault.vault.azure.net/secrets/s1"}`}, + initialKeyValues: map[string]any{"appSecret1": "oldVal1", "appKey": "appVal"}, + secretResolutionConfig: map[string]resolutionInstruction{ + "https://myvault.vault.azure.net/secrets/s1": {Value: "newVal1"}, + }, + expectedChanged: true, + expectedTimerReset: true, + expectedFinalKeyValues: map[string]any{ + "appSecret1": "newVal1", + "appKey": "appVal", + }, + }, + { + name: "Secrets changed - mix of updated, unchanged, timer ready", + initialTimer: &mockRefreshCondition{shouldRefresh: true}, + initialKeyVaultRefs: map[string]string{"s1": `{"uri":"https://myvault.vault.azure.net/secrets/s1"}`, "s3": `{"uri":"https://myvault.vault.azure.net/secrets/s3"}`}, + initialKeyValues: map[string]any{"s1": "oldVal1", "s3": "val3Unchanged", "appKey": "appVal"}, + secretResolutionConfig: map[string]resolutionInstruction{ + "https://myvault.vault.azure.net/secrets/s1": {Value: "newVal1"}, + "https://myvault.vault.azure.net/secrets/s3": {Value: "val3Unchanged"}, + }, + expectedChanged: true, + expectedTimerReset: true, + expectedFinalKeyValues: map[string]any{ + "s1": "newVal1", + "s3": "val3Unchanged", + "appKey": "appVal", + }, + }, + } + + ctx := context.Background() + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup + currentKeyValues := make(map[string]any) + if tc.initialKeyValues != nil { + for k, v := range tc.initialKeyValues { + currentKeyValues[k] = v + } + } + + mockResolver := new(mockSecretResolver) + azappcfg := &AzureAppConfiguration{ + secretRefreshTimer: tc.initialTimer, + keyVaultRefs: tc.initialKeyVaultRefs, + keyValues: currentKeyValues, + resolver: &keyVaultReferenceResolver{ + clients: sync.Map{}, + secretResolver: mockResolver, + }, + } + + if tc.initialKeyVaultRefs != nil && tc.secretResolutionConfig != nil { + for _, jsonRefString := range tc.initialKeyVaultRefs { + var kvRefInternal struct { // Re-declare locally or use the actual keyVaultReference type if accessible + URI string `json:"uri"` + } + err := json.Unmarshal([]byte(jsonRefString), &kvRefInternal) + if err != nil { + continue + } + actualURIString := kvRefInternal.URI + if actualURIString == "" { + continue + } + + if instruction, ok := tc.secretResolutionConfig[actualURIString]; ok { + parsedURL, parseErr := url.Parse(actualURIString) + require.NoError(t, parseErr, "Test setup: Failed to parse URI for mock expectation: %s", actualURIString) + mockResolver.On("ResolveSecret", ctx, *parsedURL).Return(instruction.Value, instruction.Err).Once() + } + } + } + + // Execute + changed, err := azappcfg.refreshKeyVaultSecrets(context.Background()) + + // Assert Error + if tc.expectedErrSubstring != "" { + require.Error(t, err, "Expected an error but got nil") + assert.Contains(t, err.Error(), tc.expectedErrSubstring, "Error message mismatch") + } else { + require.NoError(t, err, "Expected no error but got: %v", err) + } + + // Assert Changed Flag + assert.Equal(t, tc.expectedChanged, changed, "Changed flag mismatch") + + // Assert Timer Reset + if mockTimer, ok := tc.initialTimer.(*mockRefreshCondition); ok { + assert.Equal(t, tc.expectedTimerReset, mockTimer.resetCalled, "Timer reset state mismatch") + } else if tc.initialTimer == nil { + assert.False(t, tc.expectedTimerReset, "Timer was nil, reset should not be expected") + } + + // Assert Final KeyValues + assert.Equal(t, tc.expectedFinalKeyValues, azappcfg.keyValues, "Final keyValues mismatch") + + // Verify mock expectations + mockResolver.AssertExpectations(t) + }) + } +} diff --git a/azureappconfiguration/utils.go b/azureappconfiguration/utils.go index f567360..6bb473a 100644 --- a/azureappconfiguration/utils.go +++ b/azureappconfiguration/utils.go @@ -55,6 +55,13 @@ func verifyOptions(options *Options) error { } } + if options.KeyVaultOptions.RefreshOptions.Enabled { + if options.KeyVaultOptions.RefreshOptions.Interval != 0 && + options.KeyVaultOptions.RefreshOptions.Interval < minimalRefreshInterval { + return fmt.Errorf("refresh interval of Key Vault secrets cannot be less than %s", minimalKeyVaultRefreshInterval) + } + } + return nil } From 8f55a4deb2f041d4ae08ff7431fed5b092eb52e3 Mon Sep 17 00:00:00 2001 From: linglingye001 <143174321+linglingye001@users.noreply.github.com> Date: Thu, 15 May 2025 09:12:34 +0800 Subject: [PATCH 4/7] Add README in package folder (#24) --- azureappconfiguration/README.md | 44 +++++++++++++++++++ .../azureappconfiguration.go | 34 +++++++------- azureappconfiguration/options.go | 8 ++-- 3 files changed, 65 insertions(+), 21 deletions(-) create mode 100644 azureappconfiguration/README.md diff --git a/azureappconfiguration/README.md b/azureappconfiguration/README.md new file mode 100644 index 0000000..41f33e3 --- /dev/null +++ b/azureappconfiguration/README.md @@ -0,0 +1,44 @@ +# Azure App Configuration - Go Provider + +[](https://pkg.go.dev/github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration) + +## Overview + +[Azure App Configuration](https://docs.microsoft.com/azure/azure-app-configuration/overview) provides centralized configuration storage and management, allowing users to update their configurations without the need to rebuild and redeploy their applications. The App Configuration provider for Go is built on top of the [Azure Go SDK](https://pkg.go.dev/github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig) and is designed to simplify data consumption in App Configuration with rich features. Users can consume App Configuration key-values as strongly-typed structs with data binding or load them into popular third-party configuration libraries, minimizing code changes. The Go provider offers features such as configuration composition from multiple labels, key prefix trimming, automatic resolution of Key Vault references, feature flags, failover with geo-replication for enhanced reliability, and many more. + +## Installation + +```bash +go get github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration +``` + +## Examples + +- [Console Application](../example/console-example/): Load settings from Azure App Configuration and use in a console application. +- [Web Application](../example/gin-example/): Load settings from Azure App Configuration and use in a Gin web application. + +## Data Collection + +The software may collect information about you and your use of the software and send it to Microsoft. Microsoft may use this information to provide services and improve our products and services. You may turn off the telemetry by setting the environment variable `AZURE_APP_CONFIGURATION_TRACING_DISABLED` to `TRUE`. There are also some features in the software that may enable you and Microsoft to collect data from users of your applications. If you use these features, you must comply with applicable law, including providing appropriate notices to users of your applications together with a copy of Microsoft’s privacy statement. Our privacy statement is located at https://go.microsoft.com/fwlink/?LinkID=824704. You can learn more about data collection and use in the help documentation and our privacy statement. Your use of the software operates as your consent to these practices. + +## Contributing + +This project welcomes contributions and suggestions. Most contributions require you to agree to a +Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us +the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com. + +When you submit a pull request, a CLA bot will automatically determine whether you need to provide +a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions +provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). +For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or +contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. + +## Trademarks + +This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft +trademarks or logos is subject to and must follow +[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). +Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. +Any use of third-party trademarks or logos are subject to those third-party's policies. diff --git a/azureappconfiguration/azureappconfiguration.go b/azureappconfiguration/azureappconfiguration.go index 84f3ed0..a6228ef 100644 --- a/azureappconfiguration/azureappconfiguration.go +++ b/azureappconfiguration/azureappconfiguration.go @@ -58,13 +58,13 @@ type AzureAppConfiguration struct { // Azure App Configuration service. // // Parameters: -// - ctx: The context for the operation. -// - authentication: Authentication options for connecting to the Azure App Configuration service -// - options: Configuration options to customize behavior, such as key filters and prefix trimming +// - ctx: The context for the operation. +// - authentication: Authentication options for connecting to the Azure App Configuration service +// - options: Configuration options to customize behavior, such as key filters and prefix trimming // // Returns: -// - A configured AzureAppConfiguration instance that provides access to the loaded configuration data -// - An error if the operation fails, such as authentication errors or connectivity issues +// - A configured AzureAppConfiguration instance that provides access to the loaded configuration data +// - An error if the operation fails, such as authentication errors or connectivity issues func Load(ctx context.Context, authentication AuthenticationOptions, options *Options) (*AzureAppConfiguration, error) { if err := verifyAuthenticationOptions(authentication); err != nil { return nil, err @@ -123,11 +123,11 @@ func Load(ctx context.Context, authentication AuthenticationOptions, options *Op // For custom field mapping, use json struct tags. // // Parameters: -// - v: A pointer to the struct to populate with configuration values -// - options: Optional parameters (e,g, separator) for controlling the unmarshalling behavior +// - v: A pointer to the struct to populate with configuration values +// - options: Optional parameters (e,g, separator) for controlling the unmarshalling behavior // // Returns: -// - An error if unmarshalling fails due to type conversion issues or invalid configuration +// - An error if unmarshalling fails due to type conversion issues or invalid configuration func (azappcfg *AzureAppConfiguration) Unmarshal(v any, options *ConstructionOptions) error { if options == nil || options.Separator == "" { options = &ConstructionOptions{ @@ -162,11 +162,11 @@ func (azappcfg *AzureAppConfiguration) Unmarshal(v any, options *ConstructionOpt // This method is particularly useful for integrating with "encoding/json" package or third-party configuration packages like Viper or Koanf. // // Parameters: -// - options: Optional parameters for controlling JSON construction, particularly the key separator +// - options: Optional parameters for controlling JSON construction, particularly the key separator // // Returns: -// - A byte array containing the JSON representation of the configuration -// - An error if JSON marshalling fails or if an invalid separator is specified +// - A byte array containing the JSON representation of the configuration +// - An error if JSON marshalling fails or if an invalid separator is specified func (azappcfg *AzureAppConfiguration) GetBytes(options *ConstructionOptions) ([]byte, error) { if options == nil || options.Separator == "" { options = &ConstructionOptions{ @@ -186,17 +186,17 @@ func (azappcfg *AzureAppConfiguration) GetBytes(options *ConstructionOptions) ([ // It checks if any watched settings have changed, and if so, reloads all configuration data. // // The refresh only occurs if: -// - Refresh has been configured with RefreshOptions when the client was created -// - The configured refresh interval has elapsed since the last refresh -// - No other refresh operation is currently in progress +// - Refresh has been configured with RefreshOptions when the client was created +// - The configured refresh interval has elapsed since the last refresh +// - No other refresh operation is currently in progress // // If the configuration has changed, any callback functions registered with OnRefreshSuccess will be executed. // // Parameters: -// - ctx: The context for the operation. +// - ctx: The context for the operation. // // Returns: -// - An error if refresh is not configured, or if the refresh operation fails +// - An error if refresh is not configured, or if the refresh operation fails func (azappcfg *AzureAppConfiguration) Refresh(ctx context.Context) error { if azappcfg.kvRefreshTimer == nil && azappcfg.secretRefreshTimer == nil { return fmt.Errorf("refresh is not enabled for either key values or Key Vault secrets") @@ -246,7 +246,7 @@ func (azappcfg *AzureAppConfiguration) Refresh(ctx context.Context) error { // in the thread that initiated the refresh. // // Parameters: -// - callback: A function with no parameters that will be called after a successful refresh +// - callback: A function with no parameters that will be called after a successful refresh func (azappcfg *AzureAppConfiguration) OnRefreshSuccess(callback func()) { azappcfg.onRefreshSuccess = append(azappcfg.onRefreshSuccess, callback) } diff --git a/azureappconfiguration/options.go b/azureappconfiguration/options.go index 9716786..595e46d 100644 --- a/azureappconfiguration/options.go +++ b/azureappconfiguration/options.go @@ -85,12 +85,12 @@ type SecretResolver interface { // ResolveSecret resolves a Key Vault reference URL to the actual secret value. // // Parameters: - // - ctx: The context for the operation - // - keyVaultReference: A URL in the format "https://{keyVaultName}.vault.azure.net/secrets/{secretName}/{secretVersion}" + // - ctx: The context for the operation + // - keyVaultReference: A URL in the format "https://{keyVaultName}.vault.azure.net/secrets/{secretName}/{secretVersion}" // // Returns: - // - The resolved secret value as a string - // - An error if the secret could not be resolved + // - The resolved secret value as a string + // - An error if the secret could not be resolved ResolveSecret(ctx context.Context, keyVaultReference url.URL) (string, error) } From a1bd37ab18d3ee1648353aa9ab25f1abca11607d Mon Sep 17 00:00:00 2001 From: linglingye001 <143174321+linglingye001@users.noreply.github.com> Date: Fri, 16 May 2025 10:26:34 +0800 Subject: [PATCH 5/7] WatchAll refresh support (#23) * pageETage based refresh support * update dependencies * update * add tests * udpate --- .../azureappconfiguration.go | 34 +++++- azureappconfiguration/go.mod | 12 +- azureappconfiguration/go.sum | 36 +++--- azureappconfiguration/options.go | 3 +- azureappconfiguration/refresh_test.go | 111 +++++++++++++++--- azureappconfiguration/settings_client.go | 57 ++++++++- azureappconfiguration/utils.go | 4 - 7 files changed, 206 insertions(+), 51 deletions(-) diff --git a/azureappconfiguration/azureappconfiguration.go b/azureappconfiguration/azureappconfiguration.go index a6228ef..387366d 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 595e46d..2f0e300 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") From 8be190c28b094f43990df4ccc48bdebdf21c54c9 Mon Sep 17 00:00:00 2001 From: linglingye001 <143174321+linglingye001@users.noreply.github.com> Date: Mon, 26 May 2025 09:47:05 +0800 Subject: [PATCH 6/7] Add refresh example apps (#25) --- example/console-example-refresh/README.md | 56 ++++++ example/console-example-refresh/go.mod | 19 ++ example/console-example-refresh/go.sum | 44 +++++ example/console-example-refresh/main.go | 137 +++++++++++++ example/gin-example-refresh/README.md | 58 ++++++ example/gin-example-refresh/go.mod | 46 +++++ example/gin-example-refresh/go.sum | 124 ++++++++++++ example/gin-example-refresh/main.go | 183 ++++++++++++++++++ .../gin-example-refresh/templates/about.html | 38 ++++ .../gin-example-refresh/templates/index.html | 38 ++++ example/gin-example/main.go | 1 - 11 files changed, 743 insertions(+), 1 deletion(-) create mode 100644 example/console-example-refresh/README.md create mode 100644 example/console-example-refresh/go.mod create mode 100644 example/console-example-refresh/go.sum create mode 100644 example/console-example-refresh/main.go create mode 100644 example/gin-example-refresh/README.md create mode 100644 example/gin-example-refresh/go.mod create mode 100644 example/gin-example-refresh/go.sum create mode 100644 example/gin-example-refresh/main.go create mode 100644 example/gin-example-refresh/templates/about.html create mode 100644 example/gin-example-refresh/templates/index.html diff --git a/example/console-example-refresh/README.md b/example/console-example-refresh/README.md new file mode 100644 index 0000000..d8b143f --- /dev/null +++ b/example/console-example-refresh/README.md @@ -0,0 +1,56 @@ +# Azure App Configuration Console Refresh Example + +This example demonstrates how to use the refresh functionality of Azure App Configuration in a console/command-line application. + +## Overview + +This console application: + +1. Loads configuration values from Azure App Configuration +2. Binds them to target configuration struct +3. Automatically refreshes the configuration when changed in Azure App Configuration + +## Running the Example + +### Prerequisites + +You need [an Azure subscription](https://azure.microsoft.com/free/) and the following Azure resources to run the examples: + +- [Azure App Configuration store](https://learn.microsoft.com/en-us/azure/azure-app-configuration/quickstart-azure-app-configuration-create?tabs=azure-portal) + +The examples retrieve credentials to access your App Configuration store from environment variables. + +### Add key-values + +Add the following key-values to the App Configuration store and leave **Label** and **Content Type** with their default values: + +| Key | Value | +|------------------------|----------------| +| *Config.Message* | *Hello World!* | +| *Config.Font.Color* | *blue* | +| *Config.Font.Size* | *12* | + +### Setup + +Set the connection string as an environment variable: + +```bash +# Windows +set AZURE_APPCONFIG_CONNECTION_STRING=your-connection-string + +# Linux/macOS +export AZURE_APPCONFIG_CONNECTION_STRING=your-connection-string +``` + +### Run the Application + +```bash +go run main.go +``` + +### Testing the Refresh Functionality + +1. Start the application +2. While it's running, modify the values in your Azure App Configuration store +3. Within 10 seconds (the configured refresh interval), the application should detect and apply the changes +4. You don't need to restart the application to see the updated values diff --git a/example/console-example-refresh/go.mod b/example/console-example-refresh/go.mod new file mode 100644 index 0000000..0548ffa --- /dev/null +++ b/example/console-example-refresh/go.mod @@ -0,0 +1,19 @@ +module console-example-refresh + +go 1.23.2 + +require github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration v0.0.0-00010101000000-000000000000 + +require ( + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig v1.2.0 // indirect + 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 // indirect + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/text v0.24.0 // indirect +) + +replace github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration => ..\..\azureappconfiguration diff --git a/example/console-example-refresh/go.sum b/example/console-example-refresh/go.sum new file mode 100644 index 0000000..37ed2d9 --- /dev/null +++ b/example/console-example-refresh/go.sum @@ -0,0 +1,44 @@ +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= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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.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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/example/console-example-refresh/main.go b/example/console-example-refresh/main.go new file mode 100644 index 0000000..897fa74 --- /dev/null +++ b/example/console-example-refresh/main.go @@ -0,0 +1,137 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + "syscall" + "time" + + "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration" +) + +type Config struct { + Font Font + Message string +} + +type Font struct { + Color string + Size int +} + +// initializeAppConfiguration handles loading the configuration from Azure App Configuration +func initializeAppConfiguration() (*azureappconfiguration.AzureAppConfiguration, error) { + // Get connection string from environment variable + connectionString := os.Getenv("AZURE_APPCONFIG_CONNECTION_STRING") + + // Options setup + options := &azureappconfiguration.Options{ + Selectors: []azureappconfiguration.Selector{ + { + KeyFilter: "Config.*", + }, + }, + // Remove the prefix when mapping to struct fields + TrimKeyPrefixes: []string{"Config."}, + // Enable refresh every 10 seconds + RefreshOptions: azureappconfiguration.KeyValueRefreshOptions{ + Enabled: true, + Interval: 10 * time.Second, + }, + } + + authOptions := azureappconfiguration.AuthenticationOptions{ + ConnectionString: connectionString, + } + + // Create configuration provider with timeout + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + return azureappconfiguration.Load(ctx, authOptions, options) +} + +// displayConfig prints the current configuration values +func displayConfig(config Config) { + fmt.Println("\nCurrent Configuration Values:") + fmt.Println("--------------------") + fmt.Printf("Font Color: %s\n", config.Font.Color) + fmt.Printf("Font Size: %d\n", config.Font.Size) + fmt.Printf("Message: %s\n", config.Message) + fmt.Println("--------------------") +} + +func main() { + fmt.Println("Azure App Configuration - Console Refresh Example") + fmt.Println("----------------------------------------") + + // Load configuration + fmt.Println("Loading configuration from Azure App Configuration...") + appCfgProvider, err := initializeAppConfiguration() + if err != nil { + log.Fatalf("Error loading configuration: %s", err) + } + + // Parse initial configuration into struct + var config Config + err = appCfgProvider.Unmarshal(&config, nil) + if err != nil { + log.Fatalf("Error unmarshalling configuration: %s", err) + } + + // Display the initial configuration + displayConfig(config) + + // Register refresh callback to update and display the configuration + appCfgProvider.OnRefreshSuccess(func() { + fmt.Println("\n🔄 Configuration changed! Updating values...") + + // Re-unmarshal the configuration + var updatedConfig Config + err := appCfgProvider.Unmarshal(&updatedConfig, nil) + if err != nil { + log.Printf("Error unmarshalling updated configuration: %s", err) + return + } + + // Update our working config + config = updatedConfig + + // Display the updated configuration + displayConfig(config) + }) + + // Setup a channel to listen for termination signals + done := make(chan os.Signal, 1) + signal.Notify(done, syscall.SIGINT, syscall.SIGTERM) + + fmt.Println("\nWaiting for configuration changes...") + fmt.Println("(Update values in Azure App Configuration to see refresh in action)") + fmt.Println("Press Ctrl+C to exit") + + // Start a ticker to periodically trigger refresh + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + // Keep the application running until terminated + for { + select { + case <-ticker.C: + // Trigger refresh in background + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := appCfgProvider.Refresh(ctx); err != nil { + log.Printf("Error refreshing configuration: %s", err) + } + }() + case <-done: + fmt.Println("\nExiting...") + return + } + } +} diff --git a/example/gin-example-refresh/README.md b/example/gin-example-refresh/README.md new file mode 100644 index 0000000..af55919 --- /dev/null +++ b/example/gin-example-refresh/README.md @@ -0,0 +1,58 @@ +# Azure App Configuration Gin Web Refresh Example + +This example demonstrates how to use the refresh functionality of Azure App Configuration in a web application built with the Gin framework. + +## Overview + +This web application: + +1. Loads configuration values from Azure App Configuration +2. Configures the Gin web framework based on those values +3. Automatically refreshes the configuration when changed in Azure App Configuration + +## Running the Example + +### Prerequisites + +You need [an Azure subscription](https://azure.microsoft.com/free/) and the following Azure resources to run the examples: + +- [Azure App Configuration store](https://learn.microsoft.com/en-us/azure/azure-app-configuration/quickstart-azure-app-configuration-create?tabs=azure-portal) + +The examples retrieve credentials to access your App Configuration store from environment variables. + +### Add key-values + +Add the following key-values to the App Configuration store and leave **Label** and **Content Type** with their default values: + +| Key | Value | +|------------------------|--------------------| +| *Config.Message* | *Hello World!* | +| *Config.App.Name* | *Gin Web App* | +| *Config.App.DebugMode* | *true* | + +### Setup + +Set the connection string as an environment variable: + +```bash +# Windows +set AZURE_APPCONFIG_CONNECTION_STRING=your-connection-string + +# Linux/macOS +export AZURE_APPCONFIG_CONNECTION_STRING=your-connection-string +``` + +### Run the Application + +```bash +go run main.go +``` + +Then navigate to `http://localhost:8080` in your web browser. + +### Testing the Refresh Functionality + +1. Start the application +2. While it's running, modify the values in your Azure App Configuration store +3. Within 10 seconds (the configured refresh interval), the application should detect and apply the changes +4. Refresh your browser to see the updated values diff --git a/example/gin-example-refresh/go.mod b/example/gin-example-refresh/go.mod new file mode 100644 index 0000000..54dd10b --- /dev/null +++ b/example/gin-example-refresh/go.mod @@ -0,0 +1,46 @@ +module web-app-refresh + +go 1.23.2 + +require ( + github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration v0.0.0-00010101000000-000000000000 + github.com/gin-gonic/gin v1.10.0 +) + +require ( + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig v1.2.0 // indirect + 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 // indirect + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 // indirect + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.24.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration => ..\..\azureappconfiguration diff --git a/example/gin-example-refresh/go.sum b/example/gin-example-refresh/go.sum new file mode 100644 index 0000000..101a66b --- /dev/null +++ b/example/gin-example-refresh/go.sum @@ -0,0 +1,124 @@ +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= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +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= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/example/gin-example-refresh/main.go b/example/gin-example-refresh/main.go new file mode 100644 index 0000000..919cdc7 --- /dev/null +++ b/example/gin-example-refresh/main.go @@ -0,0 +1,183 @@ +package main + +import ( + "context" + "log" + "os" + "sync" + "time" + + "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration" + "github.com/gin-gonic/gin" +) + +type Config struct { + App App + Message string +} + +type App struct { + Name string + DebugMode bool +} + +// Global configuration that will be updated on refresh +var ( + config Config + configLock sync.RWMutex +) + +// loadConfiguration handles loading the configuration from Azure App Configuration +func loadConfiguration() (*azureappconfiguration.AzureAppConfiguration, error) { + // Get connection string from environment variable + connectionString := os.Getenv("AZURE_APPCONFIG_CONNECTION_STRING") + + // Options setup + options := &azureappconfiguration.Options{ + Selectors: []azureappconfiguration.Selector{ + { + KeyFilter: "Config.*", + }, + }, + // Remove the prefix when mapping to struct fields + TrimKeyPrefixes: []string{"Config."}, + // Enable refresh every 10 seconds + RefreshOptions: azureappconfiguration.KeyValueRefreshOptions{ + Enabled: true, + Interval: 10 * time.Second, + }, + } + + authOptions := azureappconfiguration.AuthenticationOptions{ + ConnectionString: connectionString, + } + + // Create configuration provider with timeout + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + return azureappconfiguration.Load(ctx, authOptions, options) +} + +// updateConfig safely updates the global configuration +func updateConfig(newConfig Config) { + configLock.Lock() + defer configLock.Unlock() + config = newConfig +} + +// getConfig safely retrieves the global configuration +func getConfig() Config { + configLock.RLock() + defer configLock.RUnlock() + return config +} + +// configRefreshMiddleware is a Gin middleware that attempts to refresh configuration on incoming requests +func configRefreshMiddleware(appCfgProvider *azureappconfiguration.AzureAppConfiguration) gin.HandlerFunc { + return func(c *gin.Context) { + // Start refresh in a goroutine to avoid blocking the request + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := appCfgProvider.Refresh(ctx); err != nil { + // Just log the error, don't interrupt request processing + log.Printf("Error refreshing configuration: %s", err) + } + }() + + // Continue processing the request + c.Next() + } +} + +// setupRouter creates and configures the Gin router +func setupRouter(appCfgProvider *azureappconfiguration.AzureAppConfiguration) *gin.Engine { + // Get the current config + currentConfig := getConfig() + + if currentConfig.App.DebugMode { + // Set Gin to debug mode + gin.SetMode(gin.DebugMode) + log.Println("Running in DEBUG mode") + } else { + // Set Gin to release mode for production + gin.SetMode(gin.ReleaseMode) + log.Println("Running in RELEASE mode") + } + + // Initialize Gin router + r := gin.Default() + + // Apply our configuration refresh middleware to all routes + r.Use(configRefreshMiddleware(appCfgProvider)) + + // Load HTML templates + r.LoadHTMLGlob("templates/*") + + // Define a route for the homepage + r.GET("/", func(c *gin.Context) { + // Get the latest config for each request + currentConfig := getConfig() + c.HTML(200, "index.html", gin.H{ + "Title": "Home", + "Message": currentConfig.Message, + "App": currentConfig.App.Name, + }) + }) + + // Define a route for the About page + r.GET("/about", func(c *gin.Context) { + c.HTML(200, "about.html", gin.H{ + "Title": "About", + }) + }) + + return r +} + +func main() { + // Load initial configuration + appCfgProvider, err := loadConfiguration() + if err != nil { + log.Fatalf("Error loading configuration: %s", err) + } + + // Parse configuration into struct and update the global config + var initialConfig Config + err = appCfgProvider.Unmarshal(&initialConfig, nil) + if err != nil { + log.Fatalf("Error unmarshalling configuration: %s", err) + } + updateConfig(initialConfig) + + // Register refresh callback + appCfgProvider.OnRefreshSuccess(func() { + log.Println("Configuration changed! Updating values...") + + // Re-unmarshal the configuration + var updatedConfig Config + err := appCfgProvider.Unmarshal(&updatedConfig, nil) + if err != nil { + log.Printf("Error unmarshalling updated configuration: %s", err) + return + } + + // Update our working config + updateConfig(updatedConfig) + + // Log the changes + log.Printf("Updated configuration: Message=%s, App.Name=%s, App.DebugMode=%v", + updatedConfig.Message, updatedConfig.App.Name, updatedConfig.App.DebugMode) + }) + + // Setup the router with refresh middleware + r := setupRouter(appCfgProvider) + + // Start the server + log.Println("Starting server on :8080") + if err := r.Run(":8080"); err != nil { + log.Fatalf("Error starting server: %s", err) + } +} diff --git a/example/gin-example-refresh/templates/about.html b/example/gin-example-refresh/templates/about.html new file mode 100644 index 0000000..4cac910 --- /dev/null +++ b/example/gin-example-refresh/templates/about.html @@ -0,0 +1,38 @@ + + +
+ + +This is the about page where you can add information about your website or app.
+{{.App}}
+