From 3bed54b18d57fe3788cfb10c1fbb2d00e8a4f5af Mon Sep 17 00:00:00 2001
From: Andrejs Cunskis <acunskis@gitlab.com>
Date: Fri, 10 May 2024 06:24:14 +0000
Subject: [PATCH] Add create sub-command setup

Add kind invocation and improve tests
---
 .gitlab/ci/test-on-cng/main.gitlab-ci.yml     |   4 +-
 gems/gitlab-cng/lib/gitlab/cng/cli.rb         |  44 ++----
 .../lib/gitlab/cng/commands/_command.rb       |  13 ++
 .../lib/gitlab/cng/commands/create.rb         |  30 ++++
 .../lib/gitlab/cng/commands/doctor.rb         |   6 +-
 .../lib/gitlab/cng/commands/version.rb        |   4 +-
 .../lib/gitlab/cng/helpers/output.rb          |  88 -----------
 .../lib/gitlab/cng/helpers/shell.rb           |  35 -----
 .../lib/gitlab/cng/lib/helpers/output.rb      |  61 ++++++++
 .../lib/gitlab/cng/lib/helpers/shell.rb       |  38 +++++
 .../gitlab/cng/{ => lib}/helpers/spinner.rb   |   2 +-
 .../lib/gitlab/cng/lib/helpers/thor.rb        |  31 ++++
 .../lib/gitlab/cng/lib/kind/cluster.rb        |  81 +++++++++++
 .../lib/gitlab/cng/lib/kind/configs.rb        | 102 +++++++++++++
 gems/gitlab-cng/spec/command_helper.rb        |  23 +++
 gems/gitlab-cng/spec/integration/cng_spec.rb  |   7 +-
 .../spec/integration/gitlab/cng/cli_spec.rb   |  55 +++++++
 .../integration/lib/gitlab/cng/cli_spec.rb    |  36 -----
 gems/gitlab-cng/spec/spec_helper.rb           |   3 +-
 .../unit/gitlab/cng/commands/create_spec.rb   |  32 ++++
 .../gitlab/cng/commands/doctor_spec.rb        |  15 +-
 .../gitlab/cng/commands/version_spec.rb       |   8 +-
 .../gitlab/cng/helpers/spinner_spec.rb        |   0
 .../spec/unit/gitlab/cng/kind/cluster_spec.rb | 137 ++++++++++++++++++
 scripts/qa/cng_deploy/cng-kind.sh             |  11 --
 25 files changed, 644 insertions(+), 222 deletions(-)
 create mode 100644 gems/gitlab-cng/lib/gitlab/cng/commands/create.rb
 delete mode 100644 gems/gitlab-cng/lib/gitlab/cng/helpers/output.rb
 delete mode 100644 gems/gitlab-cng/lib/gitlab/cng/helpers/shell.rb
 create mode 100644 gems/gitlab-cng/lib/gitlab/cng/lib/helpers/output.rb
 create mode 100644 gems/gitlab-cng/lib/gitlab/cng/lib/helpers/shell.rb
 rename gems/gitlab-cng/lib/gitlab/cng/{ => lib}/helpers/spinner.rb (97%)
 create mode 100644 gems/gitlab-cng/lib/gitlab/cng/lib/helpers/thor.rb
 create mode 100644 gems/gitlab-cng/lib/gitlab/cng/lib/kind/cluster.rb
 create mode 100644 gems/gitlab-cng/lib/gitlab/cng/lib/kind/configs.rb
 create mode 100644 gems/gitlab-cng/spec/command_helper.rb
 create mode 100644 gems/gitlab-cng/spec/integration/gitlab/cng/cli_spec.rb
 delete mode 100644 gems/gitlab-cng/spec/integration/lib/gitlab/cng/cli_spec.rb
 create mode 100644 gems/gitlab-cng/spec/unit/gitlab/cng/commands/create_spec.rb
 rename gems/gitlab-cng/spec/unit/{lib => }/gitlab/cng/commands/doctor_spec.rb (67%)
 rename gems/gitlab-cng/spec/unit/{lib => }/gitlab/cng/commands/version_spec.rb (56%)
 rename gems/gitlab-cng/spec/unit/{lib => }/gitlab/cng/helpers/spinner_spec.rb (100%)
 create mode 100644 gems/gitlab-cng/spec/unit/gitlab/cng/kind/cluster_spec.rb

diff --git a/.gitlab/ci/test-on-cng/main.gitlab-ci.yml b/.gitlab/ci/test-on-cng/main.gitlab-ci.yml
index 7652efb6adeaf..83395a5922dd9 100644
--- a/.gitlab/ci/test-on-cng/main.gitlab-ci.yml
+++ b/.gitlab/ci/test-on-cng/main.gitlab-ci.yml
@@ -51,9 +51,9 @@ workflow:
     - source scripts/utils.sh
     - source scripts/rspec_helpers.sh
     - source scripts/qa/cng_deploy/cng-kind.sh
-    - run_timed_command "setup_cluster scripts/qa/cng_deploy/config/kind-config.yml"
-    - run_timed_command "deploy ${GITLAB_DOMAIN}"
     - cd qa && bundle install
+    - bundle exec cng create cluster --ci
+    - deploy ${GITLAB_DOMAIN}
   script:
     - export QA_COMMAND="bundle exec bin/qa ${QA_SCENARIO:=Test::Instance::All} $QA_GITLAB_URL -- --force-color --order random --format documentation"
     - echo "Running - '$QA_COMMAND'"
