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