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