diff --git a/gems/gitlab-cng/lib/gitlab/cng/cli.rb b/gems/gitlab-cng/lib/gitlab/cng/cli.rb
index 04519d6bbcaa3..578ff8f7e613e 100644
--- a/gems/gitlab-cng/lib/gitlab/cng/cli.rb
+++ b/gems/gitlab-cng/lib/gitlab/cng/cli.rb
@@ -3,51 +3,31 @@
 require "thor"
 require "require_all"
 
-require_rel "helpers/**/*.rb"
+require_rel "lib/**/*.rb"
 require_rel "commands/**/*.rb"
 
 module Gitlab
   module Cng
     # Main CLI class handling all commands
     #
-    class CLI < Thor
+    class CLI < Commands::Command
+      extend Helpers::Thor
+
       # Error raised by this runner
       Error = Class.new(StandardError)
 
-      # Fail if unknown option is passed
-      check_unknown_options!
-
-      class << self
-        # Exit with non 0 status code if any command fails
-        #
-        # @return [Boolean]
-        def exit_on_failure?
-          true
-        end
-
-        # Register all public methods of Thor class as top level commands
-        #
-        # @param [Thor] klass
-        # @return [void]
-        def register_commands(klass)
-          raise Error, "#{klass} is not a Thor class" unless klass < Thor
-
-          klass.commands.each do |name, command|
-            raise Error, "Tried to register command '#{name}' but the command already exists" if commands[name]
-
-            # check if the method takes arguments
-            pass_args = klass.new.method(name).arity != 0
-
-            commands[name] = command
-            define_method(name) do |*args|
-              pass_args ? invoke(klass, name, *args) : invoke(klass, name)
-            end
-          end
-        end
+      # Exit with non 0 status code if any command fails
+      #
+      # @return [Boolean]
+      def self.exit_on_failure?
+        true
       end
 
       register_commands(Commands::Version)
       register_commands(Commands::Doctor)
+
+      desc "create [SUBCOMMAND]", "Manage deployment related object creation"
+      subcommand "create", Commands::Create
     end
   end
 end
diff --git a/gems/gitlab-cng/lib/gitlab/cng/commands/_command.rb b/gems/gitlab-cng/lib/gitlab/cng/commands/_command.rb
index fd0f449e6efd7..ab9c521fa937f 100644
--- a/gems/gitlab-cng/lib/gitlab/cng/commands/_command.rb
+++ b/gems/gitlab-cng/lib/gitlab/cng/commands/_command.rb
@@ -3,8 +3,21 @@
 module Gitlab
   module Cng
     module Commands
+      # Thor command base class
+      #
       class Command < Thor
         include Helpers::Output
+
+        check_unknown_options!
+
+        private
+
+        # Options hash with symbolized keys
+        #
+        # @return [Hash]
+        def symbolized_options
+          @symbolized_options ||= options.transform_keys(&:to_sym)
+        end
       end
     end
   end
diff --git a/gems/gitlab-cng/lib/gitlab/cng/commands/create.rb b/gems/gitlab-cng/lib/gitlab/cng/commands/create.rb
new file mode 100644
index 0000000000000..1b55108d5317f
--- /dev/null
+++ b/gems/gitlab-cng/lib/gitlab/cng/commands/create.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module Cng
+    module Commands
+      # Create command composed of subcommands that create various resources needed for CNG deployment
+      #
+      class Create < Command
+        desc "cluster", "Create kind cluster for local deployments"
+        option :name,
+          desc: "Cluster name",
+          default: "gitlab",
+          type: :string,
+          aliases: "-n"
+        option :ci,
+          desc: "Use CI specific configuration",
+          default: false,
+          type: :boolean,
+          aliases: "-c"
+        option :docker_hostname,
+          desc: "Custom docker hostname if remote docker instance is used, like docker-in-docker",
+          type: :string,
+          aliases: "-d"
+        def cluster
+          Kind::Cluster.new(**symbolized_options).create
+        end
+      end
+    end
+  end
+end
diff --git a/gems/gitlab-cng/lib/gitlab/cng/commands/doctor.rb b/gems/gitlab-cng/lib/gitlab/cng/commands/doctor.rb
index 4262a25e4f9fa..4390de2ce6371 100644
--- a/gems/gitlab-cng/lib/gitlab/cng/commands/doctor.rb
+++ b/gems/gitlab-cng/lib/gitlab/cng/commands/doctor.rb
@@ -5,12 +5,14 @@
 module Gitlab
   module Cng
     module Commands
+      # Command to check system dependencies
+      #
       class Doctor < Command
         TOOLS = %w[docker kind kubectl helm].freeze
 
         desc "doctor", "Validate presence of all required system dependencies"
         def doctor
-          log_info "Checking system dependencies", bright: true
+          log "Checking system dependencies", :info, bright: true
           missing_tools = TOOLS.filter_map do |tool|
             Helpers::Spinner.spin("Checking if #{tool} is installed") do
               raise "#{tool} not found in PATH" unless TTY::Which.exist?(tool)
@@ -18,7 +20,7 @@ def doctor
           rescue StandardError
             tool
           end
-          return log_success "All system dependencies are present", bright: true if missing_tools.empty?
+          return log "All system dependencies are present", :success, bright: true if missing_tools.empty?
 
           exit_with_error "The following system dependencies are missing: #{missing_tools.join(', ')}"
         end
