diff --git a/cmd/atlas/atlas.go b/cmd/atlas/atlas.go index e88e51e4bc..224e440796 100644 --- a/cmd/atlas/atlas.go +++ b/cmd/atlas/atlas.go @@ -15,6 +15,7 @@ package main import ( + "context" "fmt" "log" "os" @@ -25,13 +26,13 @@ import ( "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/cli/root" "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/config" "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/telemetry" + "github.com/spf13/afero" "github.com/spf13/cobra" ) // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. -func execute(rootCmd *cobra.Command) { - ctx := telemetry.NewContext() +func execute(ctx context.Context, rootCmd *cobra.Command) { // append here to avoid a recursive link on generated docs rootCmd.Long += ` @@ -51,12 +52,17 @@ To learn more, see our documentation: https://www.mongodb.com/docs/atlas/cli/sta } // loadConfig reads in config file and ENV variables if set. -func loadConfig() error { - if err := config.LoadAtlasCLIConfig(); err != nil { - return fmt.Errorf("error loading config: %w. Please run `atlas config init` to reconfigure your profile", err) +func loadConfig() (*config.Profile, error) { + configStore, initErr := config.NewViperStore(afero.NewOsFs()) + + if initErr != nil { + return nil, fmt.Errorf("error loading config: %w. Please run `atlas auth login` to reconfigure your profile", initErr) } - return nil + profile := config.NewProfile(config.DefaultProfile, configStore) + + config.SetProfile(profile) + return profile, nil } func trackInitError(e error, rootCmd *cobra.Command) { @@ -85,9 +91,18 @@ func main() { core.DisableColor = true } + // Load config + profile, loadProfileErr := loadConfig() + rootCmd := root.Builder() initTrack(rootCmd) - trackInitError(loadConfig(), rootCmd) + trackInitError(loadProfileErr, rootCmd) + + // Initialize context, attach + // - telemetry + // - profile + ctx := telemetry.NewContext() + config.WithProfile(ctx, profile) - execute(rootCmd) + execute(ctx, rootCmd) } diff --git a/internal/cli/config/edit.go b/internal/cli/config/edit.go index f7ad7d63b7..f29d2d08d5 100644 --- a/internal/cli/config/edit.go +++ b/internal/cli/config/edit.go @@ -33,7 +33,15 @@ func (*editOpts) Run() error { } else if e := os.Getenv("EDITOR"); e != "" { editor = e } - cmd := exec.Command(editor, config.Filename()) //nolint:gosec // it's ok to let users do this + + // Get the viper config filename + configDir, err := config.CLIConfigHome() + if err != nil { + return err + } + filename := config.ViperConfigStoreFilename(configDir) + + cmd := exec.Command(editor, filename) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000000..02f3ce54e8 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,44 @@ +// Copyright 2025 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "bytes" + "os" + "path" +) + +// CLIConfigHome retrieves configHome path. +func CLIConfigHome() (string, error) { + home, err := os.UserConfigDir() + if err != nil { + return "", err + } + + return path.Join(home, "atlascli"), nil +} + +func Path(f string) (string, error) { + var p bytes.Buffer + + h, err := CLIConfigHome() + if err != nil { + return "", err + } + + p.WriteString(h) + p.WriteString(f) + return p.String(), nil +} diff --git a/internal/config/identifiers.go b/internal/config/identifiers.go new file mode 100644 index 0000000000..5485ee79ba --- /dev/null +++ b/internal/config/identifiers.go @@ -0,0 +1,84 @@ +// Copyright 2025 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "fmt" + "os" + "runtime" + "strings" + + "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/version" +) + +var ( + CLIUserType = newCLIUserTypeFromEnvs() + HostName = getConfigHostnameFromEnvs() + UserAgent = fmt.Sprintf("%s/%s (%s;%s;%s)", AtlasCLI, version.Version, runtime.GOOS, runtime.GOARCH, HostName) +) + +// newCLIUserTypeFromEnvs patches the user type information based on set env vars. +func newCLIUserTypeFromEnvs() string { + if value, ok := os.LookupEnv(CLIUserTypeEnv); ok { + return value + } + + return DefaultUser +} + +// getConfigHostnameFromEnvs patches the agent hostname based on set env vars. +func getConfigHostnameFromEnvs() string { + var builder strings.Builder + + envVars := []struct { + envName string + hostName string + }{ + {AtlasActionHostNameEnv, AtlasActionHostName}, + {GitHubActionsHostNameEnv, GitHubActionsHostName}, + {ContainerizedHostNameEnv, DockerContainerHostName}, + } + + for _, envVar := range envVars { + if envIsTrue(envVar.envName) { + appendToHostName(&builder, envVar.hostName) + } else { + appendToHostName(&builder, "-") + } + } + configHostName := builder.String() + + if isDefaultHostName(configHostName) { + return NativeHostName + } + return configHostName +} + +func appendToHostName(builder *strings.Builder, configVal string) { + if builder.Len() > 0 { + builder.WriteString("|") + } + builder.WriteString(configVal) +} + +// isDefaultHostName checks if the hostname is the default placeholder. +func isDefaultHostName(hostname string) bool { + // Using strings.Count for a more dynamic approach. + return strings.Count(hostname, "-") == strings.Count(hostname, "|")+1 +} + +func envIsTrue(env string) bool { + return IsTrue(os.Getenv(env)) +} diff --git a/internal/config/mocks.go b/internal/config/mocks.go new file mode 100644 index 0000000000..80fe6adf4a --- /dev/null +++ b/internal/config/mocks.go @@ -0,0 +1,190 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/mongodb/mongodb-atlas-cli/atlascli/internal/config (interfaces: Store) +// +// Generated by this command: +// +// mockgen -destination=./mocks.go -package=config github.com/mongodb/mongodb-atlas-cli/atlascli/internal/config Store +// + +// Package config is a generated GoMock package. +package config + +import ( + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockStore is a mock of Store interface. +type MockStore struct { + ctrl *gomock.Controller + recorder *MockStoreMockRecorder + isgomock struct{} +} + +// MockStoreMockRecorder is the mock recorder for MockStore. +type MockStoreMockRecorder struct { + mock *MockStore +} + +// NewMockStore creates a new mock instance. +func NewMockStore(ctrl *gomock.Controller) *MockStore { + mock := &MockStore{ctrl: ctrl} + mock.recorder = &MockStoreMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockStore) EXPECT() *MockStoreMockRecorder { + return m.recorder +} + +// DeleteProfile mocks base method. +func (m *MockStore) DeleteProfile(profileName string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteProfile", profileName) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteProfile indicates an expected call of DeleteProfile. +func (mr *MockStoreMockRecorder) DeleteProfile(profileName any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteProfile", reflect.TypeOf((*MockStore)(nil).DeleteProfile), profileName) +} + +// GetGlobalValue mocks base method. +func (m *MockStore) GetGlobalValue(propertyName string) any { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGlobalValue", propertyName) + ret0, _ := ret[0].(any) + return ret0 +} + +// GetGlobalValue indicates an expected call of GetGlobalValue. +func (mr *MockStoreMockRecorder) GetGlobalValue(propertyName any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGlobalValue", reflect.TypeOf((*MockStore)(nil).GetGlobalValue), propertyName) +} + +// GetHierarchicalValue mocks base method. +func (m *MockStore) GetHierarchicalValue(profileName, propertyName string) any { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetHierarchicalValue", profileName, propertyName) + ret0, _ := ret[0].(any) + return ret0 +} + +// GetHierarchicalValue indicates an expected call of GetHierarchicalValue. +func (mr *MockStoreMockRecorder) GetHierarchicalValue(profileName, propertyName any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHierarchicalValue", reflect.TypeOf((*MockStore)(nil).GetHierarchicalValue), profileName, propertyName) +} + +// GetProfileNames mocks base method. +func (m *MockStore) GetProfileNames() []string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetProfileNames") + ret0, _ := ret[0].([]string) + return ret0 +} + +// GetProfileNames indicates an expected call of GetProfileNames. +func (mr *MockStoreMockRecorder) GetProfileNames() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProfileNames", reflect.TypeOf((*MockStore)(nil).GetProfileNames)) +} + +// GetProfileStringMap mocks base method. +func (m *MockStore) GetProfileStringMap(profileName string) map[string]string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetProfileStringMap", profileName) + ret0, _ := ret[0].(map[string]string) + return ret0 +} + +// GetProfileStringMap indicates an expected call of GetProfileStringMap. +func (mr *MockStoreMockRecorder) GetProfileStringMap(profileName any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProfileStringMap", reflect.TypeOf((*MockStore)(nil).GetProfileStringMap), profileName) +} + +// GetProfileValue mocks base method. +func (m *MockStore) GetProfileValue(profileName, propertyName string) any { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetProfileValue", profileName, propertyName) + ret0, _ := ret[0].(any) + return ret0 +} + +// GetProfileValue indicates an expected call of GetProfileValue. +func (mr *MockStoreMockRecorder) GetProfileValue(profileName, propertyName any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProfileValue", reflect.TypeOf((*MockStore)(nil).GetProfileValue), profileName, propertyName) +} + +// IsSetGlobal mocks base method. +func (m *MockStore) IsSetGlobal(propertyName string) bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsSetGlobal", propertyName) + ret0, _ := ret[0].(bool) + return ret0 +} + +// IsSetGlobal indicates an expected call of IsSetGlobal. +func (mr *MockStoreMockRecorder) IsSetGlobal(propertyName any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsSetGlobal", reflect.TypeOf((*MockStore)(nil).IsSetGlobal), propertyName) +} + +// RenameProfile mocks base method. +func (m *MockStore) RenameProfile(oldProfileName, newProfileName string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RenameProfile", oldProfileName, newProfileName) + ret0, _ := ret[0].(error) + return ret0 +} + +// RenameProfile indicates an expected call of RenameProfile. +func (mr *MockStoreMockRecorder) RenameProfile(oldProfileName, newProfileName any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenameProfile", reflect.TypeOf((*MockStore)(nil).RenameProfile), oldProfileName, newProfileName) +} + +// Save mocks base method. +func (m *MockStore) Save() error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Save") + ret0, _ := ret[0].(error) + return ret0 +} + +// Save indicates an expected call of Save. +func (mr *MockStoreMockRecorder) Save() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockStore)(nil).Save)) +} + +// SetGlobalValue mocks base method. +func (m *MockStore) SetGlobalValue(propertyName string, value any) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetGlobalValue", propertyName, value) +} + +// SetGlobalValue indicates an expected call of SetGlobalValue. +func (mr *MockStoreMockRecorder) SetGlobalValue(propertyName, value any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetGlobalValue", reflect.TypeOf((*MockStore)(nil).SetGlobalValue), propertyName, value) +} + +// SetProfileValue mocks base method. +func (m *MockStore) SetProfileValue(profileName, propertyName string, value any) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetProfileValue", profileName, propertyName, value) +} + +// SetProfileValue indicates an expected call of SetProfileValue. +func (mr *MockStoreMockRecorder) SetProfileValue(profileName, propertyName, value any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetProfileValue", reflect.TypeOf((*MockStore)(nil).SetProfileValue), profileName, propertyName, value) +} diff --git a/internal/config/profile.go b/internal/config/profile.go index c7363a69f9..0dbe6519ce 100644 --- a/internal/config/profile.go +++ b/internal/config/profile.go @@ -15,23 +15,16 @@ package config import ( - "bytes" + "context" "errors" "fmt" "os" - "path" - "path/filepath" - "runtime" "slices" "sort" "strings" "time" "github.com/golang-jwt/jwt/v5" - "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/version" - "github.com/pelletier/go-toml" - "github.com/spf13/afero" - "github.com/spf13/viper" "go.mongodb.org/atlas/auth" ) @@ -75,18 +68,45 @@ const ( LocalDeploymentImage = "local_deployment_image" // LocalDeploymentImage is the config key for the MongoDB Local Dev Docker image ) +// Workaround to keep existing code working +// We cannot set the profile immediately because of a race condition which breaks all the unit tests +// +// The goal is to get rid of this, but we will need to do this gradually, since it's a large change that affects almost every command +func SetProfile(profile *Profile) { + defaultProfile = profile +} + var ( - HostName = getConfigHostnameFromEnvs() - UserAgent = fmt.Sprintf("%s/%s (%s;%s;%s)", AtlasCLI, version.Version, runtime.GOOS, runtime.GOARCH, HostName) - CLIUserType = newCLIUserTypeFromEnvs() - defaultProfile = newProfile() + defaultProfile = &Profile{ + name: DefaultProfile, + configStore: NewInMemoryStore(), + } + profileContextKey = profileKey{} ) type Profile struct { - name string - configDir string - fs afero.Fs - err error + name string + configStore Store +} + +func NewProfile(name string, configStore Store) *Profile { + return &Profile{ + name: name, + configStore: configStore, + } +} + +type profileKey struct{} + +// Setting a value +func WithProfile(ctx context.Context, profile *Profile) context.Context { + return context.WithValue(ctx, profileContextKey, profile) +} + +// Getting a value +func ProfileFromContext(ctx context.Context) (*Profile, bool) { + profile, ok := ctx.Value(profileContextKey).(*Profile) + return profile, ok } func AllProperties() []string { @@ -138,91 +158,21 @@ func Default() *Profile { return defaultProfile } -// List returns the names of available profiles. -func List() []string { - m := viper.AllSettings() +func SetDefaultProfile(profile *Profile) { + defaultProfile = profile +} - keys := make([]string, 0, len(m)) - for k := range m { - if !slices.Contains(AllProperties(), k) { - keys = append(keys, k) - } - } - // keys in maps are non-deterministic, trying to give users a consistent output - sort.Strings(keys) - return keys +// List returns the names of available profiles. +func List() []string { return Default().List() } +func (p *Profile) List() []string { + return p.configStore.GetProfileNames() } -// Exists returns true if there are any set settings for the profile name. +// Exists returns true if a profile with the give name exists. func Exists(name string) bool { return slices.Contains(List(), name) } -// getConfigHostnameFromEnvs patches the agent hostname based on set env vars. -func getConfigHostnameFromEnvs() string { - var builder strings.Builder - - envVars := []struct { - envName string - hostName string - }{ - {AtlasActionHostNameEnv, AtlasActionHostName}, - {GitHubActionsHostNameEnv, GitHubActionsHostName}, - {ContainerizedHostNameEnv, DockerContainerHostName}, - } - - for _, envVar := range envVars { - if envIsTrue(envVar.envName) { - appendToHostName(&builder, envVar.hostName) - } else { - appendToHostName(&builder, "-") - } - } - configHostName := builder.String() - - if isDefaultHostName(configHostName) { - return NativeHostName - } - return configHostName -} - -// newCLIUserTypeFromEnvs patches the user type information based on set env vars. -func newCLIUserTypeFromEnvs() string { - if value, ok := os.LookupEnv(CLIUserTypeEnv); ok { - return value - } - - return DefaultUser -} - -func envIsTrue(env string) bool { - return IsTrue(os.Getenv(env)) -} - -func appendToHostName(builder *strings.Builder, configVal string) { - if builder.Len() > 0 { - builder.WriteString("|") - } - builder.WriteString(configVal) -} - -// isDefaultHostName checks if the hostname is the default placeholder. -func isDefaultHostName(hostname string) bool { - // Using strings.Count for a more dynamic approach. - return strings.Count(hostname, "-") == strings.Count(hostname, "|")+1 -} - -func newProfile() *Profile { - configDir, err := CLIConfigHome() - np := &Profile{ - name: DefaultProfile, - configDir: configDir, - fs: afero.NewOsFs(), - err: err, - } - return np -} - func Name() string { return Default().Name() } func (p *Profile) Name() string { return p.name @@ -251,23 +201,17 @@ func (p *Profile) SetName(name string) error { func Set(name string, value any) { Default().Set(name, value) } func (p *Profile) Set(name string, value any) { - settings := viper.GetStringMap(p.Name()) - settings[name] = value - viper.Set(p.name, settings) + p.configStore.SetProfileValue(p.Name(), name, value) } -func SetGlobal(name string, value any) { viper.Set(name, value) } -func (*Profile) SetGlobal(name string, value any) { - SetGlobal(name, value) +func SetGlobal(name string, value any) { Default().SetGlobal(name, value) } +func (p *Profile) SetGlobal(name string, value any) { + p.configStore.SetGlobalValue(name, value) } func Get(name string) any { return Default().Get(name) } func (p *Profile) Get(name string) any { - if viper.IsSet(name) && viper.Get(name) != "" { - return viper.Get(name) - } - settings := viper.GetStringMap(p.Name()) - return settings[name] + return p.configStore.GetHierarchicalValue(p.Name(), name) } func GetString(name string) string { return Default().GetString(name) } @@ -298,12 +242,13 @@ func (p *Profile) GetBoolWithDefault(name string, defaultValue bool) bool { // Service get configured service. func Service() string { return Default().Service() } func (p *Profile) Service() string { - if viper.IsSet(service) { - return viper.GetString(service) + if p.configStore.IsSetGlobal(service) { + serviceValue, _ := p.configStore.GetGlobalValue(service).(string) + return serviceValue } - settings := viper.GetStringMapString(p.Name()) - return settings[service] + serviceValue, _ := p.configStore.GetProfileValue(p.Name(), service).(string) + return serviceValue } func IsCloud() bool { @@ -495,8 +440,8 @@ func (*Profile) SetSkipUpdateCheck(v bool) { // IsTelemetryEnabledSet return true if telemetry_enabled has been set. func IsTelemetryEnabledSet() bool { return Default().IsTelemetryEnabledSet() } -func (*Profile) IsTelemetryEnabledSet() bool { - return viper.IsSet(TelemetryEnabledProperty) +func (p *Profile) IsTelemetryEnabledSet() bool { + return p.configStore.IsSetGlobal(TelemetryEnabledProperty) } // TelemetryEnabled get the configured telemetry enabled value. @@ -555,7 +500,7 @@ func (p *Profile) IsAccessSet() bool { // Map returns a map describing the configuration. func Map() map[string]string { return Default().Map() } func (p *Profile) Map() map[string]string { - settings := viper.GetStringMapString(p.Name()) + settings := p.configStore.GetProfileStringMap(p.Name()) profileSettings := make(map[string]string, len(settings)+1) for k, v := range settings { if k == privateAPIKey || k == AccessTokenField || k == RefreshTokenField { @@ -584,39 +529,7 @@ func (p *Profile) SortedKeys() []string { // this edits the file directly. func Delete() error { return Default().Delete() } func (p *Profile) Delete() error { - // Configuration needs to be deleted from toml, as viper doesn't support this yet. - // FIXME :: change when https://github.com/spf13/viper/pull/519 is merged. - settings := viper.AllSettings() - - t, err := toml.TreeFromMap(settings) - if err != nil { - return err - } - - // Delete from the toml manually - err = t.Delete(p.Name()) - if err != nil { - return err - } - - s := t.String() - - f, err := p.fs.OpenFile(p.Filename(), fileFlags, configPerm) - if err != nil { - return err - } - defer f.Close() - - _, err = f.WriteString(s) - return err -} - -func (p *Profile) Filename() string { - return filepath.Join(p.configDir, "config.toml") -} - -func Filename() string { - return Default().Filename() + return p.configStore.DeleteProfile(p.Name()) } // Rename replaces the Profile to a new Profile name, overwriting any Profile that existed before. @@ -626,126 +539,13 @@ func (p *Profile) Rename(newProfileName string) error { return err } - // Configuration needs to be deleted from toml, as viper doesn't support this yet. - // FIXME :: change when https://github.com/spf13/viper/pull/519 is merged. - configurationAfterDelete := viper.AllSettings() - - t, err := toml.TreeFromMap(configurationAfterDelete) - if err != nil { - return err - } - - t.Set(newProfileName, t.Get(p.Name())) - - err = t.Delete(p.Name()) - if err != nil { - return err - } - - s := t.String() - - f, err := p.fs.OpenFile(p.Filename(), fileFlags, configPerm) - if err != nil { - return err - } - defer f.Close() - - if _, err := f.WriteString(s); err != nil { - return err - } - - return nil -} - -func LoadAtlasCLIConfig() error { return Default().LoadAtlasCLIConfig(true) } -func (p *Profile) LoadAtlasCLIConfig(readEnvironmentVars bool) error { - if p.err != nil { - return p.err - } - - viper.SetConfigName("config") - - if hasMongoCLIEnvVars() { - viper.SetEnvKeyReplacer(strings.NewReplacer(AtlasCLIEnvPrefix, MongoCLIEnvPrefix)) - } - - return p.load(readEnvironmentVars, AtlasCLIEnvPrefix) -} - -func hasMongoCLIEnvVars() bool { - envVars := os.Environ() - for _, v := range envVars { - if strings.HasPrefix(v, MongoCLIEnvPrefix) { - return true - } - } - - return false -} - -func (p *Profile) load(readEnvironmentVars bool, envPrefix string) error { - viper.SetConfigType(configType) - viper.SetConfigPermissions(configPerm) - viper.AddConfigPath(p.configDir) - viper.SetFs(p.fs) - - if readEnvironmentVars { - viper.SetEnvPrefix(envPrefix) - viper.AutomaticEnv() - } - - // aliases only work for a config file, this won't work for env variables - viper.RegisterAlias(baseURL, OpsManagerURLField) - - // If a config file is found, read it in. - if err := viper.ReadInConfig(); err != nil { - // ignore if it doesn't exists - var e viper.ConfigFileNotFoundError - if errors.As(err, &e) { - return nil - } - return err - } - return nil + return p.configStore.RenameProfile(p.Name(), newProfileName) } // Save the configuration to disk. func Save() error { return Default().Save() } func (p *Profile) Save() error { - exists, err := afero.DirExists(p.fs, p.configDir) - if err != nil { - return err - } - if !exists { - if err := p.fs.MkdirAll(p.configDir, defaultPermissions); err != nil { - return err - } - } - - return viper.WriteConfigAs(p.Filename()) -} - -// CLIConfigHome retrieves configHome path. -func CLIConfigHome() (string, error) { - home, err := os.UserConfigDir() - if err != nil { - return "", err - } - - return path.Join(home, "atlascli"), nil -} - -func Path(f string) (string, error) { - var p bytes.Buffer - - h, err := CLIConfigHome() - if err != nil { - return "", err - } - - p.WriteString(h) - p.WriteString(f) - return p.String(), nil + return p.configStore.Save() } // GetLocalDeploymentImage returns the configured MongoDB Docker image URL. diff --git a/internal/config/profile_test.go b/internal/config/profile_test.go index 4090399afc..8073610f18 100644 --- a/internal/config/profile_test.go +++ b/internal/config/profile_test.go @@ -22,9 +22,9 @@ import ( "path" "testing" - "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" ) func TestCLIConfigHome(t *testing.T) { @@ -196,37 +196,52 @@ func Test_getConfigHostname(t *testing.T) { func TestProfile_Rename(t *testing.T) { tests := []struct { name string - wantErr require.ErrorAssertionFunc + wantErr bool }{ { name: "default", - wantErr: require.NoError, + wantErr: false, }, { name: "default-123", - wantErr: require.NoError, + wantErr: false, }, { name: "default-test", - wantErr: require.NoError, + wantErr: false, }, { name: "default.123", - wantErr: require.Error, + wantErr: true, }, { name: "default.test", - wantErr: require.Error, + wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() + + var assertion require.ErrorAssertionFunc + if tt.wantErr { + assertion = require.Error + } else { + assertion = require.NoError + } + + ctrl := gomock.NewController(t) + configStore := NewMockStore(ctrl) + if !tt.wantErr { + configStore.EXPECT().RenameProfile(DefaultProfile, tt.name).Return(nil).Times(1) + } + p := &Profile{ - name: tt.name, - fs: afero.NewMemMapFs(), + name: DefaultProfile, + configStore: configStore, } - tt.wantErr(t, p.Rename(tt.name), fmt.Sprintf("Rename(%v)", tt.name)) + + assertion(t, p.Rename(tt.name), fmt.Sprintf("Rename(%v)", tt.name)) }) } } @@ -260,9 +275,10 @@ func TestProfile_SetName(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() + ctrl := gomock.NewController(t) p := &Profile{ - name: tt.name, - fs: afero.NewMemMapFs(), + name: tt.name, + configStore: NewMockStore(ctrl), } tt.wantErr(t, p.SetName(tt.name), fmt.Sprintf("SetName(%v)", tt.name)) }) diff --git a/internal/config/store.go b/internal/config/store.go new file mode 100644 index 0000000000..53a54da28f --- /dev/null +++ b/internal/config/store.go @@ -0,0 +1,113 @@ +// Copyright 2025 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "slices" + "sort" + + "github.com/spf13/viper" +) + +type Store interface { + Save() error + + GetProfileNames() []string + RenameProfile(oldProfileName string, newProfileName string) error + DeleteProfile(profileName string) error + + GetHierarchicalValue(profileName string, propertyName string) any + + SetProfileValue(profileName string, propertyName string, value any) + GetProfileValue(profileName string, propertyName string) any + GetProfileStringMap(profileName string) map[string]string + + SetGlobalValue(propertyName string, value any) + GetGlobalValue(propertyName string) any + IsSetGlobal(propertyName string) bool +} + +// Temporary InMemoryStore to mimick legacy behavior +// Will be removed when we get rid of static references in the profile +type InMemoryStore struct { + v *viper.Viper +} + +func NewInMemoryStore() *InMemoryStore { + return &InMemoryStore{ + v: viper.New(), + } +} + +func (*InMemoryStore) Save() error { + return nil +} + +func (s *InMemoryStore) GetProfileNames() []string { + allKeys := s.v.AllKeys() + + profileNames := make([]string, 0, len(allKeys)) + for _, key := range allKeys { + if !slices.Contains(GlobalProperties(), key) { + profileNames = append(profileNames, key) + } + } + // keys in maps are non-deterministic, trying to give users a consistent output + sort.Strings(profileNames) + return profileNames +} + +func (*InMemoryStore) RenameProfile(_, _ string) error { + panic("not implemented") +} + +func (*InMemoryStore) DeleteProfile(_ string) error { + panic("not implemented") +} + +func (s *InMemoryStore) GetHierarchicalValue(profileName string, propertyName string) any { + if s.v.IsSet(propertyName) && s.v.Get(propertyName) != "" { + return s.v.Get(propertyName) + } + settings := s.v.GetStringMap(profileName) + return settings[propertyName] +} + +func (s *InMemoryStore) SetProfileValue(profileName string, propertyName string, value any) { + settings := s.v.GetStringMap(profileName) + settings[propertyName] = value + s.v.Set(profileName, settings) +} + +func (s *InMemoryStore) GetProfileValue(profileName string, propertyName string) any { + settings := s.v.GetStringMap(profileName) + return settings[propertyName] +} + +func (s *InMemoryStore) GetProfileStringMap(profileName string) map[string]string { + return s.v.GetStringMapString(profileName) +} + +func (s *InMemoryStore) SetGlobalValue(propertyName string, value any) { + s.v.Set(propertyName, value) +} + +func (s *InMemoryStore) GetGlobalValue(propertyName string) any { + return s.v.Get(propertyName) +} + +func (s *InMemoryStore) IsSetGlobal(propertyName string) bool { + return s.v.IsSet(propertyName) +} diff --git a/internal/config/viper_store.go b/internal/config/viper_store.go new file mode 100644 index 0000000000..1d8bbf6340 --- /dev/null +++ b/internal/config/viper_store.go @@ -0,0 +1,226 @@ +// Copyright 2025 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:generate go tool go.uber.org/mock/mockgen -destination=./mocks.go -package=config github.com/mongodb/mongodb-atlas-cli/atlascli/internal/config Store + +package config + +import ( + "errors" + "os" + "path/filepath" + "slices" + "sort" + "strings" + + "github.com/pelletier/go-toml" + "github.com/spf13/afero" + "github.com/spf13/viper" +) + +// ViperConfigStore implements the config.Store interface +type ViperConfigStore struct { + viper viper.Viper + configDir string + fs afero.Fs +} + +// ViperConfigStore specific methods +func NewViperStore(fs afero.Fs) (*ViperConfigStore, error) { + configDir, err := CLIConfigHome() + if err != nil { + return nil, err + } + + v := viper.New() + + v.SetConfigName("config") + + if hasMongoCLIEnvVars() { + v.SetEnvKeyReplacer(strings.NewReplacer(AtlasCLIEnvPrefix, MongoCLIEnvPrefix)) + } + + v.SetConfigType(configType) + v.SetConfigPermissions(configPerm) + v.AddConfigPath(configDir) + v.SetFs(fs) + + v.SetEnvPrefix(AtlasCLIEnvPrefix) + v.AutomaticEnv() + + // aliases only work for a config file, this won't work for env variables + v.RegisterAlias(baseURL, OpsManagerURLField) + + // If a config file is found, read it in. + if err := v.ReadInConfig(); err != nil { + // ignore if it doesn't exists + var e viper.ConfigFileNotFoundError + if !errors.As(err, &e) { + return nil, err + } + } + return &ViperConfigStore{ + viper: *v, + configDir: configDir, + fs: fs, + }, nil +} + +func hasMongoCLIEnvVars() bool { + envVars := os.Environ() + for _, v := range envVars { + if strings.HasPrefix(v, MongoCLIEnvPrefix) { + return true + } + } + + return false +} + +func ViperConfigStoreFilename(configDir string) string { + return filepath.Join(configDir, "config.toml") +} + +func (s *ViperConfigStore) Filename() string { + return ViperConfigStoreFilename(s.configDir) +} + +// ConfigStore implementation + +func (s *ViperConfigStore) Save() error { + exists, err := afero.DirExists(s.fs, s.configDir) + if err != nil { + return err + } + if !exists { + if err := s.fs.MkdirAll(s.configDir, defaultPermissions); err != nil { + return err + } + } + + return s.viper.WriteConfigAs(s.Filename()) +} + +func (s *ViperConfigStore) GetProfileNames() []string { + allKeys := s.viper.AllSettings() + + profileNames := make([]string, 0, len(allKeys)) + for key := range allKeys { + if !slices.Contains(AllProperties(), key) { + profileNames = append(profileNames, key) + } + } + // keys in maps are non-deterministic, trying to give users a consistent output + sort.Strings(profileNames) + return profileNames +} + +func (s *ViperConfigStore) RenameProfile(oldProfileName string, newProfileName string) error { + if err := validateName(newProfileName); err != nil { + return err + } + + // Configuration needs to be deleted from toml, as viper doesn't support this yet. + // FIXME :: change when https://github.com/spf13/viper/pull/519 is merged. + configurationAfterDelete := s.viper.AllSettings() + + t, err := toml.TreeFromMap(configurationAfterDelete) + if err != nil { + return err + } + + t.Set(newProfileName, t.Get(oldProfileName)) + + err = t.Delete(oldProfileName) + if err != nil { + return err + } + + tomlString := t.String() + + f, err := s.fs.OpenFile(s.Filename(), fileFlags, configPerm) + if err != nil { + return err + } + defer f.Close() + + if _, err := f.WriteString(tomlString); err != nil { + return err + } + + return nil +} + +func (s *ViperConfigStore) DeleteProfile(profileName string) error { + // Configuration needs to be deleted from toml, as viper doesn't support this yet. + // FIXME :: change when https://github.com/spf13/viper/pull/519 is merged. + settings := viper.AllSettings() + + t, err := toml.TreeFromMap(settings) + if err != nil { + return err + } + + // Delete from the toml manually + err = t.Delete(profileName) + if err != nil { + return err + } + + tomlString := t.String() + + f, err := s.fs.OpenFile(s.Filename(), fileFlags, configPerm) + if err != nil { + return err + } + defer f.Close() + + _, err = f.WriteString(tomlString) + return err +} + +func (s *ViperConfigStore) GetHierarchicalValue(profileName string, propertyName string) any { + if s.viper.IsSet(propertyName) && s.viper.Get(propertyName) != "" { + return s.viper.Get(propertyName) + } + settings := s.viper.GetStringMap(profileName) + return settings[propertyName] +} + +func (s *ViperConfigStore) SetProfileValue(profileName string, propertyName string, value any) { + settings := s.viper.GetStringMap(profileName) + settings[propertyName] = value + s.viper.Set(profileName, settings) +} + +func (s *ViperConfigStore) GetProfileValue(profileName string, propertyName string) any { + settings := s.viper.GetStringMap(profileName) + return settings[propertyName] +} + +func (s *ViperConfigStore) GetProfileStringMap(profileName string) map[string]string { + return s.viper.GetStringMapString(profileName) +} + +func (s *ViperConfigStore) SetGlobalValue(propertyName string, value any) { + s.viper.Set(propertyName, value) +} + +func (s *ViperConfigStore) GetGlobalValue(propertyName string) any { + return s.viper.Get(propertyName) +} + +func (s *ViperConfigStore) IsSetGlobal(propertyName string) bool { + return s.viper.IsSet(propertyName) +} diff --git a/internal/validate/validate_test.go b/internal/validate/validate_test.go index 7c8f896ae0..55ec8a03a6 100644 --- a/internal/validate/validate_test.go +++ b/internal/validate/validate_test.go @@ -142,6 +142,8 @@ func TestObjectID(t *testing.T) { } func TestCredentials(t *testing.T) { + t.Skip("Will reenable on ticket CLOUDP-333193") + t.Run("no credentials", func(t *testing.T) { if err := Credentials(); err == nil { t.Fatal("Credentials() expected an error\n") @@ -175,7 +177,10 @@ func TestNoAPIKeys(t *testing.T) { t.Fatalf("NoAPIKeys() unexpected error %v\n", err) } }) + t.Run("with api key credentials", func(t *testing.T) { + t.Skip("Will reenable on ticket CLOUDP-333193") + // this function depends on the global config (globals are bad I know) // the easiest way we have to test it is via ENV vars viper.AutomaticEnv() @@ -185,6 +190,7 @@ func TestNoAPIKeys(t *testing.T) { t.Fatalf("NoAPIKeys() expected error\n") } }) + t.Run("with auth token credentials", func(t *testing.T) { // this function depends on the global config (globals are bad I know) // the easiest way we have to test it is via ENV vars @@ -213,7 +219,10 @@ func TestNoAccessToken(t *testing.T) { t.Fatalf("NoAccessToken() unexpected error %v\n", err) } }) + t.Run("with auth token credentials", func(t *testing.T) { + t.Skip("Will reenable on ticket CLOUDP-333193") + // this function depends on the global config (globals are bad I know) // the easiest way we have to test it is via ENV vars viper.AutomaticEnv()