diff --git a/azureappconfiguration/azureappconfiguration.go b/azureappconfiguration/azureappconfiguration.go index e3fbd79..b8c9d65 100644 --- a/azureappconfiguration/azureappconfiguration.go +++ b/azureappconfiguration/azureappconfiguration.go @@ -480,6 +480,11 @@ func (azappcfg *AzureAppConfiguration) loadFeatureFlags(ctx context.Context, set dedupFeatureFlags := make(map[string]any, len(settingsResponse.settings)) for _, setting := range settingsResponse.settings { + // Skip non-feature flag settings + if setting.ContentType == nil || *setting.ContentType != featureFlagContentType { + continue + } + if setting.Key != nil { var v map[string]any if err := json.Unmarshal([]byte(*setting.Value), &v); err != nil { @@ -725,7 +730,9 @@ func deduplicateSelectors(selectors []Selector) []Selector { func getFeatureFlagSelectors(selectors []Selector) []Selector { for i := range selectors { - selectors[i].KeyFilter = featureFlagKeyPrefix + selectors[i].KeyFilter + if selectors[i].SnapshotName == "" { + selectors[i].KeyFilter = featureFlagKeyPrefix + selectors[i].KeyFilter + } } return selectors diff --git a/azureappconfiguration/options.go b/azureappconfiguration/options.go index 2950db7..f96ec9e 100644 --- a/azureappconfiguration/options.go +++ b/azureappconfiguration/options.go @@ -70,6 +70,12 @@ type Selector struct { // Empty string or omitted value will use the default no-label filter. // Note: Wildcards are not supported in label filters. LabelFilter string + + // Snapshot is a set of key-values selected from the App Configuration store based on the composition type and filters. + // Once created, it is stored as an immutable entity that can be referenced by name. + // SnapshotName specifies the name of the snapshot to retrieve. + // If SnapshotName is used in a selector, no key and label filter should be used for it. Otherwise, an error will be returned. + SnapshotName string } // KeyValueRefreshOptions contains optional parameters to configure the behavior of key-value settings refresh diff --git a/azureappconfiguration/settings_client.go b/azureappconfiguration/settings_client.go index e10235a..ca3bbc9 100644 --- a/azureappconfiguration/settings_client.go +++ b/azureappconfiguration/settings_client.go @@ -6,6 +6,7 @@ package azureappconfiguration import ( "context" "errors" + "fmt" "log" "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/tracing" @@ -62,25 +63,46 @@ func (s *selectorSettingsClient) getSettings(ctx context.Context) (*settingsResp settings := make([]azappconfig.Setting, 0) pageETags := make(map[Selector][]*azcore.ETag) for _, filter := range s.selectors { - selector := azappconfig.SettingSelector{ - KeyFilter: to.Ptr(filter.KeyFilter), - LabelFilter: to.Ptr(filter.LabelFilter), - Fields: azappconfig.AllSettingFields(), - } + if filter.SnapshotName == "" { + selector := azappconfig.SettingSelector{ + KeyFilter: to.Ptr(filter.KeyFilter), + LabelFilter: to.Ptr(filter.LabelFilter), + Fields: azappconfig.AllSettingFields(), + } - pager := s.client.NewListSettingsPager(selector, nil) - eTags := make([]*azcore.ETag, 0) - for pager.More() { - page, err := pager.NextPage(ctx) + pager := s.client.NewListSettingsPager(selector, nil) + eTags := make([]*azcore.ETag, 0) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, err + } else if page.Settings != nil { + settings = append(settings, page.Settings...) + eTags = append(eTags, page.ETag) + } + } + + pageETags[filter] = eTags + } else { + snapshot, err := s.client.GetSnapshot(ctx, filter.SnapshotName, nil) if err != nil { return nil, err - } else if page.Settings != nil { - settings = append(settings, page.Settings...) - eTags = append(eTags, page.ETag) } - } - pageETags[filter] = eTags + if snapshot.CompositionType == nil || *snapshot.CompositionType != azappconfig.CompositionTypeKey { + return nil, fmt.Errorf("composition type for the selected snapshot '%s' must be 'key'", filter.SnapshotName) + } + + pager := s.client.NewListSettingsForSnapshotPager(filter.SnapshotName, nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, err + } else if page.Settings != nil { + settings = append(settings, page.Settings...) + } + } + } } return &settingsResponse{ diff --git a/azureappconfiguration/snapshot_test.go b/azureappconfiguration/snapshot_test.go new file mode 100644 index 0000000..4506e91 --- /dev/null +++ b/azureappconfiguration/snapshot_test.go @@ -0,0 +1,436 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package azureappconfiguration + +import ( + "context" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestLoadKeyValues_WithSnapshot_Success(t *testing.T) { + // Create mock client + mockClient := &mockSettingsClient{} + + // Create string variables for the test values + appName := "MyApp" + appVersion := "1.0.0" + dbHost := "localhost" + + // Mock settings response for snapshot + settings := []azappconfig.Setting{ + { + Key: to.Ptr("app:name"), + Value: &appName, + ETag: to.Ptr(azcore.ETag("test-etag-1")), + }, + { + Key: to.Ptr("app:version"), + Value: &appVersion, + ETag: to.Ptr(azcore.ETag("test-etag-2")), + }, + { + Key: to.Ptr("database:host"), + Value: &dbHost, + ETag: to.Ptr(azcore.ETag("test-etag-3")), + }, + } + + mockClient.On("getSettings", mock.Anything).Return(&settingsResponse{ + settings: settings, + }, nil) + + // Create app configuration with snapshot selector + azappcfg := &AzureAppConfiguration{ + keyValues: make(map[string]any), + kvSelectors: []Selector{ + {SnapshotName: "test-snapshot"}, + }, + } + + // Load key values + err := azappcfg.loadKeyValues(context.Background(), mockClient) + + // Verify results + assert.NoError(t, err) + assert.Equal(t, &appName, azappcfg.keyValues["app:name"]) + assert.Equal(t, &appVersion, azappcfg.keyValues["app:version"]) + assert.Equal(t, &dbHost, azappcfg.keyValues["database:host"]) + + // Verify that mock was called + mockClient.AssertExpectations(t) +} + +func TestLoadKeyValues_WithSnapshot_MixedWithRegularSelectors(t *testing.T) { + // Create mock client that will be called twice (once for snapshot, once for regular selector) + mockClient := &mockSettingsClient{} + + // Create string variables for the test values + value1 := "value1" + value2 := "value2" + + // First call for snapshot + snapshotSettings := []azappconfig.Setting{ + { + Key: toPtr("snapshot:key1"), + Value: &value1, + ETag: to.Ptr(azcore.ETag("test-etag-1")), + }, + } + + // Second call for regular selector + regularSettings := []azappconfig.Setting{ + { + Key: toPtr("regular:key2"), + Value: &value2, + ETag: to.Ptr(azcore.ETag("test-etag-2")), + }, + } + + // Set up sequential mock calls + mockClient.On("getSettings", mock.Anything).Return(&settingsResponse{ + settings: append(snapshotSettings, regularSettings...), + }, nil).Once() + + // Create app configuration with mixed selectors + azappcfg := &AzureAppConfiguration{ + keyValues: make(map[string]any), + kvSelectors: []Selector{ + {SnapshotName: "test-snapshot"}, + {KeyFilter: "regular*", LabelFilter: "prod"}, + }, + } + + // Load key values + err := azappcfg.loadKeyValues(context.Background(), mockClient) + + // Verify results + assert.NoError(t, err) + assert.Equal(t, &value1, azappcfg.keyValues["snapshot:key1"]) + assert.Equal(t, &value2, azappcfg.keyValues["regular:key2"]) + + // Verify that mock was called + mockClient.AssertExpectations(t) +} + +func TestLoadFeatureFlags_WithSnapshot(t *testing.T) { + // Create mock client + mockClient := &mockSettingsClient{} + + // Mock feature flag from snapshot - create as string variable + featureFlagJson := `{ + "id": "SnapshotFeature", + "description": "Feature from snapshot", + "enabled": true, + "conditions": { + "client_filters": [] + } + }` + + settings := []azappconfig.Setting{ + { + Key: toPtr(".appconfig.featureflag/SnapshotFeature"), + Value: &featureFlagJson, + ContentType: toPtr("application/vnd.microsoft.appconfig.ff+json;charset=utf-8"), + ETag: to.Ptr(azcore.ETag("test-etag-1")), + }, + } + + mockClient.On("getSettings", mock.Anything).Return(&settingsResponse{ + settings: settings, + }, nil) + + // Create app configuration with feature flags enabled and snapshot selector + azappcfg := &AzureAppConfiguration{ + featureFlags: make(map[string]any), + ffEnabled: true, + ffSelectors: []Selector{ + {SnapshotName: "feature-snapshot"}, + }, + } + + // Load feature flags + err := azappcfg.loadFeatureFlags(context.Background(), mockClient) + + // Verify results + assert.NoError(t, err) + + // Verify feature management structure is created correctly + featureManagement, exists := azappcfg.featureFlags["feature_management"] + assert.True(t, exists) + + featureManagementMap, ok := featureManagement.(map[string]any) + assert.True(t, ok) + + // Verify feature_flags array exists + featureFlagsArray, exists := featureManagementMap["feature_flags"] + assert.True(t, exists) + + // Verify we have 1 feature flag + flags, ok := featureFlagsArray.([]any) + assert.True(t, ok) + assert.Len(t, flags, 1) + + // Verify the feature flag is properly unmarshaled + flag, ok := flags[0].(map[string]any) + assert.True(t, ok) + assert.Equal(t, "SnapshotFeature", flag["id"]) + assert.Equal(t, "Feature from snapshot", flag["description"]) + assert.Equal(t, true, flag["enabled"]) + + // Verify that mock was called + mockClient.AssertExpectations(t) +} + +func TestLoadSnapshot_MixedContent_OnlyKeyValuesWithoutFeatureFlagsEnabled(t *testing.T) { + // Create mock client + mockClient := &mockSettingsClient{} + + // Create string variables for the test values + appName := "MyApp" + appVersion := "1.0.0" + dbHost := "localhost" + featureFlagValue := `{"id": "MyFeature", "enabled": true, "conditions": {"client_filters": []}}` + + // Mock snapshot that contains both key values and feature flags + settings := []azappconfig.Setting{ + // Regular key values + { + Key: toPtr("app:name"), + Value: &appName, + ETag: to.Ptr(azcore.ETag("test-etag-1")), + }, + { + Key: toPtr("app:version"), + Value: &appVersion, + ETag: to.Ptr(azcore.ETag("test-etag-2")), + }, + // Feature flag (should be filtered out when feature flags are not enabled) + { + Key: toPtr(".appconfig.featureflag/MyFeature"), + Value: &featureFlagValue, + ContentType: toPtr("application/vnd.microsoft.appconfig.ff+json;charset=utf-8"), + ETag: to.Ptr(azcore.ETag("test-etag-3")), + }, + // Another regular key value + { + Key: toPtr("database:host"), + Value: &dbHost, + ETag: to.Ptr(azcore.ETag("test-etag-4")), + }, + } + + mockClient.On("getSettings", mock.Anything).Return(&settingsResponse{ + settings: settings, + }, nil) + + // Create app configuration with snapshot selector but WITHOUT feature flags enabled + azappcfg := &AzureAppConfiguration{ + keyValues: make(map[string]any), + featureFlags: make(map[string]any), + kvSelectors: []Selector{ + {SnapshotName: "mixed-snapshot"}, + }, + ffEnabled: false, // Feature flags are NOT enabled + } + + // Load key values + err := azappcfg.loadKeyValues(context.Background(), mockClient) + + // Verify results + assert.NoError(t, err) + + // Verify that only key values are loaded, not feature flags + assert.Equal(t, &appName, azappcfg.keyValues["app:name"]) + assert.Equal(t, &appVersion, azappcfg.keyValues["app:version"]) + assert.Equal(t, &dbHost, azappcfg.keyValues["database:host"]) + + // Verify that feature flag key is NOT loaded as a regular key value + assert.NotContains(t, azappcfg.keyValues, ".appconfig.featureflag/MyFeature") + + // Verify that feature flags map remains empty since feature flags are not enabled + assert.Empty(t, azappcfg.featureFlags) + + // Verify that mock was called + mockClient.AssertExpectations(t) +} + +func TestLoadSnapshot_MixedContent_FeatureFlagsEnabledWithDifferentSelectors(t *testing.T) { + // Create two mock clients - one for key values, one for feature flags + kvMockClient := &mockSettingsClient{} + ffMockClient := &mockSettingsClient{} + + // Create string variables for test values + appName := "MyApp" + appVersion := "1.0.0" + featureFlagValue := `{"id": "FeatureFromDifferentSnapshot", "enabled": true, "conditions": {"client_filters": []}}` + + // Mock key values from snapshot (excluding feature flags) + kvSettings := []azappconfig.Setting{ + { + Key: toPtr("app:name"), + Value: &appName, + ETag: to.Ptr(azcore.ETag("test-etag-1")), + }, + { + Key: toPtr("app:version"), + Value: &appVersion, + ETag: to.Ptr(azcore.ETag("test-etag-2")), + }, + } + + // Mock feature flags from a different snapshot + ffSettings := []azappconfig.Setting{ + { + Key: toPtr(".appconfig.featureflag/FeatureFromDifferentSnapshot"), + Value: &featureFlagValue, + ContentType: toPtr("application/vnd.microsoft.appconfig.ff+json;charset=utf-8"), + ETag: to.Ptr(azcore.ETag("test-etag-3")), + }, + } + + kvMockClient.On("getSettings", mock.Anything).Return(&settingsResponse{ + settings: kvSettings, + }, nil) + + ffMockClient.On("getSettings", mock.Anything).Return(&settingsResponse{ + settings: ffSettings, + }, nil) + + // Create app configuration with different snapshot selectors for key values and feature flags + azappcfg := &AzureAppConfiguration{ + keyValues: make(map[string]any), + featureFlags: make(map[string]any), + kvSelectors: []Selector{ + {SnapshotName: "keyvalue-snapshot"}, + }, + ffEnabled: true, + ffSelectors: []Selector{ + {SnapshotName: "featureflag-snapshot"}, + }, + } + + // Load key values and feature flags separately + err := azappcfg.loadKeyValues(context.Background(), kvMockClient) + assert.NoError(t, err) + + err = azappcfg.loadFeatureFlags(context.Background(), ffMockClient) + assert.NoError(t, err) + + // Verify results + // Key values should be loaded from keyvalue-snapshot + assert.Equal(t, &appName, azappcfg.keyValues["app:name"]) + assert.Equal(t, &appVersion, azappcfg.keyValues["app:version"]) + + // Feature flags should be loaded from featureflag-snapshot + featureManagement, exists := azappcfg.featureFlags["feature_management"] + assert.True(t, exists) + + featureManagementMap, ok := featureManagement.(map[string]any) + assert.True(t, ok) + + featureFlagsArray, exists := featureManagementMap["feature_flags"] + assert.True(t, exists) + + flags, ok := featureFlagsArray.([]any) + assert.True(t, ok) + assert.Len(t, flags, 1) + + flag, ok := flags[0].(map[string]any) + assert.True(t, ok) + assert.Equal(t, "FeatureFromDifferentSnapshot", flag["id"]) + + // Verify that both mocks were called + kvMockClient.AssertExpectations(t) + ffMockClient.AssertExpectations(t) +} + +func TestLoadSnapshot_MixedContent_KeyValueSelectorsIgnoreFeatureFlags(t *testing.T) { + // Create mock client + mockClient := &mockSettingsClient{} + + // Create string variables for test values + appName := "MyApp" + configTimeout := "30" + dbPort := "5432" + feature1Value := `{"id": "Feature1", "enabled": true, "conditions": {"client_filters": []}}` + feature2Value := `{"id": "Feature2", "enabled": false, "conditions": {"client_filters": []}}` + + // Mock snapshot containing mixed content + settings := []azappconfig.Setting{ + // Regular key values + { + Key: toPtr("app:name"), + Value: &appName, + ETag: to.Ptr(azcore.ETag("test-etag-1")), + }, + { + Key: toPtr("config:timeout"), + Value: &configTimeout, + ETag: to.Ptr(azcore.ETag("test-etag-2")), + }, + // Feature flags that should be ignored by key value loading + { + Key: toPtr(".appconfig.featureflag/Feature1"), + Value: &feature1Value, + ContentType: toPtr("application/vnd.microsoft.appconfig.ff+json;charset=utf-8"), + ETag: to.Ptr(azcore.ETag("test-etag-3")), + }, + { + Key: toPtr(".appconfig.featureflag/Feature2"), + Value: &feature2Value, + ContentType: toPtr("application/vnd.microsoft.appconfig.ff+json;charset=utf-8"), + ETag: to.Ptr(azcore.ETag("test-etag-4")), + }, + // Another regular key value + { + Key: toPtr("database:port"), + Value: &dbPort, + ETag: to.Ptr(azcore.ETag("test-etag-5")), + }, + } + + mockClient.On("getSettings", mock.Anything).Return(&settingsResponse{ + settings: settings, + }, nil) + + // Create app configuration with snapshot selector for key values only + azappcfg := &AzureAppConfiguration{ + keyValues: make(map[string]any), + featureFlags: make(map[string]any), + kvSelectors: []Selector{ + {SnapshotName: "mixed-content-snapshot"}, + }, + ffEnabled: false, // Feature flags are disabled + } + + // Load key values + err := azappcfg.loadKeyValues(context.Background(), mockClient) + + // Verify results + assert.NoError(t, err) + + // Verify that only non-feature-flag key values are loaded + assert.Equal(t, &appName, azappcfg.keyValues["app:name"]) + assert.Equal(t, &configTimeout, azappcfg.keyValues["config:timeout"]) + assert.Equal(t, &dbPort, azappcfg.keyValues["database:port"]) + + // Verify that feature flag keys are NOT loaded as regular key values + assert.NotContains(t, azappcfg.keyValues, ".appconfig.featureflag/Feature1") + assert.NotContains(t, azappcfg.keyValues, ".appconfig.featureflag/Feature2") + + // Verify that feature flags map remains empty + assert.Empty(t, azappcfg.featureFlags) + + // Verify the total number of loaded key values (should be 3, not 5) + assert.Len(t, azappcfg.keyValues, 3) + + // Verify that mock was called + mockClient.AssertExpectations(t) +} diff --git a/azureappconfiguration/utils.go b/azureappconfiguration/utils.go index 09499b7..6bee613 100644 --- a/azureappconfiguration/utils.go +++ b/azureappconfiguration/utils.go @@ -76,12 +76,18 @@ func verifyOptions(options *Options) error { func verifySelectors(selectors []Selector) error { for _, selector := range selectors { - if selector.KeyFilter == "" { - return fmt.Errorf("key filter cannot be empty") - } + if selector.SnapshotName != "" { + if selector.KeyFilter != "" || selector.LabelFilter != "" { + return fmt.Errorf("key and label filters should not be used if snapshot name is provided") + } + } else { + if selector.KeyFilter == "" { + return fmt.Errorf("one of key filter or snapshot name must be provided") + } - if strings.Contains(selector.LabelFilter, "*") || strings.Contains(selector.LabelFilter, ",") { - return fmt.Errorf("label filter cannot contain '*' or ','") + if strings.Contains(selector.LabelFilter, "*") || strings.Contains(selector.LabelFilter, ",") { + return fmt.Errorf("label filter cannot contain '*' or ','") + } } } diff --git a/azureappconfiguration/utils_test.go b/azureappconfiguration/utils_test.go index 2513d23..160b7f7 100644 --- a/azureappconfiguration/utils_test.go +++ b/azureappconfiguration/utils_test.go @@ -47,6 +47,48 @@ func TestVerifyOptions(t *testing.T) { }, expectedError: true, }, + { + name: "valid snapshot selector", + options: &Options{ + Selectors: []Selector{ + {SnapshotName: "my-snapshot"}, + }, + }, + expectedError: false, + }, + { + name: "snapshot with key filter", + options: &Options{ + Selectors: []Selector{ + {SnapshotName: "my-snapshot", KeyFilter: "app*"}, + }, + }, + expectedError: true, + }, + { + name: "feature flags with snapshot selector", + options: &Options{ + FeatureFlagOptions: FeatureFlagOptions{ + Enabled: true, + Selectors: []Selector{ + {SnapshotName: "feature-snapshot"}, + }, + }, + }, + expectedError: false, + }, + { + name: "feature flags with invalid snapshot selector", + options: &Options{ + FeatureFlagOptions: FeatureFlagOptions{ + Enabled: true, + Selectors: []Selector{ + {SnapshotName: "feature-snapshot", KeyFilter: "feature*"}, + }, + }, + }, + expectedError: true, + }, } for _, test := range tests { @@ -101,6 +143,49 @@ func TestVerifySelectors(t *testing.T) { }, expectedError: true, }, + { + name: "valid snapshot selector", + selectors: []Selector{ + {SnapshotName: "my-snapshot"}, + }, + expectedError: false, + }, + { + name: "snapshot with key filter", + selectors: []Selector{ + {SnapshotName: "my-snapshot", KeyFilter: "app*"}, + }, + expectedError: true, + }, + { + name: "snapshot with label filter", + selectors: []Selector{ + {SnapshotName: "my-snapshot", LabelFilter: "prod"}, + }, + expectedError: true, + }, + { + name: "snapshot with both key and label filters", + selectors: []Selector{ + {SnapshotName: "my-snapshot", KeyFilter: "app*", LabelFilter: "prod"}, + }, + expectedError: true, + }, + { + name: "mixed selectors with snapshot and regular", + selectors: []Selector{ + {SnapshotName: "my-snapshot"}, + {KeyFilter: "app*", LabelFilter: "prod"}, + }, + expectedError: false, + }, + { + name: "empty snapshot name", + selectors: []Selector{ + {SnapshotName: ""}, + }, + expectedError: true, + }, } for _, test := range tests { @@ -543,7 +628,7 @@ func TestVerifyRefreshOptions(t *testing.T) { }, }, }, - expectedError: "key filter cannot be empty", + expectedError: "one of key filter or snapshot name must be provided", }, // Combined scenarios