diff --git a/docs/command/atlas-kubernetes-operator-install.txt b/docs/command/atlas-kubernetes-operator-install.txt index 51c86ae9..8fcf96f1 100644 --- a/docs/command/atlas-kubernetes-operator-install.txt +++ b/docs/command/atlas-kubernetes-operator-install.txt @@ -56,6 +56,10 @@ Options - - false - Flag that indicates whether to import existing Atlas resources into the cluster for the operator to manage. + * - --ipAccessList + - string + - false + - A comma-separated list of IP or CIDR block to allowlist for Operator to communicate with Atlas APIs. Read more: https://www.mongodb.com/docs/atlas/configure-api-access-project/ * - --kubeContext - string - false @@ -123,46 +127,39 @@ Examples :copyable: false # Install the latest version of the operator targeting Atlas for Government instead of regular commercial Atlas: - atlas kubernetes operator install --atlasGov + atlas kubernetes operator install --atlasGov --ipAccessList= .. code-block:: :copyable: false # Install a specific version of the operator: - atlas kubernetes operator install --operatorVersion=2.12.0 + atlas kubernetes operator install --ipAccessList= --operatorVersion=2.12.0 .. code-block:: :copyable: false # Install a specific version of the operator to a namespace and watch only this namespace and a second one: - atlas kubernetes operator install --operatorVersion=2.12.0 --targetNamespace= --watchNamespace=, + atlas kubernetes operator install --ipAccessList= --operatorVersion=2.12.0 --targetNamespace= --watchNamespace=, .. code-block:: :copyable: false # Install and import all objects from an organization: - atlas kubernetes operator install --targetNamespace= --orgID --import + atlas kubernetes operator install --ipAccessList= --targetNamespace= --orgID --import .. code-block:: :copyable: false # Install and import objects from a specific project: - atlas kubernetes operator install --targetNamespace= --orgID --projectName --import + atlas kubernetes operator install --ipAccessList= --targetNamespace= --orgID --projectName --import .. code-block:: :copyable: false # Install the operator and disable deletion protection: - atlas kubernetes operator install --resourceDeletionProtection=false - - -.. code-block:: - :copyable: false - - # Install the operator and disable deletion protection for sub-resources (Atlas project integrations, private endpoints, etc.): - atlas kubernetes operator install --subresourceDeletionProtection=false + atlas kubernetes operator install --ipAccessList= --resourceDeletionProtection=false diff --git a/internal/cli/kubernetes/operator/install.go b/internal/cli/kubernetes/operator/install.go index 8757c3a0..324e1d26 100644 --- a/internal/cli/kubernetes/operator/install.go +++ b/internal/cli/kubernetes/operator/install.go @@ -16,7 +16,10 @@ package operator import ( "context" + "errors" "fmt" + "net" + "strings" "github.com/google/go-github/v61/github" "github.com/mongodb/atlas-cli-core/config" @@ -53,6 +56,7 @@ type InstallOpts struct { featureDeletionProtection bool featureSubDeletionProtection bool configOnly bool + ipAccessList string } func (opts *InstallOpts) defaults() error { @@ -103,6 +107,23 @@ func (opts *InstallOpts) ValidateWatchNamespace() error { return nil } +func (opts *InstallOpts) ValidateIpAccessList() error { + if opts.ipAccessList == "" { + return errors.New("IP access list cannot be empty") + } + + list := strings.Split(opts.ipAccessList, ",") + for _, entry := range list { + if _, _, err := net.ParseCIDR(entry); err != nil { + if net.ParseIP(entry) == nil { + return fmt.Errorf("IP access list \"%s\" must be a valid IP address or CIDR", entry) + } + } + } + + return nil +} + func (opts *InstallOpts) Run(ctx context.Context) error { kubeCtl, err := kubernetes.NewKubeCtl(opts.KubeConfig, opts.KubeContext) if err != nil { @@ -129,7 +150,7 @@ func (opts *InstallOpts) Run(ctx context.Context) error { return err } - err = operator.NewInstall(installer, atlasStore, credStore, featureValidator, kubeCtl, opts.operatorVersion). + err = operator.NewInstall(installer, atlasStore, credStore, featureValidator, kubeCtl, opts.operatorVersion, opts.ipAccessList). WithNamespace(opts.targetNamespace). WithWatchNamespaces(opts.watchNamespace). WithWatchProjectName(opts.projectName). @@ -164,25 +185,22 @@ The key is scoped to the project when you specify the --projectName option and t atlas kubernetes operator install # Install the latest version of the operator targeting Atlas for Government instead of regular commercial Atlas: - atlas kubernetes operator install --atlasGov + atlas kubernetes operator install --atlasGov --ipAccessList= # Install a specific version of the operator: - atlas kubernetes operator install --operatorVersion=2.12.0 + atlas kubernetes operator install --ipAccessList= --operatorVersion=2.12.0 # Install a specific version of the operator to a namespace and watch only this namespace and a second one: - atlas kubernetes operator install --operatorVersion=2.12.0 --targetNamespace= --watchNamespace=, + atlas kubernetes operator install --ipAccessList= --operatorVersion=2.12.0 --targetNamespace= --watchNamespace=, # Install and import all objects from an organization: - atlas kubernetes operator install --targetNamespace= --orgID --import + atlas kubernetes operator install --ipAccessList= --targetNamespace= --orgID --import # Install and import objects from a specific project: - atlas kubernetes operator install --targetNamespace= --orgID --projectName --import + atlas kubernetes operator install --ipAccessList= --targetNamespace= --orgID --projectName --import # Install the operator and disable deletion protection: - atlas kubernetes operator install --resourceDeletionProtection=false - - # Install the operator and disable deletion protection for sub-resources (Atlas project integrations, private endpoints, etc.): - atlas kubernetes operator install --subresourceDeletionProtection=false`, + atlas kubernetes operator install --ipAccessList= --resourceDeletionProtection=false`, PreRunE: func(_ *cobra.Command, _ []string) error { opts.versionProvider = version.NewOperatorVersion(github.NewClient(nil)) @@ -192,6 +210,7 @@ The key is scoped to the project when you specify the --projectName option and t opts.ValidateOperatorVersion, opts.ValidateTargetNamespace, opts.ValidateWatchNamespace, + opts.ValidateIpAccessList, ) }, RunE: func(cmd *cobra.Command, _ []string) error { @@ -213,6 +232,7 @@ The key is scoped to the project when you specify the --projectName option and t flags.BoolVar(&opts.featureDeletionProtection, flag.OperatorResourceDeletionProtection, true, usage.OperatorResourceDeletionProtection) flags.BoolVar(&opts.featureSubDeletionProtection, flag.OperatorSubResourceDeletionProtection, true, usage.OperatorSubResourceDeletionProtection) flags.BoolVar(&opts.configOnly, flag.OperatorConfigOnly, false, usage.OperatorConfigOnly) + flags.StringVar(&opts.ipAccessList, flag.IPAccessList, "", usage.IPAccessList) return cmd } diff --git a/internal/cli/kubernetes/operator/install_test.go b/internal/cli/kubernetes/operator/install_test.go index 243ea636..01dbbeb8 100644 --- a/internal/cli/kubernetes/operator/install_test.go +++ b/internal/cli/kubernetes/operator/install_test.go @@ -15,3 +15,48 @@ //go:build unit package operator + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestInstallOptsValidateIpAccessList(t *testing.T) { + tests := map[string]struct { + ipAccessList string + err error + }{ + "valid single IP": { + ipAccessList: "104.30.164.5", + }, + "valid CIDR block": { + ipAccessList: "192.168.100.177/24", + }, + "valid list of entries": { + ipAccessList: "104.30.164.5,192.168.100.177/24", + }, + "empty string": { + ipAccessList: "", + err: errors.New("IP access list cannot be empty"), + }, + "invalid IP": { + ipAccessList: "256.256.256.256", + err: errors.New("IP access list \"256.256.256.256\" must be a valid IP address or CIDR"), + }, + "invalid CIDR block": { + ipAccessList: "192.168.100.177/33", + err: errors.New("IP access list \"192.168.100.177/33\" must be a valid IP address or CIDR"), + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + opts := &InstallOpts{ + ipAccessList: tt.ipAccessList, + } + err := opts.ValidateIpAccessList() + assert.Equal(t, tt.err, err) + }) + } +} diff --git a/internal/flag/flags.go b/internal/flag/flags.go index dd9f2103..36e39721 100644 --- a/internal/flag/flags.go +++ b/internal/flag/flags.go @@ -39,4 +39,5 @@ const ( KubernetesClusterContext = "kubeContext" // KubeContext flag DataFederationName = "dataFederationName" // DataFederationName flag IndependentResources = "independentResources" // IndependentResources flag + IPAccessList = "ipAccessList" // IPAccessList flag ) diff --git a/internal/kubernetes/operator/install.go b/internal/kubernetes/operator/install.go index 7283b1f5..3f0e9608 100644 --- a/internal/kubernetes/operator/install.go +++ b/internal/kubernetes/operator/install.go @@ -18,6 +18,7 @@ import ( "context" "errors" "fmt" + "strings" "github.com/mongodb/atlas-cli-plugin-kubernetes/internal/kubernetes" "github.com/mongodb/atlas-cli-plugin-kubernetes/internal/kubernetes/operator/features" @@ -53,6 +54,7 @@ type Install struct { importResources bool atlasGov bool configOnly bool + ipAccessList string } func (i *Install) WithConfigOnly(configOnly bool) *Install { @@ -109,6 +111,11 @@ func (i *Install) Run(ctx context.Context, orgID string) error { return err } + err = i.addAPIKeyIPAccessList(orgID, keys.GetId()) + if err != nil { + return err + } + if err = i.installResources.InstallCRDs(ctx, i.version, len(i.watch) > 0); err != nil { return err } @@ -204,6 +211,34 @@ func (i *Install) generateKeys(orgID string) (*admin.ApiKeyUserDetails, error) { return keys, nil } +func (i *Install) addAPIKeyIPAccessList(orgID, apiKeyID string) error { + list := strings.Split(i.ipAccessList, ",") + entries := make([]admin.UserAccessListRequest, 0, len(list)) + + for _, entry := range list { + if strings.Contains(entry, "/") { + entries = append(entries, admin.UserAccessListRequest{ + CidrBlock: &entry, + }) + } else { + entries = append(entries, admin.UserAccessListRequest{ + IpAddress: &entry, + }) + } + } + + err := i.atlasStore.AddIPAccessList( + orgID, + apiKeyID, + &entries, + ) + if err != nil { + return fmt.Errorf("failed to add IP access list to API key: %w", err) + } + + return nil +} + func (i *Install) importAtlasResources(orgID, apiKeyID string) error { projectsIDs := make([]string, 0) @@ -326,6 +361,7 @@ func NewInstall( featureValidator features.FeatureValidator, kubectl *kubernetes.KubeCtl, version string, + ipAccessList string, ) *Install { return &Install{ installResources: installer, @@ -334,5 +370,6 @@ func NewInstall( featureValidator: featureValidator, kubectl: kubectl, version: version, + ipAccessList: ipAccessList, } } diff --git a/internal/kubernetes/operator/install_test.go b/internal/kubernetes/operator/install_test.go new file mode 100644 index 00000000..39122993 --- /dev/null +++ b/internal/kubernetes/operator/install_test.go @@ -0,0 +1,73 @@ +// Copyright 2024 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:build unit + +package operator + +import ( + "errors" + "fmt" + "testing" + + "github.com/golang/mock/gomock" + "github.com/mongodb/atlas-cli-plugin-kubernetes/internal/mocks" + "github.com/stretchr/testify/assert" + "go.mongodb.org/atlas-sdk/v20250312006/admin" +) + +func TestInstall_addAPIKeyIPAccessList(t *testing.T) { + tests := map[string]struct { + ipAccessList string + expectedErr error + }{ + "An IP is provided": { + ipAccessList: "104.30.164.5", + expectedErr: nil, + }, + "An CIDR is provided": { + ipAccessList: "192.168.100.177/24", + expectedErr: nil, + }, + "Multiple entries are provided": { + ipAccessList: "104.30.164.5,192.168.100.177/24", + expectedErr: nil, + }, + "API failed to add ip access list": { + ipAccessList: "104.30.164.5,192.168.100.177/24", + expectedErr: fmt.Errorf("failed to add IP access list to API key: %w", errors.New("failed to add IP access list")), + }, + } + for name, tt := range tests { + storeMock := mocks.NewMockOperatorGenericStore(gomock.NewController(t)) + storeMock.EXPECT().AddIPAccessList("orgID", "apiKeyID", gomock.Any()). + DoAndReturn(func(string, string, *[]admin.UserAccessListRequest) error { + if tt.expectedErr != nil { + return errors.New("failed to add IP access list") + } + + return nil + }). + Times(1) + + t.Run(name, func(t *testing.T) { + i := &Install{ + ipAccessList: tt.ipAccessList, + atlasStore: storeMock, + } + err := i.addAPIKeyIPAccessList("orgID", "apiKeyID") + assert.Equal(t, tt.expectedErr, err) + }) + } +} diff --git a/internal/mocks/mock_api_keys.go b/internal/mocks/mock_api_keys.go index 7ae7e1df..3f9a79fd 100644 --- a/internal/mocks/mock_api_keys.go +++ b/internal/mocks/mock_api_keys.go @@ -72,6 +72,20 @@ func (m *MockOrganizationAPIKeyCreator) EXPECT() *MockOrganizationAPIKeyCreatorM return m.recorder } +// AddIPAccessList mocks base method. +func (m *MockOrganizationAPIKeyCreator) AddIPAccessList(arg0, arg1 string, arg2 *[]admin.UserAccessListRequest) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddIPAccessList", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// AddIPAccessList indicates an expected call of AddIPAccessList. +func (mr *MockOrganizationAPIKeyCreatorMockRecorder) AddIPAccessList(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddIPAccessList", reflect.TypeOf((*MockOrganizationAPIKeyCreator)(nil).AddIPAccessList), arg0, arg1, arg2) +} + // CreateOrganizationAPIKey mocks base method. func (m *MockOrganizationAPIKeyCreator) CreateOrganizationAPIKey(arg0 string, arg1 *admin.CreateAtlasOrganizationApiKey) (*admin.ApiKeyUserDetails, error) { m.ctrl.T.Helper() diff --git a/internal/mocks/mock_atlas_generic_store.go b/internal/mocks/mock_atlas_generic_store.go index a9751d8f..08d20677 100644 --- a/internal/mocks/mock_atlas_generic_store.go +++ b/internal/mocks/mock_atlas_generic_store.go @@ -35,6 +35,20 @@ func (m *MockOperatorGenericStore) EXPECT() *MockOperatorGenericStoreMockRecorde return m.recorder } +// AddIPAccessList mocks base method. +func (m *MockOperatorGenericStore) AddIPAccessList(arg0, arg1 string, arg2 *[]admin0.UserAccessListRequest) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddIPAccessList", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// AddIPAccessList indicates an expected call of AddIPAccessList. +func (mr *MockOperatorGenericStoreMockRecorder) AddIPAccessList(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddIPAccessList", reflect.TypeOf((*MockOperatorGenericStore)(nil).AddIPAccessList), arg0, arg1, arg2) +} + // AlertConfigurations mocks base method. func (m *MockOperatorGenericStore) AlertConfigurations(arg0 string) ([]admin0.GroupAlertsConfig, error) { m.ctrl.T.Helper() diff --git a/internal/mocks/mock_atlas_operator_org_store.go b/internal/mocks/mock_atlas_operator_org_store.go index eced718d..e15ec643 100644 --- a/internal/mocks/mock_atlas_operator_org_store.go +++ b/internal/mocks/mock_atlas_operator_org_store.go @@ -34,6 +34,20 @@ func (m *MockOperatorOrgStore) EXPECT() *MockOperatorOrgStoreMockRecorder { return m.recorder } +// AddIPAccessList mocks base method. +func (m *MockOperatorOrgStore) AddIPAccessList(arg0, arg1 string, arg2 *[]admin.UserAccessListRequest) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddIPAccessList", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// AddIPAccessList indicates an expected call of AddIPAccessList. +func (mr *MockOperatorOrgStoreMockRecorder) AddIPAccessList(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddIPAccessList", reflect.TypeOf((*MockOperatorOrgStore)(nil).AddIPAccessList), arg0, arg1, arg2) +} + // AssignProjectAPIKey mocks base method. func (m *MockOperatorOrgStore) AssignProjectAPIKey(arg0, arg1 string, arg2 *admin.UpdateAtlasProjectApiKey) error { m.ctrl.T.Helper() diff --git a/internal/store/api_keys.go b/internal/store/api_keys.go index b407fd83..0ab89bac 100644 --- a/internal/store/api_keys.go +++ b/internal/store/api_keys.go @@ -29,6 +29,7 @@ type ProjectAPIKeyAssigner interface { type OrganizationAPIKeyCreator interface { CreateOrganizationAPIKey(string, *atlasv2.CreateAtlasOrganizationApiKey) (*atlasv2.ApiKeyUserDetails, error) + AddIPAccessList(string, string, *[]atlasv2.UserAccessListRequest) error } // CreateOrganizationAPIKey encapsulates the logic to manage different cloud providers. @@ -37,6 +38,12 @@ func (s *Store) CreateOrganizationAPIKey(orgID string, input *atlasv2.CreateAtla return result, err } +func (s *Store) AddIPAccessList(orgID string, apiKeyID string, ipAccessList *[]atlasv2.UserAccessListRequest) error { + _, _, err := s.clientv2.ProgrammaticAPIKeysApi.CreateApiKeyAccessList(s.ctx, orgID, apiKeyID, ipAccessList).Execute() + + return err +} + // CreateProjectAPIKey creates an API Keys for a project. func (s *Store) CreateProjectAPIKey(projectID string, apiKeyInput *atlasv2.CreateAtlasProjectApiKey) (*atlasv2.ApiKeyUserDetails, error) { result, _, err := s.clientv2.ProgrammaticAPIKeysApi.CreateProjectApiKey(s.ctx, projectID, apiKeyInput).Execute() diff --git a/internal/usage/usage.go b/internal/usage/usage.go index 6b14b4e3..054ebd2e 100644 --- a/internal/usage/usage.go +++ b/internal/usage/usage.go @@ -38,4 +38,5 @@ const ( IndependentResources = "Flag that makes the generated resources that support independent usage, to use external IDs rather than Kubernetes references." EnableWatch = "Flag that indicates whether to watch the command until it completes its execution or the watch times out. To set the time that the watch times out, use the --watchTimeout option." WatchTimeout = "Time in seconds until a watch times out. After a watch times out, the CLI no longer watches the command." + IPAccessList = "A comma-separated list of IP or CIDR block to allowlist for Operator to communicate with Atlas APIs. Read more: https://www.mongodb.com/docs/atlas/configure-api-access-project/" ) diff --git a/test/e2e/kubernetes_operator_install_test.go b/test/e2e/kubernetes_operator_install_test.go index fd5aa80b..a98edf3e 100644 --- a/test/e2e/kubernetes_operator_install_test.go +++ b/test/e2e/kubernetes_operator_install_test.go @@ -66,6 +66,7 @@ func TestKubernetesOperatorInstall(t *testing.T) { "kubernetes", "operator", "install", + "--ipAccessList", "104.30.164.5,192.168.100.177/24", "--operatorVersion", "100.0.0") cmd.Env = os.Environ() _, inErr := test.RunAndGetStdOutAndErr(cmd) @@ -78,6 +79,7 @@ func TestKubernetesOperatorInstall(t *testing.T) { "kubernetes", "operator", "install", + "--ipAccessList", "104.30.164.5,192.168.100.177/24", "--kubeconfig", "/path/to/non/existing/config") cmd.Env = os.Environ() _, inErr := test.RunAndGetStdOutAndErr(cmd) @@ -94,6 +96,7 @@ func TestKubernetesOperatorInstall(t *testing.T) { "kubernetes", "operator", "install", + "--ipAccessList", "104.30.164.5,192.168.100.177/24", "--configOnly", "--targetNamespace", operatorNamespace, "--kubeContext", context) @@ -113,6 +116,7 @@ func TestKubernetesOperatorInstall(t *testing.T) { "kubernetes", "operator", "install", + "--ipAccessList", "104.30.164.5,192.168.100.177/24", "--kubeContext", context) cmd.Env = os.Environ() resp, inErr := test.RunAndGetStdOutAndErr(cmd) @@ -131,6 +135,7 @@ func TestKubernetesOperatorInstall(t *testing.T) { "kubernetes", "operator", "install", + "--ipAccessList", "104.30.164.5,192.168.100.177/24", "--targetNamespace", operatorNamespace, "--kubeContext", context) cmd.Env = os.Environ() @@ -152,6 +157,7 @@ func TestKubernetesOperatorInstall(t *testing.T) { "kubernetes", "operator", "install", + "--ipAccessList", "104.30.164.5,192.168.100.177/24", "--targetNamespace", operatorNamespace, "--watchNamespace", operatorWatch1, "--watchNamespace", operatorWatch2, @@ -172,6 +178,7 @@ func TestKubernetesOperatorInstall(t *testing.T) { "kubernetes", "operator", "install", + "--ipAccessList", "104.30.164.5", "--targetNamespace", operatorNamespace, "--watchNamespace", operatorNamespace, "--kubeContext", context) @@ -193,6 +200,7 @@ func TestKubernetesOperatorInstall(t *testing.T) { "kubernetes", "operator", "install", + "--ipAccessList", "192.168.100.177/24", "--targetNamespace", operatorNamespace, "--projectName", projectName, "--import", @@ -268,6 +276,7 @@ func TestKubernetesOperatorInstall(t *testing.T) { "kubernetes", "operator", "install", + "--ipAccessList", "104.30.164.5,192.168.100.177/24", "--targetNamespace", operatorNamespace, "--projectName", g.projectName, "--import", @@ -298,6 +307,7 @@ func TestKubernetesOperatorInstall(t *testing.T) { "kubernetes", "operator", "install", + "--ipAccessList", "104.30.164.5,192.168.100.177/24", "--resourceDeletionProtection=false", "--subresourceDeletionProtection=false", "--targetNamespace", operatorNamespace, diff --git a/test/e2e/operator_helper_test.go b/test/e2e/operator_helper_test.go index a19c3aee..93644024 100644 --- a/test/e2e/operator_helper_test.go +++ b/test/e2e/operator_helper_test.go @@ -206,6 +206,7 @@ func (oh *operatorHelper) installOperator(namespace, version string) error { cmd := exec.Command( cliPath, "kubernetes", "operator", "install", + "--ipAccessList", "192.168.1.0/24", "--operatorVersion", version, "--targetNamespace", namespace, )