lib/gitlab/qa/component/specs.rb



# frozen_string_literal: true

# rubocop:disable Metrics/AbcSize

require 'securerandom'
require 'active_support/core_ext/array/grouping'

module Gitlab
  module QA
    module Component
      ##
      # This class represents GitLab QA specs image that is implemented in
      # the `qa/` directory located in GitLab CE / EE repositories.
      #
      class Specs < Scenario::Template
        LAST_RUN_FILE = "examples.txt"

        attr_accessor :suite,
          :release,
          :network,
          :args,
          :volumes,
          :env,
          :runner_network,
          :hostname,
          :additional_hosts,
          :retry_failed_specs,
          :infer_qa_image_from_release

        def initialize
          @docker = Docker::Engine.new(stream_output: true) # stream test output directly instead of through logger
          @env = {}
          @volumes = {}
          @additional_hosts = []
          @volumes = { '/var/run/docker.sock' => '/var/run/docker.sock' }
          @retry_failed_specs = Runtime::Env.retry_failed_specs?

          include_optional_volumes(
            Runtime::Env.qa_rspec_report_path => 'rspec',
            Runtime::Env.qa_knapsack_report_path => 'knapsack'
          )
        end

        def perform
          if Runtime::Env.use_selenoid?
            Component::Selenoid.perform do |selenoid|
              selenoid.network = network
              selenoid.instance do
                internal_perform
              end
            end
          else
            internal_perform
          end
        end

        def internal_perform
          return Runtime::Logger.info("Skipping tests.") if skip_tests?

          raise ArgumentError unless [suite, release].all?

          docker_pull_qa_image_if_needed

          Runtime::Logger.info("Running test suite `#{suite}` for #{release.project_name}")

          name = "#{release.project_name}-qa-#{SecureRandom.hex(4)}"
          run_specs(name)
        rescue Support::ShellCommand::StatusError => e
          raise e unless retry_failed_specs

          Runtime::Logger.warn("Initial test run failed, attempting to retry failed specs in new process!")
          results_file = File.join(host_artifacts_dir(name), LAST_RUN_FILE)
          raise e unless valid_last_run_file?(results_file)

          Runtime::Logger.debug("Found initial run results file '#{results_file}', retrying failed specs!")
          run_specs(name, retry_process: true, initial_run_results_host_path: results_file)
        end

        private

        # Ful path to tmp dir inside container
        #
        # @return [String]
        def tmp_dir
          @tmp_dir ||= File.join(Docker::Volumes::QA_CONTAINER_WORKDIR, 'tmp')
        end

        def feature_flag_sets
          return @feature_flag_sets if defined?(@feature_flag_sets)

          feature_flag_sets = []
          # When `args` includes:
          #   `[..., "--disable-feature", "a", "--enable-feature", "b", "--set-feature-flags", "c=enable", ...]`
          # `feature_flag_sets` will be set to:
          #   `[["--disable-feature", "a"], ["--enable-feature", "b"], ["--set-feature-flags", "c=enable"]]`
          # This will result in tests running three times, once with each feature flag option.
          while (index = args&.index { |x| x =~ /--.*-feature/ })
            feature_flag_sets << args.slice!(index, 2)
          end
          # When `args` do not have any feature flag options, we add [] so that test is run exactly once.
          feature_flag_sets << [] unless feature_flag_sets.any?

          @feature_flag_sets = feature_flag_sets
        end

        def run_specs(name, retry_process: false, initial_run_results_host_path: nil)
          container_name = retry_process ? "#{name}-retry" : name

          env_vars = if retry_process
                       Runtime::Env.variables.merge({
                         **env,
                         "QA_RSPEC_RETRIED" => "true",
                         "NO_KNAPSACK" => "true"
                       })
                     else
                       Runtime::Env.variables.merge(env)
                     end

          env_vars["RSPEC_LAST_RUN_RESULTS_FILE"] = last_run_results_file

          run_volumes = volumes.to_h.merge({ host_artifacts_dir(container_name) => tmp_dir })
          run_volumes[initial_run_results_host_path] = last_run_results_file if retry_process

          feature_flag_sets.each do |feature_flag_set|
            @docker.run(
              image: qa_image,
              args: [suite, *args_with_flags(feature_flag_set, retry_process: retry_process)],
              mask_secrets: Runtime::Env.variables_to_mask
            ) do |command|
              command << "-t --rm --net=#{network || 'bridge'}"

              unless hostname.nil?
                command << "--hostname #{hostname}"
                command.env('QA_HOSTNAME', hostname)
              end

              if Runtime::Env.docker_add_hosts.present? || additional_hosts.present?
                hosts = Runtime::Env.docker_add_hosts.concat(additional_hosts).map { |host| "--add-host=#{host} " }.join
                command << hosts # override /etc/hosts in docker container when test runs
              end

              env_vars.each { |key, value| command.env(key, value) }
              run_volumes.each { |to, from| command.volume(to, from) }

              command.name(container_name)
            end
          end
        end

        def docker_pull_qa_image_if_needed
          @docker.login(**release.login_params) if release.login_params

          @docker.pull(image: qa_image) unless Runtime::Env.skip_pull?
        end

        def args_with_flags(feature_flag_set, retry_process: false)
          return args if feature_flag_set.empty? && !retry_process

          run_args = if !retry_process
                       args.dup
                     elsif args.include?("--")
                       qa_args, rspec_args = args.split("--")
                       [*qa_args, "--"] + args_without_spec_arguments(rspec_args).push("--only-failures")
                     else
                       args.dup.push("--", "--only-failures")
                     end

          return run_args if feature_flag_set.empty?

          Runtime::Logger.info("Running with feature flag: #{feature_flag_set.join(' ')}")
          run_args.insert(1, *feature_flag_set)
        end

        # Remove particular spec argument like specific spec/folder or specific tags
        #
        # @param [Array] rspec_args
        # @return [Array]
        def args_without_spec_arguments(rspec_args)
          arg_pairs = rspec_args.flatten.each_with_object([]) do |arg, new_args|
            next new_args.push([arg]) if new_args.last.nil? || arg.start_with?("--") || new_args.last.size == 2

            new_args.last.push(arg)
          end

          arg_pairs.reject { |pair| pair.first == "--tag" || !pair.first.start_with?("--") }.flatten
        end

        def skip_tests?
          Runtime::Scenario.attributes.include?(:run_tests) && !Runtime::Scenario.run_tests
        end

        def qa_image
          infered_qa_image = "#{release.qa_image}:#{release.qa_tag}"
          return infered_qa_image if infer_qa_image_from_release || !Runtime::Scenario.attributes.include?(:qa_image)

          Runtime::Scenario.qa_image
        end

        # Adds volumes to the volumes hash if the relevant host paths exist
        #
        # @param [Array] *args volume host_path => container_path pairs
        # @return [void]
        def include_optional_volumes(args)
          args.each do |host_path, container_path|
            host_path.present? && volumes[host_path] = File.join(Docker::Volumes::QA_CONTAINER_WORKDIR, container_path)
          end
        end

        # Full path to host artifacts dir
        #
        # @param [String] name
        # @return [String]
        def host_artifacts_dir(name)
          File.join(Runtime::Env.host_artifacts_dir, name)
        end

        # Path to save or read run results file within container
        #
        # @return [String]
        def last_run_results_file
          File.join(tmp_dir, LAST_RUN_FILE)
        end

        # Validate rspec last run file
        #
        # @param [String] results_file
        # @return [Boolean]
        def valid_last_run_file?(results_file)
          unless File.exist?(results_file)
            Runtime::Logger.error("Failed to find initial run results file '#{results_file}', aborting retry!")
            return false
          end

          unless File.read(results_file).include?("failed")
            Runtime::Logger.error(
              "Initial run results file '#{results_file}' does not contain any failed tests, aborting retry!"
            )

            return false
          end

          true
        end
      end
    end
  end
end
# rubocop:enable Metrics/AbcSize