diff --git a/internal/loader/configuraiton_setting_loader_test.go b/internal/loader/configuraiton_setting_loader_test.go index 94ebda1..caa8717 100644 --- a/internal/loader/configuraiton_setting_loader_test.go +++ b/internal/loader/configuraiton_setting_loader_test.go @@ -78,6 +78,13 @@ func mockFeatureFlagSettings() []azappconfig.Setting { return settingsToReturn } +func mockVariantFeatureFlagSettings(key, label string, telemetryEnabled bool) []azappconfig.Setting { + settingsToReturn := make([]azappconfig.Setting, 1) + settingsToReturn[0] = newFeatureFlagVariant(key, label, telemetryEnabled) + + return settingsToReturn +} + func newKeyValueSelector(key string, label *string) acpv1.Selector { return acpv1.Selector{ KeyFilter: &key, @@ -133,6 +140,49 @@ func newFeatureFlagSettings(key string, label string) azappconfig.Setting { } } +func newFeatureFlagVariant(key string, label string, telemetryEnabled bool) azappconfig.Setting { + featureFlagContentType := FeatureFlagContentType + fakeETag := azcore.ETag("fakeETag") + featureFlagId := strings.TrimPrefix(key, ".appconfig.featureflag/") + featureFlagValue := fmt.Sprintf(`{ + "id": "%s", + "description": "", + "enabled": false, + "variants": [ + { + "name": "Off", + "configuration_value": false + }, + { + "name": "On", + "configuration_value": true + } + ], + "allocation": { + "percentile": [ + { + "variant": "Off", + "from": 0, + "to": 100 + } + ], + "default_when_enabled": "Off", + "default_when_disabled": "Off" + }, + "telemetry": { + "enabled": %t + } + }`, featureFlagId, telemetryEnabled) + + return azappconfig.Setting{ + Key: &key, + Value: &featureFlagValue, + Label: &label, + ContentType: &featureFlagContentType, + ETag: &fakeETag, + } +} + type MockResolveSecretReference struct { ctrl *gomock.Controller recorder *MockResolveSecretReferenceMockRecorder @@ -1074,6 +1124,108 @@ var _ = Describe("AppConfiguationProvider Get All Settings", func() { Expect(allSettings.ConfigMapSettings["settings.json"]).Should(Equal("{\"feature_management\":{\"feature_flags\":[{\"conditions\":{\"client_filters\":[]},\"description\":\"\",\"enabled\":false,\"id\":\"Beta\"}]}}")) }) + It("Succeed to get feature flag settings", func() { + By("By updating telemetry when telemetry is enabled") + featureFlagKeyFilter := "*" + testSpec := acpv1.AzureAppConfigurationProviderSpec{ + Endpoint: &EndpointName, + ReplicaDiscoveryEnabled: false, + Target: acpv1.ConfigurationGenerationParameters{ + ConfigMapName: ConfigMapName, + ConfigMapData: &acpv1.ConfigMapDataOptions{ + Type: acpv1.Json, + Key: "settings.json", + }, + }, + FeatureFlag: &acpv1.AzureAppConfigurationFeatureFlagOptions{ + Selectors: []acpv1.Selector{ + { + KeyFilter: &featureFlagKeyFilter, + }, + }, + }, + } + testProvider := acpv1.AzureAppConfigurationProvider{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "azconfig.io/v1", + Kind: "AppConfigurationProvider", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "testName", + Namespace: "testNamespace", + }, + Spec: testSpec, + } + + featureFlagsToReturn := mockVariantFeatureFlagSettings(".appconfig.featureflag/Telemetry_2", "Test", true) + featureFlagEtags := make(map[acpv1.Selector][]*azcore.ETag) + featureFlagEtags[newFeatureFlagSelector("*", nil)] = []*azcore.ETag{} + settingsResponse := &SettingsResponse{ + Settings: featureFlagsToReturn, + Etags: featureFlagEtags, + } + mockSettingsClient.EXPECT().GetSettings(gomock.Any(), gomock.Any()).Return(settingsResponse, nil).Times(2) + mockCongiurationClientManager.EXPECT().GetClients(gomock.Any()).Return([]*ConfigurationClientWrapper{&fakeClientWrapper}, nil).Times(2) + configurationProvider, _ := NewConfigurationSettingLoader(testProvider, mockCongiurationClientManager, mockSettingsClient) + allSettings, err := configurationProvider.CreateTargetSettings(context.Background(), mockResolveSecretReference) + + Expect(err).Should(BeNil()) + Expect(len(allSettings.ConfigMapSettings)).Should(Equal(1)) + Expect(allSettings.ConfigMapSettings["settings.json"]).Should( + Equal("{\"feature_management\":{\"feature_flags\":[{\"allocation\":{\"default_when_disabled\":\"Off\",\"default_when_enabled\":\"Off\",\"percentile\":[{\"from\":0,\"to\":100,\"variant\":\"Off\"}]},\"description\":\"\",\"enabled\":false,\"id\":\"Telemetry_2\",\"telemetry\":{\"enabled\":true,\"metadata\":{\"ETag\":\"fakeETag\",\"FeatureFlagId\":\"Rc8Am7HIGDT7HC5Ovs3wKN_aGaaK_Uz1mH2e11gaK0o\",\"FeatureFlagReference\":\"/kv/.appconfig.featureflag/Telemetry_2?label=Test\"}},\"variants\":[{\"configuration_value\":false,\"name\":\"Off\"},{\"configuration_value\":true,\"name\":\"On\"}]}]}}")) + }) + + It("Succeed to get feature flag settings", func() { + By("By not populating telemetry when telemetry is disabled") + featureFlagKeyFilter := "*" + testSpec := acpv1.AzureAppConfigurationProviderSpec{ + Endpoint: &EndpointName, + ReplicaDiscoveryEnabled: false, + Target: acpv1.ConfigurationGenerationParameters{ + ConfigMapName: ConfigMapName, + ConfigMapData: &acpv1.ConfigMapDataOptions{ + Type: acpv1.Json, + Key: "settings.json", + }, + }, + FeatureFlag: &acpv1.AzureAppConfigurationFeatureFlagOptions{ + Selectors: []acpv1.Selector{ + { + KeyFilter: &featureFlagKeyFilter, + }, + }, + }, + } + testProvider := acpv1.AzureAppConfigurationProvider{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "azconfig.io/v1", + Kind: "AppConfigurationProvider", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "testName", + Namespace: "testNamespace", + }, + Spec: testSpec, + } + + featureFlagsToReturn := mockVariantFeatureFlagSettings(".appconfig.featureflag/Telemetry_2", "Test", false) + featureFlagEtags := make(map[acpv1.Selector][]*azcore.ETag) + featureFlagEtags[newFeatureFlagSelector("*", nil)] = []*azcore.ETag{} + settingsResponse := &SettingsResponse{ + Settings: featureFlagsToReturn, + Etags: featureFlagEtags, + } + mockSettingsClient.EXPECT().GetSettings(gomock.Any(), gomock.Any()).Return(settingsResponse, nil).Times(2) + mockCongiurationClientManager.EXPECT().GetClients(gomock.Any()).Return([]*ConfigurationClientWrapper{&fakeClientWrapper}, nil).Times(2) + configurationProvider, _ := NewConfigurationSettingLoader(testProvider, mockCongiurationClientManager, mockSettingsClient) + allSettings, err := configurationProvider.CreateTargetSettings(context.Background(), mockResolveSecretReference) + + Expect(err).Should(BeNil()) + Expect(len(allSettings.ConfigMapSettings)).Should(Equal(1)) + Expect(allSettings.ConfigMapSettings["settings.json"]).Should( + Equal("{\"feature_management\":{\"feature_flags\":[{\"allocation\":{\"default_when_disabled\":\"Off\",\"default_when_enabled\":\"Off\",\"percentile\":[{\"from\":0,\"to\":100,\"variant\":\"Off\"}]},\"description\":\"\",\"enabled\":false,\"id\":\"Telemetry_2\",\"telemetry\":{\"enabled\":false},\"variants\":[{\"configuration_value\":false,\"name\":\"Off\"},{\"configuration_value\":true,\"name\":\"On\"}]}]}}")) + }) + It("Fail to get all configuration settings", func() { By("By getting error from Azure App Configuration") testSpec := acpv1.AzureAppConfigurationProviderSpec{ @@ -1397,6 +1549,16 @@ func TestGetFilters(t *testing.T) { assert.Equal(t, "snapshot", *filters10[1].SnapshotName) } +func TestFeatureFlagId(t *testing.T) { + telemetrySetting1 := newFeatureFlagVariant(".appconfig.featureflag/Telemetry_1", "", true) + calculatedId1 := calculateFeatureFlagId(telemetrySetting1) + assert.Equal(t, "krkOsu9dVV9huwbQDPR6gkV_2T0buWxOCS-nNsj5-6g", calculatedId1) + + telemetrySetting2 := newFeatureFlagVariant(".appconfig.featureflag/Telemetry_2", "Test", true) + calculatedId2 := calculateFeatureFlagId(telemetrySetting2) + assert.Equal(t, "Rc8Am7HIGDT7HC5Ovs3wKN_aGaaK_Uz1mH2e11gaK0o", calculatedId2) +} + func TestCompare(t *testing.T) { var nilString *string = nil stringA := "stringA" diff --git a/internal/loader/configuration_setting_loader.go b/internal/loader/configuration_setting_loader.go index 13e2fb3..9861552 100644 --- a/internal/loader/configuration_setting_loader.go +++ b/internal/loader/configuration_setting_loader.go @@ -6,6 +6,7 @@ package loader import ( acpv1 "azappconfig/provider/api/v1" "context" + "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/json" @@ -19,6 +20,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets" "golang.org/x/crypto/pkcs12" "golang.org/x/exp/maps" @@ -99,6 +101,16 @@ const ( RequestTracingEnabled string = "REQUEST_TRACING_ENABLED" ) +// Feature flag telemetry +const ( + TelemetryKey string = "telemetry" + EnabledKey string = "enabled" + MetadataKey string = "metadata" + ETagKey string = "ETag" + FeatureFlagIdKey string = "FeatureFlagId" + FeatureFlagReferenceKey string = "FeatureFlagReference" +) + func NewConfigurationSettingLoader(provider acpv1.AzureAppConfigurationProvider, clientManager ClientManager, settingsClient SettingsClient) (*ConfigurationSettingLoader, error) { return &ConfigurationSettingLoader{ AzureAppConfigurationProvider: provider, @@ -372,6 +384,10 @@ func (csl *ConfigurationSettingLoader) getFeatureFlagSettings(ctx context.Contex settingsLength := len(settingsResponse.Settings) featureFlagExist := make(map[string]bool, settingsLength) deduplicatedFeatureFlags := make([]interface{}, 0) + clientEndpoint := "" + if manager, ok := csl.ClientManager.(*ConfigurationClientManager); ok { + clientEndpoint = manager.lastSuccessfulEndpoint + } // if settings returned like this: [{"id": "Beta"...}, {"id": "Alpha"...}, {"id": "Beta"...}], we need to deduplicate it to [{"id": "Alpha"...}, {"id": "Beta"...}], the last one wins for i := settingsLength - 1; i >= 0; i-- { @@ -380,11 +396,12 @@ func (csl *ConfigurationSettingLoader) getFeatureFlagSettings(ctx context.Contex continue } featureFlagExist[key] = true - var out interface{} + var out map[string]interface{} err := json.Unmarshal([]byte(*settingsResponse.Settings[i].Value), &out) if err != nil { return nil, nil, fmt.Errorf("failed to unmarshal feature flag settings: %s", err.Error()) } + populateTelemetryMetadata(out, settingsResponse.Settings[i], clientEndpoint) deduplicatedFeatureFlags = append(deduplicatedFeatureFlags, out) } @@ -899,3 +916,54 @@ func reverseClients(clients []*ConfigurationClientWrapper, start, end int) { end-- } } + +func calculateFeatureFlagId(setting azappconfig.Setting) string { + // Create the basic value string + featureFlagId := *setting.Key + "\n" + if setting.Label != nil && strings.TrimSpace(*setting.Label) != "" { + featureFlagId += *setting.Label + } + + // Generate SHA-256 hash, and encode it to Base64 + hash := sha256.Sum256([]byte(featureFlagId)) + encodedFeatureFlag := base64.StdEncoding.EncodeToString(hash[:]) + + // Replace '+' with '-' and '/' with '_' + encodedFeatureFlag = strings.ReplaceAll(encodedFeatureFlag, "+", "-") + encodedFeatureFlag = strings.ReplaceAll(encodedFeatureFlag, "/", "_") + + // Remove all instances of "=" at the end of the string that were added as padding + if idx := strings.Index(encodedFeatureFlag, "="); idx != -1 { + encodedFeatureFlag = encodedFeatureFlag[:idx] + } + + return encodedFeatureFlag +} + +func generateFeatureFlagReference(setting azappconfig.Setting, endpoint string) string { + featureFlagReference := fmt.Sprintf("%s/kv/%s", endpoint, *setting.Key) + + // Check if the label is present and not empty + if setting.Label != nil && strings.TrimSpace(*setting.Label) != "" { + featureFlagReference += fmt.Sprintf("?label=%s", *setting.Label) + } + + return featureFlagReference +} + +func populateTelemetryMetadata(featureFlag map[string]interface{}, setting azappconfig.Setting, endpoint string) { + if telemetry, ok := featureFlag[TelemetryKey].(map[string]interface{}); ok { + if enabled, ok := telemetry[EnabledKey].(bool); ok && enabled { + metadata, _ := telemetry[MetadataKey].(map[string]interface{}) + if metadata == nil { + metadata = make(map[string]interface{}) + } + + // Set the new metadata + metadata[ETagKey] = *setting.ETag + metadata[FeatureFlagIdKey] = calculateFeatureFlagId(setting) + metadata[FeatureFlagReferenceKey] = generateFeatureFlagReference(setting, endpoint) + telemetry[MetadataKey] = metadata + } + } +}