diff --git a/config/clientconfig_factory.go b/config/clientconfig_factory.go index 7b25be9e1..3d97ffee7 100644 --- a/config/clientconfig_factory.go +++ b/config/clientconfig_factory.go @@ -17,6 +17,36 @@ import ( configtypes "github.com/vmware-tanzu/tanzu-plugin-runtime/config/types" ) +// GetClientConfig retrieves the config from the local directory with file lock +func GetClientConfig() (cfg *configtypes.ClientConfig, err error) { + // Retrieve client config node + node, err := getClientConfigNode() + if err != nil { + return nil, err + } + + cfg, err = convertNodeToClientConfig(node) + if err != nil { + return nil, err + } + + return cfg, nil +} + +// GetClientConfigNoLock retrieves the config from the local directory without acquiring the lock +func GetClientConfigNoLock() (cfg *configtypes.ClientConfig, err error) { + node, err := getClientConfigNodeNoLock() + if err != nil { + return nil, err + } + + cfg, err = convertNodeToClientConfig(node) + if err != nil { + return nil, err + } + return cfg, nil +} + // getClientConfigNode retrieves the multi config from the local directory with file lock func getClientConfigNode() (*yaml.Node, error) { useUnifiedConfig, err := UseUnifiedConfig() diff --git a/config/features.go b/config/features.go index 4bf604989..059215f92 100644 --- a/config/features.go +++ b/config/features.go @@ -8,11 +8,24 @@ import ( "strings" "github.com/pkg/errors" + + "github.com/vmware-tanzu/tanzu-plugin-runtime/config/types" + "gopkg.in/yaml.v3" "github.com/vmware-tanzu/tanzu-plugin-runtime/config/nodeutils" ) +// GetAllFeatureFlags retrieves all feature flags values from config +func GetAllFeatureFlags() (map[string]types.FeatureMap, error) { + // Retrieve client config node + cfg, err := GetClientConfig() + if err != nil { + return nil, err + } + return cfg.GetAllFeatureFlags() +} + // IsFeatureEnabled checks and returns whether specific plugin and key is true func IsFeatureEnabled(plugin, key string) (bool, error) { // Retrieve client config node @@ -204,3 +217,62 @@ func IsFeatureActivated(feature string) bool { } return status } + +// FeatureOptions is a struct that defines the options for feature flag configuration. +type FeatureOptions struct { + SkipIfExists bool // SkipIfExists indicates whether to skip setting the feature flag if it already exists. +} + +// Options is a function type that applies configuration options to FeatureOptions. +type Options func(opts *FeatureOptions) + +// SkipIfExists returns an Options function that sets the SkipIfExists option to true. +func SkipIfExists() Options { + return func(opts *FeatureOptions) { + opts.SkipIfExists = true // Sets the SkipIfExists field of FeatureOptions to true. + } +} + +// ConfigureFeatureFlags sets default feature flags to ClientConfig if they are missing. +// It accepts a map of feature flags and a variadic Options parameter to apply additional settings. +func ConfigureFeatureFlags(defaultFeatureFlags map[string]bool, opts ...Options) error { + options := new(FeatureOptions) // Initialize FeatureOptions. + for _, opt := range opts { + opt(options) // Apply each Options function to the FeatureOptions. + } + + cfg, err := GetClientConfig() // Retrieves the current client configuration. + if err != nil { + return errors.Wrap(err, "error while getting client config") // Returns an error if fetching client config fails. + } + + return configureFlags(cfg, defaultFeatureFlags, options) // Sets the feature flags based on the provided configuration and options. +} + +// configureFlags processes and sets the feature flags based on the provided configuration and options. +// It iterates over each flag in the provided map and sets them using the setFlag function. +func configureFlags(cfg *types.ClientConfig, flags map[string]bool, options *FeatureOptions) error { + for path, activated := range flags { + if err := setFlag(cfg, path, activated, options); err != nil { + return err // Returns an error if setting any feature flag fails. + } + } + return nil +} + +// setFlag configures a single feature flag based on the specified path and activation status. +// It extracts the plugin and feature from the path and sets the feature flag accordingly. +func setFlag(cfg *types.ClientConfig, path string, activated bool, options *FeatureOptions) error { + plugin, feature, err := cfg.SplitFeaturePath(path) + if err != nil { + return errors.Wrap(err, "failed to configure feature flags") // Returns an error if splitting the feature path fails. + } + + // Checks if the feature flag should be skipped if it already exists. + if options.SkipIfExists && cfg.IsFeatureFlagExists(plugin, feature) { + return nil // Skips setting the flag. + } + + // Sets the feature flag with the specified activation status. + return SetFeature(plugin, feature, strconv.FormatBool(activated)) +} diff --git a/config/features_test.go b/config/features_test.go index 88709ff87..1462f35ef 100644 --- a/config/features_test.go +++ b/config/features_test.go @@ -13,78 +13,57 @@ import ( ) func TestIsFeatureEnabled(t *testing.T) { - // setup - func() { - LocalDirName = TestLocalDirName - }() + // Setup config data + _, cleanUp := setupTestConfig(t, &CfgTestData{}) + defer func() { - cleanupDir(LocalDirName) + cleanUp() }() + tests := []struct { - name string - feature map[string]configtypes.FeatureMap - plugin string - key string - errStr string - errStrForStore string + name string + plugin string + key string + value string + errStr string + errStrForSetFeature string }{ { - name: "empty plugin", - feature: map[string]configtypes.FeatureMap{ - "": { - "context-aware-cli-for-plugins": "true", - }, - }, - plugin: "", - key: "context-aware-cli-for-plugins", - errStr: "plugin cannot be empty", - errStrForStore: "plugin cannot be empty", + name: "empty plugin", + plugin: "", + key: "feature1", + value: "true", + errStr: "plugin cannot be empty", + errStrForSetFeature: "plugin cannot be empty", }, { - name: "empty key", - feature: map[string]configtypes.FeatureMap{ - "global": { - "": "true", - }, - }, - plugin: "global", - key: "", - errStr: "key cannot be empty", - errStrForStore: "key cannot be empty", + name: "empty key", + plugin: "global", + key: "", + value: "", + errStr: "key cannot be empty", + errStrForSetFeature: "key cannot be empty", }, { - name: "empty value", - feature: map[string]configtypes.FeatureMap{ - "global": { - "context-aware-cli-for-plugins": "", - }, - }, - plugin: "global", - key: "context-aware-cli-for-plugins", - errStr: "not found", - errStrForStore: "value cannot be empty", + name: "empty value", + plugin: "global", + key: "feature1", + value: "", + errStr: "not found", + errStrForSetFeature: "value cannot be empty", }, { - name: "success context-aware-cli-for-plugins", - feature: map[string]configtypes.FeatureMap{ - "global": { - "context-aware-cli-for-plugins": "true", - }, - }, + name: "success feature1", plugin: "global", - key: "context-aware-cli-for-plugins", + key: "feature1", + value: "true", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - cfg := &configtypes.ClientConfig{ - ClientOptions: &configtypes.ClientOptions{ - Features: tc.feature, - }, - } - err := StoreClientConfig(cfg) - if tc.errStrForStore != "" { - assert.Equal(t, tc.errStrForStore, err.Error()) + err := SetFeature(tc.plugin, tc.key, tc.value) + if tc.errStrForSetFeature != "" { + assert.Equal(t, tc.errStrForSetFeature, err.Error()) } else { assert.NoError(t, err) } @@ -101,72 +80,56 @@ func TestIsFeatureEnabled(t *testing.T) { } func TestSetAndDeleteFeature(t *testing.T) { - // setup - func() { - LocalDirName = TestLocalDirName - }() + // Setup config data + _, cleanUp := setupTestConfig(t, &CfgTestData{}) + defer func() { - cleanupDir(LocalDirName) + cleanUp() }() + tests := []struct { - name string - feature map[string]configtypes.FeatureMap - plugin string - key string - value bool - persist bool + name string + plugin string + key string + value bool }{ { - name: "success context-aware-cli-for-plugins", - feature: map[string]configtypes.FeatureMap{ - "global": { - "sample": "true", - "context-aware-cli-for-plugins": "true", - }, - }, - plugin: "global", - key: "context-aware-cli-for-plugins", - value: false, - persist: true, + name: "success feature1", + plugin: "global", + key: "feature1", + value: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - cfg := &configtypes.ClientConfig{ - ClientOptions: &configtypes.ClientOptions{ - Features: tc.feature, - }, - } - err := StoreClientConfig(cfg) - assert.NoError(t, err) - err = SetFeature(tc.plugin, tc.key, strconv.FormatBool(tc.value)) + err := SetFeature(tc.plugin, tc.key, strconv.FormatBool(tc.value)) assert.NoError(t, err) + ok, err := IsFeatureEnabled(tc.plugin, tc.key) assert.NoError(t, err) assert.Equal(t, ok, tc.value) + err = DeleteFeature(tc.plugin, tc.key) assert.NoError(t, err) + ok, err = IsFeatureEnabled(tc.plugin, tc.key) assert.Equal(t, err.Error(), "not found") assert.Equal(t, ok, tc.value) - err = SetFeature(tc.plugin, tc.key, strconv.FormatBool(tc.value)) - assert.NoError(t, err) }) } } func TestSetFeature(t *testing.T) { - // setup - func() { - LocalDirName = TestLocalDirName - }() + // Setup config data + _, cleanUp := setupTestConfig(t, &CfgTestData{}) + defer func() { - cleanupDir(LocalDirName) + cleanUp() }() + tests := []struct { name string - cfg *configtypes.ClientConfig plugin string key string value bool @@ -175,16 +138,14 @@ func TestSetFeature(t *testing.T) { }{ { name: "empty plugin", - cfg: &configtypes.ClientConfig{}, plugin: "", - key: "context-aware-cli-for-plugins", + key: "feature1", value: false, errStrForSet: "plugin cannot be empty", errStrForGet: "plugin cannot be empty", }, { name: "empty key", - cfg: &configtypes.ClientConfig{}, plugin: "global", key: "", value: false, @@ -192,58 +153,28 @@ func TestSetFeature(t *testing.T) { errStrForGet: "key cannot be empty", }, { - name: "success context-aware-cli-for-plugins", - cfg: &configtypes.ClientConfig{ - ClientOptions: &configtypes.ClientOptions{ - Features: map[string]configtypes.FeatureMap{ - "global": { - "context-aware-cli-for-plugins": "true", - }, - }, - }, - }, + name: "success feature1", plugin: "global", - key: "context-aware-cli-for-plugins", + key: "feature1", value: false, }, { - name: "success context-aware-cli-for-plugins", - cfg: &configtypes.ClientConfig{ - ClientOptions: &configtypes.ClientOptions{ - Features: map[string]configtypes.FeatureMap{ - "global": { - "context-aware-cli-for-plugins": "true", - }, - }, - }, - }, + name: "success feature1", plugin: "global", - key: "context-aware-cli-for-plugins", + key: "feature1", value: false, }, { - name: "should not update the same feature value", - cfg: &configtypes.ClientConfig{ - ClientOptions: &configtypes.ClientOptions{ - Features: map[string]configtypes.FeatureMap{ - "global": { - "context-aware-cli-for-plugins": "true", - }, - }, - }, - }, + name: "should not update the same feature value", plugin: "global", - key: "context-aware-cli-for-plugins", + key: "feature1", value: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - err := StoreClientConfig(tc.cfg) - assert.NoError(t, err) - - err = SetFeature(tc.plugin, tc.key, strconv.FormatBool(tc.value)) + err := SetFeature(tc.plugin, tc.key, strconv.FormatBool(tc.value)) if tc.errStrForSet != "" { assert.Equal(t, tc.errStrForSet, err.Error()) } else { @@ -260,3 +191,170 @@ func TestSetFeature(t *testing.T) { }) } } + +func TestIsFeatureActivated(t *testing.T) { + // Setup config data + _, cleanUp := setupTestConfig(t, &CfgTestData{}) + + defer func() { + cleanUp() + }() + + defaultFlags := map[string]bool{ + "features.plugin1.feature1": true, + "features.plugin2.feature2": false, + "features.plugin3.feature3": false, + } + + testCases := []struct { + name string + feature string + expectActivated bool + }{ + { + name: "FeatureActivated", + feature: "features.plugin1.feature1", + expectActivated: true, + }, + { + name: "FeatureDeactivated", + feature: "features.plugin2.feature2", + expectActivated: false, + }, + { + name: "ConfigRetrievalFails", + feature: "features.plugin3.feature3", + expectActivated: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := ConfigureFeatureFlags(defaultFlags) + assert.NoError(t, err) + + activated := IsFeatureActivated(tc.feature) + assert.Equal(t, tc.expectActivated, activated) + }) + } +} + +func TestConfigureDefaultFeatureFlags(t *testing.T) { + // Setup config data + _, cleanUp := setupTestConfig(t, &CfgTestData{}) + + defer func() { + cleanUp() + }() + + testCases := []struct { + name string + defaultPlugin string + defaultKey string + defaultValue string + flags map[string]bool + opts []Options + resultFlags map[string]configtypes.FeatureMap + expectErr bool + }{ + { + name: "SuccessfulConfiguration of global flags", + defaultPlugin: "global", + defaultKey: "feature1", + defaultValue: "false", + flags: map[string]bool{ + "features.global.feature1": true, + "features.global.feature2": false, + "features.global.feature3": false, + }, + opts: nil, + resultFlags: map[string]configtypes.FeatureMap{ + "global": { + "feature1": "true", + "feature2": "false", + "feature3": "false", + }, + }, + expectErr: false, + }, + { + name: "SuccessfulConfiguration of global flags and skip is exists", + defaultPlugin: "global", + defaultKey: "feature1", + defaultValue: "false", + flags: map[string]bool{ + "features.global.feature1": true, + "features.global.feature2": false, + "features.global.feature3": false, + }, + opts: []Options{SkipIfExists()}, + resultFlags: map[string]configtypes.FeatureMap{ + "global": { + "feature1": "false", + "feature2": "false", + "feature3": "false", + }, + }, + expectErr: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Set default feature flag + _ = SetFeature(tc.defaultPlugin, tc.defaultKey, tc.defaultValue) + + // Configure test feature flags + err := ConfigureFeatureFlags(tc.flags, tc.opts...) + if tc.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + flags, err := GetAllFeatureFlags() + assert.NoError(t, err) + assert.Equal(t, tc.resultFlags, flags) + } + }) + } +} + +func TestGetAllFeatureFlags(t *testing.T) { + // Setup config data + _, cleanUp := setupTestConfig(t, &CfgTestData{}) + + defer func() { + cleanUp() + }() + + testCases := []struct { + name string + plugin string + key string + value string + expectedFlags map[string]configtypes.FeatureMap + }{ + { + name: "FeatureActivated", + plugin: "plugin", + key: "feature1", + value: "true", + + expectedFlags: map[string]configtypes.FeatureMap{ + "plugin": { + "feature1": "true", + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := SetFeature(tc.plugin, tc.key, tc.value) + assert.NoError(t, err) + + flags, err := GetAllFeatureFlags() + assert.NoError(t, err) + assert.Equal(t, tc.expectedFlags, flags) + }) + } +} diff --git a/config/legacy_clientconfig_factory.go b/config/legacy_clientconfig_factory.go index e158b6527..9bc888c80 100644 --- a/config/legacy_clientconfig_factory.go +++ b/config/legacy_clientconfig_factory.go @@ -12,36 +12,6 @@ import ( configtypes "github.com/vmware-tanzu/tanzu-plugin-runtime/config/types" ) -// GetClientConfig retrieves the config from the local directory with file lock -func GetClientConfig() (cfg *configtypes.ClientConfig, err error) { - // Retrieve client config node - node, err := getClientConfigNode() - if err != nil { - return nil, err - } - - cfg, err = convertNodeToClientConfig(node) - if err != nil { - return nil, err - } - - return cfg, nil -} - -// GetClientConfigNoLock retrieves the config from the local directory without acquiring the lock -func GetClientConfigNoLock() (cfg *configtypes.ClientConfig, err error) { - node, err := getClientConfigNodeNoLock() - if err != nil { - return nil, err - } - - cfg, err = convertNodeToClientConfig(node) - if err != nil { - return nil, err - } - return cfg, nil -} - // StoreClientConfig stores the config in the local directory. // Make sure to Acquire and Release tanzu lock when reading/writing to the // tanzu client configuration diff --git a/config/legacy_clientconfig_filesystem.go b/config/legacy_clientconfig_filesystem.go index 0f7b4c836..ce2dae484 100644 --- a/config/legacy_clientconfig_filesystem.go +++ b/config/legacy_clientconfig_filesystem.go @@ -10,19 +10,23 @@ import ( var ( // legacyLocalDirName is the name of the old local directory in which to look for tanzu state. This will be // removed in the future in favor of LocalDirName. + // Deprecated: This legacyLocalDirName is deprecated. Use next get config apis legacyLocalDirName = ".tanzu" ) +// Deprecated: This legacyLocalDir is deprecated. Use next get config apis func legacyLocalDir() (path string, err error) { return localDirPath(legacyLocalDirName) } // legacyConfigPath returns the legacy tanzu config path, checking for environment overrides. +// Deprecated: This legacyConfigPath is deprecated. Use next get config apis func legacyConfigPath() (path string, err error) { return legacyCfgPath(legacyLocalDir) } // legacyCfgPath constructs the full config path +// Deprecated: This legacyCfgPath is deprecated. Use next get config apis func legacyCfgPath(localDirGetter func() (string, error)) (path string, err error) { localDir, err := localDirGetter() if err != nil { diff --git a/config/types/clientconfig.go b/config/types/clientconfig.go index e39a1abf5..c8e87b2be 100644 --- a/config/types/clientconfig.go +++ b/config/types/clientconfig.go @@ -6,7 +6,6 @@ package types import ( "errors" "fmt" - "strconv" "strings" ) @@ -275,26 +274,6 @@ func (c *ClientConfig) SetUnstableVersionSelector(f VersionSelectorLevel) { c.ClientOptions.CLI.UnstableVersionSelector = AllUnstableVersions } -// IsConfigFeatureActivated return true if the feature is activated, false if not. An error if the featurePath is malformed -func (c *ClientConfig) IsConfigFeatureActivated(featurePath string) (bool, error) { - plugin, flag, err := c.SplitFeaturePath(featurePath) - if err != nil { - return false, err - } - - if c.ClientOptions == nil || c.ClientOptions.Features == nil || - c.ClientOptions.Features[plugin] == nil || c.ClientOptions.Features[plugin][flag] == "" { - return false, nil - } - - booleanValue, err := strconv.ParseBool(c.ClientOptions.Features[plugin][flag]) - if err != nil { - errMsg := "error converting " + featurePath + " entry '" + c.ClientOptions.Features[plugin][flag] + "' to boolean value: " + err.Error() - return false, errors.New(errMsg) - } - return booleanValue, nil -} - // GetEnvConfigurations returns a map of environment variables to values // it returns nil if configuration is not yet defined func (c *ClientConfig) GetEnvConfigurations() map[string]string { diff --git a/config/types/clientconfig_features.go b/config/types/clientconfig_features.go new file mode 100644 index 000000000..3a8f0aacb --- /dev/null +++ b/config/types/clientconfig_features.go @@ -0,0 +1,46 @@ +// Copyright 2022 VMware, Inc. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import ( + "strconv" + + "github.com/pkg/errors" +) + +// GetAllFeatureFlags returns all feature flags from the config file +func (c *ClientConfig) GetAllFeatureFlags() (map[string]FeatureMap, error) { + if c.ClientOptions != nil && c.ClientOptions.Features != nil { + return c.ClientOptions.Features, nil + } + return nil, errors.New("not found") +} + +// IsFeatureFlagExists returns true if the features section in the configuration object contains any value for the plugin.feature combination +func (c *ClientConfig) IsFeatureFlagExists(plugin, feature string) bool { + return c.ClientOptions != nil && + c.ClientOptions.Features != nil && + c.ClientOptions.Features[plugin] != nil && + c.ClientOptions.Features[plugin][feature] != "" +} + +// IsConfigFeatureActivated return true if the feature is activated, false if not. An error if the featurePath is malformed +func (c *ClientConfig) IsConfigFeatureActivated(featurePath string) (bool, error) { + plugin, flag, err := c.SplitFeaturePath(featurePath) + if err != nil { + return false, err + } + + if c.ClientOptions == nil || c.ClientOptions.Features == nil || + c.ClientOptions.Features[plugin] == nil || c.ClientOptions.Features[plugin][flag] == "" { + return false, nil + } + + booleanValue, err := strconv.ParseBool(c.ClientOptions.Features[plugin][flag]) + if err != nil { + errMsg := "error converting " + featurePath + " entry '" + c.ClientOptions.Features[plugin][flag] + "' to boolean value: " + err.Error() + return false, errors.New(errMsg) + } + return booleanValue, nil +} diff --git a/config/types/clientconfig_features_test.go b/config/types/clientconfig_features_test.go new file mode 100644 index 000000000..81aa2f8cb --- /dev/null +++ b/config/types/clientconfig_features_test.go @@ -0,0 +1,137 @@ +// Copyright 2024 VMware, Inc. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetAllFeatureFlags(t *testing.T) { + testCases := []struct { + name string + config *ClientConfig + expectErr bool + expectFlags map[string]FeatureMap + }{ + { + name: "FeatureFlagsPresent", + config: &ClientConfig{ + ClientOptions: &ClientOptions{ + Features: map[string]FeatureMap{"plugin1": {"feature1": "true"}}, + }, + }, + expectErr: false, + expectFlags: map[string]FeatureMap{"plugin1": {"feature1": "true"}}, + }, + { + name: "FeatureFlagsAbsent", + config: &ClientConfig{}, + expectErr: true, + expectFlags: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + flags, err := tc.config.GetAllFeatureFlags() + if tc.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expectFlags, flags) + } + }) + } +} + +func TestIsFeatureFlagExists(t *testing.T) { + clientConfig := &ClientConfig{ + ClientOptions: &ClientOptions{ + Features: map[string]FeatureMap{"plugin1": {"feature1": "true"}}, + }, + } + + testCases := []struct { + name string + plugin string + feature string + expectExists bool + }{ + { + name: "FeatureFlagExists", + plugin: "plugin1", + feature: "feature1", + expectExists: true, + }, + { + name: "FeatureFlagDoesNotExist", + plugin: "plugin2", + feature: "feature2", + expectExists: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + exists := clientConfig.IsFeatureFlagExists(tc.plugin, tc.feature) + assert.Equal(t, tc.expectExists, exists) + }) + } +} + +func TestIsConfigFeatureActivated(t *testing.T) { + clientConfig := &ClientConfig{ + ClientOptions: &ClientOptions{ + Features: map[string]FeatureMap{ + "plugin1": {"feature1": "true", "feature2": "false", "feature3": "invalid"}, + }, + }, + } + + testCases := []struct { + name string + featurePath string + expectActivated bool + expectErr bool + }{ + { + name: "FeatureActivated", + featurePath: "features.plugin1.feature1", + expectActivated: true, + expectErr: false, + }, + { + name: "FeatureDeactivated", + featurePath: "features.plugin1.feature2", + expectActivated: false, + expectErr: false, + }, + { + name: "InvalidFeatureValue", + featurePath: "features.plugin1.feature3", + expectActivated: false, + expectErr: true, + }, + { + name: "InvalidFeaturePath", + featurePath: "invalid", + expectActivated: false, + expectErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + activated, err := clientConfig.IsConfigFeatureActivated(tc.featurePath) + if tc.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expectActivated, activated) + } + }) + } +}