diff --git a/.replicated.yaml b/.replicated.yaml index 34e2d70c8..190287dc3 100644 --- a/.replicated.yaml +++ b/.replicated.yaml @@ -1,3 +1,4 @@ +profile: "prod" appId: "" appSlug: "" promoteToChannelIds: [] diff --git a/cli/cmd/profile.go b/cli/cmd/profile.go new file mode 100644 index 000000000..2b84624a5 --- /dev/null +++ b/cli/cmd/profile.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +func (r *runners) InitProfileCommand(parent *cobra.Command) *cobra.Command { + cmd := &cobra.Command{ + Use: "profile", + Short: "Manage authentication profiles", + Long: `The profile command allows you to manage authentication profiles for the Replicated CLI. + +Profiles let you store multiple sets of credentials and easily switch between them. +This is useful when working with different Replicated accounts (production, development, etc.) +or different API endpoints. + +Credentials are stored in ~/.replicated/config.yaml with file permissions set to 600 (owner read/write only). +You can reference profiles in your .replicated.yaml files using the 'profile' field. + +Authentication priority: +1. REPLICATED_API_TOKEN environment variable (highest priority) +2. Profile specified in .replicated.yaml +3. Default profile from ~/.replicated/config.yaml +4. Legacy single token (backward compatibility) + +Use the various subcommands to: +- Add new profiles +- List all profiles +- Remove profiles +- Set the default profile`, + Example: `# Add a production profile +replicated profile add prod --token=your-prod-token + +# Add a development profile with custom API origin +replicated profile add dev --token=your-dev-token --api-origin=https://vendor-api-dev.com + +# List all profiles +replicated profile ls + +# Set default profile +replicated profile set-default prod + +# Remove a profile +replicated profile rm dev`, + } + parent.AddCommand(cmd) + + return cmd +} diff --git a/cli/cmd/profile_add.go b/cli/cmd/profile_add.go new file mode 100644 index 000000000..4130965a6 --- /dev/null +++ b/cli/cmd/profile_add.go @@ -0,0 +1,79 @@ +package cmd + +import ( + "fmt" + + "github.com/pkg/errors" + "github.com/replicatedhq/replicated/pkg/credentials" + "github.com/replicatedhq/replicated/pkg/credentials/types" + "github.com/spf13/cobra" +) + +func (r *runners) InitProfileAddCommand(parent *cobra.Command) *cobra.Command { + cmd := &cobra.Command{ + Use: "add [profile-name]", + Short: "Add a new authentication profile", + Long: `Add a new authentication profile with the specified name. + +You must provide an API token. Optionally, you can specify custom API and registry origins. +If a profile with the same name already exists, it will be updated. + +The profile will be stored in ~/.replicated/config.yaml with file permissions 600 (owner read/write only).`, + Example: `# Add a production profile +replicated profile add prod --token=your-prod-token + +# Add a development profile with custom origins +replicated profile add dev \ + --token=your-dev-token \ + --api-origin=https://vendor-api-noahecampbell.okteto.repldev.com \ + --registry-origin=vendor-registry-v2-noahecampbell.okteto.repldev.com`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: r.profileAdd, + } + parent.AddCommand(cmd) + + cmd.Flags().StringVar(&r.args.profileAddToken, "token", "", "API token for this profile (required)") + cmd.Flags().StringVar(&r.args.profileAddAPIOrigin, "api-origin", "", "API origin (optional, e.g., https://api.replicated.com/vendor)") + cmd.Flags().StringVar(&r.args.profileAddRegistryOrigin, "registry-origin", "", "Registry origin (optional, e.g., registry.replicated.com)") + + cmd.MarkFlagRequired("token") + + return cmd +} + +func (r *runners) profileAdd(_ *cobra.Command, args []string) error { + profileName := args[0] + + if profileName == "" { + return errors.New("profile name cannot be empty") + } + + if r.args.profileAddToken == "" { + return errors.New("token is required") + } + + profile := types.Profile{ + APIToken: r.args.profileAddToken, + APIOrigin: r.args.profileAddAPIOrigin, + RegistryOrigin: r.args.profileAddRegistryOrigin, + } + + if err := credentials.AddProfile(profileName, profile); err != nil { + return errors.Wrap(err, "failed to add profile") + } + + fmt.Printf("Profile '%s' added successfully\n", profileName) + + // Check if this is the only profile - if so, it's now the default + _, defaultProfile, err := credentials.ListProfiles() + if err != nil { + return errors.Wrap(err, "failed to check default profile") + } + + if defaultProfile == profileName { + fmt.Printf("Profile '%s' set as default\n", profileName) + } + + return nil +} diff --git a/cli/cmd/profile_ls.go b/cli/cmd/profile_ls.go new file mode 100644 index 000000000..959e06c98 --- /dev/null +++ b/cli/cmd/profile_ls.go @@ -0,0 +1,87 @@ +package cmd + +import ( + "fmt" + "os" + "text/tabwriter" + + "github.com/pkg/errors" + "github.com/replicatedhq/replicated/pkg/credentials" + "github.com/spf13/cobra" +) + +func (r *runners) InitProfileLsCommand(parent *cobra.Command) *cobra.Command { + cmd := &cobra.Command{ + Use: "ls", + Short: "List all authentication profiles", + Long: `List all authentication profiles configured in ~/.replicated/config.yaml. + +The default profile is indicated with an asterisk (*). API tokens are masked for security.`, + Example: `# List all profiles +replicated profile ls`, + SilenceUsage: true, + RunE: r.profileLs, + } + parent.AddCommand(cmd) + + return cmd +} + +func (r *runners) profileLs(_ *cobra.Command, _ []string) error { + profiles, defaultProfile, err := credentials.ListProfiles() + if err != nil { + return errors.Wrap(err, "failed to list profiles") + } + + if len(profiles) == 0 { + fmt.Println("No profiles configured") + fmt.Println("") + fmt.Println("To add a profile, run:") + fmt.Println(" replicated profile add --token=") + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "DEFAULT\tNAME\tAPI ORIGIN\tREGISTRY ORIGIN\tTOKEN") + + for name, profile := range profiles { + isDefault := "" + if name == defaultProfile { + isDefault = "*" + } + + apiOrigin := profile.APIOrigin + if apiOrigin == "" { + apiOrigin = "" + } + + registryOrigin := profile.RegistryOrigin + if registryOrigin == "" { + registryOrigin = "" + } + + // Mask the token for security (show first 8 chars) + maskedToken := maskToken(profile.APIToken) + + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", + isDefault, + name, + apiOrigin, + registryOrigin, + maskedToken, + ) + } + + w.Flush() + return nil +} + +func maskToken(token string) string { + if token == "" { + return "" + } + if len(token) <= 8 { + return token[:len(token)/2] + "..." + } + return token[:8] + "..." + token[len(token)-4:] +} diff --git a/cli/cmd/profile_rm.go b/cli/cmd/profile_rm.go new file mode 100644 index 000000000..c3e3ecb00 --- /dev/null +++ b/cli/cmd/profile_rm.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "fmt" + + "github.com/pkg/errors" + "github.com/replicatedhq/replicated/pkg/credentials" + "github.com/spf13/cobra" +) + +func (r *runners) InitProfileRmCommand(parent *cobra.Command) *cobra.Command { + cmd := &cobra.Command{ + Use: "rm [profile-name]", + Short: "Remove an authentication profile", + Long: `Remove an authentication profile by name. + +If the removed profile was the default profile, the default will be automatically +set to another available profile (if any exist).`, + Example: `# Remove a profile +replicated profile rm dev`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: r.profileRm, + } + parent.AddCommand(cmd) + + return cmd +} + +func (r *runners) profileRm(_ *cobra.Command, args []string) error { + profileName := args[0] + + if profileName == "" { + return errors.New("profile name cannot be empty") + } + + // Check if profile exists before removing + _, err := credentials.GetProfile(profileName) + if err == credentials.ErrProfileNotFound { + return errors.Errorf("profile '%s' not found", profileName) + } + if err != nil { + return errors.Wrap(err, "failed to get profile") + } + + // Remove the profile + if err := credentials.RemoveProfile(profileName); err != nil { + return errors.Wrap(err, "failed to remove profile") + } + + fmt.Printf("Profile '%s' removed successfully\n", profileName) + + // Check if there's a new default + _, newDefault, err := credentials.ListProfiles() + if err != nil { + return errors.Wrap(err, "failed to check new default profile") + } + + if newDefault != "" { + fmt.Printf("Default profile is now '%s'\n", newDefault) + } else { + fmt.Println("No profiles remaining") + } + + return nil +} diff --git a/cli/cmd/profile_set_default.go b/cli/cmd/profile_set_default.go new file mode 100644 index 000000000..dd64e5c3a --- /dev/null +++ b/cli/cmd/profile_set_default.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "fmt" + + "github.com/pkg/errors" + "github.com/replicatedhq/replicated/pkg/credentials" + "github.com/spf13/cobra" +) + +func (r *runners) InitProfileSetDefaultCommand(parent *cobra.Command) *cobra.Command { + cmd := &cobra.Command{ + Use: "set-default [profile-name]", + Short: "Set the default authentication profile", + Long: `Set the default authentication profile that will be used when no profile is specified +in .replicated.yaml and no environment variables are set.`, + Example: `# Set production as the default profile +replicated profile set-default prod`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: r.profileSetDefault, + } + parent.AddCommand(cmd) + + return cmd +} + +func (r *runners) profileSetDefault(_ *cobra.Command, args []string) error { + profileName := args[0] + + if profileName == "" { + return errors.New("profile name cannot be empty") + } + + // Check if profile exists + _, err := credentials.GetProfile(profileName) + if err == credentials.ErrProfileNotFound { + return errors.Errorf("profile '%s' not found", profileName) + } + if err != nil { + return errors.Wrap(err, "failed to get profile") + } + + // Set as default + if err := credentials.SetDefaultProfile(profileName); err != nil { + return errors.Wrap(err, "failed to set default profile") + } + + fmt.Printf("Default profile set to '%s'\n", profileName) + return nil +} diff --git a/cli/cmd/root.go b/cli/cmd/root.go index f60562a34..63d2d0dda 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -15,6 +15,7 @@ import ( "github.com/replicatedhq/replicated/pkg/credentials" "github.com/replicatedhq/replicated/pkg/kotsclient" "github.com/replicatedhq/replicated/pkg/platformclient" + "github.com/replicatedhq/replicated/pkg/tools" "github.com/replicatedhq/replicated/pkg/types" "github.com/replicatedhq/replicated/pkg/version" "github.com/spf13/cobra" @@ -292,6 +293,12 @@ func Execute(rootCmd *cobra.Command, stdin io.Reader, stdout io.Writer, stderr i runCmds.InitLoginCommand(runCmds.rootCmd) runCmds.InitLogoutCommand(runCmds.rootCmd) + profileCmd := runCmds.InitProfileCommand(runCmds.rootCmd) + runCmds.InitProfileAddCommand(profileCmd) + runCmds.InitProfileLsCommand(profileCmd) + runCmds.InitProfileRmCommand(profileCmd) + runCmds.InitProfileSetDefaultCommand(profileCmd) + apiCmd := runCmds.InitAPICommand(runCmds.rootCmd) runCmds.InitAPIGet(apiCmd) runCmds.InitAPIPost(apiCmd) @@ -306,15 +313,42 @@ func Execute(rootCmd *cobra.Command, stdin io.Reader, stdout io.Writer, stderr i preRunSetupAPIs := func(cmd *cobra.Command, args []string) error { if apiToken == "" { - creds, err := credentials.GetCurrentCredentials() + // Try to load profile from .replicated.yaml + var profileName string + configParser := tools.NewConfigParser() + config, err := configParser.FindAndParseConfig("") + if err == nil && config.Profile != "" { + profileName = config.Profile + } + + // Get credentials with profile support + creds, err := credentials.GetCredentialsWithProfile(profileName) if err != nil { - if err == credentials.ErrCredentialsNotFound { - return errors.New("Please provide your API token or log in with `replicated login`") + if err == credentials.ErrCredentialsNotFound || err == credentials.ErrProfileNotFound { + msg := "Please provide your API token or log in with `replicated login`" + if profileName != "" { + msg = fmt.Sprintf("%s (profile '%s' not found; run `replicated profile add %s --token=`)", msg, profileName, profileName) + } + return errors.New(msg) } return errors.Wrap(err, "get current credentials") } apiToken = creds.APIToken + + // If using a profile, check if it has custom origins + if creds.IsProfile && profileName != "" { + apiOrigin, registryOrigin, err := credentials.GetProfileOrigins(profileName) + if err == nil { + if apiOrigin != "" { + platformOrigin = apiOrigin + } + if registryOrigin != "" { + // Store registry origin for later use (if needed by commands) + os.Setenv("REPLICATED_REGISTRY_ORIGIN", registryOrigin) + } + } + } } // allow override @@ -428,9 +462,9 @@ func printIfError(cmd *cobra.Command, err error) { switch err := errors.Cause(err).(type) { case platformclient.APIError: - fmt.Fprintln(os.Stderr, fmt.Sprintf("ERROR: %d", err.StatusCode)) - fmt.Fprintln(os.Stderr, fmt.Sprintf("METHOD: %s", err.Method)) - fmt.Fprintln(os.Stderr, fmt.Sprintf("ENDPOINT: %s", err.Endpoint)) + fmt.Fprintf(os.Stderr, "ERROR: %d\n", err.StatusCode) + fmt.Fprintf(os.Stderr, "METHOD: %s\n", err.Method) + fmt.Fprintf(os.Stderr, "ENDPOINT: %s\n", err.Endpoint) fmt.Fprintln(os.Stderr, err.Message) // note that this can have multiple lines case ClusterTimeoutError: fmt.Fprintf(os.Stderr, "Error: Wait timeout exceeded for cluster %s\n", err.Cluster.ID) diff --git a/cli/cmd/runner.go b/cli/cmd/runner.go index 35866a123..7cdb0313b 100644 --- a/cli/cmd/runner.go +++ b/cli/cmd/runner.go @@ -276,4 +276,9 @@ type runnerArgs struct { demoteChannelSequence int64 unDemoteReleaseSequence int64 unDemoteChannelSequence int64 + + // Profile management + profileAddToken string + profileAddAPIOrigin string + profileAddRegistryOrigin string } diff --git a/pkg/credentials/credentials.go b/pkg/credentials/credentials.go index d96ccbddf..f666488d5 100644 --- a/pkg/credentials/credentials.go +++ b/pkg/credentials/credentials.go @@ -12,6 +12,7 @@ import ( var ( ErrCredentialsNotFound = errors.New("credentials not found") + ErrProfileNotFound = errors.New("profile not found") ) func SetCurrentCredentials(token string) error { @@ -45,10 +46,16 @@ func RemoveCurrentCredentials() error { } func GetCurrentCredentials() (*types.Credentials, error) { - // priority order: - // 1. env vars - // 2. config file + return GetCredentialsWithProfile("") +} +// GetCredentialsWithProfile retrieves credentials with the following priority: +// 1. Environment variables (REPLICATED_API_TOKEN) +// 2. Named profile (if profileName is provided) +// 3. Default profile from config file (if profileName is empty) +// 4. Legacy single token from config file (backward compatibility) +func GetCredentialsWithProfile(profileName string) (*types.Credentials, error) { + // Priority 1: Check environment variables first envCredentials, err := getEnvCredentials() if err != nil && err != ErrCredentialsNotFound { return nil, err @@ -57,11 +64,20 @@ func GetCurrentCredentials() (*types.Credentials, error) { return envCredentials, nil } + // Priority 2 & 3: Check profile-based credentials + profileCredentials, err := getProfileCredentials(profileName) + if err != nil && err != ErrCredentialsNotFound && err != ErrProfileNotFound { + return nil, err + } + if err == nil { + return profileCredentials, nil + } + + // Priority 4: Fall back to legacy config file credentials configFileCredentials, err := getConfigFileCredentials() if err != nil && err != ErrCredentialsNotFound { return nil, err } - if err == nil { return configFileCredentials, nil } @@ -69,6 +85,56 @@ func GetCurrentCredentials() (*types.Credentials, error) { return nil, ErrCredentialsNotFound } +// getProfileCredentials retrieves credentials from a named profile +// If profileName is empty, uses the default profile +func getProfileCredentials(profileName string) (*types.Credentials, error) { + config, err := readConfigFile() + if err != nil { + return nil, err + } + + // If no profile name provided, use default + if profileName == "" { + profileName = config.DefaultProfile + } + + // If still no profile name, return not found + if profileName == "" { + return nil, ErrProfileNotFound + } + + // Get the profile + profile, exists := config.Profiles[profileName] + if !exists { + return nil, ErrProfileNotFound + } + + // Validate that profile has a token + if profile.APIToken == "" { + return nil, errors.New("profile has no API token") + } + + return &types.Credentials{ + APIToken: profile.APIToken, + IsProfile: true, + }, nil +} + +// GetProfileOrigins returns the API and registry origins for a given profile +// Returns empty strings if profile doesn't exist or doesn't specify origins +func GetProfileOrigins(profileName string) (apiOrigin, registryOrigin string, err error) { + if profileName == "" { + return "", "", nil + } + + profile, err := GetProfile(profileName) + if err != nil { + return "", "", err + } + + return profile.APIOrigin, profile.RegistryOrigin, nil +} + func getEnvCredentials() (*types.Credentials, error) { if os.Getenv("REPLICATED_API_TOKEN") != "" { return &types.Credentials{ @@ -104,6 +170,164 @@ func getConfigFileCredentials() (*types.Credentials, error) { return &credentials, nil } +// Profile management functions + +// readConfigFile reads the config file and returns the parsed ConfigFile struct +func readConfigFile() (*types.ConfigFile, error) { + configFile := configFilePath() + if _, err := os.Stat(configFile); err != nil { + if os.IsNotExist(err) { + // Return empty config if file doesn't exist + return &types.ConfigFile{ + Profiles: make(map[string]types.Profile), + }, nil + } + return nil, err + } + + b, err := os.ReadFile(configFile) + if err != nil { + return nil, err + } + + var config types.ConfigFile + if err := json.Unmarshal(b, &config); err != nil { + // Try legacy format (just a Credentials struct) + var legacyCreds types.Credentials + if legacyErr := json.Unmarshal(b, &legacyCreds); legacyErr == nil && legacyCreds.APIToken != "" { + // Convert legacy format to new format + return &types.ConfigFile{ + Token: legacyCreds.APIToken, + Profiles: make(map[string]types.Profile), + }, nil + } + return nil, err + } + + // Initialize profiles map if nil + if config.Profiles == nil { + config.Profiles = make(map[string]types.Profile) + } + + return &config, nil +} + +// writeConfigFile writes the ConfigFile struct to the config file +func writeConfigFile(config *types.ConfigFile) error { + b, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + + configFile := configFilePath() + if err := os.MkdirAll(path.Dir(configFile), 0755); err != nil { + return err + } + + if err := os.WriteFile(configFile, b, 0600); err != nil { + return err + } + + return nil +} + +// AddProfile adds or updates a profile in the config file +func AddProfile(name string, profile types.Profile) error { + if name == "" { + return errors.New("profile name cannot be empty") + } + + config, err := readConfigFile() + if err != nil { + return err + } + + config.Profiles[name] = profile + + // Set as default if it's the first profile + if config.DefaultProfile == "" { + config.DefaultProfile = name + } + + return writeConfigFile(config) +} + +// RemoveProfile removes a profile from the config file +func RemoveProfile(name string) error { + config, err := readConfigFile() + if err != nil { + return err + } + + if _, exists := config.Profiles[name]; !exists { + return ErrProfileNotFound + } + + delete(config.Profiles, name) + + // Clear default if it was the removed profile + if config.DefaultProfile == name { + config.DefaultProfile = "" + // Set to first available profile if any exist + for profileName := range config.Profiles { + config.DefaultProfile = profileName + break + } + } + + return writeConfigFile(config) +} + +// GetProfile retrieves a specific profile by name +func GetProfile(name string) (*types.Profile, error) { + config, err := readConfigFile() + if err != nil { + return nil, err + } + + profile, exists := config.Profiles[name] + if !exists { + return nil, ErrProfileNotFound + } + + return &profile, nil +} + +// ListProfiles returns all profiles and the default profile name +func ListProfiles() (map[string]types.Profile, string, error) { + config, err := readConfigFile() + if err != nil { + return nil, "", err + } + + return config.Profiles, config.DefaultProfile, nil +} + +// SetDefaultProfile sets the default profile +func SetDefaultProfile(name string) error { + config, err := readConfigFile() + if err != nil { + return err + } + + if _, exists := config.Profiles[name]; !exists { + return ErrProfileNotFound + } + + config.DefaultProfile = name + return writeConfigFile(config) +} + +// GetDefaultProfile returns the name of the default profile +func GetDefaultProfile() (string, error) { + config, err := readConfigFile() + if err != nil { + return "", err + } + + return config.DefaultProfile, nil +} + func configFilePath() string { return filepath.Join(homeDir(), ".replicated", "config.yaml") } diff --git a/pkg/credentials/types/types.go b/pkg/credentials/types/types.go index a530bf8d7..704b7d6e3 100644 --- a/pkg/credentials/types/types.go +++ b/pkg/credentials/types/types.go @@ -6,4 +6,22 @@ type Credentials struct { IsEnv bool `json:"-"` IsConfigFile bool `json:"-"` + IsProfile bool `json:"-"` +} + +// Profile represents a named authentication profile +type Profile struct { + APIToken string `json:"apiToken"` + APIOrigin string `json:"apiOrigin,omitempty"` + RegistryOrigin string `json:"registryOrigin,omitempty"` +} + +// ConfigFile represents the structure of ~/.replicated/config.yaml +type ConfigFile struct { + // Legacy single token (for backward compatibility) + Token string `json:"token,omitempty"` + + // New profile-based configuration + Profiles map[string]Profile `json:"profiles,omitempty"` + DefaultProfile string `json:"defaultProfile,omitempty"` } diff --git a/pkg/tools/types.go b/pkg/tools/types.go index 1e929c99d..90124c849 100644 --- a/pkg/tools/types.go +++ b/pkg/tools/types.go @@ -10,6 +10,7 @@ type Config struct { Preflights []PreflightConfig `yaml:"preflights,omitempty"` ReleaseLabel string `yaml:"releaseLabel,omitempty"` Manifests []string `yaml:"manifests,omitempty"` + Profile string `yaml:"profile,omitempty"` ReplLint *ReplLintConfig `yaml:"repl-lint,omitempty"` }