diff --git a/azureappconfiguration/azureappconfiguration.go b/azureappconfiguration/azureappconfiguration.go index 98ed36e..7c534e0 100644 --- a/azureappconfiguration/azureappconfiguration.go +++ b/azureappconfiguration/azureappconfiguration.go @@ -36,20 +36,25 @@ import ( // An AzureAppConfiguration is a configuration provider that stores and manages settings sourced from Azure App Configuration. type AzureAppConfiguration struct { // Settings loaded from Azure App Configuration - keyValues map[string]any + keyValues map[string]any + featureFlags map[string]any // Settings configured from Options kvSelectors []Selector + ffEnabled bool + ffSelectors []Selector trimPrefixes []string watchedSettings []WatchedSetting // Settings used for refresh scenarios sentinelETags map[WatchedSetting]*azcore.ETag watchAll bool - pageETags map[Selector][]*azcore.ETag + kvETags map[Selector][]*azcore.ETag + ffETags map[Selector][]*azcore.ETag keyVaultRefs map[string]string // unversioned Key Vault references kvRefreshTimer refresh.Condition secretRefreshTimer refresh.Condition + ffRefreshTimer refresh.Condition onRefreshSuccess []func() tracingOptions tracing.Options @@ -92,7 +97,10 @@ func Load(ctx context.Context, authentication AuthenticationOptions, options *Op azappcfg := new(AzureAppConfiguration) azappcfg.tracingOptions = configureTracingOptions(options) azappcfg.keyValues = make(map[string]any) + azappcfg.featureFlags = make(map[string]any) azappcfg.kvSelectors = deduplicateSelectors(options.Selectors) + azappcfg.ffEnabled = options.FeatureFlagOptions.Enabled + azappcfg.trimPrefixes = options.TrimKeyPrefixes azappcfg.clientManager = clientManager azappcfg.resolver = &keyVaultReferenceResolver{ @@ -105,7 +113,7 @@ func Load(ctx context.Context, authentication AuthenticationOptions, options *Op azappcfg.kvRefreshTimer = refresh.NewTimer(options.RefreshOptions.Interval) azappcfg.watchedSettings = normalizedWatchedSettings(options.RefreshOptions.WatchedSettings) azappcfg.sentinelETags = make(map[WatchedSetting]*azcore.ETag) - azappcfg.pageETags = make(map[Selector][]*azcore.ETag) + azappcfg.kvETags = make(map[Selector][]*azcore.ETag) if len(options.RefreshOptions.WatchedSettings) == 0 { azappcfg.watchAll = true } @@ -117,6 +125,14 @@ func Load(ctx context.Context, authentication AuthenticationOptions, options *Op azappcfg.tracingOptions.KeyVaultRefreshConfigured = true } + if azappcfg.ffEnabled { + azappcfg.ffSelectors = getFeatureFlagSelectors(deduplicateSelectors(options.FeatureFlagOptions.Selectors)) + if options.FeatureFlagOptions.RefreshOptions.Enabled { + azappcfg.ffRefreshTimer = refresh.NewTimer(options.FeatureFlagOptions.RefreshOptions.Interval) + azappcfg.ffETags = make(map[Selector][]*azcore.ETag) + } + } + if err := azappcfg.load(ctx); err != nil { return nil, err } @@ -208,8 +224,8 @@ func (azappcfg *AzureAppConfiguration) GetBytes(options *ConstructionOptions) ([ // Returns: // - An error if refresh is not configured, or if the refresh operation fails func (azappcfg *AzureAppConfiguration) Refresh(ctx context.Context) error { - if azappcfg.kvRefreshTimer == nil && azappcfg.secretRefreshTimer == nil { - return fmt.Errorf("refresh is not enabled for either key values or Key Vault secrets") + if azappcfg.kvRefreshTimer == nil && azappcfg.secretRefreshTimer == nil && azappcfg.ffRefreshTimer == nil { + return fmt.Errorf("refresh is not configured for key values, Key Vault secrets, or feature flags") } // Try to set refreshInProgress to true, returning false if it was already true @@ -236,8 +252,13 @@ func (azappcfg *AzureAppConfiguration) Refresh(ctx context.Context) error { } } + featureFlagRefreshed, err := azappcfg.refreshFeatureFlags(ctx, azappcfg.newFeatureFlagRefreshClient()) + if err != nil { + return fmt.Errorf("failed to refresh feature flags: %w", err) + } + // Only execute callbacks if actual changes were applied - if keyValueRefreshed || secretRefreshed { + if keyValueRefreshed || secretRefreshed || featureFlagRefreshed { for _, callback := range azappcfg.onRefreshSuccess { if callback != nil { callback() @@ -287,6 +308,17 @@ func (azappcfg *AzureAppConfiguration) load(ctx context.Context) error { }) } + if azappcfg.ffEnabled { + eg.Go(func() error { + ffClient := &selectorSettingsClient{ + selectors: azappcfg.ffSelectors, + client: azappcfg.clientManager.staticClient.client, + tracingOptions: azappcfg.tracingOptions, + } + return azappcfg.loadFeatureFlags(egCtx, ffClient) + }) + } + return eg.Wait() } @@ -369,7 +401,7 @@ func (azappcfg *AzureAppConfiguration) loadKeyValues(ctx context.Context, settin maps.Copy(kvSettings, secrets) azappcfg.keyValues = kvSettings azappcfg.keyVaultRefs = getUnversionedKeyVaultRefs(keyVaultRefs) - azappcfg.pageETags = settingsResponse.pageETags + azappcfg.kvETags = settingsResponse.pageETags return nil } @@ -402,7 +434,7 @@ func (azappcfg *AzureAppConfiguration) loadKeyVaultSecrets(ctx context.Context, return secrets, fmt.Errorf("failed to resolve Key Vault references: %w", err) } - resolvedSecrets.Range(func(key, value interface{}) bool { + resolvedSecrets.Range(func(key, value any) bool { secrets[key.(string)] = value.(string) return true }) @@ -410,6 +442,43 @@ func (azappcfg *AzureAppConfiguration) loadKeyVaultSecrets(ctx context.Context, return secrets, nil } +func (azappcfg *AzureAppConfiguration) loadFeatureFlags(ctx context.Context, settingsClient settingsClient) error { + settingsResponse, err := settingsClient.getSettings(ctx) + if err != nil { + return err + } + + dedupFeatureFlags := make(map[string]any, len(settingsResponse.settings)) + for _, setting := range settingsResponse.settings { + if setting.Key != nil { + var v map[string]any + if err := json.Unmarshal([]byte(*setting.Value), &v); err != nil { + log.Printf("Invalid feature flag setting: key=%s, error=%s, just ignore", *setting.Key, err.Error()) + continue + } + azappcfg.updateFeatureFlagTracing(v) + dedupFeatureFlags[*setting.Key] = v + } + } + + featureFlags := make([]any, 0, len(dedupFeatureFlags)) + for _, v := range dedupFeatureFlags { + featureFlags = append(featureFlags, v) + } + + // "feature_management": {"feature_flags": [{...}, {...}]} + ffSettings := map[string]any{ + featureManagementSectionKey: map[string]any{ + featureFlagSectionKey: featureFlags, + }, + } + + azappcfg.ffETags = settingsResponse.pageETags + azappcfg.featureFlags = ffSettings + + 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) { @@ -492,6 +561,42 @@ func (azappcfg *AzureAppConfiguration) refreshKeyVaultSecrets(ctx context.Contex return changed, nil } +func (azappcfg *AzureAppConfiguration) refreshFeatureFlags(ctx context.Context, refreshClient refreshClient) (bool, error) { + if azappcfg.ffRefreshTimer == nil || + !azappcfg.ffRefreshTimer.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 feature flag settings have changed: %w", err) + } + + if !eTagChanged { + // No changes detected, reset timer and return + azappcfg.ffRefreshTimer.Reset() + return false, nil + } + + // Reload feature flags + eg, egCtx := errgroup.WithContext(ctx) + eg.Go(func() error { + settingsClient := refreshClient.loader + return azappcfg.loadFeatureFlags(egCtx, settingsClient) + }) + + if err := eg.Wait(); err != nil { + // Don't reset the timer if reload failed + return false, fmt.Errorf("failed to reload feature flag configuration: %w", err) + } + + // Reset the timer only after successful refresh + azappcfg.ffRefreshTimer.Reset() + return true, nil +} + func (azappcfg *AzureAppConfiguration) trimPrefix(key string) string { result := key for _, prefix := range azappcfg.trimPrefixes { @@ -539,6 +644,14 @@ func deduplicateSelectors(selectors []Selector) []Selector { return result } +func getFeatureFlagSelectors(selectors []Selector) []Selector { + for i := range selectors { + selectors[i].KeyFilter = featureFlagKeyPrefix + selectors[i].KeyFilter + } + + return selectors +} + // constructHierarchicalMap converts a flat map with delimited keys to a hierarchical structure func (azappcfg *AzureAppConfiguration) constructHierarchicalMap(separator string) map[string]any { tree := &tree.Tree{} @@ -546,7 +659,12 @@ func (azappcfg *AzureAppConfiguration) constructHierarchicalMap(separator string tree.Insert(strings.Split(k, separator), v) } - return tree.Build() + constructedMap := tree.Build() + if azappcfg.ffEnabled { + maps.Copy(constructedMap, azappcfg.featureFlags) + } + + return constructedMap } func configureTracingOptions(options *Options) tracing.Options { @@ -568,6 +686,10 @@ func configureTracingOptions(options *Options) tracing.Options { tracingOption.KeyVaultConfigured = true } + if options.FeatureFlagOptions.Enabled { + tracingOption.FeatureFlagTracing = &tracing.FeatureFlagTracing{} + } + return tracingOption } @@ -592,7 +714,7 @@ func (azappcfg *AzureAppConfiguration) newKeyValueRefreshClient() refreshClient monitor = &pageETagsClient{ client: azappcfg.clientManager.staticClient.client, tracingOptions: azappcfg.tracingOptions, - pageETags: azappcfg.pageETags, + pageETags: azappcfg.kvETags, } } else { monitor = &watchedSettingClient{ @@ -616,3 +738,56 @@ func (azappcfg *AzureAppConfiguration) newKeyValueRefreshClient() refreshClient }, } } + +func (azappcfg *AzureAppConfiguration) newFeatureFlagRefreshClient() refreshClient { + return refreshClient{ + loader: &selectorSettingsClient{ + selectors: azappcfg.ffSelectors, + client: azappcfg.clientManager.staticClient.client, + tracingOptions: azappcfg.tracingOptions, + }, + monitor: &pageETagsClient{ + client: azappcfg.clientManager.staticClient.client, + tracingOptions: azappcfg.tracingOptions, + pageETags: azappcfg.ffETags, + }, + } +} + +func (azappcfg *AzureAppConfiguration) updateFeatureFlagTracing(featureFlag map[string]any) { + if azappcfg.tracingOptions.FeatureFlagTracing == nil { + return + } + + // Check for client filters and update filter tracing + if conditions, ok := featureFlag[conditionsKeyName].(map[string]any); ok { + if clientFilters, ok := conditions[clientFiltersKeyName].([]any); ok { + for _, filter := range clientFilters { + if filterMap, ok := filter.(map[string]any); ok { + if filterName, ok := filterMap[nameKey].(string); ok { + azappcfg.tracingOptions.FeatureFlagTracing.UpdateFeatureFilterTracing(filterName) + } + } + } + } + } + + // Update max variants count + if variants, ok := featureFlag[variantsKeyName].([]any); ok { + azappcfg.tracingOptions.FeatureFlagTracing.UpdateMaxVariants(len(variants)) + } + + // Check if telemetry is enabled + if telemetry, ok := featureFlag[telemetryKey].(map[string]any); ok { + if enabled, ok := telemetry[enabledKey].(bool); ok && enabled { + azappcfg.tracingOptions.FeatureFlagTracing.UsesTelemetry = true + } + } + + // Check if allocation has a seed + if allocation, ok := featureFlag[allocationKeyName].(map[string]any); ok { + if _, hasSeed := allocation[seedKeyName]; hasSeed { + azappcfg.tracingOptions.FeatureFlagTracing.UsesSeed = true + } + } +} diff --git a/azureappconfiguration/azureappconfiguration_test.go b/azureappconfiguration/azureappconfiguration_test.go index 38feef2..6eed209 100644 --- a/azureappconfiguration/azureappconfiguration_test.go +++ b/azureappconfiguration/azureappconfiguration_test.go @@ -11,7 +11,11 @@ import ( "testing" "time" + "encoding/json" + + "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/fm" "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/tracing" + "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -92,6 +96,98 @@ func TestLoadKeyValues_WithKeyVaultReferences(t *testing.T) { mockSecretResolver.AssertExpectations(t) } +func TestLoadFeatureFlags_Success(t *testing.T) { + ctx := context.Background() + mockClient := new(mockSettingsClient) + + value1 := `{ + "id": "Beta", + "description": "", + "enabled": false, + "conditions": { + "client_filters": [] + } + }` + + value2 := `{ + "id": "Alpha", + "description": "Test feature", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "TestGroup", + "parameters": {"Users": ["user1@example.com", "user2@example.com"]} + } + ] + } + }` + + mockResponse := &settingsResponse{ + settings: []azappconfig.Setting{ + {Key: toPtr(".appconfig.featureflag/Beta"), Value: &value1, ContentType: toPtr(featureFlagContentType)}, + {Key: toPtr(".appconfig.featureflag/Alpha"), Value: &value2, ContentType: toPtr(featureFlagContentType)}, + }, + pageETags: map[Selector][]*azcore.ETag{}, + } + + mockClient.On("getSettings", ctx).Return(mockResponse, nil) + + azappcfg := &AzureAppConfiguration{ + clientManager: &configurationClientManager{ + staticClient: &configurationClientWrapper{client: &azappconfig.Client{}}, + }, + ffSelectors: getFeatureFlagSelectors([]Selector{}), + featureFlags: make(map[string]any), + } + + err := azappcfg.loadFeatureFlags(ctx, mockClient) + assert.NoError(t, err) + // Verify feature flag structure is created correctly + assert.Contains(t, azappcfg.featureFlags, featureManagementSectionKey) + featureManagement, ok := azappcfg.featureFlags[featureManagementSectionKey].(map[string]any) + assert.True(t, ok, "feature_management should be a map") + + // Verify feature_flags array exists + assert.Contains(t, featureManagement, featureFlagSectionKey) + featureFlags, ok := featureManagement[featureFlagSectionKey].([]any) + assert.True(t, ok, "feature_flags should be an array") + + // Verify we have 2 feature flags + assert.Len(t, featureFlags, 2) + + // Verify the feature flags are properly unmarshaled + foundBeta := false + foundAlpha := false + for _, flag := range featureFlags { + flagMap, ok := flag.(map[string]any) + assert.True(t, ok, "feature flag should be a map") + + if id, ok := flagMap["id"].(string); ok { + switch id { + case "Beta": + foundBeta = true + assert.Equal(t, false, flagMap["enabled"]) + case "Alpha": + foundAlpha = true + assert.Equal(t, true, flagMap["enabled"]) + assert.Equal(t, "Test feature", flagMap["description"]) + + // Verify conditions structure + conditions, ok := flagMap["conditions"].(map[string]any) + assert.True(t, ok, "conditions should be a map") + + clientFilters, ok := conditions["client_filters"].([]any) + assert.True(t, ok, "client_filters should be an array") + assert.Len(t, clientFilters, 1) + } + } + } + + assert.True(t, foundBeta, "Should have found Beta feature flag") + assert.True(t, foundAlpha, "Should have found Alpha feature flag") +} + func TestLoadKeyValues_WithTrimPrefix(t *testing.T) { ctx := context.Background() mockClient := new(mockSettingsClient) @@ -846,3 +942,663 @@ func TestCorrelationContextHeader(t *testing.T) { assert.Contains(t, correlationCtx, tracing.FeaturesKey+"="+ tracing.AIConfigurationTag+tracing.DelimiterPlus+tracing.AIChatCompletionConfigurationTag) } + +func TestUnmarshal_FeatureManagement(t *testing.T) { + // Setup a feature flag configuration + azappcfg := &AzureAppConfiguration{ + ffEnabled: true, + featureFlags: map[string]any{ + "feature_management": map[string]any{ + "feature_flags": []any{ + map[string]any{ + "id": "BasicFlag", + "description": "A simple feature flag", + "enabled": true, + }, + map[string]any{ + "id": "FlagWithConditions", + "description": "A flag with conditions", + "enabled": false, + "conditions": map[string]any{ + "client_filters": []any{ + map[string]any{ + "name": "Microsoft.TimeWindow", + "parameters": map[string]any{ + "Start": "2023-01-01T00:00:00Z", + "End": "2023-12-31T23:59:59Z", + }, + }, + }, + }, + }, + map[string]any{ + "id": "FlagWithVariants", + "description": "A flag with variants", + "enabled": true, + "variants": []any{ + map[string]any{ + "name": "variantA", + "configuration_value": "value-a", + }, + map[string]any{ + "name": "variantB", + "configuration_value": "value-b", + "status_override": "Disabled", + }, + }, + "allocation": map[string]any{ + "default_when_enabled": "variantA", + "percentile": []any{ + map[string]any{ + "variant": "variantB", + "from": 0.0, + "to": 50.0, + }, + map[string]any{ + "variant": "variantA", + "from": 50.0, + "to": 100.0, + }, + }, + }, + }, + }, + }, + }, + keyValues: make(map[string]any), + } + + // Create a structure to unmarshal into + type ConfigWithFeatureManagement struct { + FeatureManagement fm.FeatureManagement `json:"feature_management"` + } + + // Unmarshal into the struct + var config ConfigWithFeatureManagement + err := azappcfg.Unmarshal(&config, nil) + + // Verify results + assert.NoError(t, err) + assert.NotNil(t, config.FeatureManagement) + assert.Len(t, config.FeatureManagement.FeatureFlags, 3) + + // Verify BasicFlag + basicFlag := findFeatureFlag(config.FeatureManagement.FeatureFlags, "BasicFlag") + assert.NotNil(t, basicFlag) + assert.Equal(t, "A simple feature flag", basicFlag.Description) + assert.True(t, basicFlag.Enabled) + assert.Nil(t, basicFlag.Conditions) + assert.Nil(t, basicFlag.Variants) + + // Verify FlagWithConditions + conditionsFlag := findFeatureFlag(config.FeatureManagement.FeatureFlags, "FlagWithConditions") + assert.NotNil(t, conditionsFlag) + assert.Equal(t, "A flag with conditions", conditionsFlag.Description) + assert.False(t, conditionsFlag.Enabled) + assert.NotNil(t, conditionsFlag.Conditions) + assert.Len(t, conditionsFlag.Conditions.ClientFilters, 1) + assert.Equal(t, "Microsoft.TimeWindow", conditionsFlag.Conditions.ClientFilters[0].Name) + assert.Contains(t, conditionsFlag.Conditions.ClientFilters[0].Parameters, "Start") + assert.Contains(t, conditionsFlag.Conditions.ClientFilters[0].Parameters, "End") + + // Verify FlagWithVariants + variantsFlag := findFeatureFlag(config.FeatureManagement.FeatureFlags, "FlagWithVariants") + assert.NotNil(t, variantsFlag) + assert.Equal(t, "A flag with variants", variantsFlag.Description) + assert.True(t, variantsFlag.Enabled) + assert.Nil(t, variantsFlag.Conditions) + assert.Len(t, variantsFlag.Variants, 2) + assert.Equal(t, "variantA", variantsFlag.Variants[0].Name) + assert.Equal(t, "value-a", variantsFlag.Variants[0].ConfigurationValue) + assert.Equal(t, "variantB", variantsFlag.Variants[1].Name) + assert.Equal(t, "value-b", variantsFlag.Variants[1].ConfigurationValue) + assert.Equal(t, fm.StatusOverrideDisabled, variantsFlag.Variants[1].StatusOverride) + + // Verify allocation + assert.NotNil(t, variantsFlag.Allocation) + assert.Equal(t, "variantA", variantsFlag.Allocation.DefaultWhenEnabled) + assert.Len(t, variantsFlag.Allocation.Percentile, 2) + assert.Equal(t, "variantB", variantsFlag.Allocation.Percentile[0].Variant) + assert.Equal(t, 0.0, variantsFlag.Allocation.Percentile[0].From) + assert.Equal(t, 50.0, variantsFlag.Allocation.Percentile[0].To) + assert.Equal(t, "variantA", variantsFlag.Allocation.Percentile[1].Variant) + assert.Equal(t, 50.0, variantsFlag.Allocation.Percentile[1].From) + assert.Equal(t, 100.0, variantsFlag.Allocation.Percentile[1].To) +} + +// Helper function to find a feature flag by ID +func findFeatureFlag(flags []fm.FeatureFlag, id string) *fm.FeatureFlag { + for i := range flags { + if flags[i].ID == id { + return &flags[i] + } + } + return nil +} + +func TestUnmarshal_FeatureFlagsWithKeyValues(t *testing.T) { + // Setup an AzureAppConfiguration with both feature flags and key-values + azappcfg := &AzureAppConfiguration{ + ffEnabled: true, + featureFlags: map[string]any{ + "feature_management": map[string]any{ + "feature_flags": []any{ + map[string]any{ + "id": "BetaFeature", + "enabled": true, + }, + map[string]any{ + "id": "AlphaFeature", + "enabled": false, + }, + }, + }, + }, + keyValues: map[string]any{ + "AppName": "TestApp", + "Version": "1.0.0", + "Database.Host": "localhost", + "Database.Port": 5432, + "EnableLogging": true, + }, + } + + // Create a structure that has both feature flags and regular config + type Database struct { + Host string + Port int + } + + type Config struct { + AppName string + Version string + Database Database + EnableLogging bool + FeatureManagement fm.FeatureManagement `json:"feature_management"` + } + + // Unmarshal into the combined struct + var config Config + err := azappcfg.Unmarshal(&config, nil) + + // Verify results + assert.NoError(t, err) + + // Check regular config values + assert.Equal(t, "TestApp", config.AppName) + assert.Equal(t, "1.0.0", config.Version) + assert.Equal(t, "localhost", config.Database.Host) + assert.Equal(t, 5432, config.Database.Port) + assert.True(t, config.EnableLogging) + + // Check feature flags + assert.Len(t, config.FeatureManagement.FeatureFlags, 2) + + betaFlag := findFeatureFlag(config.FeatureManagement.FeatureFlags, "BetaFeature") + assert.NotNil(t, betaFlag) + assert.True(t, betaFlag.Enabled) + + alphaFlag := findFeatureFlag(config.FeatureManagement.FeatureFlags, "AlphaFeature") + assert.NotNil(t, alphaFlag) + assert.False(t, alphaFlag.Enabled) +} + +// Test for complex conditional feature flag scenario +func TestUnmarshal_ComplexFeatureFlag(t *testing.T) { + // Set up a complex feature flag with multiple filter types + azappcfg := &AzureAppConfiguration{ + ffEnabled: true, + featureFlags: map[string]any{ + "feature_management": map[string]any{ + "feature_flags": []any{ + map[string]any{ + "id": "ComplexFlag", + "description": "Complex feature flag with multiple conditions", + "enabled": true, + "conditions": map[string]any{ + "requirement_type": "All", + "client_filters": []any{ + map[string]any{ + "name": "Microsoft.Targeting", + "parameters": map[string]any{ + "Audience": map[string]any{ + "Users": []any{ + "user1@example.com", + "user2@example.com", + }, + "Groups": []any{ + "Developers", + "Testers", + }, + "DefaultRolloutPercentage": 25, + }, + }, + }, + map[string]any{ + "name": "Microsoft.TimeWindow", + "parameters": map[string]any{ + "Start": "2023-01-01T00:00:00Z", + "End": "2023-12-31T23:59:59Z", + }, + }, + }, + }, + "telemetry": map[string]any{ + "enabled": true, + "metadata": map[string]any{ + "source": "unit-test", + "version": "1.0", + }, + }, + }, + }, + }, + }, + keyValues: make(map[string]any), + } + + // Create the struct to unmarshal into + var config struct { + FeatureManagement fm.FeatureManagement `json:"feature_management"` + } + + // Unmarshal the data + err := azappcfg.Unmarshal(&config, nil) + + // Verify results + assert.NoError(t, err) + assert.Len(t, config.FeatureManagement.FeatureFlags, 1) + + complexFlag := config.FeatureManagement.FeatureFlags[0] + assert.Equal(t, "ComplexFlag", complexFlag.ID) + assert.Equal(t, "Complex feature flag with multiple conditions", complexFlag.Description) + assert.True(t, complexFlag.Enabled) + + // Check conditions + assert.NotNil(t, complexFlag.Conditions) + assert.Equal(t, fm.RequirementTypeAll, complexFlag.Conditions.RequirementType) + assert.Len(t, complexFlag.Conditions.ClientFilters, 2) + + // Check targeting filter + targetingFilter := findClientFilter(complexFlag.Conditions.ClientFilters, "Microsoft.Targeting") + assert.NotNil(t, targetingFilter) + + audienceParam, ok := targetingFilter.Parameters["Audience"].(map[string]any) + assert.True(t, ok, "Audience parameter should be a map") + + users, ok := audienceParam["Users"].([]any) + assert.True(t, ok, "Users should be an array") + assert.Len(t, users, 2) + assert.Contains(t, users, "user1@example.com") + assert.Contains(t, users, "user2@example.com") + + // Check time window filter + timeFilter := findClientFilter(complexFlag.Conditions.ClientFilters, "Microsoft.TimeWindow") + assert.NotNil(t, timeFilter) + assert.Equal(t, "2023-01-01T00:00:00Z", timeFilter.Parameters["Start"]) + + // Check telemetry + assert.NotNil(t, complexFlag.Telemetry) + assert.True(t, complexFlag.Telemetry.Enabled) + assert.Equal(t, "unit-test", complexFlag.Telemetry.Metadata["source"]) + assert.Equal(t, "1.0", complexFlag.Telemetry.Metadata["version"]) +} + +// Helper function to find a client filter by name +func findClientFilter(filters []fm.ClientFilter, name string) *fm.ClientFilter { + for i := range filters { + if filters[i].Name == name { + return &filters[i] + } + } + return nil +} + +func TestGetBytes_SimpleKeyValues(t *testing.T) { + // Setup a provider with only key values + azappcfg := &AzureAppConfiguration{ + keyValues: map[string]any{ + "AppName": "TestApp", + "Version": "1.0.0", + "Database.Host": "localhost", + "Database.Port": 5432, + "EnableLogging": true, + }, + featureFlags: make(map[string]any), + } + + // Get the JSON bytes + bytes, err := azappcfg.GetBytes(nil) + assert.NoError(t, err) + assert.NotEmpty(t, bytes) + + // Parse the JSON back into a map to verify content + var result map[string]any + err = json.Unmarshal(bytes, &result) + assert.NoError(t, err) + + // Verify the expected values are present + assert.Equal(t, "TestApp", result["AppName"]) + assert.Equal(t, "1.0.0", result["Version"]) + + // Verify nested structure + db, ok := result["Database"].(map[string]any) + assert.True(t, ok, "Database should be a nested map") + assert.Equal(t, "localhost", db["Host"]) + assert.Equal(t, float64(5432), db["Port"]) // JSON numbers are parsed as float64 + + // Verify bool value + assert.Equal(t, true, result["EnableLogging"]) + + // Verify feature flags section does not exist + _, hasFM := result["feature_management"] + assert.False(t, hasFM, "feature_management section should not exist") +} + +func TestGetBytes_FeatureFlags(t *testing.T) { + // Setup a provider with only feature flags + azappcfg := &AzureAppConfiguration{ + ffEnabled: true, + featureFlags: map[string]any{ + "feature_management": map[string]any{ + "feature_flags": []any{ + map[string]any{ + "id": "Beta", + "enabled": true, + }, + map[string]any{ + "id": "Alpha", + "enabled": false, + }, + }, + }, + }, + keyValues: make(map[string]any), + } + + // Get the JSON bytes + bytes, err := azappcfg.GetBytes(nil) + assert.NoError(t, err) + assert.NotEmpty(t, bytes) + + // Parse the JSON back into a map to verify content + var result map[string]any + err = json.Unmarshal(bytes, &result) + assert.NoError(t, err) + + // Verify feature_management section exists + fm, hasFM := result["feature_management"].(map[string]any) + assert.True(t, hasFM, "feature_management section should exist") + + // Verify feature_flags array exists + flags, hasFlags := fm["feature_flags"].([]any) + assert.True(t, hasFlags, "feature_flags array should exist") + assert.Len(t, flags, 2, "There should be 2 feature flags") + + // Verify the two feature flags + foundBeta, foundAlpha := false, false + for _, flag := range flags { + if flagMap, ok := flag.(map[string]any); ok { + if id, ok := flagMap["id"].(string); ok { + switch id { + case "Beta": + foundBeta = true + assert.Equal(t, true, flagMap["enabled"]) + case "Alpha": + foundAlpha = true + assert.Equal(t, false, flagMap["enabled"]) + } + } + } + } + assert.True(t, foundBeta, "Beta feature flag should exist") + assert.True(t, foundAlpha, "Alpha feature flag should exist") +} + +func TestGetBytes_CombinedKeyValuesAndFeatureFlags(t *testing.T) { + // Setup a provider with both key values and feature flags + azappcfg := &AzureAppConfiguration{ + ffEnabled: true, + keyValues: map[string]any{ + "AppName": "TestApp", + "Version": "1.0.0", + "Database.Host": "localhost", + }, + featureFlags: map[string]any{ + "feature_management": map[string]any{ + "feature_flags": []any{ + map[string]any{ + "id": "Beta", + "enabled": true, + }, + }, + }, + }, + } + + // Get the JSON bytes + bytes, err := azappcfg.GetBytes(nil) + assert.NoError(t, err) + assert.NotEmpty(t, bytes) + + // Parse the JSON back into a map to verify content + var result map[string]any + err = json.Unmarshal(bytes, &result) + assert.NoError(t, err) + + // Verify regular key values + assert.Equal(t, "TestApp", result["AppName"]) + assert.Equal(t, "1.0.0", result["Version"]) + db, ok := result["Database"].(map[string]any) + assert.True(t, ok) + assert.Equal(t, "localhost", db["Host"]) + + // Verify feature_management section + fm, hasFM := result["feature_management"].(map[string]any) + assert.True(t, hasFM, "feature_management section should exist") + + flags, hasFlags := fm["feature_flags"].([]any) + assert.True(t, hasFlags, "feature_flags array should exist") + assert.Len(t, flags, 1, "There should be 1 feature flag") + + flag, ok := flags[0].(map[string]any) + assert.True(t, ok) + assert.Equal(t, "Beta", flag["id"]) + assert.Equal(t, true, flag["enabled"]) +} + +func TestGetBytes_CustomSeparator(t *testing.T) { + // Setup a provider with hierarchical keys using a custom separator + azappcfg := &AzureAppConfiguration{ + keyValues: map[string]any{ + "App:Name": "TestApp", + "App:Version": "1.0.0", + "Database:Host": "localhost", + "Database:Port": 5432, + }, + featureFlags: make(map[string]any), + } + + // Get the JSON bytes with custom separator + bytes, err := azappcfg.GetBytes(&ConstructionOptions{Separator: ":"}) + assert.NoError(t, err) + assert.NotEmpty(t, bytes) + + // Parse the JSON back into a map to verify content + var result map[string]any + err = json.Unmarshal(bytes, &result) + assert.NoError(t, err) + + // Verify hierarchical structure with custom separator + app, hasApp := result["App"].(map[string]any) + assert.True(t, hasApp, "App should be a nested map") + assert.Equal(t, "TestApp", app["Name"]) + assert.Equal(t, "1.0.0", app["Version"]) + + db, hasDb := result["Database"].(map[string]any) + assert.True(t, hasDb, "Database should be a nested map") + assert.Equal(t, "localhost", db["Host"]) + assert.Equal(t, float64(5432), db["Port"]) +} + +func TestGetBytes_InvalidSeparator(t *testing.T) { + azappcfg := &AzureAppConfiguration{ + keyValues: map[string]any{ + "App|Name": "TestApp", + }, + } + + // Invalid separator should cause an error + _, err := azappcfg.GetBytes(&ConstructionOptions{Separator: "|"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid separator") +} + +func TestLoadFeatureFlags_TracingUpdated(t *testing.T) { + ctx := context.Background() + mockClient := new(mockSettingsClient) + + // Feature flag with targeting filter, telemetry enabled, and variants + value1 := `{ + "id": "TargetingFlag", + "description": "", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Microsoft.Targeting", + "parameters": { + "Audience": { + "Users": ["user1@example.com"] + } + } + } + ] + }, + "variants": [ + {"name": "A", "configuration_value": "value-a"}, + {"name": "B", "configuration_value": "value-b"}, + {"name": "C", "configuration_value": "value-c"} + ], + "telemetry": { + "enabled": true + } + }` + + // Feature flag with time window filter + value2 := `{ + "id": "TimeWindowFlag", + "description": "", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Microsoft.TimeWindow", + "parameters": { + "Start": "2023-01-01T00:00:00Z", + "End": "2023-12-31T23:59:59Z" + } + } + ] + } + }` + + // Feature flag with custom filter and allocation seed + value3 := `{ + "id": "CustomFilterFlag", + "description": "", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "CustomFilter", + "parameters": {} + } + ] + }, + "allocation": { + "seed": "consistent-hash", + "percentile": [ + { + "variant": "on", + "from": 0, + "to": 50 + } + ] + } + }` + + mockResponse := &settingsResponse{ + settings: []azappconfig.Setting{ + { + Key: toPtr(".appconfig.featureflag/TargetingFlag"), + Value: &value1, + ContentType: toPtr(featureFlagContentType), + }, + { + Key: toPtr(".appconfig.featureflag/TimeWindowFlag"), + Value: &value2, + ContentType: toPtr(featureFlagContentType), + }, + { + Key: toPtr(".appconfig.featureflag/CustomFilterFlag"), + Value: &value3, + ContentType: toPtr(featureFlagContentType), + }, + }, + pageETags: map[Selector][]*azcore.ETag{}, + } + + mockClient.On("getSettings", ctx).Return(mockResponse, nil) + + // Create a tracing options object with feature flag tracing enabled + ffTracing := &tracing.FeatureFlagTracing{} + tracingOptions := tracing.Options{ + Enabled: true, + FeatureFlagTracing: ffTracing, + } + + azappcfg := &AzureAppConfiguration{ + clientManager: &configurationClientManager{ + staticClient: &configurationClientWrapper{client: &azappconfig.Client{}}, + }, + ffSelectors: getFeatureFlagSelectors([]Selector{}), + featureFlags: make(map[string]any), + tracingOptions: tracingOptions, + } + + // Call the method under test + err := azappcfg.loadFeatureFlags(ctx, mockClient) + assert.NoError(t, err) + + // Verify tracing information was properly updated + assert.True(t, ffTracing.UsesTargetingFilter, "Targeting filter should be detected") + assert.True(t, ffTracing.UsesTimeWindowFilter, "Time window filter should be detected") + assert.True(t, ffTracing.UsesCustomFilter, "Custom filter should be detected") + assert.True(t, ffTracing.UsesTelemetry, "Telemetry should be detected") + assert.True(t, ffTracing.UsesSeed, "Seed should be detected") + assert.Equal(t, 3, ffTracing.MaxVariants, "Max variants should be 3") + + // Verify feature flags array exists and has correct data + featureManagement := azappcfg.featureFlags[featureManagementSectionKey].(map[string]any) + featureFlags := featureManagement[featureFlagSectionKey].([]any) + assert.Len(t, featureFlags, 3) + + // Test creation of tracing headers + header := tracing.CreateCorrelationContextHeader(ctx, azappcfg.tracingOptions) + correlationCtx := header.Get(tracing.CorrelationContextHeader) + + // Verify feature filter string is included in the correlation context + assert.Contains(t, correlationCtx, tracing.FeatureFilterTypeKey+"=") + assert.Contains(t, correlationCtx, tracing.CustomFilterKey) + assert.Contains(t, correlationCtx, tracing.TimeWindowFilterKey) + assert.Contains(t, correlationCtx, tracing.TargetingFilterKey) + + // Verify feature flag tracing features are included + assert.Contains(t, correlationCtx, tracing.FFFeaturesKey+"=") + assert.Contains(t, correlationCtx, tracing.FFSeedUsedTag) + assert.Contains(t, correlationCtx, tracing.FFTelemetryUsedTag) + + // Verify max variants is included + assert.Contains(t, correlationCtx, tracing.FFMaxVariantsKey+"=3") +} diff --git a/azureappconfiguration/constants.go b/azureappconfiguration/constants.go index 204be8a..3b7bb58 100644 --- a/azureappconfiguration/constants.go +++ b/azureappconfiguration/constants.go @@ -14,11 +14,36 @@ const ( // General configuration constants const ( - defaultLabel = "\x00" - wildCard = "*" - defaultSeparator = "." - secretReferenceContentType string = "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8" - featureFlagContentType string = "application/vnd.microsoft.appconfig.ff+json;charset=utf-8" + defaultLabel = "\x00" + wildCard = "*" + defaultSeparator = "." + secretReferenceContentType string = "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8" + featureFlagContentType string = "application/vnd.microsoft.appconfig.ff+json;charset=utf-8" + featureFlagKeyPrefix string = ".appconfig.featureflag/" + featureManagementSectionKey string = "feature_management" + featureFlagSectionKey string = "feature_flags" +) + +// Feature flag constants +const ( + enabledKey string = "enabled" + telemetryKey string = "telemetry" + metadataKey string = "metadata" + nameKey string = "name" + eTagKey string = "ETag" + featureFlagReferenceKey string = "FeatureFlagReference" + allocationKeyName string = "allocation" + defaultWhenEnabledKey string = "default_when_enabled" + percentileKeyName string = "percentile" + fromKeyName string = "from" + toKeyName string = "to" + seedKeyName string = "seed" + variantKeyName string = "variant" + variantsKeyName string = "variants" + configurationValueKey string = "configuration_value" + allocationIdKeyName string = "AllocationId" + conditionsKeyName string = "conditions" + clientFiltersKeyName string = "client_filters" ) // Refresh interval constants diff --git a/azureappconfiguration/go.mod b/azureappconfiguration/go.mod index 6ba3318..9f9ecc1 100644 --- a/azureappconfiguration/go.mod +++ b/azureappconfiguration/go.mod @@ -16,7 +16,7 @@ require ( 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/go-viper/mapstructure/v2 v2.3.0 github.com/stretchr/testify v1.10.0 golang.org/x/net v0.40.0 // indirect golang.org/x/sync v0.14.0 diff --git a/azureappconfiguration/go.sum b/azureappconfiguration/go.sum index c04e7af..eb9db14 100644 --- a/azureappconfiguration/go.sum +++ b/azureappconfiguration/go.sum @@ -14,8 +14,8 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJ 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/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= +github.com/go-viper/mapstructure/v2 v2.3.0/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= diff --git a/azureappconfiguration/internal/fm/featureflag.go b/azureappconfiguration/internal/fm/featureflag.go new file mode 100644 index 0000000..13f739d --- /dev/null +++ b/azureappconfiguration/internal/fm/featureflag.go @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package fm + +type FeatureManagement struct { + FeatureFlags []FeatureFlag `json:"feature_flags"` +} + +// FeatureFlag represents a feature flag definition according to the v2.0.0 schema +type FeatureFlag struct { + // ID uniquely identifies the feature + ID string `json:"id"` + // Description provides details about the feature's purpose + Description string `json:"description,omitempty"` + // DisplayName is a human-friendly name for display purposes + DisplayName string `json:"display_name,omitempty"` + // Enabled indicates if the feature is on or off + Enabled bool `json:"enabled"` + // Conditions defines when the feature should be dynamically enabled + Conditions *Conditions `json:"conditions,omitempty"` + // Variants represents different configurations of this feature + Variants []VariantDefinition `json:"variants,omitempty"` + // Allocation determines how variants are assigned to users + Allocation *VariantAllocation `json:"allocation,omitempty"` + // Telemetry contains feature flag telemetry configuration + Telemetry *Telemetry `json:"telemetry,omitempty"` +} + +// Conditions defines the rules for enabling a feature dynamically +type Conditions struct { + // RequirementType determines if any or all filters must be satisfied + // Values: "Any" or "All" + RequirementType RequirementType `json:"requirement_type,omitempty"` + // ClientFilters are the filter conditions that must be evaluated by the client + ClientFilters []ClientFilter `json:"client_filters,omitempty"` +} + +// ClientFilter represents a filter that must be evaluated for feature enablement +type ClientFilter struct { + // Name is the identifier for this filter type + Name string `json:"name"` + // Parameters are the configuration values for the filter + Parameters map[string]any `json:"parameters,omitempty"` +} + +// VariantDefinition represents a feature configuration variant +type VariantDefinition struct { + // Name uniquely identifies this variant + Name string `json:"name"` + // ConfigurationValue holds the value for this variant + ConfigurationValue any `json:"configuration_value,omitempty"` + // StatusOverride overrides the enabled state of the feature when this variant is assigned + // Values: "None", "Enabled", "Disabled" + StatusOverride StatusOverride `json:"status_override,omitempty"` +} + +// VariantAllocation defines rules for assigning variants to users +type VariantAllocation struct { + // DefaultWhenDisabled specifies which variant to use when feature is disabled + DefaultWhenDisabled string `json:"default_when_disabled,omitempty"` + // DefaultWhenEnabled specifies which variant to use when feature is enabled + DefaultWhenEnabled string `json:"default_when_enabled,omitempty"` + // User defines variant assignments for specific users + User []UserAllocation `json:"user,omitempty"` + // Group defines variant assignments for user groups + Group []GroupAllocation `json:"group,omitempty"` + // Percentile defines variant assignments by percentage ranges + Percentile []PercentileAllocation `json:"percentile,omitempty"` + // Seed is used to ensure consistent percentile calculations across features + Seed string `json:"seed,omitempty"` +} + +// UserAllocation assigns a variant to specific users +type UserAllocation struct { + // Variant is the name of the variant to use + Variant string `json:"variant"` + // Users is the collection of user IDs to apply this variant to + Users []string `json:"users"` +} + +// GroupAllocation assigns a variant to specific user groups +type GroupAllocation struct { + // Variant is the name of the variant to use + Variant string `json:"variant"` + // Groups is the collection of group IDs to apply this variant to + Groups []string `json:"groups"` +} + +// PercentileAllocation assigns a variant to a percentage range of users +type PercentileAllocation struct { + // Variant is the name of the variant to use + Variant string `json:"variant"` + // From is the lower end of the percentage range (0-100) + From float64 `json:"from"` + // To is the upper end of the percentage range (0-100) + To float64 `json:"to"` +} + +// Telemetry contains options for feature flag telemetry +type Telemetry struct { + // Enabled indicates if telemetry is enabled for this feature + Enabled bool `json:"enabled,omitempty"` + // Metadata contains additional data to include with telemetry + Metadata map[string]string `json:"metadata,omitempty"` +} + +// VariantAssignmentReason represents the reason a variant was assigned +type VariantAssignmentReason string + +const ( + // VariantAssignmentReasonNone indicates no specific reason for variant assignment + VariantAssignmentReasonNone VariantAssignmentReason = "None" + // VariantAssignmentReasonDefaultWhenDisabled indicates the variant was assigned because it's the default for disabled features + VariantAssignmentReasonDefaultWhenDisabled VariantAssignmentReason = "DefaultWhenDisabled" + // VariantAssignmentReasonDefaultWhenEnabled indicates the variant was assigned because it's the default for enabled features + VariantAssignmentReasonDefaultWhenEnabled VariantAssignmentReason = "DefaultWhenEnabled" + // VariantAssignmentReasonUser indicates the variant was assigned based on the user's ID + VariantAssignmentReasonUser VariantAssignmentReason = "User" + // VariantAssignmentReasonGroup indicates the variant was assigned based on the user's group + VariantAssignmentReasonGroup VariantAssignmentReason = "Group" + // VariantAssignmentReasonPercentile indicates the variant was assigned based on percentile calculations + VariantAssignmentReasonPercentile VariantAssignmentReason = "Percentile" +) + +type RequirementType string + +const ( + // RequirementTypeAny indicates that any of the filters must be satisfied + RequirementTypeAny RequirementType = "Any" + // RequirementTypeAll indicates that all filters must be satisfied + RequirementTypeAll RequirementType = "All" +) + +type StatusOverride string + +const ( + // StatusOverrideNone indicates no override + StatusOverrideNone StatusOverride = "None" + // StatusOverrideEnabled indicates the feature is enabled + StatusOverrideEnabled StatusOverride = "Enabled" + // StatusOverrideDisabled indicates the feature is disabled + StatusOverrideDisabled StatusOverride = "Disabled" +) diff --git a/azureappconfiguration/internal/tracing/featureflag_tracing.go b/azureappconfiguration/internal/tracing/featureflag_tracing.go new file mode 100644 index 0000000..d7ac20e --- /dev/null +++ b/azureappconfiguration/internal/tracing/featureflag_tracing.go @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package tracing + +import "strings" + +type FeatureFlagTracing struct { + UsesCustomFilter bool + UsesTimeWindowFilter bool + UsesTargetingFilter bool + UsesTelemetry bool + UsesSeed bool + MaxVariants int +} + +func (f *FeatureFlagTracing) UpdateFeatureFilterTracing(filterName string) { + if filterName == TimeWindowFilterName { + f.UsesTimeWindowFilter = true + } else if filterName == TargetingFilterName { + f.UsesTargetingFilter = true + } else { + f.UsesCustomFilter = true + } +} + +func (f *FeatureFlagTracing) UpdateMaxVariants(currentVariants int) { + if currentVariants > f.MaxVariants { + f.MaxVariants = currentVariants + } +} + +func (f *FeatureFlagTracing) UsesAnyFeatureFilter() bool { + return f.UsesCustomFilter || f.UsesTimeWindowFilter || f.UsesTargetingFilter +} + +func (f *FeatureFlagTracing) UsesAnyTracingFeature() bool { + return f.UsesSeed || f.UsesTelemetry +} + +func (f *FeatureFlagTracing) CreateFeatureFiltersString() string { + if !f.UsesAnyFeatureFilter() { + return "" + } + + res := make([]string, 0, 3) + + if f.UsesCustomFilter { + res = append(res, CustomFilterKey) + } + + if f.UsesTimeWindowFilter { + res = append(res, TimeWindowFilterKey) + } + + if f.UsesTargetingFilter { + res = append(res, TargetingFilterKey) + } + + return strings.Join(res, DelimiterPlus) +} + +// CreateFeaturesString creates a string representation of the used tracing features +func (f *FeatureFlagTracing) CreateFeaturesString() string { + if !f.UsesAnyTracingFeature() { + return "" + } + + res := make([]string, 0, 2) + + if f.UsesSeed { + res = append(res, FFSeedUsedTag) + } + + if f.UsesTelemetry { + res = append(res, FFTelemetryUsedTag) + } + + return strings.Join(res, DelimiterPlus) +} diff --git a/azureappconfiguration/internal/tracing/tracing.go b/azureappconfiguration/internal/tracing/tracing.go index f677eb2..6270b98 100644 --- a/azureappconfiguration/internal/tracing/tracing.go +++ b/azureappconfiguration/internal/tracing/tracing.go @@ -7,6 +7,7 @@ import ( "context" "net/http" "os" + "strconv" "strings" ) @@ -40,6 +41,18 @@ const ( AIConfigurationTag = "AI" AIChatCompletionConfigurationTag = "AICC" + // Feature flag usage tracing + FeatureFilterTypeKey = "Filter" + CustomFilterKey = "CSTM" + TimeWindowFilterKey = "TIME" + TargetingFilterKey = "TRGT" + FFTelemetryUsedTag = "Telemetry" + FFMaxVariantsKey = "MaxVariants" + FFSeedUsedTag = "Seed" + FFFeaturesKey = "FFFeatures" + TimeWindowFilterName = "Microsoft.TimeWindow" + TargetingFilterName = "Microsoft.Targeting" + AIMimeProfile = "https://azconfig.io/mime-profiles/ai" AIChatCompletionMimeProfile = "https://azconfig.io/mime-profiles/ai/chat-completion" @@ -56,6 +69,7 @@ type Options struct { KeyVaultRefreshConfigured bool UseAIConfiguration bool UseAIChatCompletionConfiguration bool + FeatureFlagTracing *FeatureFlagTracing } func GetHostType() HostType { @@ -109,6 +123,18 @@ func CreateCorrelationContextHeader(ctx context.Context, options Options) http.H output = append(output, featureStr) } + if options.FeatureFlagTracing != nil { + if options.FeatureFlagTracing.UsesAnyFeatureFilter() { + output = append(output, FeatureFilterTypeKey+"="+options.FeatureFlagTracing.CreateFeatureFiltersString()) + } + if options.FeatureFlagTracing.UsesAnyTracingFeature() { + output = append(output, FFFeaturesKey+"="+options.FeatureFlagTracing.CreateFeaturesString()) + } + if options.FeatureFlagTracing.MaxVariants > 0 { + output = append(output, FFMaxVariantsKey+"="+strconv.Itoa(options.FeatureFlagTracing.MaxVariants)) + } + } + 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 index 4729901..cc245b6 100644 --- a/azureappconfiguration/internal/tracing/tracing_test.go +++ b/azureappconfiguration/internal/tracing/tracing_test.go @@ -196,3 +196,294 @@ func TestCreateCorrelationContextHeader(t *testing.T) { assert.Len(t, parts, 3, "Should have 3 parts separated by commas") }) } + +func TestFeatureFlagTracing_UpdateFeatureFilterTracing(t *testing.T) { + tests := []struct { + name string + filterName string + expectCustom bool + expectTime bool + expectTarget bool + }{ + { + name: "Microsoft.TimeWindow filter", + filterName: TimeWindowFilterName, + expectCustom: false, + expectTime: true, + expectTarget: false, + }, + { + name: "Microsoft.Targeting filter", + filterName: TargetingFilterName, + expectCustom: false, + expectTime: false, + expectTarget: true, + }, + { + name: "Custom filter", + filterName: "Microsoft.CustomFilter", + expectCustom: true, + expectTime: false, + expectTarget: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tracing := FeatureFlagTracing{} + tracing.UpdateFeatureFilterTracing(test.filterName) + + assert.Equal(t, test.expectCustom, tracing.UsesCustomFilter) + assert.Equal(t, test.expectTime, tracing.UsesTimeWindowFilter) + assert.Equal(t, test.expectTarget, tracing.UsesTargetingFilter) + }) + } +} + +func TestFeatureFlagTracing_UpdateMaxVariants(t *testing.T) { + tests := []struct { + name string + initialMax int + newValue int + expectedMax int + }{ + { + name: "Update with larger value", + initialMax: 2, + newValue: 5, + expectedMax: 5, + }, + { + name: "Update with smaller value", + initialMax: 5, + newValue: 3, + expectedMax: 5, + }, + { + name: "Update with equal value", + initialMax: 3, + newValue: 3, + expectedMax: 3, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tracing := FeatureFlagTracing{MaxVariants: test.initialMax} + tracing.UpdateMaxVariants(test.newValue) + + assert.Equal(t, test.expectedMax, tracing.MaxVariants) + }) + } +} + +func TestFeatureFlagTracing_CreateFeatureFiltersString(t *testing.T) { + tests := []struct { + name string + tracing FeatureFlagTracing + expectedResult string + }{ + { + name: "No filters", + tracing: FeatureFlagTracing{ + UsesCustomFilter: false, + UsesTimeWindowFilter: false, + UsesTargetingFilter: false, + }, + expectedResult: "", + }, + { + name: "Only custom filter", + tracing: FeatureFlagTracing{ + UsesCustomFilter: true, + UsesTimeWindowFilter: false, + UsesTargetingFilter: false, + }, + expectedResult: CustomFilterKey, + }, + { + name: "Only time window filter", + tracing: FeatureFlagTracing{ + UsesCustomFilter: false, + UsesTimeWindowFilter: true, + UsesTargetingFilter: false, + }, + expectedResult: TimeWindowFilterKey, + }, + { + name: "Only targeting filter", + tracing: FeatureFlagTracing{ + UsesCustomFilter: false, + UsesTimeWindowFilter: false, + UsesTargetingFilter: true, + }, + expectedResult: TargetingFilterKey, + }, + { + name: "Multiple filters", + tracing: FeatureFlagTracing{ + UsesCustomFilter: true, + UsesTimeWindowFilter: true, + UsesTargetingFilter: true, + }, + expectedResult: CustomFilterKey + DelimiterPlus + TimeWindowFilterKey + DelimiterPlus + TargetingFilterKey, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := test.tracing.CreateFeatureFiltersString() + assert.Equal(t, test.expectedResult, result) + }) + } +} + +func TestFeatureFlagTracing_CreateFeaturesString(t *testing.T) { + tests := []struct { + name string + tracing FeatureFlagTracing + expectedResult string + }{ + { + name: "No features", + tracing: FeatureFlagTracing{ + UsesSeed: false, + UsesTelemetry: false, + }, + expectedResult: "", + }, + { + name: "Only seed", + tracing: FeatureFlagTracing{ + UsesSeed: true, + UsesTelemetry: false, + }, + expectedResult: FFSeedUsedTag, + }, + { + name: "Only telemetry", + tracing: FeatureFlagTracing{ + UsesSeed: false, + UsesTelemetry: true, + }, + expectedResult: FFTelemetryUsedTag, + }, + { + name: "Both features", + tracing: FeatureFlagTracing{ + UsesSeed: true, + UsesTelemetry: true, + }, + expectedResult: FFSeedUsedTag + DelimiterPlus + FFTelemetryUsedTag, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := test.tracing.CreateFeaturesString() + assert.Equal(t, test.expectedResult, result) + }) + } +} + +func TestCreateCorrelationContextHeader_WithFeatureFlagTracing(t *testing.T) { + tests := []struct { + name string + tracing *FeatureFlagTracing + expected []string + notExpected []string + }{ + { + name: "All feature flags features", + tracing: &FeatureFlagTracing{ + UsesCustomFilter: true, + UsesTimeWindowFilter: true, + UsesTargetingFilter: true, + UsesTelemetry: true, + UsesSeed: true, + MaxVariants: 3, + }, + expected: []string{ + FeatureFilterTypeKey + "=" + CustomFilterKey + DelimiterPlus + TimeWindowFilterKey + DelimiterPlus + TargetingFilterKey, + FFFeaturesKey + "=" + FFSeedUsedTag + DelimiterPlus + FFTelemetryUsedTag, + FFMaxVariantsKey + "=3", + }, + notExpected: []string{}, + }, + { + name: "No feature flags features", + tracing: &FeatureFlagTracing{ + UsesCustomFilter: false, + UsesTimeWindowFilter: false, + UsesTargetingFilter: false, + UsesTelemetry: false, + UsesSeed: false, + MaxVariants: 0, + }, + expected: []string{}, + notExpected: []string{ + FeatureFilterTypeKey, + FFFeaturesKey, + FFMaxVariantsKey, + }, + }, + { + name: "Only feature filters", + tracing: &FeatureFlagTracing{ + UsesCustomFilter: true, + UsesTimeWindowFilter: false, + UsesTargetingFilter: true, + UsesTelemetry: false, + UsesSeed: false, + MaxVariants: 0, + }, + expected: []string{ + FeatureFilterTypeKey + "=" + CustomFilterKey + DelimiterPlus + TargetingFilterKey, + }, + notExpected: []string{ + FFFeaturesKey, + TimeWindowFilterKey, + }, + }, + { + name: "Only variant count", + tracing: &FeatureFlagTracing{ + UsesCustomFilter: false, + UsesTimeWindowFilter: false, + UsesTargetingFilter: false, + UsesTelemetry: false, + UsesSeed: false, + MaxVariants: 5, + }, + expected: []string{ + FFMaxVariantsKey + "=5", + }, + notExpected: []string{ + FeatureFilterTypeKey, + FFFeaturesKey, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx := context.Background() + options := Options{ + FeatureFlagTracing: test.tracing, + } + + header := CreateCorrelationContextHeader(ctx, options) + correlationCtx := header.Get(CorrelationContextHeader) + + // Check expected strings + for _, exp := range test.expected { + assert.Contains(t, correlationCtx, exp) + } + + // Check not expected strings + for _, notExp := range test.notExpected { + assert.NotContains(t, correlationCtx, notExp) + } + }) + } +} diff --git a/azureappconfiguration/options.go b/azureappconfiguration/options.go index 2f0e300..5201867 100644 --- a/azureappconfiguration/options.go +++ b/azureappconfiguration/options.go @@ -30,6 +30,9 @@ type Options struct { // KeyVaultOptions configures how Key Vault references are resolved. KeyVaultOptions KeyVaultOptions + // FeatureFlagOptions contains optional parameters for Azure App Configuration feature flags. + FeatureFlagOptions FeatureFlagOptions + // ClientOptions provides options for configuring the underlying Azure App Configuration client. ClientOptions *azappconfig.ClientOptions } @@ -111,6 +114,20 @@ type KeyVaultOptions struct { RefreshOptions RefreshOptions } +// FeatureFlagOptions contains optional parameters for Azure App Configuration feature flags that will be parsed and transformed into feature management configuration. +type FeatureFlagOptions struct { + // Enabled specifies whether feature flags will be loaded from Azure App Configuration. + Enabled bool + + // If no selectors are provided, all feature flags with no label will be loaded when enabled feature flags. + Selectors []Selector + + // RefreshOptions specifies the behavior of feature flags refresh. + // Refresh interval must be greater than 1 second. If not provided, the default interval 30 seconds will be used + // All loaded feature flags will be automatically watched when feature flags refresh is enabled. + 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 diff --git a/azureappconfiguration/refresh_test.go b/azureappconfiguration/refresh_test.go index e4bdb1d..b0fe243 100644 --- a/azureappconfiguration/refresh_test.go +++ b/azureappconfiguration/refresh_test.go @@ -58,7 +58,7 @@ func TestRefresh_NotConfigured(t *testing.T) { // Verify that an error is returned require.Error(t, err) - assert.Contains(t, err.Error(), "refresh is not enabled for either key values or Key Vault secrets") + assert.Contains(t, err.Error(), "refresh is not configured") } func TestRefreshEnabled_IntervalTooShort(t *testing.T) { @@ -644,3 +644,138 @@ func TestRefreshKeyValues_NoChanges_WatchAll(t *testing.T) { 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") } + +// TestRefreshFeatureFlags_NoChanges tests when no ETags change is detected for feature flags +func TestRefreshFeatureFlags_NoChanges(t *testing.T) { + // Setup mocks + mockTimer := &mockRefreshCondition{shouldRefresh: true} + mockMonitor := &mockETagsClient{changed: false} + mockLoader := &mockKvRefreshClient{} + + mockClient := refreshClient{ + loader: mockLoader, + monitor: mockMonitor, + } + + // Setup provider with feature flags refresh timer + azappcfg := &AzureAppConfiguration{ + ffRefreshTimer: mockTimer, + } + + // Call refreshFeatureFlags + refreshed, err := azappcfg.refreshFeatureFlags(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") +} + +// TestRefreshFeatureFlags_ChangesDetected tests when ETags changed and reload succeeds +func TestRefreshFeatureFlags_ChangesDetected(t *testing.T) { + // Setup mocks for successful refresh + mockTimer := &mockRefreshCondition{shouldRefresh: true} + mockMonitor := &mockETagsClient{changed: true} + + // Create mock feature flag values + value1 := `{ + "id": "Beta", + "description": "", + "enabled": false, + "conditions": { + "client_filters": [] + } + }` + + mockSettings := []azappconfig.Setting{ + {Key: toPtr(".appconfig.featureflag/Beta"), Value: &value1, ContentType: toPtr(featureFlagContentType)}, + } + + mockLoader := &mockKvRefreshClient{settings: mockSettings} + + mockClient := refreshClient{ + loader: mockLoader, + monitor: mockMonitor, + } + + // Setup provider with feature flags refresh timer + azappcfg := &AzureAppConfiguration{ + ffRefreshTimer: mockTimer, + featureFlags: make(map[string]any), + } + + // Call refreshFeatureFlags + refreshed, err := azappcfg.refreshFeatureFlags(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.True(t, mockTimer.resetCalled, "Timer should be reset after successful refresh") + + // Verify the feature flags structure was created correctly + assert.Contains(t, azappcfg.featureFlags, featureManagementSectionKey) + featureManagement, ok := azappcfg.featureFlags[featureManagementSectionKey].(map[string]any) + assert.True(t, ok, "feature_management should be a map") + assert.Contains(t, featureManagement, featureFlagSectionKey) +} + +// TestRefreshFeatureFlags_TimerNotExpired tests when the refresh timer hasn't expired yet +func TestRefreshFeatureFlags_TimerNotExpired(t *testing.T) { + // Setup mocks with non-expired timer + mockTimer := &mockRefreshCondition{shouldRefresh: false} + mockMonitor := &mockETagsClient{} + mockLoader := &mockKvRefreshClient{} + + mockClient := refreshClient{ + loader: mockLoader, + monitor: mockMonitor, + } + + // Setup provider + azappcfg := &AzureAppConfiguration{ + ffRefreshTimer: mockTimer, + } + + // Call refreshFeatureFlags + refreshed, err := azappcfg.refreshFeatureFlags(context.Background(), mockClient) + + // Verify results + assert.NoError(t, err) + assert.False(t, refreshed, "Should return false when timer hasn't expired") + assert.Equal(t, 0, mockMonitor.checkCallCount, "Monitor should not be called when timer hasn't expired") + assert.Equal(t, 0, mockLoader.getCallCount, "Loader should not be called when timer hasn't expired") + assert.False(t, mockTimer.resetCalled, "Timer should not be reset when it hasn't expired") +} + +// TestRefreshFeatureFlags_LoaderError tests when loader client returns an error +func TestRefreshFeatureFlags_LoaderError(t *testing.T) { + // Setup mocks with loader error + mockTimer := &mockRefreshCondition{shouldRefresh: true} + mockMonitor := &mockETagsClient{changed: true} + mockLoader := &mockKvRefreshClient{err: fmt.Errorf("feature flag loader error")} + + mockClient := refreshClient{ + loader: mockLoader, + monitor: mockMonitor, + } + + // Setup provider + azappcfg := &AzureAppConfiguration{ + ffRefreshTimer: mockTimer, + } + + // Call refreshFeatureFlags + refreshed, err := azappcfg.refreshFeatureFlags(context.Background(), mockClient) + + // Verify results + assert.Error(t, err) + assert.False(t, refreshed, "Should return false when error occurs") + assert.Contains(t, err.Error(), "feature flag 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") +} diff --git a/azureappconfiguration/utils.go b/azureappconfiguration/utils.go index 104b9fb..09499b7 100644 --- a/azureappconfiguration/utils.go +++ b/azureappconfiguration/utils.go @@ -53,11 +53,24 @@ func verifyOptions(options *Options) error { if options.KeyVaultOptions.RefreshOptions.Enabled { if options.KeyVaultOptions.RefreshOptions.Interval != 0 && - options.KeyVaultOptions.RefreshOptions.Interval < minimalRefreshInterval { + options.KeyVaultOptions.RefreshOptions.Interval < minimalKeyVaultRefreshInterval { return fmt.Errorf("refresh interval of Key Vault secrets cannot be less than %s", minimalKeyVaultRefreshInterval) } } + if options.FeatureFlagOptions.Enabled { + if err := verifySelectors(options.FeatureFlagOptions.Selectors); err != nil { + return err + } + + if options.FeatureFlagOptions.RefreshOptions.Enabled { + if options.FeatureFlagOptions.RefreshOptions.Interval != 0 && + options.FeatureFlagOptions.RefreshOptions.Interval < minimalRefreshInterval { + return fmt.Errorf("feature flag refresh interval cannot be less than %s", minimalRefreshInterval) + } + } + } + return nil } diff --git a/azureappconfiguration/utils_test.go b/azureappconfiguration/utils_test.go index 7754640..2513d23 100644 --- a/azureappconfiguration/utils_test.go +++ b/azureappconfiguration/utils_test.go @@ -355,6 +355,302 @@ func TestIsAIChatCompletionContentType(t *testing.T) { } } +func TestVerifyRefreshOptions(t *testing.T) { + tests := []struct { + name string + options *Options + expectedError string // Empty string means no error expected + }{ + // KeyValue refresh options tests + { + name: "valid key value refresh interval", + options: &Options{ + RefreshOptions: KeyValueRefreshOptions{ + Enabled: true, + Interval: 5 * minimalRefreshInterval, + }, + }, + expectedError: "", + }, + { + name: "too small key value refresh interval", + options: &Options{ + RefreshOptions: KeyValueRefreshOptions{ + Enabled: true, + Interval: minimalRefreshInterval / 2, + }, + }, + expectedError: "key value refresh interval cannot be less than", + }, + { + name: "valid watched settings", + options: &Options{ + RefreshOptions: KeyValueRefreshOptions{ + Enabled: true, + WatchedSettings: []WatchedSetting{ + {Key: "validKey", Label: "validLabel"}, + {Key: "anotherKey"}, + }, + }, + }, + expectedError: "", + }, + { + name: "empty key in watched setting", + options: &Options{ + RefreshOptions: KeyValueRefreshOptions{ + Enabled: true, + WatchedSettings: []WatchedSetting{ + {Key: "", Label: "validLabel"}, + }, + }, + }, + expectedError: "watched setting key cannot be empty", + }, + { + name: "wildcard in key of watched setting", + options: &Options{ + RefreshOptions: KeyValueRefreshOptions{ + Enabled: true, + WatchedSettings: []WatchedSetting{ + {Key: "invalid*Key", Label: "validLabel"}, + }, + }, + }, + expectedError: "watched setting key cannot contain", + }, + { + name: "comma in key of watched setting", + options: &Options{ + RefreshOptions: KeyValueRefreshOptions{ + Enabled: true, + WatchedSettings: []WatchedSetting{ + {Key: "invalid,Key", Label: "validLabel"}, + }, + }, + }, + expectedError: "watched setting key cannot contain", + }, + { + name: "wildcard in label of watched setting", + options: &Options{ + RefreshOptions: KeyValueRefreshOptions{ + Enabled: true, + WatchedSettings: []WatchedSetting{ + {Key: "validKey", Label: "invalid*Label"}, + }, + }, + }, + expectedError: "watched setting label cannot contain", + }, + { + name: "comma in label of watched setting", + options: &Options{ + RefreshOptions: KeyValueRefreshOptions{ + Enabled: true, + WatchedSettings: []WatchedSetting{ + {Key: "validKey", Label: "invalid,Label"}, + }, + }, + }, + expectedError: "watched setting label cannot contain", + }, + { + name: "empty label is allowed in watched setting", + options: &Options{ + RefreshOptions: KeyValueRefreshOptions{ + Enabled: true, + WatchedSettings: []WatchedSetting{ + {Key: "validKey", Label: ""}, + }, + }, + }, + expectedError: "", + }, + + // KeyVault refresh options tests + { + name: "valid Key Vault refresh interval", + options: &Options{ + KeyVaultOptions: KeyVaultOptions{ + RefreshOptions: RefreshOptions{ + Enabled: true, + Interval: 5 * minimalKeyVaultRefreshInterval, + }, + }, + }, + expectedError: "", + }, + { + name: "too small Key Vault refresh interval", + options: &Options{ + KeyVaultOptions: KeyVaultOptions{ + RefreshOptions: RefreshOptions{ + Enabled: true, + Interval: minimalKeyVaultRefreshInterval / 2, + }, + }, + }, + expectedError: "refresh interval of Key Vault secrets cannot be less than", + }, + + // Feature Flag refresh options tests + { + name: "valid feature flag refresh interval", + options: &Options{ + FeatureFlagOptions: FeatureFlagOptions{ + Enabled: true, + RefreshOptions: RefreshOptions{ + Enabled: true, + Interval: 5 * minimalRefreshInterval, + }, + }, + }, + expectedError: "", + }, + { + name: "too small feature flag refresh interval", + options: &Options{ + FeatureFlagOptions: FeatureFlagOptions{ + Enabled: true, + RefreshOptions: RefreshOptions{ + Enabled: true, + Interval: minimalRefreshInterval / 2, + }, + }, + }, + expectedError: "feature flag refresh interval cannot be less than", + }, + { + name: "valid feature flag selectors", + options: &Options{ + FeatureFlagOptions: FeatureFlagOptions{ + Enabled: true, + Selectors: []Selector{ + {KeyFilter: "validKey", LabelFilter: "validLabel"}, + }, + }, + }, + expectedError: "", + }, + { + name: "invalid feature flag selectors - empty key filter", + options: &Options{ + FeatureFlagOptions: FeatureFlagOptions{ + Enabled: true, + Selectors: []Selector{ + {KeyFilter: "", LabelFilter: "validLabel"}, + }, + }, + }, + expectedError: "key filter cannot be empty", + }, + + // Combined scenarios + { + name: "multiple valid refresh options", + options: &Options{ + RefreshOptions: KeyValueRefreshOptions{ + Enabled: true, + Interval: 10 * minimalRefreshInterval, + }, + KeyVaultOptions: KeyVaultOptions{ + RefreshOptions: RefreshOptions{ + Enabled: true, + Interval: 10 * minimalKeyVaultRefreshInterval, + }, + }, + FeatureFlagOptions: FeatureFlagOptions{ + Enabled: true, + RefreshOptions: RefreshOptions{ + Enabled: true, + Interval: 10 * minimalRefreshInterval, + }, + }, + }, + expectedError: "", + }, + { + name: "multiple refresh options with one invalid", + options: &Options{ + RefreshOptions: KeyValueRefreshOptions{ + Enabled: true, + Interval: 10 * minimalRefreshInterval, + }, + KeyVaultOptions: KeyVaultOptions{ + RefreshOptions: RefreshOptions{ + Enabled: true, + Interval: minimalKeyVaultRefreshInterval / 2, // Invalid + }, + }, + FeatureFlagOptions: FeatureFlagOptions{ + Enabled: true, + RefreshOptions: RefreshOptions{ + Enabled: true, + Interval: 10 * minimalRefreshInterval, + }, + }, + }, + expectedError: "refresh interval of Key Vault secrets cannot be less than", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := verifyOptions(test.options) + if test.expectedError == "" { + assert.NoError(t, err) + } else { + assert.Error(t, err) + assert.Contains(t, err.Error(), test.expectedError) + } + }) + } +} + +func TestGetFeatureFlagSelectors(t *testing.T) { + tests := []struct { + name string + input []Selector + expected []Selector + }{ + { + name: "single selector", + input: []Selector{ + {KeyFilter: "Beta", LabelFilter: "dev"}, + }, + expected: []Selector{ + {KeyFilter: featureFlagKeyPrefix + "Beta", LabelFilter: "dev"}, + }, + }, + { + name: "multiple selectors", + input: []Selector{ + {KeyFilter: "Beta", LabelFilter: "dev"}, + {KeyFilter: "Alpha", LabelFilter: "prod"}, + {KeyFilter: "*", LabelFilter: ""}, + }, + expected: []Selector{ + {KeyFilter: featureFlagKeyPrefix + "Beta", LabelFilter: "dev"}, + {KeyFilter: featureFlagKeyPrefix + "Alpha", LabelFilter: "prod"}, + {KeyFilter: featureFlagKeyPrefix + "*", LabelFilter: ""}, + }, + }, + { + name: "empty input", + input: []Selector{}, + expected: []Selector{}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := getFeatureFlagSelectors(test.input) + assert.Equal(t, test.expected, result) + }) + } +} + // 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 fb3a7de..fb22caf 100644 --- a/azureappconfiguration/version.go +++ b/azureappconfiguration/version.go @@ -5,5 +5,5 @@ package azureappconfiguration const ( moduleName = "azcfg-go" - moduleVersion = "1.0.0" + moduleVersion = "1.1.0-beta.1" ) diff --git a/example/console_app/console_example/go.mod b/example/console_app/console_example/go.mod index 79a086c..154fbae 100644 --- a/example/console_app/console_example/go.mod +++ b/example/console_app/console_example/go.mod @@ -5,13 +5,13 @@ go 1.23.0 require github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration v1.0.0 require ( - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig v1.2.0-beta.1 // indirect - github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect + 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.35.0 // indirect - golang.org/x/sync v0.11.0 // indirect - golang.org/x/text v0.22.0 // indirect + github.com/go-viper/mapstructure/v2 v2.3.0 // indirect + golang.org/x/net v0.40.0 // indirect + golang.org/x/sync v0.14.0 // indirect + golang.org/x/text v0.25.0 // indirect ) diff --git a/example/console_app/console_example/go.sum b/example/console_app/console_example/go.sum index 2724029..827f918 100644 --- a/example/console_app/console_example/go.sum +++ b/example/console_app/console_example/go.sum @@ -1,23 +1,25 @@ -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ= -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.2.0-beta.1 h1:wSwUNd/Tbq0e0zZjWuRQL4tsBxoZ0tYIJe+rBZZQApY= -github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig v1.2.0-beta.1/go.mod h1:0uyyPvSFLlPiPzoTTLXN6wR9sFFqL6iPVd4FAugCooo= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= +github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration v1.0.0 h1:RAfoZfilahJimbCqp2EMpW0/zU+YJLSPraOz8EMgD8g= +github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration v1.0.0/go.mod h1:y8XIVN80KDKlhrzUk9CupV1vikCSY75IZmD2CKy7lL0= +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.3.3 h1:H5xDQaE3XowWfhZRUpnfC+rGZMEVoSiji+b+/HFAPU4= -github.com/AzureAD/microsoft-authentication-library-for-go v1.3.3/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +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.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= +github.com/go-viper/mapstructure/v2 v2.3.0/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= @@ -30,15 +32,15 @@ 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.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= -golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= -golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= -golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= -golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= -golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +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/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_app/console_example_refresh/go.mod b/example/console_app/console_example_refresh/go.mod index cfe3e67..3ea4aa5 100644 --- a/example/console_app/console_example_refresh/go.mod +++ b/example/console_app/console_example_refresh/go.mod @@ -10,8 +10,8 @@ require ( 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 + github.com/go-viper/mapstructure/v2 v2.3.0 // indirect + golang.org/x/net v0.40.0 // indirect + golang.org/x/sync v0.14.0 // indirect + golang.org/x/text v0.25.0 // indirect ) diff --git a/example/console_app/console_example_refresh/go.sum b/example/console_app/console_example_refresh/go.sum index 37ed2d9..827f918 100644 --- a/example/console_app/console_example_refresh/go.sum +++ b/example/console_app/console_example_refresh/go.sum @@ -1,3 +1,5 @@ +github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration v1.0.0 h1:RAfoZfilahJimbCqp2EMpW0/zU+YJLSPraOz8EMgD8g= +github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration v1.0.0/go.mod h1:y8XIVN80KDKlhrzUk9CupV1vikCSY75IZmD2CKy7lL0= 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= @@ -14,8 +16,8 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJ 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/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= +github.com/go-viper/mapstructure/v2 v2.3.0/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= @@ -30,15 +32,15 @@ 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/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.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 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/gin_web_app/gin-example-refresh/go.mod b/example/gin_web_app/gin-example-refresh/go.mod index 06ab26c..70a88f5 100644 --- a/example/gin_web_app/gin-example-refresh/go.mod +++ b/example/gin_web_app/gin-example-refresh/go.mod @@ -22,7 +22,7 @@ require ( 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/go-viper/mapstructure/v2 v2.3.0 // 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 @@ -34,11 +34,11 @@ require ( 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/crypto v0.38.0 // indirect + golang.org/x/net v0.40.0 // indirect + golang.org/x/sync v0.14.0 // indirect golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.24.0 // indirect + golang.org/x/text v0.25.0 // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/example/gin_web_app/gin-example-refresh/go.sum b/example/gin_web_app/gin-example-refresh/go.sum index 101a66b..31a7699 100644 --- a/example/gin_web_app/gin-example-refresh/go.sum +++ b/example/gin_web_app/gin-example-refresh/go.sum @@ -1,3 +1,5 @@ +github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration v1.0.0 h1:RAfoZfilahJimbCqp2EMpW0/zU+YJLSPraOz8EMgD8g= +github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration v1.0.0/go.mod h1:y8XIVN80KDKlhrzUk9CupV1vikCSY75IZmD2CKy7lL0= 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= @@ -37,8 +39,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn 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/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= +github.com/go-viper/mapstructure/v2 v2.3.0/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= @@ -98,18 +100,18 @@ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ 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/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.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/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 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= diff --git a/example/gin_web_app/gin-example/go.mod b/example/gin_web_app/gin-example/go.mod index 3d9fab6..1ac5dde 100644 --- a/example/gin_web_app/gin-example/go.mod +++ b/example/gin_web_app/gin-example/go.mod @@ -8,15 +8,15 @@ require ( ) require ( - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig v1.2.0-beta.1 // indirect - github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect + 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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/google/go-cmp v0.5.9 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - golang.org/x/sync v0.11.0 // indirect + golang.org/x/sync v0.14.0 // indirect ) require ( @@ -29,7 +29,7 @@ require ( 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.23.0 // indirect - github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/go-viper/mapstructure/v2 v2.3.0 // indirect github.com/goccy/go-json v0.10.3 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect @@ -41,10 +41,10 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.12.0 // indirect - golang.org/x/crypto v0.33.0 // indirect - golang.org/x/net v0.35.0 // indirect - golang.org/x/sys v0.30.0 // indirect - golang.org/x/text v0.22.0 // indirect + golang.org/x/crypto v0.38.0 // indirect + golang.org/x/net v0.40.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.25.0 // indirect google.golang.org/protobuf v1.35.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/example/gin_web_app/gin-example/go.sum b/example/gin_web_app/gin-example/go.sum index fcf9186..3495942 100644 --- a/example/gin_web_app/gin-example/go.sum +++ b/example/gin_web_app/gin-example/go.sum @@ -1,17 +1,19 @@ -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ= -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.2.0-beta.1 h1:wSwUNd/Tbq0e0zZjWuRQL4tsBxoZ0tYIJe+rBZZQApY= -github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig v1.2.0-beta.1/go.mod h1:0uyyPvSFLlPiPzoTTLXN6wR9sFFqL6iPVd4FAugCooo= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= +github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration v1.0.0 h1:RAfoZfilahJimbCqp2EMpW0/zU+YJLSPraOz8EMgD8g= +github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration v1.0.0/go.mod h1:y8XIVN80KDKlhrzUk9CupV1vikCSY75IZmD2CKy7lL0= +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.3.3 h1:H5xDQaE3XowWfhZRUpnfC+rGZMEVoSiji+b+/HFAPU4= -github.com/AzureAD/microsoft-authentication-library-for-go v1.3.3/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +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.12.4 h1:9Csb3c9ZJhfUWeMtpCDCq6BUoH5ogfDFLUgQ/jG+R0k= github.com/bytedance/sonic v1.12.4/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= @@ -39,12 +41,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= github.com/go-playground/validator/v10 v10.23.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/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= +github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +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.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -98,17 +100,17 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg= golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= -golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= -golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= -golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= -golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= -golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= -golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +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= google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=