diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index fa367857d..198f49f16 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -34,16 +34,20 @@ const ( ServiceAccountCustomEndpointKey = "service_account_custom_endpoint" SKECustomEndpointKey = "ske_custom_endpoint" + ProjectNameKey = "project_name" + AsyncDefault = false SessionTimeLimitDefault = "2h" ) -// Backend config keys const ( - configFolder = "stackit" + configFolder = "stackit" + configFileName = "cli-config" configFileExtension = "json" - ProjectNameKey = "project_name" + + profileFileName = "cli-profile" + profileFileExtension = "txt" ) var ConfigKeys = []string{ @@ -72,17 +76,14 @@ var ConfigKeys = []string{ SKECustomEndpointKey, } -var folderPath string +var configFolderPath string func InitConfig() { configDir, err := os.UserConfigDir() cobra.CheckErr(err) - configFolderPath := filepath.Join(configDir, configFolder) + configFolderPath = filepath.Join(configDir, configFolder) configFilePath := filepath.Join(configFolderPath, fmt.Sprintf("%s.%s", configFileName, configFileExtension)) - // Write config dir path to global variable - folderPath = configFolderPath - // This hack is required to allow creating the config file with `viper.WriteConfig` // see https://github.com/spf13/viper/issues/851#issuecomment-789393451 viper.SetConfigFile(configFilePath) @@ -107,22 +108,9 @@ func InitConfig() { viper.SetEnvPrefix("stackit") } -func createFolderIfNotExists() error { - _, err := os.Stat(folderPath) - if os.IsNotExist(err) { - err := os.MkdirAll(folderPath, os.ModePerm) - if err != nil { - return err - } - } else if err != nil { - return err - } - return nil -} - // Write saves the config file (wrapping `viper.WriteConfig`) and ensures that its directory exists func Write() error { - if err := createFolderIfNotExists(); err != nil { + if err := createFolderIfNotExists(configFolderPath); err != nil { return fmt.Errorf("create config directory: %w", err) } return viper.WriteConfig() diff --git a/internal/pkg/config/file_utils.go b/internal/pkg/config/file_utils.go new file mode 100644 index 000000000..44b0ab38a --- /dev/null +++ b/internal/pkg/config/file_utils.go @@ -0,0 +1,40 @@ +package config + +import ( + "fmt" + "os" +) + +// createFolderIfNotExists creates a folder if it does not exist. +func createFolderIfNotExists(folderPath string) error { + _, err := os.Stat(folderPath) + if os.IsNotExist(err) { + err := os.MkdirAll(folderPath, os.ModePerm) + if err != nil { + return err + } + } else if err != nil { + return err + } + return nil +} + +// readFileIfExists reads the contents of a file and returns it as a string, along with a boolean indicating if the file exists. +// If the file does not exist, it returns an empty string and no error. +// If the file exists but cannot be read, it returns an error. +func readFileIfExists(filePath string) (contents string, exists bool, err error) { + _, err = os.Stat(filePath) + if err != nil { + if os.IsNotExist(err) { + return "", false, nil + } + return "", true, err + } + + content, err := os.ReadFile(filePath) + if err != nil { + return "", true, fmt.Errorf("read file: %w", err) + } + + return string(content), true, nil +} diff --git a/internal/pkg/config/file_utils_test.go b/internal/pkg/config/file_utils_test.go new file mode 100644 index 000000000..b3453c661 --- /dev/null +++ b/internal/pkg/config/file_utils_test.go @@ -0,0 +1,89 @@ +package config + +import ( + "os" + "testing" +) + +func TestCreateFolderIfNotExists(t *testing.T) { + tests := []struct { + description string + folderPath string + needsCleanUp bool + }{ + { + description: "folder exists", + folderPath: "test-data/folder-exists", + }, + { + description: "folder does not exist", + folderPath: "test-data/folder-does-not-exist", + needsCleanUp: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := createFolderIfNotExists(tt.folderPath) + if err != nil { + t.Errorf("create folder: %v", err) + } + + // Check if the folder was created + _, err = os.Stat(tt.folderPath) + if os.IsNotExist(err) { + t.Errorf("expected folder to exist but it does not") + } + + // Clean up + if tt.needsCleanUp { + err = os.RemoveAll(tt.folderPath) + if err != nil { + t.Errorf("remove folder: %v", err) + } + } + }) + } +} + +func TestReadFileIfExists(t *testing.T) { + tests := []struct { + description string + filePath string + exists bool + content string + }{ + { + description: "file exists", + filePath: "test-data/file-with-content.txt", + exists: true, + content: "my-content", + }, + { + description: "file does not exist", + filePath: "test-data/file-does-not-exist.txt", + content: "", + }, + { + description: "empty file", + filePath: "test-data/empty-file.txt", + exists: true, + content: "", + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + content, exists, err := readFileIfExists(tt.filePath) + if err != nil { + t.Errorf("read file: %v", err) + } + if exists != tt.exists { + t.Errorf("expected exists to be %t but got %t", tt.exists, exists) + } + if content != tt.content { + t.Errorf("expected content to be %q but got %q", tt.content, content) + } + }) + } +} diff --git a/internal/pkg/config/profiles.go b/internal/pkg/config/profiles.go new file mode 100644 index 000000000..186af0b02 --- /dev/null +++ b/internal/pkg/config/profiles.go @@ -0,0 +1,51 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "regexp" +) + +// GetProfile returns the current profile to be used by the CLI. +// +// The profile is determined by the value of the STACKIT_CLI_PROFILE environment variable, or, if not set, +// by the contents of the profile file in the CLI config folder. +// +// If the environment variable is not set and the profile file does not exist, it returns an empty string. +// +// If the profile is not valid, it returns an error. +func GetProfile() (string, error) { + profile, profileSet := os.LookupEnv("STACKIT_CLI_PROFILE") + if !profileSet { + profileFilePath := filepath.Join(configFolderPath, fmt.Sprintf("%s.%s", profileFileName, profileFileExtension)) + contents, exists, err := readFileIfExists(profileFilePath) + if err != nil { + return "", fmt.Errorf("read profile from file: %w", err) + } + if !exists { + return "", nil + } + profile = contents + } + + err := validateProfile(profile) + if err != nil { + return "", fmt.Errorf("validate profile from env var: %w", err) + } + return profile, nil +} + +// validateProfile validates the profile name. +// It can only use letters, numbers, or "-" and cannot be empty. +// If the profile is invalid, it returns an error. +func validateProfile(profile string) error { + match, err := regexp.MatchString("^[a-zA-Z0-9-]+$", profile) + if err != nil { + return fmt.Errorf("match string regex: %w", err) + } + if !match { + return fmt.Errorf("profile name can only contain letters, numbers, and \"-\" and cannot be empty") + } + return nil +} diff --git a/internal/pkg/config/profiles_test.go b/internal/pkg/config/profiles_test.go new file mode 100644 index 000000000..8e96a6e88 --- /dev/null +++ b/internal/pkg/config/profiles_test.go @@ -0,0 +1,59 @@ +package config + +import "testing" + +func TestValidateProfile(t *testing.T) { + tests := []struct { + description string + profile string + isValid bool + }{ + { + description: "valid profile with letters", + profile: "myprofile", + isValid: true, + }, + { + description: "valid profile with uppercase letters", + profile: "myProfile", + isValid: true, + }, + { + description: "valid with letters and hyphen", + profile: "my-profile", + isValid: true, + }, + { + description: "valid with letters, numbers, and hyphen", + profile: "my-profile-123", + isValid: true, + }, + { + description: "invalid empty", + profile: "", + isValid: false, + }, + { + description: "invalid with special characters", + profile: "my_profile", + isValid: false, + }, + { + description: "invalid with spaces", + profile: "my profile", + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := validateProfile(tt.profile) + if tt.isValid && err != nil { + t.Errorf("expected profile to be valid but got error: %v", err) + } + if !tt.isValid && err == nil { + t.Errorf("expected profile to be invalid but got no error") + } + }) + } +} diff --git a/internal/pkg/config/test-data/empty-file.txt b/internal/pkg/config/test-data/empty-file.txt new file mode 100644 index 000000000..e69de29bb diff --git a/internal/pkg/config/test-data/file-with-content.txt b/internal/pkg/config/test-data/file-with-content.txt new file mode 100644 index 000000000..d89fbd214 --- /dev/null +++ b/internal/pkg/config/test-data/file-with-content.txt @@ -0,0 +1 @@ +my-content \ No newline at end of file diff --git a/internal/pkg/config/test-data/folder-exists/dummy.txt b/internal/pkg/config/test-data/folder-exists/dummy.txt new file mode 100644 index 000000000..e69de29bb