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/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 2cd4c14..387366d 100644 --- a/azureappconfiguration/azureappconfiguration.go +++ b/azureappconfiguration/azureappconfiguration.go @@ -17,41 +17,69 @@ import ( "encoding/json" "fmt" "log" - "regexp" + "maps" + "os" + "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" + "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 - kvSelectors []Selector - trimPrefixes []string - + // 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 + + refreshInProgress atomic.Bool } // Load initializes a new AzureAppConfiguration instance and loads the configuration data from // 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 } + if err := verifyOptions(options); err != nil { + return nil, err + } + if options == nil { options = &Options{} } @@ -62,6 +90,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 @@ -72,25 +101,43 @@ 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) + azappcfg.pageETags = make(map[Selector][]*azcore.ETag) + if len(options.RefreshOptions.WatchedSettings) == 0 { + azappcfg.watchAll = true + } + } + + 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 } + // Set the initial load finished flag + azappcfg.tracingOptions.InitialLoadFinished = true 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. // 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{ @@ -121,15 +168,15 @@ 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: -// - 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{ @@ -145,13 +192,112 @@ 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 && 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 + 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) + + // Attempt to refresh and check if any values were actually updated + 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 keyValueRefreshed || secretRefreshed { + 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, + 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 + } + + // Store ETags for all watched settings + if settingsResponse != nil && settingsResponse.watchedETags != nil { + azappcfg.sentinelETags = settingsResponse.watchedETags } - return azappcfg.loadKeyValues(ctx, keyValuesClient) + return nil } func (azappcfg *AzureAppConfiguration) loadKeyValues(ctx context.Context, settingsClient settingsClient) error { @@ -160,8 +306,8 @@ func (azappcfg *AzureAppConfiguration) loadKeyValues(ctx context.Context, settin return err } - 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 @@ -171,13 +317,19 @@ 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 } - switch *setting.ContentType { + switch strings.TrimSpace(strings.ToLower(*setting.ContentType)) { case featureFlagContentType: continue // ignore feature flag while getting key value settings case secretReferenceContentType: @@ -190,44 +342,150 @@ 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 } } } - 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") - } + azappcfg.tracingOptions.UseAIConfiguration = useAIConfiguration + azappcfg.tracingOptions.UseAIChatCompletionConfiguration = useAIChatCompletionConfiguration - 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 - }) - } + secrets, err := azappcfg.loadKeyVaultSecrets(ctx, keyVaultRefs) + if err != nil { + return fmt.Errorf("failed to load Key Vault secrets: %w", err) + } - if err := eg.Wait(); err != nil { - return err - } + maps.Copy(kvSettings, secrets) + azappcfg.keyValues = kvSettings + azappcfg.keyVaultRefs = getUnversionedKeyVaultRefs(keyVaultRefs) + azappcfg.pageETags = settingsResponse.pageETags + + 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 secrets, nil +} - 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) { + 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 { + return false, fmt.Errorf("failed to check if key value 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) 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 { @@ -242,15 +500,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 +544,71 @@ 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 +} + +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 { + 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, + 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, + tracingOptions: azappcfg.tracingOptions, + }, + } +} 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/constants.go b/azureappconfiguration/constants.go index 608489f..204be8a 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,11 @@ 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 + // minimalKeyVaultRefreshInterval is the minimum allowed refresh interval for Key Vault references + minimalKeyVaultRefreshInterval time.Duration = 1 * time.Minute +) diff --git a/azureappconfiguration/go.mod b/azureappconfiguration/go.mod index 702cfb8..6ba3318 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.40.0 // indirect + golang.org/x/sync v0.14.0 + golang.org/x/text v0.25.0 // indirect ) diff --git a/azureappconfiguration/go.sum b/azureappconfiguration/go.sum index ab81167..c04e7af 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.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.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.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 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/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 new file mode 100644 index 0000000..f677eb2 --- /dev/null +++ b/azureappconfiguration/internal/tracing/tracing.go @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package tracing + +import ( + "context" + "net/http" + "os" + "strings" +) + +type RequestType string +type RequestTracingKey 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" + + RequestTypeKey = "RequestType" + HostTypeKey = "Host" + KeyVaultConfiguredTag = "UsesKeyVault" + KeyVaultRefreshConfiguredTag = "RefreshesKeyVault" + 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 + InitialLoadFinished bool + Host HostType + KeyVaultConfigured bool + KeyVaultRefreshConfigured 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 !options.InitialLoadFinished { + output = append(output, RequestTypeKey+"="+string(RequestTypeStartUp)) + } else { + output = append(output, RequestTypeKey+"="+string(RequestTypeWatch)) + } + + if options.Host != "" { + output = append(output, HostTypeKey+"="+string(options.Host)) + } + + if options.KeyVaultConfigured { + output = append(output, KeyVaultConfiguredTag) + } + + if options.KeyVaultRefreshConfigured { + output = append(output, KeyVaultRefreshConfiguredTag) + } + + 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..4729901 --- /dev/null +++ b/azureappconfiguration/internal/tracing/tracing_test.go @@ -0,0 +1,198 @@ +// 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, "RequestType=StartUp", corrContext) + }) + + t.Run("with RequestTypeStartUp", func(t *testing.T) { + options := Options{} + + header := CreateCorrelationContextHeader(context.Background(), options) + + // Should contain RequestTypeStartUp + corrContext := header.Get(CorrelationContextHeader) + assert.Contains(t, corrContext, RequestTypeKey+"="+string(RequestTypeStartUp)) + }) + + t.Run("with RequestTypeWatch", func(t *testing.T) { + options := Options{ + InitialLoadFinished: true, + } + + header := CreateCorrelationContextHeader(context.Background(), 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 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{ + 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) { + options := Options{ + Host: HostTypeAzureFunction, + KeyVaultConfigured: true, + UseAIConfiguration: true, + UseAIChatCompletionConfiguration: true, + } + + header := CreateCorrelationContextHeader(context.Background(), 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) { + options := Options{ + Host: HostTypeAzureWebApp, + KeyVaultConfigured: true, + } + + header := CreateCorrelationContextHeader(context.Background(), 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/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 f3f5371..2f0e300 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,25 +22,28 @@ 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. // 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 + 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 +53,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,18 +61,37 @@ 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 { // 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) } @@ -82,6 +105,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 new file mode 100644 index 0000000..e4bdb1d --- /dev/null +++ b/azureappconfiguration/refresh_test.go @@ -0,0 +1,646 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package azureappconfiguration + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "sync" + "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/mock" + "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 enabled for either key values or Key Vault secrets") +} + +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{shouldRefresh: true} + 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{shouldRefresh: true} + 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{shouldRefresh: true} + 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{shouldRefresh: true} + 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{shouldRefresh: true} + 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) +} + +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) + }) + } +} + +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 463340c..e10235a 100644 --- a/azureappconfiguration/settings_client.go +++ b/azureappconfiguration/settings_client.go @@ -5,27 +5,62 @@ 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 + pageETags map[Selector][]*azcore.ETag } type selectorSettingsClient struct { - selectors []Selector - client *azappconfig.Client + selectors []Selector + client *azappconfig.Client + tracingOptions tracing.Options +} + +type watchedSettingClient struct { + watchedSettings []WatchedSetting + eTags map[WatchedSetting]*azcore.ETag + client *azappconfig.Client + 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) } +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)) + } + settings := make([]azappconfig.Setting, 0) + pageETags := make(map[Selector][]*azcore.ETag) for _, filter := range s.selectors { selector := azappconfig.SettingSelector{ KeyFilter: to.Ptr(filter.KeyFilter), @@ -34,17 +69,119 @@ 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, + pageETags: pageETags, + }, 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, + 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 +} + +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 36672b0..104b9fb 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 { @@ -26,6 +29,35 @@ 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) + } + + 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 ','") + } + } + } + + 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 } @@ -65,3 +97,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 +} diff --git a/azureappconfiguration/version.go b/azureappconfiguration/version.go index 9b6b4ad..0f6c447 100644 --- a/azureappconfiguration/version.go +++ b/azureappconfiguration/version.go @@ -5,5 +5,5 @@ package azureappconfiguration const ( moduleName = "azcfg-go" - moduleVersion = "1.0.0-beta.1" + moduleVersion = "1.0.0-beta.2" ) 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}}
+