diff --git a/.golangci.yml b/.golangci.yml index 9bd812899d..c4ab9b21e5 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -37,6 +37,8 @@ linters-settings: alias: corev1 - pkg: k8s.io/api/rbac/v1 alias: rbacv1 + - pkg: k8s.io/api/batch/v1 + alias: batchv1 - pkg: k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1 alias: apiextensionsv1 - pkg: k8s.io/apiserver/pkg/storage/names diff --git a/docs/command/atlas-kubernetes-dry-run.txt b/docs/command/atlas-kubernetes-dry-run.txt new file mode 100644 index 0000000000..8f4e120b58 --- /dev/null +++ b/docs/command/atlas-kubernetes-dry-run.txt @@ -0,0 +1,87 @@ +.. _atlas-kubernetes-dry-run: + +======================== +atlas kubernetes dry-run +======================== + +.. default-domain:: mongodb + +.. contents:: On this page + :local: + :backlinks: none + :depth: 1 + :class: singlecol + +Deploy and run Atlas Kubernetes Operator in dry-run mode + +This command deploys the Atlas Kubernetes operator with the DryRun mode. + +TODO: ask Dan about the proper description of the dry-run mode. + + +Syntax +------ + +.. code-block:: + :caption: Command Syntax + + atlas kubernetes dry-run [options] + +.. Code end marker, please don't delete this comment + +Options +------- + +.. list-table:: + :header-rows: 1 + :widths: 20 10 10 60 + + * - Name + - Type + - Required + - Description + * - -h, --help + - + - false + - help for dry-run + * - --operatorVersion + - string + - false + - Version of Atlas Kubernetes Operator to generate resources for. This value defaults to "2.6.0". + * - --orgId + - string + - false + - Organization ID to use. This option overrides the settings in the configuration file or environment variable. + * - --targetNamespace + - string + - false + - Namespaces to use for generated kubernetes entities + * - --watch + - + - false + - 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. + * - --watchNamespaces + - strings + - false + - List that contains namespaces that the operator will listen to. + * - --watchTimeout + - int + - false + - Time in seconds until a watch times out. After a watch times out, the CLI no longer watches the command. This value defaults to 120. + +Inherited Options +----------------- + +.. list-table:: + :header-rows: 1 + :widths: 20 10 10 60 + + * - Name + - Type + - Required + - Description + * - -P, --profile + - string + - false + - Name of the profile to use from your configuration file. To learn about profiles for the Atlas CLI, see https://dochub.mongodb.org/core/atlas-cli-save-connection-settings. + diff --git a/docs/command/atlas-kubernetes-operator-install.txt b/docs/command/atlas-kubernetes-operator-install.txt index 881f335a6e..b29952550a 100644 --- a/docs/command/atlas-kubernetes-operator-install.txt +++ b/docs/command/atlas-kubernetes-operator-install.txt @@ -44,6 +44,10 @@ Options - - false - Flag that indicates whether to configure Atlas for Government as a target of the operator. + * - --configOnly + - + - false + - Flag that indicates whether to generate only the operator configuration files without installing the Operator * - -h, --help - - false @@ -84,7 +88,7 @@ Options - string - false - Namespace where to install the operator. - * - --watchNamespace + * - --watchNamespaces - strings - false - List that contains namespaces that the operator will listen to. diff --git a/docs/command/atlas-kubernetes.txt b/docs/command/atlas-kubernetes.txt index 5c8586c12c..7a9c535392 100644 --- a/docs/command/atlas-kubernetes.txt +++ b/docs/command/atlas-kubernetes.txt @@ -52,6 +52,7 @@ Related Commands ---------------- * :ref:`atlas-kubernetes-config` - Manage Kubernetes configuration resources. +* :ref:`atlas-kubernetes-dry-run` - Deploy and run Atlas Kubernetes Operator in dry-run mode * :ref:`atlas-kubernetes-operator` - Manage Atlas Kubernetes Operator. @@ -59,5 +60,6 @@ Related Commands :titlesonly: config + dry-run operator diff --git a/internal/cli/kubernetes/dryrun/dryrun.go b/internal/cli/kubernetes/dryrun/dryrun.go new file mode 100644 index 0000000000..371ca13daa --- /dev/null +++ b/internal/cli/kubernetes/dryrun/dryrun.go @@ -0,0 +1,114 @@ +// 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. + +// 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 dryrun + +import ( + "fmt" + "strings" + + "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/cli" + "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/cli/require" + "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/flag" + "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/kubernetes/operator/features" + "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/usage" + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/util/validation" +) + +var ErrUnsupportedOperatorVersionFmt = "version %q is not supported. Supported versions: %v" + +const defaultTimeoutSec = 120 + +type Opts struct { + cli.OrgOpts + cli.OutputOpts + + operatorVersion string + targetNamespace string + watchNamespaces []string + waitForJob bool + waitTimeout int64 +} + +func (opts *Opts) ValidateTargetNamespace() error { + if errs := validation.IsDNS1123Label(opts.targetNamespace); len(errs) != 0 { + return fmt.Errorf("%s parameter is invalid: %v", flag.OperatorTargetNamespace, errs) + } + return nil +} + +func (opts *Opts) ValidateOperatorVersion() error { + if _, versionFound := features.GetResourcesForVersion(opts.operatorVersion); versionFound { + return nil + } + return fmt.Errorf(ErrUnsupportedOperatorVersionFmt, opts.operatorVersion, features.SupportedVersions()) +} + +func (opts *Opts) Run() error { + worker := NewWorker(). + WithTargetNamespace(opts.targetNamespace). + WithWatchNamespaces(strings.Join(opts.watchNamespaces, ",")). + WithOperatorVersion(opts.operatorVersion). + WithWaitForCompletion(opts.waitForJob). + WithWaitTimeoutSec(opts.waitTimeout) + return worker.Run() +} + +// Builder builds a cobra.Command for the Kubernetes dryrun installation. +func Builder() *cobra.Command { + const use = "dry-run" + + opts := &Opts{} + + cmd := &cobra.Command{ + Use: use, + Args: require.NoArgs, + Aliases: cli.GenerateAliases(use), + Short: "Deploy and run Atlas Kubernetes Operator in dry-run mode", + Long: `This command deploys the Atlas Kubernetes operator with the DryRun mode. + +TODO: ask Dan about the proper description of the dry-run mode. +`, + PreRunE: func(_ *cobra.Command, _ []string) error { + return opts.OrgOpts.PreRunE( + opts.ValidateTargetNamespace, + opts.ValidateOperatorVersion, + ) + }, + RunE: func(_ *cobra.Command, _ []string) error { + return opts.Run() + }, + } + + opts.AddOrgOptFlags(cmd) + cmd.Flags().StringVar(&opts.targetNamespace, flag.OperatorTargetNamespace, "", usage.OperatorTargetNamespace) + cmd.Flags().StringSliceVar(&opts.watchNamespaces, flag.OperatorWatchNamespaces, []string{}, usage.OperatorWatchNamespace) + cmd.Flags().StringVar(&opts.operatorVersion, flag.OperatorVersion, features.LatestOperatorMajorVersion, usage.OperatorVersion) + cmd.Flags().BoolVar(&opts.waitForJob, flag.EnableWatch, false, usage.EnableWatch) + cmd.Flags().Int64Var(&opts.waitTimeout, flag.WatchTimeout, defaultTimeoutSec, usage.WatchTimeout) + return cmd +} diff --git a/internal/cli/kubernetes/dryrun/operator.go b/internal/cli/kubernetes/dryrun/operator.go new file mode 100644 index 0000000000..bfdf942da9 --- /dev/null +++ b/internal/cli/kubernetes/dryrun/operator.go @@ -0,0 +1,211 @@ +// 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. + +// 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 dryrun + +import ( + "context" + "fmt" + "time" + + "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/kubernetes/operator/resources" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/config" +) + +const repeatInterval = 5 * time.Second +const liveProbePort = 8081 +const initialDelaySeconds = 15 +const periodSeconds = 20 + +type Worker struct { + targetNamespace string + watchNamespaces string + wait bool + akoVersion string + waitSec int64 +} + +func NewWorker() *Worker { + return &Worker{} +} + +func (r *Worker) WithTargetNamespace(targetNamespace string) *Worker { + r.targetNamespace = targetNamespace + return r +} + +func (r *Worker) WithWatchNamespaces(watchNamespaces string) *Worker { + r.watchNamespaces = watchNamespaces + return r +} + +func (r *Worker) WithOperatorVersion(operatorVersion string) *Worker { + r.akoVersion = operatorVersion + return r +} + +func (r *Worker) WithWaitForCompletion(waitForCompletion bool) *Worker { + r.wait = waitForCompletion + return r +} + +func (r *Worker) WithWaitTimeoutSec(waitSec int64) *Worker { + r.waitSec = waitSec + return r +} + +func (r *Worker) Run() error { + conf, err := config.GetConfig() + if err != nil { + return fmt.Errorf("failed to get k8s config: %w", err) + } + + c, err := client.New(conf, client.Options{}) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + jb := &batchv1.Job{ + TypeMeta: metav1.TypeMeta{ + Kind: "Job", + APIVersion: "batch/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + GenerateName: resources.NormalizeAtlasName("ako-dry-run-", resources.AtlasNameToKubernetesName()), + Namespace: r.targetNamespace, + }, + Spec: batchv1.JobSpec{ + BackoffLimit: makePtr[int32](1), + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + ServiceAccountName: "mongodb-atlas-operator", + RestartPolicy: corev1.RestartPolicyNever, + Containers: []corev1.Container{ + { + Name: "ako-dry-run", + Image: "quay.io/mongodb/atlas-kubernetes-operator:" + r.akoVersion, + //Image: "docker.io/ikarpukhin/mongodb-atlas-kubernetes:dry-run", + Command: []string{"/manager"}, + Args: []string{ + "--atlas-domain=https://cloud-qa.mongodb.com/", + "--log-level=info", + "--log-encoder=json", + "--dry-run", + }, + Env: []corev1.EnvVar{ + { + Name: "OPERATOR_POD_NAME", + Value: "ako-dry-run", + }, + { + Name: "OPERATOR_NAMESPACE", + Value: r.targetNamespace, + }, + { + Name: "WATCH_NAMESPACE", + Value: r.targetNamespace, + }, + { + Name: "JOB_NAME", + Value: "ako-dry-run", + }, + }, + LivenessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + HTTPGet: &corev1.HTTPGetAction{ + Path: "/healthz", + Port: intstr.IntOrString{IntVal: liveProbePort}, + }, + }, + InitialDelaySeconds: initialDelaySeconds, + PeriodSeconds: periodSeconds, + }, + ImagePullPolicy: "Always", + }, + }, + }, + }, + }, + } + + if err := c.Create(context.Background(), jb); err != nil { + return fmt.Errorf("failed to create job: %w", err) + } + + fmt.Printf("AKO dry run job '%s' created successfully at '%s'\r\n", + jb.Name, jb.CreationTimestamp.Format(time.DateTime)) + + if !r.wait { + return nil + } + + ctx, timeoutF := context.WithTimeout(context.Background(), time.Duration(r.waitSec)*time.Second) + defer timeoutF() + + if err := waitForJob(ctx, c, jb); err != nil { + return fmt.Errorf("failed to wait for job: %w", err) + } + + fmt.Printf("AKO dry run job '%s' completed successfully at '%s'\r\n", + jb.Name, time.Now().Format(time.DateTime)) + return nil +} + +func waitForJob(ctx context.Context, c client.Client, job *batchv1.Job) error { + attempts := 0 + for { + select { + case <-ctx.Done(): + return fmt.Errorf("timeout: job did not complete within the expected time: %w", ctx.Err()) + default: + jb := &batchv1.Job{} + if err := c.Get(ctx, client.ObjectKey{Name: job.Name, Namespace: job.Namespace}, jb); err != nil { + return fmt.Errorf("failed to get job: %w", err) + } + + if jb.Status.Succeeded > 0 { + return nil + } + + if jb.Status.Failed > 0 { + return fmt.Errorf("job failed with conditions: %+v", jb.Status.Conditions) + } + + time.Sleep(repeatInterval) + attempts++ + fmt.Printf("Waiting for job to complete... Attempt #%d\r\n", attempts) + } + } +} + +func makePtr[T any](v T) *T { + return &v +} diff --git a/internal/cli/kubernetes/kubernetes.go b/internal/cli/kubernetes/kubernetes.go index a6fd92c6ac..a1def24aa8 100644 --- a/internal/cli/kubernetes/kubernetes.go +++ b/internal/cli/kubernetes/kubernetes.go @@ -16,6 +16,7 @@ package kubernetes import ( "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/cli/kubernetes/config" + "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/cli/kubernetes/dryrun" "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/cli/kubernetes/operator" "github.com/spf13/cobra" ) @@ -28,7 +29,7 @@ func Builder() *cobra.Command { Long: `This command provides access to Kubernetes features within Atlas.`, } - cmd.AddCommand(config.Builder(), operator.Builder()) + cmd.AddCommand(config.Builder(), operator.Builder(), dryrun.Builder()) return cmd } diff --git a/internal/cli/kubernetes/operator/install.go b/internal/cli/kubernetes/operator/install.go index 3491bdfc9e..0d15bb5d24 100644 --- a/internal/cli/kubernetes/operator/install.go +++ b/internal/cli/kubernetes/operator/install.go @@ -52,6 +52,7 @@ type InstallOpts struct { KubeContext string featureDeletionProtection bool featureSubDeletionProtection bool + configOnly bool } func (opts *InstallOpts) defaults() error { @@ -95,7 +96,7 @@ func (opts *InstallOpts) ValidateOperatorVersion() error { func (opts *InstallOpts) ValidateWatchNamespace() error { for _, ns := range opts.watchNamespace { if errs := validation.IsDNS1123Label(ns); len(errs) != 0 { - return fmt.Errorf("item %s of %s parameter is invalid: %v", ns, flag.OperatorWatchNamespace, errs) + return fmt.Errorf("item %s of %s parameter is invalid: %v", ns, flag.OperatorWatchNamespaces, errs) } } @@ -136,6 +137,7 @@ func (opts *InstallOpts) Run(ctx context.Context) error { WithResourceDeletionProtection(opts.featureDeletionProtection). WithSubResourceDeletionProtection(opts.featureSubDeletionProtection). WithAtlasGov(opts.atlasGov). + WithConfigOnly(opts.configOnly). Run(ctx, opts.OrgID) if err != nil { @@ -202,7 +204,7 @@ The key is scoped to the project when you specify the --projectName option and t flags.StringVar(&opts.OrgID, flag.OrgID, "", usage.OrgID) flags.StringVar(&opts.operatorVersion, flag.OperatorVersion, "", usage.OperatorVersionInstall) flags.StringVar(&opts.targetNamespace, flag.OperatorTargetNamespace, "", usage.OperatorTargetNamespaceInstall) - flags.StringSliceVar(&opts.watchNamespace, flag.OperatorWatchNamespace, []string{}, usage.OperatorWatchNamespace) + flags.StringSliceVar(&opts.watchNamespace, flag.OperatorWatchNamespaces, []string{}, usage.OperatorWatchNamespace) flags.StringVar(&opts.projectName, flag.OperatorProjectName, "", usage.OperatorProjectName) flags.BoolVar(&opts.importResources, flag.OperatorImport, false, usage.OperatorImport) flags.BoolVar(&opts.atlasGov, flag.OperatorAtlasGov, false, usage.OperatorAtlasGov) @@ -210,6 +212,7 @@ The key is scoped to the project when you specify the --projectName option and t flags.StringVar(&opts.KubeContext, flag.KubernetesClusterContext, "", usage.KubernetesClusterContext) 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) return cmd } diff --git a/internal/flag/flags.go b/internal/flag/flags.go index b59514f798..ebbbd1ce96 100644 --- a/internal/flag/flags.go +++ b/internal/flag/flags.go @@ -248,12 +248,13 @@ const ( BackupPolicy = "policy" // BackupPolicy flag OperatorIncludeSecrets = "includeSecrets" // OperatorIncludeSecrets flag OperatorTargetNamespace = "targetNamespace" // OperatorTargetNamespace flag - OperatorWatchNamespace = "watchNamespace" // OperatorTargetNamespace flag + OperatorWatchNamespaces = "watchNamespaces" // OperatorTargetNamespace flag OperatorVersion = "operatorVersion" // OperatorVersion flag OperatorProjectName = "projectName" // OperatorProjectName flag OperatorImport = "import" // OperatorImport flag OperatorResourceDeletionProtection = "resourceDeletionProtection" // OperatorResourceDeletionProtection flag OperatorSubResourceDeletionProtection = "subresourceDeletionProtection" // Operator OperatorSubResourceDeletionProtection flag + OperatorConfigOnly = "configOnly" // OperatorConfigOnly config flag OperatorAtlasGov = "atlasGov" // OperatorAtlasGov flag KubernetesClusterConfig = "kubeconfig" // Kubeconfig flag KubernetesClusterContext = "kubeContext" // KubeContext flag diff --git a/internal/kubernetes/operator/install.go b/internal/kubernetes/operator/install.go index 32dfff6945..fb3c84797d 100644 --- a/internal/kubernetes/operator/install.go +++ b/internal/kubernetes/operator/install.go @@ -52,6 +52,13 @@ type Install struct { projectName string importResources bool atlasGov bool + configOnly bool +} + +func (i *Install) WithConfigOnly(configOnly bool) *Install { + i.configOnly = configOnly + + return i } func (i *Install) WithNamespace(namespace string) *Install { @@ -113,6 +120,7 @@ func (i *Install) Run(ctx context.Context, orgID string) error { ResourceDeletionProtectionEnabled: i.featureDeletionProtection, SubResourceDeletionProtectionEnabled: i.featureSubDeletionProtection, AtlasGov: i.atlasGov, + ConfigOnly: i.configOnly, }); err != nil { return err } diff --git a/internal/kubernetes/operator/install_resources.go b/internal/kubernetes/operator/install_resources.go index 3f233acebd..2deec8d91e 100644 --- a/internal/kubernetes/operator/install_resources.go +++ b/internal/kubernetes/operator/install_resources.go @@ -50,6 +50,7 @@ type InstallConfig struct { ResourceDeletionProtectionEnabled bool SubResourceDeletionProtectionEnabled bool AtlasGov bool + ConfigOnly bool } type Installer interface { @@ -143,6 +144,9 @@ func (ir *InstallResources) handleKind(ctx context.Context, installConfig *Insta case "ClusterRoleBinding": return ir.addClusterRoleBinding(ctx, config, installConfig.Namespace) case "Deployment": + if installConfig.ConfigOnly { + return nil + } return ir.addDeployment(ctx, config, installConfig) } return nil diff --git a/internal/usage/usage.go b/internal/usage/usage.go index bc058e9eff..b467486479 100644 --- a/internal/usage/usage.go +++ b/internal/usage/usage.go @@ -333,6 +333,7 @@ dbName and collection are required only for built-in roles.` KubernetesClusterContext = "Name of the kubeconfig context to use." OperatorResourceDeletionProtection = "Toggle atlas operator deletion protection for resources like Projects, Deployments, etc. Read more: https://dochub.mongodb.org/core/ako-deletion-protection" OperatorSubResourceDeletionProtection = "Toggle atlas operator deletion protection for subresources like Alerts, Integrations, etc. Read more: https://dochub.mongodb.org/core/ako-deletion-protection" + OperatorConfigOnly = "Flag that indicates whether to generate only the operator configuration files without installing the Operator" ExportID = "Unique string that identifies the AWS S3 bucket to which you export your snapshots." RequiredRole = "To use this command, you must authenticate with a user account or an API key with the %s role." RestoreJobID = "Unique identifier that identifies the Restore Job."