From 55a9251ad080c959dbb0d13f81153fda0146b10a Mon Sep 17 00:00:00 2001 From: Andrejs Cunskis <acunskis@gitlab.com> Date: Mon, 6 May 2024 10:50:37 +0000 Subject: [PATCH] Implement doctor command Add base log method Improve output code Update qa lockfile Add tests for doctor command --- gems/gitlab-cng/.rubocop.yml | 7 ++ gems/gitlab-cng/Gemfile.lock | 7 ++ gems/gitlab-cng/gitlab-cng.gemspec | 3 + gems/gitlab-cng/lib/gitlab/cng/cli.rb | 2 + .../lib/gitlab/cng/commands/_command.rb | 11 ++ .../lib/gitlab/cng/commands/doctor.rb | 28 +++++ .../lib/gitlab/cng/commands/version.rb | 4 +- .../lib/gitlab/cng/helpers/output.rb | 88 +++++++++++++ .../lib/gitlab/cng/helpers/shell.rb | 35 ++++++ .../lib/gitlab/cng/helpers/spinner.rb | 117 ++++++++++++++++++ gems/gitlab-cng/spec/integration/cng_spec.rb | 1 + .../integration/lib/gitlab/cng/cli_spec.rb | 21 ++++ .../spec/unit/lib/gitlab/cng/cli_spec.rb | 32 ----- .../lib/gitlab/cng/commands/doctor_spec.rb | 44 +++++++ .../lib/gitlab/cng/helpers/spinner_spec.rb | 99 +++++++++++++++ qa/Gemfile.lock | 7 ++ 16 files changed, 472 insertions(+), 34 deletions(-) create mode 100644 gems/gitlab-cng/lib/gitlab/cng/commands/_command.rb create mode 100644 gems/gitlab-cng/lib/gitlab/cng/commands/doctor.rb create mode 100644 gems/gitlab-cng/lib/gitlab/cng/helpers/output.rb create mode 100644 gems/gitlab-cng/lib/gitlab/cng/helpers/shell.rb create mode 100644 gems/gitlab-cng/lib/gitlab/cng/helpers/spinner.rb delete mode 100644 gems/gitlab-cng/spec/unit/lib/gitlab/cng/cli_spec.rb create mode 100644 gems/gitlab-cng/spec/unit/lib/gitlab/cng/commands/doctor_spec.rb create mode 100644 gems/gitlab-cng/spec/unit/lib/gitlab/cng/helpers/spinner_spec.rb diff --git a/gems/gitlab-cng/.rubocop.yml b/gems/gitlab-cng/.rubocop.yml index cb5f1c9568d04..c070ad0dc70a8 100644 --- a/gems/gitlab-cng/.rubocop.yml +++ b/gems/gitlab-cng/.rubocop.yml @@ -6,3 +6,10 @@ Gemfile/MissingFeatureCategory: Rails/Output: Enabled: false + +Rails/Exit: + Enabled: false + +RSpec/MultipleMemoizedHelpers: + AllowSubject: true + Max: 8 diff --git a/gems/gitlab-cng/Gemfile.lock b/gems/gitlab-cng/Gemfile.lock index 84ab002930f3a..7b6908ca3db64 100644 --- a/gems/gitlab-cng/Gemfile.lock +++ b/gems/gitlab-cng/Gemfile.lock @@ -2,8 +2,11 @@ PATH remote: . specs: gitlab-cng (0.0.1) + rainbow (~> 3.1) require_all (~> 3.0) thor (~> 1.3) + tty-spinner (~> 0.9.3) + tty-which (~> 0.5.0) GEM remote: https://rubygems.org/ @@ -101,6 +104,10 @@ GEM rubocop (~> 1.40) ruby-progressbar (1.13.0) thor (1.3.1) + tty-cursor (0.7.1) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + tty-which (0.5.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (2.5.0) diff --git a/gems/gitlab-cng/gitlab-cng.gemspec b/gems/gitlab-cng/gitlab-cng.gemspec index e1705169f658d..2d382d0e68b89 100644 --- a/gems/gitlab-cng/gitlab-cng.gemspec +++ b/gems/gitlab-cng/gitlab-cng.gemspec @@ -19,8 +19,11 @@ Gem::Specification.new do |spec| spec.executables = "cng" spec.require_paths = ["lib"] + spec.add_dependency "rainbow", "~> 3.1" spec.add_dependency "require_all", "~> 3.0" spec.add_dependency "thor", "~> 1.3" + spec.add_dependency "tty-spinner", "~> 0.9.3" + spec.add_dependency "tty-which", "~> 0.5.0" spec.add_development_dependency "gitlab-styles", "~> 11.0" spec.add_development_dependency "pry", "~> 0.14.2" diff --git a/gems/gitlab-cng/lib/gitlab/cng/cli.rb b/gems/gitlab-cng/lib/gitlab/cng/cli.rb index d887a932ef792..04519d6bbcaa3 100644 --- a/gems/gitlab-cng/lib/gitlab/cng/cli.rb +++ b/gems/gitlab-cng/lib/gitlab/cng/cli.rb @@ -3,6 +3,7 @@ require "thor" require "require_all" +require_rel "helpers/**/*.rb" require_rel "commands/**/*.rb" module Gitlab @@ -46,6 +47,7 @@ def register_commands(klass) end register_commands(Commands::Version) + register_commands(Commands::Doctor) 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 new file mode 100644 index 0000000000000..fd0f449e6efd7 --- /dev/null +++ b/gems/gitlab-cng/lib/gitlab/cng/commands/_command.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Gitlab + module Cng + module Commands + class Command < Thor + include Helpers::Output + 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 new file mode 100644 index 0000000000000..4262a25e4f9fa --- /dev/null +++ b/gems/gitlab-cng/lib/gitlab/cng/commands/doctor.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "tty-which" + +module Gitlab + module Cng + module Commands + 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 + 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) + end + rescue StandardError + tool + end + return log_success "All system dependencies are present", bright: true if missing_tools.empty? + + exit_with_error "The following system dependencies are missing: #{missing_tools.join(', ')}" + end + end + end + end +end diff --git a/gems/gitlab-cng/lib/gitlab/cng/commands/version.rb b/gems/gitlab-cng/lib/gitlab/cng/commands/version.rb index b25c6884db2bb..3e1643236e1b9 100644 --- a/gems/gitlab-cng/lib/gitlab/cng/commands/version.rb +++ b/gems/gitlab-cng/lib/gitlab/cng/commands/version.rb @@ -3,8 +3,8 @@ module Gitlab module Cng module Commands - class Version < Thor - desc 'version', 'Prints cng orchestrator version' + class Version < Command + desc "version", "Prints 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 new file mode 100644 index 0000000000000..b3ba6d13a290f --- /dev/null +++ b/gems/gitlab-cng/lib/gitlab/cng/helpers/output.rb @@ -0,0 +1,88 @@ +# 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 new file mode 100644 index 0000000000000..40483007439cc --- /dev/null +++ b/gems/gitlab-cng/lib/gitlab/cng/helpers/shell.rb @@ -0,0 +1,35 @@ +# 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/helpers/spinner.rb b/gems/gitlab-cng/lib/gitlab/cng/helpers/spinner.rb new file mode 100644 index 0000000000000..38a42a6e0b50c --- /dev/null +++ b/gems/gitlab-cng/lib/gitlab/cng/helpers/spinner.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require "tty-spinner" + +module Gitlab + module Cng + module Helpers + # Spinner helper class + # + class Spinner + include Output + + def initialize(spinner_message, raise_on_error: true) + @spinner_message = spinner_message + @raise_on_error = raise_on_error + end + + # Run code block inside spinner + # + # @param [String] spinner_message + # @param [String] done_message + # @param [Boolean] exit_on_error + # @param [Proc] &block + # @return [Object] + def self.spin(spinner_message, done_message: "done", raise_on_error: true, &block) + new(spinner_message, raise_on_error: raise_on_error).spin(done_message, &block) + end + + # Run code block inside spinner + # + # @param [String] done_message + # @return [Object] + def spin(done_message = "done") + spinner.auto_spin + result = yield + spinner_success(done_message) + + result + rescue StandardError => e + spinner_error(e) + return result unless raise_on_error + + raise(e) + end + + private + + attr_reader :spinner_message, :raise_on_error + + # Error message color + # + # @return [Symbol] + def error_color + @error_color ||= raise_on_error ? :red : :yellow + end + + # Success mark + # + # @return [String] + def success_mark + @success_mark ||= colorize(TTY::Spinner::TICK, :green) + end + + # Error mark + # + # @return [String] + def error_mark + colorize(TTY::Spinner::CROSS, error_color) + end + + # Spinner instance + # + # @return [TTY::Spinner] + def spinner + @spinner ||= TTY::Spinner.new( + "[:spinner] #{spinner_message} ...", + format: :dots, + success_mark: success_mark, + error_mark: error_mark + ) + end + + # Check tty + # + # @return [Boolean] + def tty? + spinner.send(:tty?) # rubocop:disable GitlabSecurity/PublicSend -- method is public on master branch but not released yet + end + + # Return spinner success + # + # @param [String] done_message + # @return [void] + def spinner_success(done_message) + return spinner.success(done_message) if tty? + + spinner.stop + puts("[#{success_mark}] #{spinner_message} ... #{done_message}") + end + + # Return spinner error + # + # @param [StandardError] error + # @return [void] + def spinner_error(error) + message = ["failed", error.message] + + colored_message = colorize(message.compact.join("\n"), error_color) + return spinner.error(colored_message) if tty? + + spinner.stop + puts("[#{error_mark}] #{spinner_message} ... #{colored_message}") + end + end + end + end +end diff --git a/gems/gitlab-cng/spec/integration/cng_spec.rb b/gems/gitlab-cng/spec/integration/cng_spec.rb index eaa512e909e43..5a12f84437528 100644 --- a/gems/gitlab-cng/spec/integration/cng_spec.rb +++ b/gems/gitlab-cng/spec/integration/cng_spec.rb @@ -4,6 +4,7 @@ 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 USAGE 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 index 25e1bf0e70f51..04f5ca5953f41 100644 --- a/gems/gitlab-cng/spec/integration/lib/gitlab/cng/cli_spec.rb +++ b/gems/gitlab-cng/spec/integration/lib/gitlab/cng/cli_spec.rb @@ -12,4 +12,25 @@ 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/unit/lib/gitlab/cng/cli_spec.rb b/gems/gitlab-cng/spec/unit/lib/gitlab/cng/cli_spec.rb deleted file mode 100644 index 4b1941043f1b0..0000000000000 --- a/gems/gitlab-cng/spec/unit/lib/gitlab/cng/cli_spec.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Gitlab::Cng::CLI do - let(:cli_klass) { Class.new(described_class) } - let(:instance) { cli_klass.new } - - let(:command_klass) do - Class.new(Thor) do - desc "command", "description" - def command; end - end - end - - before do - cli_klass.register_commands(command_klass) - - allow(instance).to receive(:invoke) - end - - it "registers command", :aggregate_failures do - instance.command - - expect(instance).to have_received(:invoke).with(command_klass, "command") - expect(cli_klass.commands["command"].to_h).to include({ - description: "description", - long_description: nil, - name: "command", - options: {}, - usage: "command" - }) - end -end diff --git a/gems/gitlab-cng/spec/unit/lib/gitlab/cng/commands/doctor_spec.rb b/gems/gitlab-cng/spec/unit/lib/gitlab/cng/commands/doctor_spec.rb new file mode 100644 index 0000000000000..f017f25a5a1f4 --- /dev/null +++ b/gems/gitlab-cng/spec/unit/lib/gitlab/cng/commands/doctor_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +RSpec.describe Gitlab::Cng::Commands::Doctor do + subject(:command) { described_class.new } + + let(:spinner) { instance_double(Gitlab::Cng::Helpers::Spinner) } + let(:command_name) { "doctor" } + let(:tools_present) { true } + let(:tools) { %w[docker kind kubectl helm] } + + 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({ + description: "Validate presence of all required system dependencies", + long_description: nil, + name: command_name, + options: {}, + usage: command_name + }) + end + + 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/) + end + end + + context "with missing tools" do + 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(', ')}/) + end + end +end diff --git a/gems/gitlab-cng/spec/unit/lib/gitlab/cng/helpers/spinner_spec.rb b/gems/gitlab-cng/spec/unit/lib/gitlab/cng/helpers/spinner_spec.rb new file mode 100644 index 0000000000000..91133769620e5 --- /dev/null +++ b/gems/gitlab-cng/spec/unit/lib/gitlab/cng/helpers/spinner_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +RSpec.describe Gitlab::Cng::Helpers::Spinner, :aggregate_failures do + subject(:spin) do + described_class.spin(spinner_message, **args) { success ? "success" : raise("error") } + end + + let(:spinner) { instance_double(TTY::Spinner, auto_spin: nil, stop: nil, success: nil, error: nil, tty?: tty) } + + let(:spinner_message) { "spinner message" } + let(:tty) { true } + let(:success) { true } + let(:success_mark) { Rainbow.new.wrap(TTY::Spinner::TICK).color(:green) } + + before do + allow(TTY::Spinner).to receive(:new) { spinner } + end + + context "without errors" do + let(:args) { { done_message: "custom done" } } + + it "starts spinner and returns result of yielded block" do + result = spin + + expect(spinner).to have_received(:auto_spin) + expect(spinner).to have_received(:success).with("custom done") + expect(result).to eq("success") + end + + context "without tty" do + let(:tty) { false } + + it "prints plain success message with default done message" do + expect { spin }.to output("[#{Rainbow.new.wrap(success_mark)}] #{spinner_message} ... custom done\n").to_stdout + expect(spinner).to have_received(:stop) + end + end + end + + context "with errors" do + let(:success) { false } + let(:error_message) { "failed\nerror" } + + context "with raise_on_error: true" do + let(:args) { { raise_on_error: true } } + let(:error_mark) { Rainbow.new.wrap(TTY::Spinner::CROSS).color(:red) } + + it "raises error and prints red error message" do + expect { spin }.to raise_error("error") + expect(TTY::Spinner).to have_received(:new).with( + "[:spinner] #{spinner_message} ...", + format: :dots, + success_mark: success_mark, + error_mark: error_mark + ) + expect(spinner).to have_received(:error).with(Rainbow.new.wrap(error_message).color(:red)) + end + + context "without tty" do + let(:tty) { false } + + it "raises error and prints plain red error message" do + output = "[#{error_mark}] #{spinner_message} ... #{Rainbow.new.wrap(error_message).color(:red)}\n" + + expect { expect { spin }.to raise_error("error") }.to output(output).to_stdout + expect(spinner).to have_received(:stop) + end + end + end + + context "with exit_on_error: false" do + let(:args) { { raise_on_error: false } } + let(:error_mark) { Rainbow.new.wrap(TTY::Spinner::CROSS).color(:yellow) } + + it "does not raise error and prints warning in yellow" do + spin + + expect(TTY::Spinner).to have_received(:new).with( + "[:spinner] #{spinner_message} ...", + format: :dots, + success_mark: success_mark, + error_mark: error_mark + ) + expect(spinner).to have_received(:error).with(Rainbow.new.wrap(error_message).color(:yellow)) + end + + context "without tty" do + let(:tty) { false } + + it "does not raise error and prints plain warning in yellow" do + output = "[#{error_mark}] #{spinner_message} ... #{Rainbow.new.wrap(error_message).color(:yellow)}\n" + + expect { spin }.to output(output).to_stdout + expect(spinner).to have_received(:stop) + end + end + end + end +end diff --git a/qa/Gemfile.lock b/qa/Gemfile.lock index 5556cbdbf3a41..2c906b88c2701 100644 --- a/qa/Gemfile.lock +++ b/qa/Gemfile.lock @@ -2,8 +2,11 @@ PATH remote: ../gems/gitlab-cng specs: gitlab-cng (0.0.1) + rainbow (~> 3.1) require_all (~> 3.0) thor (~> 1.3) + tty-spinner (~> 0.9.3) + tty-which (~> 0.5.0) PATH remote: ../gems/gitlab-utils @@ -332,6 +335,10 @@ GEM tins (1.32.1) sync trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + tty-which (0.5.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) uber (0.1.0) -- GitLab