diff --git a/gems/gitlab-cng/lib/gitlab/cng/commands/version.rb b/gems/gitlab-cng/lib/gitlab/cng/commands/version.rb
index 3e1643236e1b9..f62a4c9072e0a 100644
--- a/gems/gitlab-cng/lib/gitlab/cng/commands/version.rb
+++ b/gems/gitlab-cng/lib/gitlab/cng/commands/version.rb
@@ -3,8 +3,10 @@
 module Gitlab
   module Cng
     module Commands
+      # Basic command to print the version of cng orchestrator
+      #
       class Version < Command
-        desc "version", "Prints cng orchestrator version"
+        desc "version", "Print cng orchestrator version"
         def version
           puts Cng::VERSION
         end
diff --git a/gems/gitlab-cng/lib/gitlab/cng/helpers/output.rb b/gems/gitlab-cng/lib/gitlab/cng/helpers/output.rb
deleted file mode 100644
index b3ba6d13a290f..0000000000000
--- a/gems/gitlab-cng/lib/gitlab/cng/helpers/output.rb
+++ /dev/null
@@ -1,88 +0,0 @@
-# frozen_string_literal: true
-
-require "rainbow"
-
-module Gitlab
-  module Cng
-    module Helpers
-      # Console output helpers to include in command implementations
-      #
-      module Output
-        private
-
-        # Print base output without specific color
-        #
-        # @param [String] message
-        # @param [Boolean] bright
-        # @return [void]
-        def log(message, bright: false)
-          puts colorize(message, nil, bright: bright)
-        end
-
-        # Print info in magenta color
-        #
-        # @param [String] message
-        # @param [Boolean] bright
-        # @return [nil]
-        def log_info(message, bright: false)
-          puts colorize(message, :magenta, bright: bright)
-        end
-
-        # Print success message in green color
-        #
-        # @param [String] message
-        # @param [Boolean] bright
-        # @return [nil]
-        def log_success(message, bright: false)
-          puts colorize(message, :green, bright: bright)
-        end
-
-        # Print warning message in yellow color
-        #
-        # @param [String] message
-        # @param [Boolean] bright
-        # @return [nil]
-        def log_warn(message, bright: false)
-          puts colorize(message, :yellow, bright: bright)
-        end
-
-        # Print error message in red color
-        #
-        # @param [String] message
-        # @param [Boolean] bright
-        # @return [nil]
-        def log_error(message, bright: false)
-          puts colorize(message, :red, bright: bright)
-        end
-
-        # Exit with non zero exit code and print error message
-        #
-        # @param [String] message
-        # @return [void]
-        def exit_with_error(message)
-          log_error(message, bright: true)
-          exit 1
-        end
-
-        # Colorize message string and output to stdout
-        #
-        # @param [String] message
-        # @param [<Symbol, nil>] color
-        # @param [Boolean] bright
-        # @return [String]
-        def colorize(message, color, bright: false)
-          rainbow.wrap(message)
-            .then { |m| bright ? m.bright : m }
-            .then { |m| color ? m.color(color) : m }
-        end
-
-        # Instance of rainbow colorization class
-        #
-        # @return [Rainbow]
-        def rainbow
-          @rainbow ||= Rainbow.new
-        end
-      end
-    end
-  end
-end
diff --git a/gems/gitlab-cng/lib/gitlab/cng/helpers/shell.rb b/gems/gitlab-cng/lib/gitlab/cng/helpers/shell.rb
deleted file mode 100644
index 40483007439cc..0000000000000
--- a/gems/gitlab-cng/lib/gitlab/cng/helpers/shell.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-# frozen_string_literal: true
-
-require "open3"
-
-module Gitlab
-  module Cng
-    module Helpers
-      # Wrapper for shell command execution
-      #
-      module Shell
-        CommandFailure = Class.new(StandardError)
-
-        # Execute shell command
-        #
-        # @param [String] command
-        # @return [String] output
-        def self.execute_shell(command)
-          out, err, status = Open3.capture3(command)
-
-          cmd_output = []
-          cmd_output << "Out: #{out}" unless out.empty?
-          cmd_output << "Err: #{err}" unless err.empty?
-          output = cmd_output.join("\n")
-
-          unless status.success?
-            err_msg = "Command '#{command}' failed!\n#{output}"
-            raise(CommandFailure, err_msg)
-          end
-
-          output
-        end
-      end
-    end
-  end
-end
diff --git a/gems/gitlab-cng/lib/gitlab/cng/lib/helpers/output.rb b/gems/gitlab-cng/lib/gitlab/cng/lib/helpers/output.rb
new file mode 100644
index 0000000000000..2b89aca39494f
--- /dev/null
+++ b/gems/gitlab-cng/lib/gitlab/cng/lib/helpers/output.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require "rainbow"
+
+module Gitlab
+  module Cng
+    module Helpers
+      # Console output helpers to include in command implementations
+      #
+      module Output
+        LOG_COLOR = {
+          default: nil,
+          info: :magenta,
+          success: :green,
+          warn: :yellow,
+          error: :red
+        }.freeze
+
+        private
+
+        # Print colorized log message to stdout
+        #
+        # @param [String] message
+        # @param [Symbol] type
+        # @param [Boolean] bright
+        # @return [void]
+        def log(message, type = :default, bright: false)
+          puts colorize(message, LOG_COLOR.fetch(type), bright: bright)
+        end
+
+        # Exit with non zero exit code and print error message
+        #
+        # @param [String] message
+        # @return [void]
+        def exit_with_error(message)
+          log(message, :error, bright: true)
+          exit 1
+        end
+
+        # Colorize message string and output to stdout
+        #
+        # @param [String] message
+        # @param [<Symbol, nil>] color
+        # @param [Boolean] bright
+        # @return [String]
+        def colorize(message, color, bright: false)
+          rainbow.wrap(message)
+            .then { |m| bright ? m.bright : m }
+            .then { |m| color ? m.color(color) : m }
+        end
+
+        # Instance of rainbow colorization class
+        #
+        # @return [Rainbow]
+        def rainbow
+          @rainbow ||= Rainbow.new
+        end
+      end
+    end
+  end
+end
diff --git a/gems/gitlab-cng/lib/gitlab/cng/lib/helpers/shell.rb b/gems/gitlab-cng/lib/gitlab/cng/lib/helpers/shell.rb
new file mode 100644
index 0000000000000..4a6001c0d7317
--- /dev/null
+++ b/gems/gitlab-cng/lib/gitlab/cng/lib/helpers/shell.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require "open3"
+
+module Gitlab
+  module Cng
+    module Helpers
+      # Wrapper for shell command execution
+      #
+      module Shell
+        CommandFailure = Class.new(StandardError)
+
+        # Execute shell command
+        #
+        # @param [Array] command
+        # @param [Boolean] raise_on_failure
+        # @param [Hash] env
+        # @return [<String, Array>] return command output and status if raise_on_failure is false
+        def execute_shell(cmd, raise_on_failure: true, env: {})
+          raise "System commands must be given as an array of strings" unless cmd.is_a?(Array)
+
+          if cmd.one? && cmd.first.match?(/\s/)
+            raise "System commands must be split into an array of space-separated values"
+          end
+
+          out, status = Open3.capture2e(env, *cmd)
+
+          if raise_on_failure && !status.success?
+            err_msg = "Command '#{cmd}' failed!\n#{out}"
+            raise(CommandFailure, err_msg)
+          end
+
+          raise_on_failure ? out : [out, status]
+        end
+      end
+    end
+  end
+end
diff --git a/gems/gitlab-cng/lib/gitlab/cng/helpers/spinner.rb b/gems/gitlab-cng/lib/gitlab/cng/lib/helpers/spinner.rb
similarity index 97%
rename from gems/gitlab-cng/lib/gitlab/cng/helpers/spinner.rb
rename to gems/gitlab-cng/lib/gitlab/cng/lib/helpers/spinner.rb
index 38a42a6e0b50c..c723bf4ddfeac 100644
--- a/gems/gitlab-cng/lib/gitlab/cng/helpers/spinner.rb
+++ b/gems/gitlab-cng/lib/gitlab/cng/lib/helpers/spinner.rb
@@ -5,7 +5,7 @@
 module Gitlab
   module Cng
     module Helpers
