diff --git a/README.md b/README.md index d01e8a57..6bf945ea 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ Available Commands: version Version of Harbor CLI Flags: - --config string config file (default is $HOME/.harbor/config.yaml) (default "/Users/vadim/.harbor/config.yaml") + -c, --config string config file (default is $HOME/.config/harbor-cli/config.yaml) -h, --help help for harbor -o, --output-format string Output format. One of: json|yaml -v, --verbose verbose output @@ -100,8 +100,32 @@ Use "harbor [command] --help" for more information about a command. ``` - - +#### Config Management + +##### Hierachy + Use the `--config` flag to specify a custom configuration file path (highest priority). + ```bash + harbor --config /path/to/custom/config.yaml artifact list + ``` + If `--config` is not provided, Harbor CLI checks the `HARBOR_CLI_CONFIG` environment variable for the config file path. + ```bash + export HARBOR_CLI_CONFIG=/path/to/custom/config.yaml + harbor artifact list + ``` + If neither is set, it defaults to `$XDG_CONFIG_HOME/harbor-cli/config.yaml` or `$HOME/.config/harbor-cli/config.yaml` if `XDG_CONFIG_HOME` is unset. + ```bash + harbor artifact list + ``` + +##### Data Path + - Data paths are determined by the `XDG_DATA_HOME` environment variable. + - If `XDG_DATA_HOME` is not set, it defaults to `$HOME/.local/share/harbor-cli/data.yaml`. + - The data file always contains the path of the latest config used. + +##### Config TL;DR + - `--config` flag > `HARBOR_CLI_CONFIG` environment variable > default XDG config paths. + - Environment variables override default settings, and the `--config` flag takes precedence over both environment variables and defaults. + - The data file always contains the path of the latest config used. #### Log in to Harbor Registry diff --git a/cmd/harbor/root/cmd.go b/cmd/harbor/root/cmd.go index 1bdc448d..e439602c 100644 --- a/cmd/harbor/root/cmd.go +++ b/cmd/harbor/root/cmd.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/goharbor/harbor-cli/cmd/harbor/root/artifact" + "github.com/goharbor/harbor-cli/cmd/harbor/root/config" "github.com/goharbor/harbor-cli/cmd/harbor/root/labels" "github.com/goharbor/harbor-cli/cmd/harbor/root/project" "github.com/goharbor/harbor-cli/cmd/harbor/root/registry" @@ -61,6 +62,7 @@ harbor help root.AddCommand( versionCommand(), LoginCommand(), + config.Config(), project.Project(), registry.Registry(), repositry.Repository(), diff --git a/cmd/harbor/root/config/cmd.go b/cmd/harbor/root/config/cmd.go new file mode 100644 index 00000000..bd91163c --- /dev/null +++ b/cmd/harbor/root/config/cmd.go @@ -0,0 +1,20 @@ +package config + +import "github.com/spf13/cobra" + +func Config() *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Manage the config of the Harbor Cli", + Long: `Manage repositories in Harbor config`, + } + cmd.AddCommand( + ListConfigCommand(), + GetConfigItemCommand(), + SetConfigItemCommand(), + DeleteConfigItemCommand(), + ) + + return cmd + +} diff --git a/cmd/harbor/root/config/delete.go b/cmd/harbor/root/config/delete.go new file mode 100644 index 00000000..ad61c15b --- /dev/null +++ b/cmd/harbor/root/config/delete.go @@ -0,0 +1,180 @@ +package config + +import ( + "fmt" + "reflect" + "strings" + + "github.com/goharbor/harbor-cli/pkg/utils" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +// DeleteConfigItemCommand creates the 'harbor config delete' subcommand, +// allowing you to do: harbor config delete +func DeleteConfigItemCommand() *cobra.Command { + var credentialName string + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete (clear) a specific config item", + Example: ` + # Clear the current credential's password + harbor config delete credentials.password + + # Clear a specific credential's password using --name + harbor config delete credentials.password --name harbor-cli@http://demo.goharbor.io +`, + Long: `Clear the value of a specific CLI config item by setting it to its zero value. +Case-insensitive field lookup, but uses the canonical (Go) field name internally. +If you specify --name, that credential (rather than the "current" one) will be used.`, + Args: cobra.ExactArgs(1), + + // Use RunE so we can propagate errors + RunE: func(cmd *cobra.Command, args []string) error { + // 1. Load the current config + config, err := utils.GetCurrentHarborConfig() + if err != nil { + return fmt.Errorf("failed to load Harbor config: %w", err) + } + + // 2. Parse the user-supplied item path (e.g., "credentials.password") + itemPath := strings.Split(args[0], ".") + + // 3. Reflection-based delete (zero out) + actualSegments := []string{} + if err := deleteValueInConfig(config, itemPath, &actualSegments, credentialName); err != nil { + return fmt.Errorf("failed to delete value in config: %w", err) + } + + // 4. Persist the updated config to disk + if err := utils.UpdateConfigFile(config); err != nil { + return fmt.Errorf("failed to save updated config: %w", err) + } + + // 5. Confirm to the user (no error here) + canonicalPath := strings.Join(actualSegments, ".") + logrus.Infof("Successfully cleared %s", canonicalPath) + + return nil + }, + } + + // Add --name / -n to let the user pick a specific credential + cmd.Flags().StringVarP( + &credentialName, + "name", + "n", + "", + "Name of the credential to delete fields from (default: the current credential)", + ) + + return cmd +} + +// deleteValueInConfig checks whether the user is deleting something +// under "credentials" (i.e., *a* credential) or a top-level field. +// +// If the user says "credentials.*" AND provides --name, we'll look +// up that specific credential by name. Otherwise, we use CurrentCredentialName. +func deleteValueInConfig( + config *utils.HarborConfig, + path []string, + actualSegments *[]string, + credentialName string, +) error { + if len(path) == 0 { + return fmt.Errorf("no config item specified") + } + + // If the first segment is "credentials", pivot to the chosen credential. + if strings.EqualFold(path[0], "credentials") { + *actualSegments = append(*actualSegments, "Credentials") + + // Figure out which credential name to use + credName := config.CurrentCredentialName + if credentialName != "" { + credName = credentialName + } + + // Find the matching credential + var targetCred *utils.Credential + for i := range config.Credentials { + if strings.EqualFold(config.Credentials[i].Name, credName) { + targetCred = &config.Credentials[i] + break + } + } + if targetCred == nil { + return fmt.Errorf("no matching credential found for '%s'", credName) + } + + // Remove "credentials" from path, delete the value in that credential + return deleteNestedValue(targetCred, path[1:], actualSegments) + } + + // Otherwise, we delete a field in the main HarborConfig struct + return deleteNestedValue(config, path, actualSegments) +} + +// deleteNestedValue navigates a pointer to a struct, following the path segments +// in a case-insensitive manner, until the last segment, where it sets the field +// to its zero value. +func deleteNestedValue(obj interface{}, path []string, actualSegments *[]string) error { + // We require obj to be a pointer to a struct so we can modify it. + val := reflect.ValueOf(obj) + if val.Kind() != reflect.Ptr { + return fmt.Errorf("object must be a pointer to a struct, got %s", val.Kind()) + } + val = val.Elem() // dereference pointer + + for i, segment := range path { + if val.Kind() != reflect.Struct { + return fmt.Errorf("cannot traverse non-struct for segment '%s'", segment) + } + t := val.Type() + + // Case-insensitive field lookup + fieldIndex := -1 + for j := 0; j < val.NumField(); j++ { + if strings.EqualFold(t.Field(j).Name, segment) { + fieldIndex = j + break + } + } + if fieldIndex < 0 { + return fmt.Errorf("config item '%s' does not exist", segment) + } + + field := t.Field(fieldIndex) + fieldValue := val.Field(fieldIndex) + + // Record the actual field name + *actualSegments = append(*actualSegments, field.Name) + + // If this is NOT the last path segment, move deeper + if i < len(path)-1 { + // If the field is a pointer and nil, we can't go deeper + if fieldValue.Kind() == reflect.Ptr && fieldValue.IsNil() { + return fmt.Errorf("field '%s' is nil and cannot be traversed", field.Name) + } + // Descend + val = fieldValue + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + continue + } + + // If this is the last segment, set the field to zero value + if !fieldValue.CanSet() { + return fmt.Errorf("cannot set field '%s' to zero value", field.Name) + } + + // The zero value for that field can be obtained with reflect.Zero(). + zeroVal := reflect.Zero(fieldValue.Type()) + fieldValue.Set(zeroVal) + } + + return nil +} diff --git a/cmd/harbor/root/config/get.go b/cmd/harbor/root/config/get.go new file mode 100644 index 00000000..60d0e6e9 --- /dev/null +++ b/cmd/harbor/root/config/get.go @@ -0,0 +1,183 @@ +package config + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + + "github.com/goharbor/harbor-cli/pkg/utils" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "gopkg.in/yaml.v3" +) + +// GetConfigItemCommand creates the 'harbor config get' subcommand. +func GetConfigItemCommand() *cobra.Command { + var credentialName string + + cmd := &cobra.Command{ + Use: "get ", + Short: "Get a specific config item", + Example: ` + # Get the current credential's username + harbor config get credentials.username + + # Get a credential's username by specifying the credential name + harbor config get credentials.username --name harbor-cli@http://demo.goharbor.io +`, + Long: `Get the value of a specific CLI config item. +If you specify --name, that credential (rather than the "current" one) will be used.`, + Args: cobra.ExactArgs(1), + + RunE: func(cmd *cobra.Command, args []string) error { + // 1. Load config + config, err := utils.GetCurrentHarborConfig() + if err != nil { + return fmt.Errorf("failed to get config: %w", err) + } + + // 2. Parse the user-supplied item path (e.g., "credentials.username") + itemPath := strings.Split(args[0], ".") + + // 3. Get the value from the config (and track actual field segments for output) + actualSegments := []string{} + result, err := getValueFromConfig(config, itemPath, &actualSegments, credentialName) + if err != nil { + return err + } + + // 4. Prepare the final output as a map for JSON/YAML rendering. + canonicalPath := strings.Join(actualSegments, ".") + output := map[string]interface{}{ + canonicalPath: result, + } + + // 5. Determine the output format (json, yaml, etc.) and print. + formatFlag := viper.GetString("output-format") + switch formatFlag { + case "json": + data, err := json.MarshalIndent(output, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal output to JSON: %w", err) + } + fmt.Println(string(data)) + + case "yaml", "": + data, err := yaml.Marshal(output) + if err != nil { + return fmt.Errorf("failed to marshal output to YAML: %w", err) + } + fmt.Println(string(data)) + + default: + return fmt.Errorf("unsupported output format: %s", formatFlag) + } + + return nil + }, + } + + // Add a --name / -n flag to allow specifying a credential + cmd.Flags().StringVarP( + &credentialName, + "name", + "n", + "", + "Name of the credential to get fields from (default: the current credential)", + ) + + return cmd +} + +// getValueFromConfig decides if the user requested something under "credentials" +// and if so, filters down to the *requested credential*, otherwise +// it just searches in the top-level config object. +func getValueFromConfig( + config *utils.HarborConfig, + path []string, + actualSegments *[]string, + credentialName string, +) (interface{}, error) { + if len(path) == 0 { + return nil, fmt.Errorf("no config item specified") + } + + // If the first segment is "credentials", we pivot to a credential. + if strings.EqualFold(path[0], "credentials") { + *actualSegments = append(*actualSegments, "Credentials") + + // Determine which credential name to use + credName := config.CurrentCredentialName + if credentialName != "" { + credName = credentialName + } + + // Find the matching credential + var targetCred *utils.Credential + for i := range config.Credentials { + if strings.EqualFold(config.Credentials[i].Name, credName) { + targetCred = &config.Credentials[i] + break + } + } + if targetCred == nil { + return nil, fmt.Errorf("no matching credential found for '%s'", credName) + } + + // Remove "credentials" from the path, keep the rest + return getNestedValue(*targetCred, path[1:], actualSegments) + } + + // Otherwise, search in the overall config struct + return getNestedValue(*config, path, actualSegments) +} + +// getNestedValue uses reflection to walk through struct fields +// (case-insensitive) according to the provided path. +// +// 'actualSegments' is updated with the actual field names as we go. +func getNestedValue(obj interface{}, path []string, actualSegments *[]string) (interface{}, error) { + current := reflect.ValueOf(obj) + + for _, key := range path { + // If it's a pointer, dereference + if current.Kind() == reflect.Ptr { + current = current.Elem() + } + if current.Kind() != reflect.Struct { + return nil, fmt.Errorf("cannot traverse non-struct for key '%s'", key) + } + + // Find the actual field by name, ignoring case + var foundField reflect.StructField + var fieldValue reflect.Value + fieldFound := false + + t := current.Type() + for i := 0; i < current.NumField(); i++ { + field := t.Field(i) + if strings.EqualFold(field.Name, key) { + foundField = field + fieldValue = current.Field(i) + fieldFound = true + break + } + } + if !fieldFound { + return nil, fmt.Errorf("config item '%s' does not exist", key) + } + + // Record the *actual* field name in our slice + *actualSegments = append(*actualSegments, foundField.Name) + + // Descend for the next iteration + current = fieldValue + } + + // Finally, if we ended on a pointer, dereference it + if current.Kind() == reflect.Ptr { + current = current.Elem() + } + return current.Interface(), nil +} diff --git a/cmd/harbor/root/config/list.go b/cmd/harbor/root/config/list.go new file mode 100644 index 00000000..84334835 --- /dev/null +++ b/cmd/harbor/root/config/list.go @@ -0,0 +1,48 @@ +package config + +import ( + "fmt" + + "github.com/goharbor/harbor-cli/pkg/utils" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "gopkg.in/yaml.v3" +) + +func ListConfigCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List config items", + Example: ` harbor config list`, + Long: `Get information of all CLI config items`, + Args: cobra.MaximumNArgs(0), + Run: func(cmd *cobra.Command, args []string) { + config, err := utils.GetCurrentHarborConfig() + if err != nil { + logrus.Errorf("Failed to get config: %v", err) + return + } + + // Get the output format + formatFlag := viper.GetString("output-format") + if formatFlag != "" { + // Use utils.PrintFormat if available + err = utils.PrintFormat(config, formatFlag) + if err != nil { + logrus.Errorf("Failed to print config: %v", err) + } + } else { + // Default to YAML format + data, err := yaml.Marshal(config) + if err != nil { + logrus.Errorf("Failed to marshal config to YAML: %v", err) + return + } + fmt.Println(string(data)) + } + }, + } + + return cmd +} diff --git a/cmd/harbor/root/config/update.go b/cmd/harbor/root/config/update.go new file mode 100644 index 00000000..68239f1a --- /dev/null +++ b/cmd/harbor/root/config/update.go @@ -0,0 +1,254 @@ +package config + +import ( + "fmt" + "reflect" + "strconv" + "strings" + + "github.com/goharbor/harbor-cli/pkg/utils" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +// SetConfigItemCommand creates the 'harbor config set' subcommand, +// allowing you to do: harbor config set . +func SetConfigItemCommand() *cobra.Command { + var credentialName string + + cmd := &cobra.Command{ + Use: "set ", + Short: "Set a specific config item", + Example: ` + # Set the current credential's password + harbor config set credentials.password myNewSecret + + # Set a credential's password by specifying the credential name + harbor config set credentials.password myNewSecret --name harbor-cli@http://demo.goharbor.io +`, + Long: `Set the value of a specific CLI config item. +Case-insensitive field lookup, but uses the canonical (Go) field name internally. +If you specify --name, that credential (rather than the "current" one) will be updated.`, + Args: cobra.ExactArgs(2), + + // Switch from Run to RunE so we can propagate errors + RunE: func(cmd *cobra.Command, args []string) error { + // 1. Load the current config + config, err := utils.GetCurrentHarborConfig() + if err != nil { + return fmt.Errorf("failed to load Harbor config: %w", err) + } + + // 2. Parse the user-supplied item path (e.g., "credentials.password") + itemPath := strings.Split(args[0], ".") + newValue := args[1] + + // 3. Reflection-based set + actualSegments := []string{} + if err := setValueInConfig(config, itemPath, newValue, &actualSegments, credentialName); err != nil { + return fmt.Errorf("failed to set value in config: %w", err) + } + + // 4. Persist the updated config to disk + if err := utils.UpdateConfigFile(config); err != nil { + return fmt.Errorf("failed to save updated config: %w", err) + } + + // 5. Confirm to the user (logrus.Info is fine here; no error) + canonicalPath := strings.Join(actualSegments, ".") + logrus.Infof("Successfully updated %s to '%s'", canonicalPath, newValue) + + return nil + }, + } + + // Add a --name / -n flag to allow specifying a credential + cmd.Flags().StringVarP( + &credentialName, + "name", + "n", + "", + "Name of the credential to set fields on (default: the current credential)", + ) + + return cmd +} + +// setValueInConfig checks whether the user is updating something +// under "credentials" (i.e., a credential) or a top-level field. +// +// If path[0] == "credentials", we decide which credential to modify: +// - If credentialName is non-empty, use that +// - Otherwise, fallback to config.CurrentCredentialName +func setValueInConfig( + config *utils.HarborConfig, + path []string, + newValue string, + actualSegments *[]string, + credentialName string, +) error { + if len(path) == 0 { + return fmt.Errorf("no config item specified") + } + + // If the first segment is "credentials", then we pivot to a specific credential. + if strings.EqualFold(path[0], "credentials") { + *actualSegments = append(*actualSegments, "Credentials") + + // Determine which credential name to use + credName := config.CurrentCredentialName + if credentialName != "" { + credName = credentialName + } + + // find the matching credential + var matchingCred *utils.Credential + for i := range config.Credentials { + if strings.EqualFold(config.Credentials[i].Name, credName) { + matchingCred = &config.Credentials[i] + break + } + } + if matchingCred == nil { + return fmt.Errorf("no matching credential found for '%s'", credName) + } + + // Remove "credentials" from the path, and set the value in that credential + return setNestedValue(matchingCred, path[1:], newValue, actualSegments) + } + + // Otherwise, we set a field in the main HarborConfig struct + return setNestedValue(config, path, newValue, actualSegments) +} + +// setNestedValue navigates a pointer to a struct, following the path segments +// in a case-insensitive manner, until the last segment, where it sets the value. +// +// If the last segment is Credentials.Password, it encrypts the user-supplied +// password before storing it. +func setNestedValue(obj interface{}, path []string, newValue string, actualSegments *[]string) error { + // We require obj to be a pointer to a struct so we can modify it. + val := reflect.ValueOf(obj) + if val.Kind() != reflect.Ptr { + return fmt.Errorf("object must be a pointer to a struct, got %s", val.Kind()) + } + val = val.Elem() // dereference pointer + + for i, segment := range path { + if val.Kind() != reflect.Struct { + return fmt.Errorf("cannot traverse non-struct for segment '%s'", segment) + } + t := val.Type() + + // Case-insensitive field lookup + fieldIndex := -1 + for j := 0; j < val.NumField(); j++ { + if strings.EqualFold(t.Field(j).Name, segment) { + fieldIndex = j + break + } + } + if fieldIndex < 0 { + return fmt.Errorf("config item '%s' does not exist", segment) + } + + field := t.Field(fieldIndex) + fieldValue := val.Field(fieldIndex) + + // Record the actual field name + *actualSegments = append(*actualSegments, field.Name) + + // If this is NOT the last path segment, move deeper + if i < len(path)-1 { + // If the field is a pointer and nil, allocate a new instance + if fieldValue.Kind() == reflect.Ptr && fieldValue.IsNil() { + newElem := reflect.New(fieldValue.Type().Elem()) + fieldValue.Set(newElem) + } + // Descend + val = fieldValue + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + continue + } + + // If this is the last segment, set the value + if !fieldValue.CanSet() { + return fmt.Errorf("cannot set field '%s'", field.Name) + } + + switch fieldValue.Kind() { + case reflect.String: + // Special case: If we are setting Credentials.Password, encrypt it + // We'll check the last two actual segments, e.g. ["Credentials", "Password"]. + if isCredentialsPassword(*actualSegments) { + encrypted, err := encryptPassword(newValue) + if err != nil { + return err + } + fieldValue.SetString(encrypted) + } else { + fieldValue.SetString(newValue) + } + + case reflect.Bool: + boolVal, err := strconv.ParseBool(newValue) + if err != nil { + return fmt.Errorf("field '%s' expects a bool, but got '%s'", field.Name, newValue) + } + fieldValue.SetBool(boolVal) + + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + intVal, err := strconv.ParseInt(newValue, 10, 64) + if err != nil { + return fmt.Errorf("field '%s' expects an integer, but got '%s'", field.Name, newValue) + } + fieldValue.SetInt(intVal) + + // If you need to handle other types (e.g. float, slice), add them here. + + default: + return fmt.Errorf( + "unsupported field type '%s' for field '%s'", + fieldValue.Kind().String(), field.Name, + ) + } + } + + return nil +} + +// isCredentialsPassword checks if the actualSegments match ["Credentials", "Password"] +// (case-insensitive). +func isCredentialsPassword(actualSegments []string) bool { + if len(actualSegments) < 2 { + return false + } + // e.g. last two items might be Credentials, Password + last := actualSegments[len(actualSegments)-1] + secondLast := actualSegments[len(actualSegments)-2] + return strings.EqualFold(secondLast, "Credentials") && + strings.EqualFold(last, "Password") +} + +// encryptPassword uses your existing utility functions to generate/retrieve a key +// and return an encrypted version of the supplied password. +func encryptPassword(plaintext string) (string, error) { + // Make sure a key exists + if err := utils.GenerateEncryptionKey(); err != nil { + // It's okay if the key already exists; that might not be a fatal error for you + logrus.Debugf("Encryption key might already exist: %v", err) + } + + key, err := utils.GetEncryptionKey() + if err != nil { + return "", fmt.Errorf("failed to get encryption key: %w", err) + } + + encrypted, err := utils.Encrypt(key, []byte(plaintext)) + if err != nil { + return "", fmt.Errorf("failed to encrypt password: %w", err) + } + return encrypted, nil +} diff --git a/cmd/harbor/root/login.go b/cmd/harbor/root/login.go index 78750fbe..68f45139 100644 --- a/cmd/harbor/root/login.go +++ b/cmd/harbor/root/login.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "strings" "github.com/goharbor/go-client/pkg/harbor" "github.com/goharbor/go-client/pkg/sdk/v2.0/client/user" @@ -56,14 +57,71 @@ func LoginCommand() *cobra.Command { loginView.Name = fmt.Sprintf("%s@%s", loginView.Username, utils.SanitizeServerAddress(loginView.Server)) } + // Check whether there is already a config with credentials var err error + var config *utils.HarborConfig + var creds []utils.Credential + config, err = utils.GetCurrentHarborConfig() + if err != nil { + return fmt.Errorf("failed to get current harbor config: %s", err) + } + currentCredentialName := config.CurrentCredentialName if loginView.Server != "" && loginView.Username != "" && loginView.Password != "" { err = runLogin(loginView) + // check whether the user entered a new credential than already in config + } else if currentCredentialName != "" && loginView.Name != "" && loginView.Name != currentCredentialName { + var resolvedLoginView login.LoginView + creds = config.Credentials + for _, cred := range creds { + if cred.Name == loginView.Name { + resolvedLoginView = login.LoginView{ + Server: cred.ServerAddress, + Username: cred.Username, + Password: cred.Password, + Name: cred.Name, + } + } + } + if resolvedLoginView.Server != "" && resolvedLoginView.Username != "" && resolvedLoginView.Password != "" { + err = runLogin(resolvedLoginView) + } else { + err = createLoginView(&loginView) + } + } else if currentCredentialName != "" && loginView.Name == "" && loginView.Server == "" && loginView.Username == "" && loginView.Password == "" { + var resolvedLoginView login.LoginView + creds := config.Credentials + key, err := utils.GetEncryptionKey() + if err != nil { + return fmt.Errorf("failed to get encryption key: %w", err) + } + var targetCred *utils.Credential + for _, cred := range creds { + if strings.EqualFold(cred.Name, currentCredentialName) { + targetCred = &cred + break + } + } + if targetCred == nil { + return fmt.Errorf("no matching credential found for '%s'", currentCredentialName) + } + decryptedPassword, err := utils.Decrypt(key, string(targetCred.Password)) + if err != nil { + return fmt.Errorf("failed to decrypt password: %w", err) + } + resolvedLoginView = login.LoginView{ + Server: targetCred.ServerAddress, + Username: targetCred.Username, + Password: decryptedPassword, + Name: targetCred.Name, + } + err = runLogin(resolvedLoginView) + if err != nil { + return fmt.Errorf("failed to run login: %w", err) + } } else { err = createLoginView(&loginView) } - if err != nil { return err } @@ -103,17 +161,31 @@ func runLogin(opts login.LoginView) error { Password: opts.Password, } client := utils.GetClientByConfig(clientConfig) - ctx := context.Background() _, err := client.User.GetCurrentUserInfo(ctx, &user.GetCurrentUserInfoParams{}) if err != nil { return fmt.Errorf("login failed, please check your credentials: %s", err) } + if err := utils.GenerateEncryptionKey(); err != nil { + fmt.Println("Encryption key already exists or could not be created:", err) + } + + key, err := utils.GetEncryptionKey() + if err != nil { + fmt.Println("Error getting encryption key:", err) + return fmt.Errorf("failed to get encryption key: %s", err) + } + + encryptedPassword, err := utils.Encrypt(key, []byte(opts.Password)) + if err != nil { + fmt.Println("Error encrypting password:", err) + return fmt.Errorf("failed to encrypt password: %s", err) + } cred := utils.Credential{ Name: opts.Name, Username: opts.Username, - Password: opts.Password, + Password: encryptedPassword, ServerAddress: opts.Server, } harborData, err := utils.GetCurrentHarborData() @@ -125,7 +197,7 @@ func runLogin(opts login.LoginView) error { existingCred, err := utils.GetCredentials(opts.Name) if err == nil { if existingCred.Username == opts.Username && existingCred.ServerAddress == opts.Server { - if existingCred.Password == opts.Password { + if existingCred.Password == encryptedPassword { log.Warn("Credentials already exist in the config file. They were not added again.") return nil } else { diff --git a/doc/cli-docs/harbor-artifact-view.md b/doc/cli-docs/harbor-artifact-view.md new file mode 100644 index 00000000..0c7a12f9 --- /dev/null +++ b/doc/cli-docs/harbor-artifact-view.md @@ -0,0 +1,42 @@ +--- +title: harbor artifact view +weight: 75 +--- +## harbor artifact view + +### Description + +##### Get information of an artifact + +### Synopsis + +Get information of an artifact + +```sh +harbor artifact view [flags] +``` + +### Examples + +```sh +harbor artifact view // +``` + +### Options + +```sh + -h, --help help for view +``` + +### Options inherited from parent commands + +```sh + -c, --config string config file (default is $HOME/.config/harbor-cli/config.yaml) + -o, --output-format string Output format. One of: json|yaml + -v, --verbose verbose output +``` + +### SEE ALSO + +* [harbor artifact](harbor-artifact.md) - Manage artifacts + diff --git a/doc/cli-docs/harbor-config-delete.md b/doc/cli-docs/harbor-config-delete.md new file mode 100644 index 00000000..b9040b6b --- /dev/null +++ b/doc/cli-docs/harbor-config-delete.md @@ -0,0 +1,51 @@ +--- +title: harbor config delete +weight: 85 +--- +## harbor config delete + +### Description + +##### Delete (clear) a specific config item + +### Synopsis + +Clear the value of a specific CLI config item by setting it to its zero value. +Case-insensitive field lookup, but uses the canonical (Go) field name internally. +If you specify --name, that credential (rather than the "current" one) will be used. + +```sh +harbor config delete [flags] +``` + +### Examples + +```sh + + # Clear the current credential's password + harbor config delete credentials.password + + # Clear a specific credential's password using --name + harbor config delete credentials.password --name harbor-cli@http://demo.goharbor.io + +``` + +### Options + +```sh + -h, --help help for delete + -n, --name string Name of the credential to delete fields from (default: the current credential) +``` + +### Options inherited from parent commands + +```sh + -c, --config string config file (default is $HOME/.config/harbor-cli/config.yaml) + -o, --output-format string Output format. One of: json|yaml + -v, --verbose verbose output +``` + +### SEE ALSO + +* [harbor config](harbor-config.md) - Manage the config of the Harbor Cli + diff --git a/doc/cli-docs/harbor-config-get.md b/doc/cli-docs/harbor-config-get.md new file mode 100644 index 00000000..b9231f9b --- /dev/null +++ b/doc/cli-docs/harbor-config-get.md @@ -0,0 +1,50 @@ +--- +title: harbor config get +weight: 30 +--- +## harbor config get + +### Description + +##### Get a specific config item + +### Synopsis + +Get the value of a specific CLI config item. +If you specify --name, that credential (rather than the "current" one) will be used. + +```sh +harbor config get [flags] +``` + +### Examples + +```sh + + # Get the current credential's username + harbor config get credentials.username + + # Get a credential's username by specifying the credential name + harbor config get credentials.username --name harbor-cli@http://demo.goharbor.io + +``` + +### Options + +```sh + -h, --help help for get + -n, --name string Name of the credential to get fields from (default: the current credential) +``` + +### Options inherited from parent commands + +```sh + -c, --config string config file (default is $HOME/.config/harbor-cli/config.yaml) + -o, --output-format string Output format. One of: json|yaml + -v, --verbose verbose output +``` + +### SEE ALSO + +* [harbor config](harbor-config.md) - Manage the config of the Harbor Cli + diff --git a/doc/cli-docs/harbor-config-list.md b/doc/cli-docs/harbor-config-list.md new file mode 100644 index 00000000..c402e170 --- /dev/null +++ b/doc/cli-docs/harbor-config-list.md @@ -0,0 +1,42 @@ +--- +title: harbor config list +weight: 80 +--- +## harbor config list + +### Description + +##### List config items + +### Synopsis + +Get information of all CLI config items + +```sh +harbor config list [flags] +``` + +### Examples + +```sh + harbor config list +``` + +### Options + +```sh + -h, --help help for list +``` + +### Options inherited from parent commands + +```sh + -c, --config string config file (default is $HOME/.config/harbor-cli/config.yaml) + -o, --output-format string Output format. One of: json|yaml + -v, --verbose verbose output +``` + +### SEE ALSO + +* [harbor config](harbor-config.md) - Manage the config of the Harbor Cli + diff --git a/doc/cli-docs/harbor-config-set.md b/doc/cli-docs/harbor-config-set.md new file mode 100644 index 00000000..feb6b248 --- /dev/null +++ b/doc/cli-docs/harbor-config-set.md @@ -0,0 +1,51 @@ +--- +title: harbor config set +weight: 45 +--- +## harbor config set + +### Description + +##### Set a specific config item + +### Synopsis + +Set the value of a specific CLI config item. +Case-insensitive field lookup, but uses the canonical (Go) field name internally. +If you specify --name, that credential (rather than the "current" one) will be updated. + +```sh +harbor config set [flags] +``` + +### Examples + +```sh + + # Set the current credential's password + harbor config set credentials.password myNewSecret + + # Set a credential's password by specifying the credential name + harbor config set credentials.password myNewSecret --name harbor-cli@http://demo.goharbor.io + +``` + +### Options + +```sh + -h, --help help for set + -n, --name string Name of the credential to set fields on (default: the current credential) +``` + +### Options inherited from parent commands + +```sh + -c, --config string config file (default is $HOME/.config/harbor-cli/config.yaml) + -o, --output-format string Output format. One of: json|yaml + -v, --verbose verbose output +``` + +### SEE ALSO + +* [harbor config](harbor-config.md) - Manage the config of the Harbor Cli + diff --git a/doc/cli-docs/harbor-config.md b/doc/cli-docs/harbor-config.md new file mode 100644 index 00000000..c96eeab7 --- /dev/null +++ b/doc/cli-docs/harbor-config.md @@ -0,0 +1,50 @@ +--- +title: harbor config +weight: 15 +--- +## harbor config + +### Description + +##### Manage the config of the Harbor cli + +### Synopsis + +#### Config +Use the `--config` flag to specify a custom configuration file path (highest priority). +```bash +harbor --config /path/to/custom/config.yaml artifact list +``` +If `--config` is not provided, Harbor CLI checks the `HARBOR_CLI_CONFIG` environment variable for the config file path. +```bash +export HARBOR_CLI_CONFIG=/path/to/custom/config.yaml +harbor artifact list +``` +If neither is set, it defaults to `$XDG_CONFIG_HOME/harbor-cli/config.yaml` or `$HOME/.config/harbor-cli/config.yaml` if `XDG_CONFIG_HOME` is unset. +```bash +harbor artifact list +``` + +#### Data + - Data paths are determined by the `XDG_DATA_HOME` environment variable. + - If `XDG_DATA_HOME` is not set, it defaults to `$HOME/.local/share/harbor-cli/data.yaml`. + - The data file always contains the path of the latest config used. + +### Options + +```sh + -c, --config string config file (default is $HOME/.config/harbor-cli/config.yaml) + -h, --help help for harbor + -o, --output-format string Output format. One of: json|yaml + -v, --verbose verbose output +``` + +### SEE ALSO + +* [harbor artifact](harbor-artifact.md) - Manage artifacts +* [harbor login](harbor-login.md) - Log in to Harbor registry +* [harbor project](harbor-project.md) - Manage projects and assign resources to them +* [harbor registry](harbor-registry.md) - Manage registries +* [harbor repo](harbor-repo.md) - Manage repositories +* [harbor user](harbor-user.md) - Manage users +* [harbor version](harbor-version.md) - Version of Harbor CLI diff --git a/doc/cli-docs/harbor-label-create.md b/doc/cli-docs/harbor-label-create.md new file mode 100644 index 00000000..d1ceb283 --- /dev/null +++ b/doc/cli-docs/harbor-label-create.md @@ -0,0 +1,46 @@ +--- +title: harbor label create +weight: 80 +--- +## harbor label create + +### Description + +##### create label + +### Synopsis + +create label in harbor + +```sh +harbor label create [flags] +``` + +### Examples + +```sh +harbor label create +``` + +### Options + +```sh + --color string Color of the label.color is in hex value (default "#FFFFFF") + -d, --description string Description of the label + -h, --help help for create + -n, --name string Name of the label + -s, --scope string Scope of the label. eg- g(global), p(specific project) (default "g") +``` + +### Options inherited from parent commands + +```sh + -c, --config string config file (default is $HOME/.config/harbor-cli/config.yaml) + -o, --output-format string Output format. One of: json|yaml + -v, --verbose verbose output +``` + +### SEE ALSO + +* [harbor label](harbor-label.md) - Manage labels in Harbor + diff --git a/doc/cli-docs/harbor-label-delete.md b/doc/cli-docs/harbor-label-delete.md new file mode 100644 index 00000000..9d9ced98 --- /dev/null +++ b/doc/cli-docs/harbor-label-delete.md @@ -0,0 +1,39 @@ +--- +title: harbor label delete +weight: 0 +--- +## harbor label delete + +### Description + +##### delete label + +```sh +harbor label delete [flags] +``` + +### Examples + +```sh +harbor label delete [labelname] +``` + +### Options + +```sh + -h, --help help for delete + -s, --scope string default(global).'p' for project labels.Query scope of the label (default "g") +``` + +### Options inherited from parent commands + +```sh + -c, --config string config file (default is $HOME/.config/harbor-cli/config.yaml) + -o, --output-format string Output format. One of: json|yaml + -v, --verbose verbose output +``` + +### SEE ALSO + +* [harbor label](harbor-label.md) - Manage labels in Harbor + diff --git a/doc/cli-docs/harbor-label-list.md b/doc/cli-docs/harbor-label-list.md new file mode 100644 index 00000000..99b09a46 --- /dev/null +++ b/doc/cli-docs/harbor-label-list.md @@ -0,0 +1,38 @@ +--- +title: harbor label list +weight: 15 +--- +## harbor label list + +### Description + +##### list labels + +```sh +harbor label list [flags] +``` + +### Options + +```sh + -h, --help help for list + --page int Page number (default 1) + --page-size int Size of per page (default 20) + -i, --projectid int project ID when query project labels (default 1) + -q, --query string Query string to query resources + -s, --scope string default(global).'p' for project labels.Query scope of the label (default "g") + --sort string Sort the label list in ascending or descending order +``` + +### Options inherited from parent commands + +```sh + -c, --config string config file (default is $HOME/.config/harbor-cli/config.yaml) + -o, --output-format string Output format. One of: json|yaml + -v, --verbose verbose output +``` + +### SEE ALSO + +* [harbor label](harbor-label.md) - Manage labels in Harbor + diff --git a/doc/cli-docs/harbor-label-update.md b/doc/cli-docs/harbor-label-update.md new file mode 100644 index 00000000..e2072cfe --- /dev/null +++ b/doc/cli-docs/harbor-label-update.md @@ -0,0 +1,42 @@ +--- +title: harbor label update +weight: 70 +--- +## harbor label update + +### Description + +##### update label + +```sh +harbor label update [flags] +``` + +### Examples + +```sh +harbor label update [labelname] +``` + +### Options + +```sh + --color string Color of the label.color is in hex value + -d, --description string Description of the label + -h, --help help for update + -n, --name string Name of the label + -s, --scope string Scope of the label. eg- g(global), p(specific project) (default "g") +``` + +### Options inherited from parent commands + +```sh + -c, --config string config file (default is $HOME/.config/harbor-cli/config.yaml) + -o, --output-format string Output format. One of: json|yaml + -v, --verbose verbose output +``` + +### SEE ALSO + +* [harbor label](harbor-label.md) - Manage labels in Harbor + diff --git a/doc/cli-docs/harbor-label.md b/doc/cli-docs/harbor-label.md new file mode 100644 index 00000000..5171e162 --- /dev/null +++ b/doc/cli-docs/harbor-label.md @@ -0,0 +1,32 @@ +--- +title: harbor label +weight: 65 +--- +## harbor label + +### Description + +##### Manage labels in Harbor + +### Options + +```sh + -h, --help help for label +``` + +### Options inherited from parent commands + +```sh + -c, --config string config file (default is $HOME/.config/harbor-cli/config.yaml) + -o, --output-format string Output format. One of: json|yaml + -v, --verbose verbose output +``` + +### SEE ALSO + +* [harbor](harbor.md) - Official Harbor CLI +* [harbor label create](harbor-label-create.md) - create label +* [harbor label delete](harbor-label-delete.md) - delete label +* [harbor label list](harbor-label-list.md) - list labels +* [harbor label update](harbor-label-update.md) - update label + diff --git a/doc/cli-docs/harbor-login.md b/doc/cli-docs/harbor-login.md index 4e401829..3e428115 100644 --- a/doc/cli-docs/harbor-login.md +++ b/doc/cli-docs/harbor-login.md @@ -8,6 +8,25 @@ weight: 15 ##### Log in to Harbor registry +Authenticate with Harbor Registry. Depending on how the command is invoked, it behaves differently: + +##### With `-u` / `-p` Flags and `server` + - Opens the login view to obtain new credentials. + - Updates the config file with the new credentials. + - If the specified credential name already exists, it updates the existing entry. + - If the credential name does not exist, it adds a new entry for the credential. + +##### Without `-u` / `-p` Flags and `server` +a. No Existing Credentials in Config: + - Opens the login view to input credentials. + - Stores the entered credentials in the config file. + +b. Existing Credentials in Config: + - Uses the stored credentials from the config file. + - Skips the login view and proceeds to authenticate using the existing credentials. + +For more info on the harbor-cli config management see the [harbor config docs](harbor-config.md) + ### Synopsis Authenticate with Harbor Registry. @@ -36,4 +55,5 @@ harbor login [server] [flags] ### SEE ALSO * [harbor](harbor.md) - Official Harbor CLI +* [harbor config](harbor-config.md) - Harbor Config Management diff --git a/doc/cli-docs/harbor-project-search.md b/doc/cli-docs/harbor-project-search.md new file mode 100644 index 00000000..86b92d0f --- /dev/null +++ b/doc/cli-docs/harbor-project-search.md @@ -0,0 +1,32 @@ +--- +title: harbor project search +weight: 20 +--- +## harbor project search + +### Description + +##### search project based on their names + +```sh +harbor project search [flags] +``` + +### Options + +```sh + -h, --help help for search +``` + +### Options inherited from parent commands + +```sh + -c, --config string config file (default is $HOME/.config/harbor-cli/config.yaml) + -o, --output-format string Output format. One of: json|yaml + -v, --verbose verbose output +``` + +### SEE ALSO + +* [harbor project](harbor-project.md) - Manage projects and assign resources to them + diff --git a/doc/cli-docs/harbor-repo-search.md b/doc/cli-docs/harbor-repo-search.md new file mode 100644 index 00000000..de5c0494 --- /dev/null +++ b/doc/cli-docs/harbor-repo-search.md @@ -0,0 +1,32 @@ +--- +title: harbor repo search +weight: 30 +--- +## harbor repo search + +### Description + +##### search repository based on their names + +```sh +harbor repo search [flags] +``` + +### Options + +```sh + -h, --help help for search +``` + +### Options inherited from parent commands + +```sh + -c, --config string config file (default is $HOME/.config/harbor-cli/config.yaml) + -o, --output-format string Output format. One of: json|yaml + -v, --verbose verbose output +``` + +### SEE ALSO + +* [harbor repo](harbor-repo.md) - Manage repositories + diff --git a/doc/cli-docs/harbor-repo-view.md b/doc/cli-docs/harbor-repo-view.md new file mode 100644 index 00000000..9b5855e3 --- /dev/null +++ b/doc/cli-docs/harbor-repo-view.md @@ -0,0 +1,42 @@ +--- +title: harbor repo view +weight: 55 +--- +## harbor repo view + +### Description + +##### Get repository information + +### Synopsis + +Get information of a particular repository in a project + +```sh +harbor repo view [flags] +``` + +### Examples + +```sh + harbor repo view / +``` + +### Options + +```sh + -h, --help help for view +``` + +### Options inherited from parent commands + +```sh + -c, --config string config file (default is $HOME/.config/harbor-cli/config.yaml) + -o, --output-format string Output format. One of: json|yaml + -v, --verbose verbose output +``` + +### SEE ALSO + +* [harbor repo](harbor-repo.md) - Manage repositories + diff --git a/doc/cli-docs/harbor-schedule-list.md b/doc/cli-docs/harbor-schedule-list.md new file mode 100644 index 00000000..b6e9d3f0 --- /dev/null +++ b/doc/cli-docs/harbor-schedule-list.md @@ -0,0 +1,34 @@ +--- +title: harbor schedule list +weight: 70 +--- +## harbor schedule list + +### Description + +##### show all schedule jobs in Harbor + +```sh +harbor schedule list [flags] +``` + +### Options + +```sh + -h, --help help for list + --page int Page number (default 1) + --page-size int Size of per page (default 10) +``` + +### Options inherited from parent commands + +```sh + -c, --config string config file (default is $HOME/.config/harbor-cli/config.yaml) + -o, --output-format string Output format. One of: json|yaml + -v, --verbose verbose output +``` + +### SEE ALSO + +* [harbor schedule](harbor-schedule.md) - Schedule jobs in Harbor + diff --git a/doc/cli-docs/harbor-schedule.md b/doc/cli-docs/harbor-schedule.md new file mode 100644 index 00000000..65798692 --- /dev/null +++ b/doc/cli-docs/harbor-schedule.md @@ -0,0 +1,29 @@ +--- +title: harbor schedule +weight: 25 +--- +## harbor schedule + +### Description + +##### Schedule jobs in Harbor + +### Options + +```sh + -h, --help help for schedule +``` + +### Options inherited from parent commands + +```sh + -c, --config string config file (default is $HOME/.config/harbor-cli/config.yaml) + -o, --output-format string Output format. One of: json|yaml + -v, --verbose verbose output +``` + +### SEE ALSO + +* [harbor](harbor.md) - Official Harbor CLI +* [harbor schedule list](harbor-schedule-list.md) - show all schedule jobs in Harbor + diff --git a/doc/cli-docs/harbor.md b/doc/cli-docs/harbor.md index 5b7f98e8..9afa230b 100644 --- a/doc/cli-docs/harbor.md +++ b/doc/cli-docs/harbor.md @@ -27,7 +27,7 @@ harbor help ### Options ```sh - --config string config file (default is $HOME/.harbor/config.yaml) (default "/home/user/.harbor/config.yaml") + -c, --config string config file (default is $HOME/.config/harbor-cli/config.yaml) -h, --help help for harbor -o, --output-format string Output format. One of: json|yaml -v, --verbose verbose output diff --git a/go.mod b/go.mod index 0bf4138e..eb6c8ec3 100644 --- a/go.mod +++ b/go.mod @@ -11,21 +11,25 @@ require ( github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.19.0 github.com/stretchr/testify v1.9.0 + github.com/zalando/go-keyring v0.2.6 golang.org/x/term v0.6.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + al.essio.dev/pkg/shellescape v1.5.1 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.2.0 // indirect github.com/charmbracelet/x/ansi v0.2.3 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240725160154-f9f6568126ec // indirect github.com/charmbracelet/x/term v0.2.0 // indirect + github.com/danieljoos/wincred v1.2.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/magiconair/properties v1.8.7 // indirect @@ -81,7 +85,7 @@ require ( go.opentelemetry.io/otel/metric v1.31.0 // indirect go.opentelemetry.io/otel/sdk v1.28.0 // indirect go.opentelemetry.io/otel/trace v1.31.0 // indirect - golang.org/x/sys v0.25.0 // indirect + golang.org/x/sys v0.26.0 // indirect golang.org/x/text v0.18.0 // indirect ) diff --git a/go.sum b/go.sum index 599c9bbc..09b3f689 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= +al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= @@ -27,6 +29,8 @@ github.com/charmbracelet/x/exp/strings v0.0.0-20240725160154-f9f6568126ec/go.mod github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -64,10 +68,14 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58= github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/goharbor/go-client v0.210.0 h1:QwgLcWNSC3MFhBe7lq3BxDPtKQiD3k6hf6Lt26NChOI= github.com/goharbor/go-client v0.210.0/go.mod h1:XMWHucuHU9VTRx6U6wYwbRuyCVhE6ffJGRjaeo0nvwo= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= 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/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= @@ -142,6 +150,7 @@ github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -152,6 +161,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= go.mongodb.org/mongo-driver v1.16.0 h1:tpRsfBJMROVHKpdGyc1BBEzzjDUWjItxbVSZ8Ls4BQ4= go.mongodb.org/mongo-driver v1.16.0/go.mod h1:oB6AhJQvFQL4LEHyXi6aJzQJtBiTQHiAd83l0GdFaiw= go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= @@ -171,8 +182,8 @@ golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= diff --git a/pkg/utils/client.go b/pkg/utils/client.go index 388e380a..03b66995 100644 --- a/pkg/utils/client.go +++ b/pkg/utils/client.go @@ -3,7 +3,6 @@ package utils import ( "context" "fmt" - "os" "sync" "github.com/goharbor/go-client/pkg/harbor" @@ -30,7 +29,7 @@ func GetClient() (*v2client.HarborAPI, error) { return } - clientInstance = GetClientByCredentialName(credentialName) + clientInstance, clientErr = GetClientByCredentialName(credentialName) if clientErr != nil { log.Errorf("failed to initialize client: %v", clientErr) return @@ -58,16 +57,28 @@ func GetClientByConfig(clientConfig *harbor.ClientSetConfig) *v2client.HarborAPI } // Returns Harbor v2 client after resolving the credential name -func GetClientByCredentialName(credentialName string) *v2client.HarborAPI { +func GetClientByCredentialName(credentialName string) (*v2client.HarborAPI, error) { credential, err := GetCredentials(credentialName) if err != nil { - fmt.Print(err) - os.Exit(1) + return nil, fmt.Errorf("failed to get credentials: %w", err) } + + // Get encryption key + key, err := GetEncryptionKey() + if err != nil { + return nil, fmt.Errorf("failed to get encryption key: %w", err) + } + + // Decrypt password + decryptedPassword, err := Decrypt(key, string(credential.Password)) + if err != nil { + return nil, fmt.Errorf("failed to decrypt password: %w", err) + } + clientConfig := &harbor.ClientSetConfig{ URL: credential.ServerAddress, Username: credential.Username, - Password: credential.Password, + Password: decryptedPassword, } - return GetClientByConfig(clientConfig) + return GetClientByConfig(clientConfig), nil } diff --git a/pkg/utils/config.go b/pkg/utils/config.go index ec83542c..6549e160 100644 --- a/pkg/utils/config.go +++ b/pkg/utils/config.go @@ -328,9 +328,26 @@ func CreateConfigFile(configPath string) error { v := viper.New() v.SetConfigType("yaml") + key, err := GetEncryptionKey() + if err != nil { + fmt.Println("Error getting encryption key:", err) + return fmt.Errorf("failed to get encryption key: %s", err) + } + encryptedPassword, err := Encrypt(key, []byte("Harbor12345")) + if err != nil { + fmt.Println("Error encrypting password:", err) + return fmt.Errorf("failed to encrypt password: %s", err) + } defaultConfig := HarborConfig{ - CurrentCredentialName: "", - Credentials: []Credential{}, + CurrentCredentialName: "harbor-cli@demo-goharbor-io", + Credentials: []Credential{ + { + Name: "harbor-cli@demo-goharbor-io", + ServerAddress: "https://demo.goharbor.io", + Username: "harbor-cli", + Password: encryptedPassword, + }, + }, } v.Set("current-credential-name", defaultConfig.CurrentCredentialName) @@ -348,6 +365,49 @@ func CreateConfigFile(configPath string) error { return nil } +// UpdateConfigFile updates the YAML config file on disk with the +// values in the given HarborConfig, and also updates the in-memory CurrentHarborConfig. +func UpdateConfigFile(config *HarborConfig) error { + configMutex.Lock() + defer configMutex.Unlock() + + // Ensure we know where to write the config + if CurrentHarborData == nil { + return errors.New("harbor data is nil – check that your config initialization completed") + } + configPath := CurrentHarborData.ConfigPath + + // Ensure the file actually exists + if _, err := os.Stat(configPath); os.IsNotExist(err) { + return fmt.Errorf("config file does not exist at %s", configPath) + } else if err != nil { + return fmt.Errorf("error checking config file: %v", err) + } + + // Read the existing config file via viper + v := viper.New() + v.SetConfigFile(configPath) + v.SetConfigType("yaml") + if err := v.ReadInConfig(); err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + + // Overwrite the specific fields we care about + v.Set("current-credential-name", config.CurrentCredentialName) + v.Set("credentials", config.Credentials) + + // Write back to disk + if err := v.WriteConfig(); err != nil { + return fmt.Errorf("failed to write updated config file: %w", err) + } + + // Also update our global in-memory config + CurrentHarborConfig = config + + log.Infof("Updated config file at %s", configPath) + return nil +} + func GetCredentials(credentialName string) (Credential, error) { currentConfig, err := GetCurrentHarborConfig() if err != nil { @@ -425,6 +485,7 @@ func UpdateCredentialsInConfigFile(updatedCredential Credential, configPath stri for i, cred := range c.Credentials { if cred.Name == updatedCredential.Name { c.Credentials[i] = updatedCredential + c.CurrentCredentialName = updatedCredential.Name updated = true break } @@ -441,6 +502,7 @@ func UpdateCredentialsInConfigFile(updatedCredential Credential, configPath stri log.Fatalf("failed to write updated config file: %v", err) } - log.Infof("Updated credential '%s' in config file at %s", updatedCredential.Name, configPath) + log.Infof("Updated credential '%s' in config file at %s.", updatedCredential.Name, configPath) + log.Infof("Switched to context '%s'", updatedCredential.Name) return nil } diff --git a/pkg/utils/encryption.go b/pkg/utils/encryption.go new file mode 100644 index 00000000..c4b8a87d --- /dev/null +++ b/pkg/utils/encryption.go @@ -0,0 +1,120 @@ +package utils + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "fmt" + "io" + + "github.com/zalando/go-keyring" +) + +type KeyringProvider interface { + Set(service, user, password string) error + Get(service, user string) (string, error) + Delete(service, user string) error +} + +type SystemKeyring struct{} + +func (s *SystemKeyring) Set(service, user, password string) error { + return keyring.Set(service, user, password) +} + +func (s *SystemKeyring) Get(service, user string) (string, error) { + return keyring.Get(service, user) +} + +func (s *SystemKeyring) Delete(service, user string) error { + return keyring.Delete(service, user) +} + +var keyringProvider KeyringProvider = &SystemKeyring{} + +func SetKeyringProvider(provider KeyringProvider) { + keyringProvider = provider +} + +const KeyringService = "harbor-cli" +const KeyringUser = "harbor-cli-encryption-key" + +func GenerateEncryptionKey() error { + existingKey, err := keyringProvider.Get(KeyringService, KeyringUser) + if err == nil && existingKey != "" { + return nil + } + + key := make([]byte, 32) // AES-256 key + if _, err := rand.Read(key); err != nil { + return fmt.Errorf("failed to generate encryption key: %w", err) + } + return keyringProvider.Set(KeyringService, KeyringUser, base64.StdEncoding.EncodeToString(key)) +} + +func GetEncryptionKey() ([]byte, error) { + keyBase64, err := keyringProvider.Get(KeyringService, KeyringUser) + if err != nil || keyBase64 == "" { + // Attempt to generate a new key if not found + if genErr := GenerateEncryptionKey(); genErr != nil { + return nil, fmt.Errorf("failed to retrieve or generate encryption key: %w", err) + } + keyBase64, err = keyringProvider.Get(KeyringService, KeyringUser) + if err != nil { + return nil, fmt.Errorf("failed to retrieve encryption key after generation: %w", err) + } + } + return base64.StdEncoding.DecodeString(keyBase64) +} + +func Encrypt(key, plaintext []byte) (string, error) { + block, err := aes.NewCipher(key) + if err != nil { + return "", fmt.Errorf("failed to create cipher: %w", err) + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("failed to create GCM: %w", err) + } + + nonce := make([]byte, aesGCM.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("failed to generate nonce: %w", err) + } + + ciphertext := aesGCM.Seal(nonce, nonce, plaintext, nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +func Decrypt(key []byte, ciphertext string) (string, error) { + block, err := aes.NewCipher(key) + if err != nil { + return "", fmt.Errorf("failed to create cipher: %w", err) + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("failed to create GCM: %w", err) + } + + data, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil { + return "", fmt.Errorf("failed to decode ciphertext: %w", err) + } + + nonceSize := aesGCM.NonceSize() + if len(data) < nonceSize { + return "", fmt.Errorf("ciphertext too short") + } + + nonce := data[:nonceSize] + ciphertextBytes := data[nonceSize:] + plaintext, err := aesGCM.Open(nil, nonce, ciphertextBytes, nil) + if err != nil { + return "", fmt.Errorf("failed to decrypt ciphertext: %w", err) + } + + return string(plaintext), nil +} diff --git a/pkg/utils/encryption_mock.go b/pkg/utils/encryption_mock.go new file mode 100644 index 00000000..ffd62d96 --- /dev/null +++ b/pkg/utils/encryption_mock.go @@ -0,0 +1,34 @@ +package utils + +import "fmt" + +type MockKeyring struct { + store map[string]map[string]string +} + +func NewMockKeyring() *MockKeyring { + return &MockKeyring{store: make(map[string]map[string]string)} +} + +func (m *MockKeyring) Set(service, user, password string) error { + if m.store[service] == nil { + m.store[service] = make(map[string]string) + } + m.store[service][user] = password + return nil +} + +func (m *MockKeyring) Get(service, user string) (string, error) { + if val, ok := m.store[service][user]; ok { + return val, nil + } + return "", fmt.Errorf("key not found") +} + +func (m *MockKeyring) Delete(service, user string) error { + if _, ok := m.store[service][user]; ok { + delete(m.store[service], user) + return nil + } + return fmt.Errorf("key not found") +} diff --git a/pkg/views/project/create/view.go b/pkg/views/project/create/view.go index 89ea0c79..b5c70ea1 100644 --- a/pkg/views/project/create/view.go +++ b/pkg/views/project/create/view.go @@ -23,9 +23,13 @@ type CreateView struct { func getRegistryList() (*registry.ListRegistriesOK, error) { credentialName := viper.GetString("current-credential-name") - client := utils.GetClientByCredentialName(credentialName) + client, err := utils.GetClientByCredentialName(credentialName) + if err != nil { + return nil, err + } ctx := context.Background() - response, err := client.Registry.ListRegistries(ctx, ®istry.ListRegistriesParams{}) + var response *registry.ListRegistriesOK + response, err = client.Registry.ListRegistries(ctx, ®istry.ListRegistriesParams{}) if err != nil { return nil, err diff --git a/test/e2e/config_cmd_test.go b/test/e2e/config_cmd_test.go new file mode 100644 index 00000000..3f67421f --- /dev/null +++ b/test/e2e/config_cmd_test.go @@ -0,0 +1,353 @@ +package e2e + +import ( + "testing" + + "github.com/goharbor/harbor-cli/cmd/harbor/root" + "github.com/goharbor/harbor-cli/pkg/utils" + "github.com/stretchr/testify/assert" +) + +func Test_ConfigCmd(t *testing.T) { + tempDir := t.TempDir() + data := Initialize(t, tempDir) + defer ConfigCleanup(t, data) + SetMockKeyring(t) + rootCmd := root.RootCmd() + rootCmd.SetArgs([]string{"config"}) + err := rootCmd.Execute() + assert.Nil(t, err) +} + +func Test_ConfigListCmd(t *testing.T) { + tempDir := t.TempDir() + data := Initialize(t, tempDir) + defer ConfigCleanup(t, data) + SetMockKeyring(t) + rootCmd := root.RootCmd() + rootCmd.SetArgs([]string{"config", "list"}) + err := rootCmd.Execute() + assert.Nil(t, err) +} + +func Test_ConfigGetCmd_Success(t *testing.T) { + tempDir := t.TempDir() + data := Initialize(t, tempDir) + defer ConfigCleanup(t, data) + SetMockKeyring(t) + testConfig := &utils.HarborConfig{ + CurrentCredentialName: "harbor-cli@http://demo.goharbor.io", + Credentials: []utils.Credential{ + { + Name: "harbor-cli@http://demo.goharbor.io", + ServerAddress: "http://demo.goharbor.io", + Username: "harbor-cli", + Password: "Harbor12345", + }, + }, + } + err := utils.UpdateConfigFile(testConfig) + if err != nil { + t.Fatal(err) + } + rootCmd := root.RootCmd() + rootCmd.SetArgs([]string{"config", "get", "credentials.serveraddress"}) + err = rootCmd.Execute() + assert.NoError(t, err) +} + +func Test_ConfigGetCmd_Failure(t *testing.T) { + tempDir := t.TempDir() + data := Initialize(t, tempDir) + defer ConfigCleanup(t, data) + SetMockKeyring(t) + testConfig := &utils.HarborConfig{ + CurrentCredentialName: "harbor-cli@http://demo.goharbor.io", + Credentials: []utils.Credential{ + { + Name: "harbor-cli@http://demo.goharbor.io", + ServerAddress: "http://demo.goharbor.io", + Username: "harbor-cli", + Password: "Harbor12345", + }, + }, + } + err := utils.UpdateConfigFile(testConfig) + if err != nil { + t.Fatal(err) + } + rootCmd := root.RootCmd() + rootCmd.SetArgs([]string{"config", "get", "serveraddress"}) + err = rootCmd.Execute() + assert.Error(t, err, "Expected an error when getting a non-existent config item") +} + +func Test_ConfigGetCmd_CredentialName_Success(t *testing.T) { + tempDir := t.TempDir() + data := Initialize(t, tempDir) + defer ConfigCleanup(t, data) + SetMockKeyring(t) + testConfig := &utils.HarborConfig{ + CurrentCredentialName: "harbor-cli@http://demo.goharbor.io", + Credentials: []utils.Credential{ + { + Name: "harbor-cli@http://demo.goharbor.io", + ServerAddress: "http://demo.goharbor.io", + Username: "harbor-cli", + Password: "Harbor12345", + }, + }, + } + err := utils.UpdateConfigFile(testConfig) + if err != nil { + t.Fatal(err) + } + rootCmd := root.RootCmd() + rootCmd.SetArgs([]string{"config", "get", "credentials.serveraddress", "--name", "harbor-cli@http://demo.goharbor.io"}) + err = rootCmd.Execute() + assert.NoError(t, err) +} + +func Test_ConfigGetCmd_CredentialName_Failure(t *testing.T) { + tempDir := t.TempDir() + data := Initialize(t, tempDir) + defer ConfigCleanup(t, data) + SetMockKeyring(t) + testConfig := &utils.HarborConfig{ + CurrentCredentialName: "harbor-cli@http://demo.goharbor.io", + Credentials: []utils.Credential{ + { + Name: "harbor-cli@http://demo.goharbor.io", + ServerAddress: "http://demo.goharbor.io", + Username: "harbor-cli", + Password: "Harbor12345", + }, + }, + } + err := utils.UpdateConfigFile(testConfig) + if err != nil { + t.Fatal(err) + } + rootCmd := root.RootCmd() + rootCmd.SetArgs([]string{"config", "get", "credentials.serveraddress", "--name", "harbor-cli@http://goharbor.io"}) + err = rootCmd.Execute() + assert.Error(t, err, "Expected an error when getting a non-existent credential name") +} + +func Test_ConfigSetCmd_Success(t *testing.T) { + tempDir := t.TempDir() + data := Initialize(t, tempDir) + defer ConfigCleanup(t, data) + SetMockKeyring(t) + testConfig := &utils.HarborConfig{ + CurrentCredentialName: "harbor-cli@http://demo.goharbor.io", + Credentials: []utils.Credential{ + { + Name: "harbor-cli@http://demo.goharbor.io", + ServerAddress: "http://demo.goharbor.io", + Username: "harbor-cli", + Password: "Harbor12345", + }, + }, + } + err := utils.UpdateConfigFile(testConfig) + if err != nil { + t.Fatal(err) + } + rootCmd := root.RootCmd() + rootCmd.SetArgs([]string{"config", "set", "credentials.serveraddress", "http://demo.goharbor.io"}) + err = rootCmd.Execute() + assert.NoError(t, err) +} + +func Test_ConfigSetCmd_CredentialName_Success(t *testing.T) { + tempDir := t.TempDir() + data := Initialize(t, tempDir) + defer ConfigCleanup(t, data) + SetMockKeyring(t) + testConfig := &utils.HarborConfig{ + CurrentCredentialName: "harbor-cli@http://demo.goharbor.io", + Credentials: []utils.Credential{ + { + Name: "harbor-cli@http://demo.goharbor.io", + ServerAddress: "http://demo.goharbor.io", + Username: "harbor-cli", + Password: "Harbor12345", + }, + }, + } + err := utils.UpdateConfigFile(testConfig) + if err != nil { + t.Fatal(err) + } + rootCmd := root.RootCmd() + rootCmd.SetArgs([]string{"config", "set", "credentials.serveraddress", "http://demo.goharbor.io", "--name", "harbor-cli@http://demo.goharbor.io"}) + err = rootCmd.Execute() + assert.NoError(t, err) +} + +func Test_ConfigSetCmd_CredentialName_Failure(t *testing.T) { + tempDir := t.TempDir() + data := Initialize(t, tempDir) + defer ConfigCleanup(t, data) + SetMockKeyring(t) + testConfig := &utils.HarborConfig{ + CurrentCredentialName: "harbor-cli@http://demo.goharbor.io", + Credentials: []utils.Credential{ + { + Name: "harbor-cli@http://demo.goharbor.io", + ServerAddress: "http://demo.goharbor.io", + Username: "harbor-cli", + Password: "Harbor12345", + }, + }, + } + err := utils.UpdateConfigFile(testConfig) + if err != nil { + t.Fatal(err) + } + rootCmd := root.RootCmd() + rootCmd.SetArgs([]string{"config", "set", "credentials.serveraddress", "http://demo.goharbor.io", "--name", "harbor-cli@http://goharbor.io"}) + err = rootCmd.Execute() + assert.Error(t, err, "Expected an error when setting a non-existent credential name") +} + +func Test_ConfigSetCmd_Failure(t *testing.T) { + tempDir := t.TempDir() + data := Initialize(t, tempDir) + defer ConfigCleanup(t, data) + SetMockKeyring(t) + testConfig := &utils.HarborConfig{ + CurrentCredentialName: "harbor-cli@http://demo.goharbor.io", + Credentials: []utils.Credential{ + { + Name: "harbor-cli@http://demo.goharbor.io", + ServerAddress: "http://demo.goharbor.io", + Username: "harbor-cli", + Password: "Harbor12345", + }, + }, + } + err := utils.UpdateConfigFile(testConfig) + if err != nil { + t.Fatal(err) + } + rootCmd := root.RootCmd() + rootCmd.SetArgs([]string{"config", "set", "serveraddress", "http://demo.goharbor.io"}) + err = rootCmd.Execute() + assert.Error(t, err, "Expected an error when setting a non-existent config item") +} + +func Test_ConfigDeleteCmd_Success(t *testing.T) { + tempDir := t.TempDir() + data := Initialize(t, tempDir) + defer ConfigCleanup(t, data) + SetMockKeyring(t) + testConfig := &utils.HarborConfig{ + CurrentCredentialName: "harbor-cli@http://demo.goharbor.io", + Credentials: []utils.Credential{ + { + Name: "harbor-cli@http://demo.goharbor.io", + ServerAddress: "http://demo.goharbor.io", + Username: "harbor-cli", + Password: "Harbor12345", + }, + }, + } + err := utils.UpdateConfigFile(testConfig) + if err != nil { + t.Fatal(err) + } + rootCmd := root.RootCmd() + rootCmd.SetArgs([]string{"config", "delete", "credentials.serveraddress"}) + err = rootCmd.Execute() + assert.NoError(t, err) + config, err := utils.GetCurrentHarborConfig() + if err != nil { + t.Fatal(err) + } + assert.Empty(t, config.Credentials[0].ServerAddress) +} + +func Test_ConfigDeleteCmd_Failure(t *testing.T) { + tempDir := t.TempDir() + data := Initialize(t, tempDir) + defer ConfigCleanup(t, data) + SetMockKeyring(t) + testConfig := &utils.HarborConfig{ + CurrentCredentialName: "harbor-cli@http://demo.goharbor.io", + Credentials: []utils.Credential{ + { + Name: "harbor-cli@http://demo.goharbor.io", + ServerAddress: "http://demo.goharbor.io", + Username: "harbor-cli", + Password: "Harbor12345", + }, + }, + } + err := utils.UpdateConfigFile(testConfig) + if err != nil { + t.Fatal(err) + } + rootCmd := root.RootCmd() + rootCmd.SetArgs([]string{"config", "delete", "serveraddress"}) + err = rootCmd.Execute() + assert.Error(t, err, "Expected an error when deleting a non-existent config item") +} + +func Test_ConfigDeleteCmd_CredentialName_Success(t *testing.T) { + tempDir := t.TempDir() + data := Initialize(t, tempDir) + defer ConfigCleanup(t, data) + SetMockKeyring(t) + testConfig := &utils.HarborConfig{ + CurrentCredentialName: "harbor-cli@http://demo.goharbor.io", + Credentials: []utils.Credential{ + { + Name: "harbor-cli@http://demo.goharbor.io", + ServerAddress: "http://demo.goharbor.io", + Username: "harbor-cli", + Password: "Harbor12345", + }, + }, + } + err := utils.UpdateConfigFile(testConfig) + if err != nil { + t.Fatal(err) + } + rootCmd := root.RootCmd() + rootCmd.SetArgs([]string{"config", "delete", "credentials.serveraddress", "--name", "harbor-cli@http://demo.goharbor.io"}) + err = rootCmd.Execute() + assert.NoError(t, err) + config, err := utils.GetCurrentHarborConfig() + if err != nil { + t.Fatal(err) + } + assert.Empty(t, config.Credentials[0].ServerAddress) +} + +func Test_ConfigDeleteCmd_CredentialName_Failure(t *testing.T) { + tempDir := t.TempDir() + data := Initialize(t, tempDir) + defer ConfigCleanup(t, data) + SetMockKeyring(t) + testConfig := &utils.HarborConfig{ + CurrentCredentialName: "harbor-cli@http://demo.goharbor.io", + Credentials: []utils.Credential{ + { + Name: "harbor-cli@http://demo.goharbor.io", + ServerAddress: "http://demo.goharbor.io", + Username: "harbor-cli", + Password: "Harbor12345", + }, + }, + } + err := utils.UpdateConfigFile(testConfig) + if err != nil { + t.Fatal(err) + } + rootCmd := root.RootCmd() + rootCmd.SetArgs([]string{"config", "delete", "credentials.serveraddress", "--name", "harbor-cli@http://goharbor.io"}) + err = rootCmd.Execute() + assert.Error(t, err, "Expected an error when deleting a non-existent credential name") +} diff --git a/test/e2e/config_test.go b/test/e2e/config_test.go index 239491c5..b0f44c67 100644 --- a/test/e2e/config_test.go +++ b/test/e2e/config_test.go @@ -50,8 +50,18 @@ func ConfigCleanup(t *testing.T, data *utils.HarborData) { data = nil } +func SetMockKeyring(t *testing.T) { + mockKeyring := utils.NewMockKeyring() + utils.SetKeyringProvider(mockKeyring) + + t.Cleanup(func() { + utils.SetKeyringProvider(&utils.SystemKeyring{}) + }) +} + func Initialize(t *testing.T, tempDir string) *utils.HarborData { utils.ConfigInitialization.Reset() // Reset sync.Once for the test + SetMockKeyring(t) safeSetEnv("XDG_DATA_HOME", filepath.Join(tempDir, ".data")) utils.InitConfig(filepath.Join(tempDir, ".config", "config.yaml"), true) cds := root.RootCmd() @@ -66,6 +76,7 @@ func Initialize(t *testing.T, tempDir string) *utils.HarborData { func Test_Config_EnvVar(t *testing.T) { utils.ConfigInitialization.Reset() // Reset sync.Once for the test + SetMockKeyring(t) tempDir := t.TempDir() safeSetEnv("HARBOR_CLI_CONFIG", filepath.Join(tempDir, "config.yaml")) safeSetEnv("XDG_DATA_HOME", filepath.Join(tempDir, ".data")) @@ -89,6 +100,7 @@ func Test_Config_EnvVar(t *testing.T) { func Test_Config_Vanilla(t *testing.T) { utils.ConfigInitialization.Reset() // Reset sync.Once for the test + SetMockKeyring(t) utils.InitConfig("", false) cds := root.RootCmd() err := cds.Execute() @@ -108,6 +120,7 @@ func Test_Config_Vanilla(t *testing.T) { func Test_Config_Xdg(t *testing.T) { utils.ConfigInitialization.Reset() // Reset sync.Once for the test + SetMockKeyring(t) tempDir := t.TempDir() safeSetEnv("HARBOR_CLI_CONFIG", filepath.Join(tempDir, "config.yaml")) safeSetEnv("XDG_CONFIG_HOME", filepath.Join(tempDir, ".config")) diff --git a/test/e2e/encryption_test.go b/test/e2e/encryption_test.go new file mode 100644 index 00000000..105dd747 --- /dev/null +++ b/test/e2e/encryption_test.go @@ -0,0 +1,39 @@ +package e2e + +import ( + "testing" + + "github.com/goharbor/harbor-cli/pkg/utils" +) + +func Test_EncryptionWithMockKeyring(t *testing.T) { + // Use mock keyring for tests + mockKeyring := utils.NewMockKeyring() + utils.SetKeyringProvider(mockKeyring) + + // Run tests + err := utils.GenerateEncryptionKey() + if err != nil { + t.Fatalf("failed to generate encryption key: %v", err) + } + + key, err := utils.GetEncryptionKey() + if err != nil { + t.Fatalf("failed to get encryption key: %v", err) + } + + plaintext := "my-secret" + encrypted, err := utils.Encrypt(key, []byte(plaintext)) + if err != nil { + t.Fatalf("failed to encrypt: %v", err) + } + + decrypted, err := utils.Decrypt(key, encrypted) + if err != nil { + t.Fatalf("failed to decrypt: %v", err) + } + + if decrypted != plaintext { + t.Fatalf("expected %s but got %s", plaintext, decrypted) + } +} diff --git a/test/e2e/login_test.go b/test/e2e/login_test.go index e3869057..335d9934 100644 --- a/test/e2e/login_test.go +++ b/test/e2e/login_test.go @@ -11,13 +11,15 @@ func Test_Login_Success(t *testing.T) { tempDir := t.TempDir() data := Initialize(t, tempDir) defer ConfigCleanup(t, data) + + SetMockKeyring(t) + cmd := root.LoginCommand() validServerAddresses := []string{ "http://demo.goharbor.io:80", "https://demo.goharbor.io:443", "http://demo.goharbor.io", "https://demo.goharbor.io", - // "demo.goharbor.io", } for _, serverAddress := range validServerAddresses {