-      # Spinner helper class
+      # Spinner helper class for wrapping long running tasks in progress spinner
       #
       class Spinner
         include Output
diff --git a/gems/gitlab-cng/lib/gitlab/cng/lib/helpers/thor.rb b/gems/gitlab-cng/lib/gitlab/cng/lib/helpers/thor.rb
new file mode 100644
index 0000000000000..fa396058e5b8c
--- /dev/null
+++ b/gems/gitlab-cng/lib/gitlab/cng/lib/helpers/thor.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module Cng
+    module Helpers
+      # Thor command specific helpers
+      #
+      module Thor
+        # Register all public methods of Thor class inside another Thor class as commands
+        #
+        # @param [Thor] klass
+        # @return [void]
+        def register_commands(klass)
+          raise "#{klass} is not a Thor class" unless klass < ::Thor
+
+          klass.commands.each do |name, command|
+            raise "Tried to register command '#{name}' but the command already exists" if commands[name]
+
+            # check if the method takes arguments
+            pass_args = klass.new.method(name).arity != 0
+
+            commands[name] = command
+            define_method(name) do |*args|
+              pass_args ? invoke(klass, name, *args) : invoke(klass, name)
+            end
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/gems/gitlab-cng/lib/gitlab/cng/lib/kind/cluster.rb b/gems/gitlab-cng/lib/gitlab/cng/lib/kind/cluster.rb
new file mode 100644
index 0000000000000..a5f23330f3edc
--- /dev/null
+++ b/gems/gitlab-cng/lib/gitlab/cng/lib/kind/cluster.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+require "uri"
+
+require_relative "configs"
+
+module Gitlab
+  module Cng
+    module Kind
+      # Class responsible for creating kind cluster
+      #
+      class Cluster
+        include Helpers::Output
+        include Helpers::Shell
+        include Configs
+
+        def initialize(ci:, name:, docker_hostname: nil)
+          @ci = ci
+          @name = name
+          @docker_hostname = ci ? docker_hostname || "docker" : docker_hostname
+        end
+
+        def create
+          log "Creating cluster '#{name}'", :info
+          return log "  cluster '#{name}' already exists, skipping!" if cluster_exists?
+
+          create_cluster
+          update_server_url
+          log "Cluster '#{name}' created", :success
+        rescue Helpers::Shell::CommandFailure
+          # Exit cleanly without stacktrace if shell command fails
+          exit(1)
+        end
+
+        private
+
+        attr_reader :ci, :name, :docker_hostname
+
+        # Check if cluster exists
+        #
+        # @return [Boolean]
+        def cluster_exists?
+          execute_shell(%w[kind get clusters]).include?(name)
+        end
+
+        # Create kind cluster
+        #
+        # @return [void]
+        def create_cluster
+          Helpers::Spinner.spin("performing cluster creation") do
+            execute_shell([
+              "kind",
+              "create",
+              "cluster",
+              "--name", name,
+              "--wait", "10s",
+              "--config", ci ? ci_config(docker_hostname) : default_config(docker_hostname)
+            ])
+          end
+        end
+
+        # Update server url in kubeconfig for kubectl to work correctly with remote docker
+        #
+        # @return [void]
+        def update_server_url
+          return unless docker_hostname
+
+          Helpers::Spinner.spin("updating kind cluster server url") do
+            cluster_name = "kind-#{name}"
+            server = execute_shell([
+              "kubectl", "config", "view",
+              "-o", "jsonpath={.clusters[?(@.name == \"#{cluster_name}\")].cluster.server}"
+            ])
+            uri = URI.parse(server).tap { |uri| uri.host = docker_hostname }
+            execute_shell(%W[kubectl config set-cluster #{cluster_name} --server=#{uri}])
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/gems/gitlab-cng/lib/gitlab/cng/lib/kind/configs.rb b/gems/gitlab-cng/lib/gitlab/cng/lib/kind/configs.rb
new file mode 100644
index 0000000000000..7406b4bb6141d
--- /dev/null
+++ b/gems/gitlab-cng/lib/gitlab/cng/lib/kind/configs.rb
@@ -0,0 +1,102 @@
+# frozen_string_literal: true
+
+require "tmpdir"
+require "erb"
+
+module Gitlab
+  module Cng
+    module Kind
+      # Kind configuration file templates
+      #
+      module Configs
+        # Temporary ci specific kind configuration file
+        #
+        # @param [String] docker_hostname
+        # @return [String] file path
+        def ci_config(docker_hostname)
+          config_yml = <<~YML
+            apiVersion: kind.x-k8s.io/v1alpha4
+            kind: Cluster
+            networking:
+              apiServerAddress: "0.0.0.0"
+            nodes:
+              - role: control-plane
+                kubeadmConfigPatches:
+                  - |
+                    kind: InitConfiguration
+                    nodeRegistration:
+                      kubeletExtraArgs:
+                        node-labels: "ingress-ready=true"
+                  - |
+                    kind: ClusterConfiguration
+                    apiServer:
+                      certSANs:
+                        - "#{docker_hostname}"
+                extraPortMappings:
+                    # containerPort below must match the values file:
+                    #   nginx-ingress.controller.service.nodePorts.http
+                  - containerPort: 32080
+                    hostPort: 80
+                    listenAddress: "0.0.0.0"
+                    # containerPort below must match the values file:
+                    #   nginx-ingress.controller.service.nodePorts.gitlab-shell
+                  - containerPort: 32022
+                    hostPort: 22
+                    listenAddress: "0.0.0.0"
+          YML
+
+          tmp_config_file(config_yml)
+        end
+
+        # Temporary kind configuration file
+        #
+        # @param [String, nil] docker_hostname
+        # @return [String] file path
+        def default_config(docker_hostname)
+          template = ERB.new(<<~YML, trim_mode: "-")
+            kind: Cluster
+            apiVersion: kind.x-k8s.io/v1alpha4
+            nodes:
+            - role: control-plane
+              kubeadmConfigPatches:
+                - |
+                  kind: InitConfiguration
+                  nodeRegistration:
+                    kubeletExtraArgs:
+                      node-labels: "ingress-ready=true"
+            <% if docker_hostname -%>
+                - |
+                  kind: ClusterConfiguration
+                  apiServer:
+                    certSANs:
+                      - "<%= docker_hostname %>"
+            <% end -%>
+              extraPortMappings:
+                # containerPort below must match the values file:
+                #   nginx-ingress.controller.service.nodePorts.http
+              - containerPort: 32080
+                hostPort: 32080
+                listenAddress: "0.0.0.0"
+                # containerPort below must match the values file:
+                #   nginx-ingress.controller.service.nodePorts.ssh
+              - containerPort: 32022
+                hostPort: 32022
+                listenAddress: "0.0.0.0"
+          YML
+
+          tmp_config_file(template.result(binding))
+        end
+
+        # Create temporary kind config file
+        #
+        # @param [String] config_yml
+        # @return [String]
+        def tmp_config_file(config_yml)
+          File.join(Dir.tmpdir, "kind-config.yml").tap do |path|
+            File.write(path, config_yml)
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/gems/gitlab-cng/spec/command_helper.rb b/gems/gitlab-cng/spec/command_helper.rb
new file mode 100644
index 0000000000000..493b4e5dce99f
--- /dev/null
+++ b/gems/gitlab-cng/spec/command_helper.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+RSpec.shared_context "with command testing helper" do
+  let(:command_instance) { described_class.new }
+
+  # Invoke command with args
+  #
+  # @param [String] command
+  # @param [Array] args
+  # @return [void]
+  def invoke_command(command, args = [], options = {})
+    command_instance.invoke(command, args, options)
+  end
+
+  # Expect command to have attributes
+  #
+  # @param [String] command
+  # @param [Hash] attributes
+  # @return [void]
+  def expect_command_to_include_attributes(command, attributes)
+    expect(described_class.commands[command].to_h).to include(attributes)
+  end
+end
diff --git a/gems/gitlab-cng/spec/integration/cng_spec.rb b/gems/gitlab-cng/spec/integration/cng_spec.rb
index 5a12f84437528..8a33eb85bc260 100644
--- a/gems/gitlab-cng/spec/integration/cng_spec.rb
+++ b/gems/gitlab-cng/spec/integration/cng_spec.rb
@@ -4,9 +4,10 @@
   let(:usage) do
     <<~USAGE
       Commands:
-        cng doctor          # Validate presence of all required system dependencies
-        cng help [COMMAND]  # Describe available commands or one specific command
-        cng version         # Prints cng orchestrator version
+        cng create [SUBCOMMAND]  # Manage deployment related object creation
+        cng doctor               # Validate presence of all required system dependencies
+        cng help [COMMAND]       # Describe available commands or one specific command
+        cng version              # Print cng orchestrator version
     USAGE
   end
 
diff --git a/gems/gitlab-cng/spec/integration/gitlab/cng/cli_spec.rb b/gems/gitlab-cng/spec/integration/gitlab/cng/cli_spec.rb
new file mode 100644
index 0000000000000..1fe400dd4a72c
--- /dev/null
+++ b/gems/gitlab-cng/spec/integration/gitlab/cng/cli_spec.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+RSpec.describe Gitlab::Cng::CLI do
+  shared_examples "command with help" do |args, help_output|
+    it "shows help" do
+      expect { cli.invoke(*args) }.to output(/#{help_output}/).to_stdout
+    end
+  end
+
+  subject(:cli) { described_class.new }
+
+  describe "version command" do
+    it_behaves_like "command with help", [:help, ["version"]], /Print cng orchestrator version/
+
+    it "executes version command" do
+      expect { cli.invoke(:version) }.to output(/#{Gitlab::Cng::VERSION}/o).to_stdout
+    end
+  end
+
+  describe "doctor command" do
+    let(:command_instance) { Gitlab::Cng::Commands::Doctor.new }
+
+    before do
+      allow(Gitlab::Cng::Commands::Doctor).to receive(:new).and_return(command_instance)
+      allow(command_instance).to receive(:doctor)
+    end
+
+    it_behaves_like "command with help", [:help, ["doctor"]], /Validate presence of all required system dependencies/
+
+    it "invokes doctor command" do
+      cli.invoke(:doctor)
+
+      expect(command_instance).to have_received(:doctor)
+    end
+  end
+
+  describe "create command" do
+    context "with cluster subcommand" do
+      let(:cluster_instance) { instance_double(Gitlab::Cng::Kind::Cluster, create: nil) }
+
+      before do
+        allow(Gitlab::Cng::Kind::Cluster).to receive(:new).and_return(cluster_instance)
+      end
+
+      it_behaves_like "command with help", [:help, %w[create cluster]], /Create kind cluster for local deployments/
+
+      it "invokes cluster create command" do
+        cli.invoke(:create, %w[cluster])
+
+        expect(Gitlab::Cng::Kind::Cluster).to have_received(:new).with(ci: false, name: "gitlab")
+        expect(cluster_instance).to have_received(:create)
+      end
+    end
+  end
+end
diff --git a/gems/gitlab-cng/spec/integration/lib/gitlab/cng/cli_spec.rb b/gems/gitlab-cng/spec/integration/lib/gitlab/cng/cli_spec.rb
deleted file mode 100644
index 04f5ca5953f41..0000000000000
--- a/gems/gitlab-cng/spec/integration/lib/gitlab/cng/cli_spec.rb
+++ /dev/null
@@ -1,36 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.describe Gitlab::Cng::CLI do
-  let(:cli) { described_class.new }
-
-  describe "version command" do
-    it "shows version command help" do
-      expect { cli.invoke(:help, ["version"]) }.to output(/Prints cng orchestrator version/).to_stdout
-    end
-
-    it "executes version command" do
-      expect { cli.invoke(:version) }.to output(/#{Gitlab::Cng::VERSION}/o).to_stdout
-    end
-  end
-
-  describe "doctor command" do
-    let(:command_instance) { Gitlab::Cng::Commands::Doctor.new }
-
-    before do
-      allow(Gitlab::Cng::Commands::Doctor).to receive(:new).and_return(command_instance)
-      allow(command_instance).to receive(:doctor)
-    end
-
-    it "shows doctor command help" do
-      expect { cli.invoke(:help, ["doctor"]) }.to output(
-        /Validate presence of all required system dependencies/
-      ).to_stdout
-    end
-
-    it "invokes doctor command" do
-      cli.invoke(:doctor)
-
-      expect(command_instance).to have_received(:doctor)
-    end
-  end
-end
diff --git a/gems/gitlab-cng/spec/spec_helper.rb b/gems/gitlab-cng/spec/spec_helper.rb
index 64f319a3c7daa..888c6f5603d7d 100644
--- a/gems/gitlab-cng/spec/spec_helper.rb
+++ b/gems/gitlab-cng/spec/spec_helper.rb
@@ -1,9 +1,10 @@
 # frozen_string_literal: true
 
 require "rspec"
-
 require "gitlab/cng/cli"
 
+require_relative "command_helper"
+
 RSpec.configure do |config|
   # Disable RSpec exposing methods globally on `Module` and `main`
   config.disable_monkey_patching!
diff --git a/gems/gitlab-cng/spec/unit/gitlab/cng/commands/create_spec.rb b/gems/gitlab-cng/spec/unit/gitlab/cng/commands/create_spec.rb
new file mode 100644
index 0000000000000..b94342c7832d8
--- /dev/null
+++ b/gems/gitlab-cng/spec/unit/gitlab/cng/commands/create_spec.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+RSpec.describe Gitlab::Cng::Commands::Create do
+  include_context "with command testing helper"
+
+  describe "cluster command" do
+    let(:command_name) { "cluster" }
+    let(:kind_cluster) { instance_double(Gitlab::Cng::Kind::Cluster, create: nil) }
+
+    before do
+      allow(Gitlab::Cng::Kind::Cluster).to receive(:new).and_return(kind_cluster)
+    end
+
+    it "defines cluster command" do
+      expect_command_to_include_attributes(command_name, {
+        description: "Create kind cluster for local deployments",
+        name: command_name,
+        usage: command_name
+      })
+    end
+
+    it "invokes kind cluster creation with correct arguments" do
+      invoke_command(command_name, [], { ci: true, name: "test-cluster" })
+
+      expect(kind_cluster).to have_received(:create)
+      expect(Gitlab::Cng::Kind::Cluster).to have_received(:new).with({
+        ci: true,
+        name: "test-cluster"
+      })
+    end
+  end
+end
diff --git a/gems/gitlab-cng/spec/unit/lib/gitlab/cng/commands/doctor_spec.rb b/gems/gitlab-cng/spec/unit/gitlab/cng/commands/doctor_spec.rb
similarity index 67%
rename from gems/gitlab-cng/spec/unit/lib/gitlab/cng/commands/doctor_spec.rb
rename to gems/gitlab-cng/spec/unit/gitlab/cng/commands/doctor_spec.rb
index f017f25a5a1f4..4524194d1b2af 100644
--- a/gems/gitlab-cng/spec/unit/lib/gitlab/cng/commands/doctor_spec.rb
+++ b/gems/gitlab-cng/spec/unit/gitlab/cng/commands/doctor_spec.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
 RSpec.describe Gitlab::Cng::Commands::Doctor do
-  subject(:command) { described_class.new }
+  include_context "with command testing helper"
 
   let(:spinner) { instance_double(Gitlab::Cng::Helpers::Spinner) }
   let(:command_name) { "doctor" }
@@ -11,13 +11,12 @@
   before do
     allow(Gitlab::Cng::Helpers::Spinner).to receive(:new) { spinner }
     allow(spinner).to receive(:spin).and_yield
-    allow(command).to receive(:puts)
 
     tools.each { |tool| allow(TTY::Which).to receive(:exist?).with(tool).and_return(tools_present) }
   end
 
   it "defines a doctor command" do
-    expect(described_class.commands[command_name].to_h).to include({
+    expect_command_to_include_attributes(command_name, {
       description: "Validate presence of all required system dependencies",
       long_description: nil,
       name: command_name,
@@ -28,8 +27,9 @@
 
   context "with all tools present" do
     it "does not raise an error", :aggregate_failures do
-      expect { command.doctor }.not_to raise_error
-      expect(command).to have_received(:puts).with(/All system dependencies are present/)
+      expect do
+        expect { invoke_command(command_name) }.not_to raise_error
+      end.to output(/All system dependencies are present/).to_stdout
     end
   end
 
@@ -37,8 +37,9 @@
     let(:tools_present) { false }
 
     it "exits and prints missing dependencies error", :aggregate_failures do
-      expect { command.doctor }.to raise_error(SystemExit)
-      expect(command).to have_received(:puts).with(/The following system dependencies are missing: #{tools.join(', ')}/)
+      expect do
+        expect { invoke_command(command_name) }.to raise_error(SystemExit)
+      end.to output(/The following system dependencies are missing: #{tools.join(', ')}/).to_stdout
     end
   end
 end
diff --git a/gems/gitlab-cng/spec/unit/lib/gitlab/cng/commands/version_spec.rb b/gems/gitlab-cng/spec/unit/gitlab/cng/commands/version_spec.rb
similarity index 56%
rename from gems/gitlab-cng/spec/unit/lib/gitlab/cng/commands/version_spec.rb
rename to gems/gitlab-cng/spec/unit/gitlab/cng/commands/version_spec.rb
index 58e13883603d3..f0f4e97af1a83 100644
--- a/gems/gitlab-cng/spec/unit/lib/gitlab/cng/commands/version_spec.rb
+++ b/gems/gitlab-cng/spec/unit/gitlab/cng/commands/version_spec.rb
@@ -1,11 +1,13 @@
 # frozen_string_literal: true
 
 RSpec.describe Gitlab::Cng::Commands::Version do
+  include_context "with command testing helper"
+
   let(:version) { Gitlab::Cng::VERSION }
 
   it "defines a version command" do
-    expect(described_class.commands["version"].to_h).to include({
-      description: "Prints cng orchestrator version",
+    expect_command_to_include_attributes("version", {
+      description: "Print cng orchestrator version",
       long_description: nil,
       name: "version",
       options: {},
@@ -14,6 +16,6 @@
   end
 
   it "prints the version" do
-    expect { described_class.new.version }.to output(/#{version}/).to_stdout
+    expect { invoke_command("version") }.to output(/#{version}/).to_stdout
   end
 end
diff --git a/gems/gitlab-cng/spec/unit/lib/gitlab/cng/helpers/spinner_spec.rb b/gems/gitlab-cng/spec/unit/gitlab/cng/helpers/spinner_spec.rb
similarity index 100%
rename from gems/gitlab-cng/spec/unit/lib/gitlab/cng/helpers/spinner_spec.rb
rename to gems/gitlab-cng/spec/unit/gitlab/cng/helpers/spinner_spec.rb
diff --git a/gems/gitlab-cng/spec/unit/gitlab/cng/kind/cluster_spec.rb b/gems/gitlab-cng/spec/unit/gitlab/cng/kind/cluster_spec.rb
new file mode 100644
index 0000000000000..760bd9b592381
--- /dev/null
+++ b/gems/gitlab-cng/spec/unit/gitlab/cng/kind/cluster_spec.rb
@@ -0,0 +1,137 @@
+# frozen_string_literal: true
+
+RSpec.describe Gitlab::Cng::Kind::Cluster do
+  subject(:cluster) { described_class.new(ci: ci, name: name, docker_hostname: docker_hostname) }
+
+  let(:ci) { false }
+  let(:name) { "gitlab" }
+  let(:docker_hostname) { nil }
+  let(:tmp_config_path) { File.join(Dir.tmpdir, "kind-config.yml") }
+  let(:command_status) { instance_double(Process::Status, success?: true) }
+  let(:clusters) { "kind" }
+
+  before do
+    allow(Gitlab::Cng::Helpers::Spinner).to receive(:spin).and_yield
+    allow(File).to receive(:write).with(tmp_config_path, kind_config_content)
+
+    allow(Open3).to receive(:capture2e).with({}, *%w[
+      kind get clusters
+    ]).and_return([clusters, command_status])
+    allow(Open3).to receive(:capture2e).with({}, *[
+      "kind",
+      "create",
+      "cluster",
+      "--name", name,
+      "--wait", "10s",
+      "--config", tmp_config_path
+    ]).and_return(["", command_status])
+  end
+
+  context "with ci specific setup" do
+    let(:ci) { true }
+    let(:docker_hostname) { "docker" }
+
+    let(:kind_config_content) do
+      <<~YML
+        apiVersion: kind.x-k8s.io/v1alpha4
+        kind: Cluster
+        networking:
+          apiServerAddress: "0.0.0.0"
+        nodes:
+          - role: control-plane
+            kubeadmConfigPatches:
+              - |
+                kind: InitConfiguration
+                nodeRegistration:
+                  kubeletExtraArgs:
+                    node-labels: "ingress-ready=true"
+              - |
+                kind: ClusterConfiguration
+                apiServer:
+                  certSANs:
+                    - "#{docker_hostname}"
+            extraPortMappings:
+                # containerPort below must match the values file:
+                #   nginx-ingress.controller.service.nodePorts.http
+              - containerPort: 32080
+                hostPort: 80
+                listenAddress: "0.0.0.0"
+                # containerPort below must match the values file:
+                #   nginx-ingress.controller.service.nodePorts.gitlab-shell
+              - containerPort: 32022
+                hostPort: 22
+                listenAddress: "0.0.0.0"
+      YML
+    end
+
+    context "without existing cluster" do
+      before do
+        allow(Open3).to receive(:capture2e).with({}, *[
+          "kubectl", "config", "view", "-o", "jsonpath={.clusters[?(@.name == \"kind-#{name}\")].cluster.server}"
+        ]).and_return(["https://127.0.0.1:6443", command_status])
+        allow(Open3).to receive(:capture2e).with({}, *%W[
+          kubectl config set-cluster kind-#{name} --server=https://#{docker_hostname}:6443
+        ]).and_return(["", command_status])
+      end
+
+      it "creates cluster with ci specific configuration" do
+        expect { cluster.create }.to output(/Cluster '#{name}' created/).to_stdout
+      end
+    end
+  end
+
+  context "without ci specific setup" do
+    let(:ci) { false }
+    let(:docker_hostname) { nil }
+
+    let(:kind_config_content) do
+      <<~YML
+        kind: Cluster
+        apiVersion: kind.x-k8s.io/v1alpha4
+        nodes:
+        - role: control-plane
+          kubeadmConfigPatches:
+            - |
+              kind: InitConfiguration
+              nodeRegistration:
+                kubeletExtraArgs:
+                  node-labels: "ingress-ready=true"
+          extraPortMappings:
+            # containerPort below must match the values file:
+            #   nginx-ingress.controller.service.nodePorts.http
+          - containerPort: 32080
+            hostPort: 32080
+            listenAddress: "0.0.0.0"
+            # containerPort below must match the values file:
+            #   nginx-ingress.controller.service.nodePorts.ssh
+          - containerPort: 32022
+            hostPort: 32022
+            listenAddress: "0.0.0.0"
+      YML
+    end
+
+    context "with already created cluster" do
+      let(:clusters) { "kind\n#{name}" }
+
+      it "skips clusters creation" do
+        expect { cluster.create }.to output(/cluster '#{name}' already exists, skipping!/).to_stdout
+      end
+    end
+
+    context "without existing cluster" do
+      it "creates cluster with default config" do
+        expect { cluster.create }.to output(/Cluster '#{name}' created/).to_stdout
+      end
+    end
+
+    context "with command failure" do
+      let(:command_status) { instance_double(Process::Status, success?: false) }
+
+      it "exits on command failures" do
+        expect do
+          expect { cluster.create }.to output.to_stdout
+        end.to raise_error(SystemExit)
+      end
+    end
+  end
+end
diff --git a/scripts/qa/cng_deploy/cng-kind.sh b/scripts/qa/cng_deploy/cng-kind.sh
index ed7b89a6d59e8..b53420631e967 100644
--- a/scripts/qa/cng_deploy/cng-kind.sh
+++ b/scripts/qa/cng_deploy/cng-kind.sh
@@ -185,17 +185,6 @@ EOF
   log "success!"
 }
 
-function setup_cluster() {
-  local kind_config=$1
-
-  log_with_header "Create kind kubernetes cluster"
-  kind create cluster --config "$kind_config"
-  sed -i -E -e "s/localhost|0\.0\.0\.0/docker/g" "$KUBECONFIG"
-
-  log_with_header "Print cluster info"
-  kubectl cluster-info
-}
-
 function deploy() {
   local domain=$1
   local values=$(chart_values $domain)
-- 
GitLab