diff options
Diffstat (limited to 'qa/qa/tools')
-rw-r--r-- | qa/qa/tools/ci/ff_changes.rb | 66 | ||||
-rw-r--r-- | qa/qa/tools/ci/helpers.rb | 50 | ||||
-rw-r--r-- | qa/qa/tools/ci/non_empty_suites.rb | 98 | ||||
-rw-r--r-- | qa/qa/tools/ci/qa_changes.rb | 116 | ||||
-rw-r--r-- | qa/qa/tools/ci/test_results.rb | 78 | ||||
-rw-r--r-- | qa/qa/tools/revoke_all_personal_access_tokens.rb | 44 | ||||
-rw-r--r-- | qa/qa/tools/revoke_user_personal_access_tokens.rb | 94 | ||||
-rw-r--r-- | qa/qa/tools/test_resources_handler.rb | 1 |
8 files changed, 502 insertions, 45 deletions
diff --git a/qa/qa/tools/ci/ff_changes.rb b/qa/qa/tools/ci/ff_changes.rb new file mode 100644 index 00000000000..67e52633833 --- /dev/null +++ b/qa/qa/tools/ci/ff_changes.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "yaml" + +module QA + module Tools + module Ci + class FfChanges + include Helpers + + def initialize(mr_diff) + @mr_diff = mr_diff + end + + # Return list of feature flags changed in mr with inverse or deleted state + # + # @return [String] + def fetch + logger.info("Detecting feature flag changes") + ff_toggles = mr_diff.map do |change| + ff_yaml = ff_yaml_for_file(change) + next unless ff_yaml + + state = if ff_yaml[:deleted] + "deleted" + else + ff_yaml[:default_enabled] ? 'disabled' : 'enabled' + end + + logger.info(" found changes in feature flag '#{ff_yaml[:name]}'") + "#{ff_yaml[:name]}=#{state}" + end.compact + + if ff_toggles.empty? + logger.info(" no changes to feature flags detected, skipping!") + return + end + + logger.info(" constructed feature flag states: '#{ff_toggles}'") + ff_toggles.join(",") + end + + private + + attr_reader :mr_diff + + # Loads the YAML feature flag definition based on changed files in merge requests. + # The definition is loaded from the definition file itself. + # + # @param [Hash] change mr file change + # @return [Hash] a hash containing the YAML data for the feature flag definition + def ff_yaml_for_file(change) + return unless change[:path] =~ %r{/feature_flags/(development|ops)/.*\.yml} + if change[:deleted_file] + return { name: change[:path].split("/").last.gsub(/\.(yml|yaml)/, ""), deleted: true } + end + + YAML.safe_load( + File.read(File.expand_path("../#{change[:path]}", QA::Runtime::Path.qa_root)), + symbolize_names: true + ) + end + end + end + end +end diff --git a/qa/qa/tools/ci/helpers.rb b/qa/qa/tools/ci/helpers.rb new file mode 100644 index 00000000000..55bb123de20 --- /dev/null +++ b/qa/qa/tools/ci/helpers.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module QA + module Tools + module Ci + # Helpers for CI related tasks + # + module Helpers + include Support::API + + # Logger instance + # + # @return [Logger] + def logger + @logger ||= Gitlab::QA::TestLogger.logger( + level: Gitlab::QA::Runtime::Env.log_level, + source: "CI Tools" + ) + end + + # Api get request + # + # @param [String] path + # @param [Hash] args + # @return [Hash, Array] + def api_get(path, **args) + response = get("#{api_url}/#{path}", { headers: { "PRIVATE-TOKEN" => access_token }, **args }) + response = response.follow_redirection if response.code == Support::API::HTTP_STATUS_PERMANENT_REDIRECT + raise "Request failed: '#{response.body}'" unless response.code == Support::API::HTTP_STATUS_OK + + args[:raw_response] ? response : parse_body(response) + end + + # Gitlab api url + # + # @return [String] + def api_url + @api_url ||= ENV.fetch('CI_API_V4_URL', 'https://gitlab.com/api/v4') + end + + # Api access token + # + # @return [String] + def access_token + @access_token ||= ENV.fetch('QA_GITLAB_CI_TOKEN') { raise('Variable QA_GITLAB_CI_TOKEN missing') } + end + end + end + end +end diff --git a/qa/qa/tools/ci/non_empty_suites.rb b/qa/qa/tools/ci/non_empty_suites.rb new file mode 100644 index 00000000000..687c11a3e62 --- /dev/null +++ b/qa/qa/tools/ci/non_empty_suites.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'open3' + +module QA + module Tools + module Ci + # Run count commands for scenarios and detect which ones have more than 0 examples to run + # + class NonEmptySuites + include Helpers + + # rubocop:disable Layout/LineLength + SCENARIOS = [ + { klass: "Test::Instance::All" }, + { klass: "Test::Instance::Smoke" }, + { klass: "Test::Instance::Reliable" }, + { klass: "Test::Instance::ReviewBlocking" }, + { klass: "Test::Instance::ReviewNonBlocking" }, + { klass: "Test::Instance::CloudActivation" }, + { klass: "Test::Instance::Integrations" }, + { klass: "Test::Instance::Jira" }, + { klass: "Test::Instance::LargeSetup" }, + { klass: "Test::Instance::Metrics" }, + { klass: "Test::Instance::ObjectStorage" }, + { klass: "Test::Instance::Packages" }, + { klass: "Test::Instance::RepositoryStorage" }, + { klass: "Test::Integration::ServicePingDisabled" }, + { klass: "Test::Integration::LDAPNoTLS" }, + { klass: "Test::Integration::LDAPTLS" }, + { klass: "Test::Integration::LDAPNoServer" }, + { klass: "Test::Integration::InstanceSAML" }, + { klass: "Test::Integration::RegistryWithCDN" }, + { klass: "Test::Integration::RegistryTLS" }, + { klass: "Test::Integration::Registry" }, + { klass: "Test::Integration::SMTP" }, + { klass: "QA::EE::Scenario::Test::Integration::Elasticsearch" }, + { klass: "QA::EE::Scenario::Test::Integration::GroupSAML" }, + { + klass: "QA::EE::Scenario::Test::Geo", + args: "--primary-address http://dummy1.test --primary-name gitlab-primary --secondary-address http://dummy2.test --secondary-name gitlab-secondary --without-setup" + }, + { + klass: "Test::Integration::Mattermost", + args: "--mattermost-address http://mattermost.test" + } + ].freeze + # rubocop:enable Layout/LineLength + + def initialize(qa_tests) + @qa_tests = qa_tests + end + + # Run counts and return runnable scenario list + # + # @return [String] + def fetch + logger.info("Checking for runnable suites") + scenarios = SCENARIOS.each_with_object([]) do |scenario, runnable_scenarios| + logger.info(" fetching runnable specs for '#{scenario[:klass]}'") + + out, err, status = run_command(**scenario) + + unless status.success? + logger.error(" example count failed!\n#{err}") + next + end + + count = out.split("\n").last.to_i + logger.info(" found #{count} examples to run") + runnable_scenarios << scenario[:klass] if count > 0 + end + + scenarios.join(",") + end + + private + + attr_reader :qa_tests + + # Run scenario count command + # + # @param [String] klass + # @param [String] args + # @return [String] + def run_command(klass:, args: nil) + cmd = ["bundle exec bin/qa"] + cmd << klass + cmd << "--count-examples-only --address http://dummy1.test" + cmd << args if args + cmd << "-- #{qa_tests}" unless qa_tests.blank? + + Open3.capture3(cmd.join(" ")) + end + end + end + end +end diff --git a/qa/qa/tools/ci/qa_changes.rb b/qa/qa/tools/ci/qa_changes.rb new file mode 100644 index 00000000000..75274961efe --- /dev/null +++ b/qa/qa/tools/ci/qa_changes.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require "pathname" + +module QA + module Tools + module Ci + # Determine specific qa specs or paths to execute based on changes + class QaChanges + include Helpers + + QA_PATTERN = %r{^qa/}.freeze + SPEC_PATTERN = %r{^qa/qa/specs/features/}.freeze + + def initialize(mr_diff, mr_labels) + @mr_diff = mr_diff + @mr_labels = mr_labels + end + + # Specific specs to run + # + # @return [String] + def qa_tests + return if mr_diff.empty? + # make paths relative to qa directory + return changed_files&.map { |path| path.delete_prefix("qa/") }&.join(" ") if only_spec_changes? + return qa_spec_directories_for_devops_stage&.join(" ") if non_qa_changes? && mr_labels.any? + end + + # Qa framework changes + # + # @return [Boolean] + def framework_changes? + return false if mr_diff.empty? + return false if only_spec_changes? + + changed_files + # TODO: expand pattern to other non spec paths that shouldn't trigger full suite + .select { |file_path| file_path.match?(QA_PATTERN) && !file_path.match?(SPEC_PATTERN) } + .any? + end + + def quarantine_changes? + return false if mr_diff.empty? + return false if mr_diff.any? { |change| change[:new_file] || change[:deleted_file] } + + files_count = 0 + specs_count = 0 + quarantine_specs_count = 0 + + mr_diff.each do |change| + path = change[:path] + next if File.directory?(File.expand_path("../#{path}", QA::Runtime::Path.qa_root)) + + files_count += 1 + next unless path.match?(SPEC_PATTERN) && path.end_with?('_spec.rb') + + specs_count += 1 + quarantine_specs_count += 1 if change[:diff].match?(/^\+.*,? quarantine:/) + end + + return false if specs_count == 0 + return true if quarantine_specs_count == specs_count && quarantine_specs_count == files_count + + false + end + + private + + # @return [Array] + attr_reader :mr_diff + + # @return [Array] + attr_reader :mr_labels + + # Are the changed files only qa specs? + # + # @return [Boolean] whether the changes files are only qa specs + def only_spec_changes? + changed_files.all? { |file_path| file_path =~ SPEC_PATTERN } + end + + # Are the changed files only outside the qa directory? + # + # @return [Boolean] whether the changes files are outside of qa directory + def non_qa_changes? + changed_files.none? { |file_path| file_path =~ QA_PATTERN } + end + + # Extract devops stage from MR labels + # + # @return [String] a devops stage + def devops_stage_from_mr_labels + mr_labels.find { |label| label =~ /^devops::/ }&.delete_prefix('devops::') + end + + # Get qa spec directories for devops stage + # + # @return [Array] qa spec directories + def qa_spec_directories_for_devops_stage + devops_stage = devops_stage_from_mr_labels + return unless devops_stage + + Dir.glob("qa/specs/**/*/").select { |dir| dir =~ %r{\d+_#{devops_stage}/$} } + end + + # Change files in merge request + # + # @return [Array<String>] + def changed_files + @changed_files ||= mr_diff.map { |change| change[:path] } # rubocop:disable Rails/Pluck + end + end + end + end +end diff --git a/qa/qa/tools/ci/test_results.rb b/qa/qa/tools/ci/test_results.rb new file mode 100644 index 00000000000..635b69f6ca0 --- /dev/null +++ b/qa/qa/tools/ci/test_results.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module QA + module Tools + module Ci + class TestResults + include Helpers + + def initialize(pipeline_name, test_report_job_name, report_path) + @pipeline_name = pipeline_name + @test_report_job_name = test_report_job_name + @report_path = report_path + end + + # Get test report artifacts from downstream pipeline + # + # @param [String] pipeline_name + # @param [String] test_report_job_name + # @param [String] report_path + # @return [void] + def self.get(pipeline_name, test_report_job_name, report_path) + new(pipeline_name, test_report_job_name, report_path).download_test_results + end + + # Download test results from child pipeline + # + # @return [void] + def download_test_results + logger.info("Fetching test results for '#{pipeline_name}'") + + logger.debug(" fetching pipeline id of '#{pipeline_name}' child pipeline") + downstream_pipeline_id = api_get("#{pipelines_url(pipeline_id)}/bridges") + .find { |bridge| bridge[:name] == pipeline_name } + &.dig(:downstream_pipeline, :id) + return logger.error("Child pipeline '#{pipeline_name}' not found!") unless downstream_pipeline_id + + logger.debug(" fetching job id of test report job") + job_id = api_get("#{pipelines_url(downstream_pipeline_id)}/jobs") + .find { |job| job[:name] == test_report_job_name } + &.fetch(:id) + return logger.error("Test report job '#{test_report_job_name}' not found!") unless job_id + + logger.debug(" fetching test results artifact archive") + response = api_get("/projects/#{project_id}/jobs/#{job_id}/artifacts", raw_response: true) + + logger.info("Extracting test result archive") + system("unzip", "-o", "-d", report_path, response.file.path) + end + + private + + attr_reader :pipeline_name, :test_report_job_name, :report_path + + # Base get pipeline url + # + # @param [Integer] id + # @return [String] + def pipelines_url(id) + "/projects/#{project_id}/pipelines/#{id}" + end + + # Current pipeline id + # + # @return [String] + def pipeline_id + ENV["CI_PIPELINE_ID"] + end + + # Current project id + # + # @return [String] + def project_id + ENV["CI_PROJECT_ID"] + end + end + end + end +end diff --git a/qa/qa/tools/revoke_all_personal_access_tokens.rb b/qa/qa/tools/revoke_all_personal_access_tokens.rb deleted file mode 100644 index b4fa02a36d4..00000000000 --- a/qa/qa/tools/revoke_all_personal_access_tokens.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -require 'net/protocol' - -# This script revokes all personal access tokens with the name of 'api-test-token' on the host specified by GITLAB_ADDRESS -# Required environment variables: GITLAB_USERNAME, GITLAB_PASSWORD and GITLAB_ADDRESS -# Run `rake revoke_personal_access_tokens` - -module QA - module Tools - class RevokeAllPersonalAccessTokens - def run - do_run - rescue Net::ReadTimeout - $stdout.puts 'Net::ReadTimeout during run. Trying again' - run - end - - private - - def do_run - raise ArgumentError, "Please provide GITLAB_USERNAME" unless ENV['GITLAB_USERNAME'] - raise ArgumentError, "Please provide GITLAB_PASSWORD" unless ENV['GITLAB_PASSWORD'] - raise ArgumentError, "Please provide GITLAB_ADDRESS" unless ENV['GITLAB_ADDRESS'] - - $stdout.puts 'Running...' - - Runtime::Browser.visit(ENV['GITLAB_ADDRESS'], Page::Main::Login) - Page::Main::Login.perform(&:sign_in_using_credentials) - Page::Main::Menu.perform(&:click_edit_profile_link) - Page::Profile::Menu.perform(&:click_access_tokens) - - token_name = 'api-test-token' - - Page::Profile::PersonalAccessTokens.perform do |tokens_page| - while tokens_page.has_token_row_for_name?(token_name) - tokens_page.revoke_first_token_with_name(token_name) - print "\e[32m.\e[0m" - end - end - end - end - end -end diff --git a/qa/qa/tools/revoke_user_personal_access_tokens.rb b/qa/qa/tools/revoke_user_personal_access_tokens.rb new file mode 100644 index 00000000000..2854241f420 --- /dev/null +++ b/qa/qa/tools/revoke_user_personal_access_tokens.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +# This script revokes all active personal access tokens owned by a given USER_ID +# up to a given date (Date.today - 1 by default) +# Required environment variables: USER_ID, GITLAB_QA_ACCESS_TOKEN and GITLAB_ADDRESS +# Run `rake revoke_user_pats` + +module QA + module Tools + class RevokeUserPersonalAccessTokens + include Support::API + + def initialize(revoke_before: (Date.today - 1).to_s, dry_run: false) + raise ArgumentError, "Please provide GITLAB_ADDRESS environment variable" unless ENV['GITLAB_ADDRESS'] + + unless ENV['GITLAB_QA_ACCESS_TOKEN'] + raise ArgumentError, "Please provide GITLAB_QA_ACCESS_TOKEN environment variable" + end + + raise ArgumentError, "Please provide USER_ID environment variable" unless ENV['USER_ID'] + + @revoke_before = Date.parse(revoke_before) + @dry_run = dry_run + @api_client = Runtime::API::Client.new(ENV['GITLAB_ADDRESS'], + personal_access_token: ENV['GITLAB_QA_ACCESS_TOKEN']) + end + + def run + $stdout.puts 'Running...' + + tokens_head_response = head Runtime::API::Request.new(@api_client, + "/personal_access_tokens?user_id=#{ENV['USER_ID']}", + per_page: "100").url + + total_token_pages = tokens_head_response.headers[:x_total_pages] + total_tokens = tokens_head_response.headers[:x_total] + + $stdout.puts "Total tokens: #{total_tokens}. Total pages: #{total_token_pages}" + + tokens = fetch_tokens + + revoke_tokens(tokens, @api_client, @dry_run) unless tokens.empty? + $stdout.puts "\nDone" + end + + private + + def fetch_tokens + fetched_tokens = [] + + page_no = 1 + + while page_no > 0 + tokens_response = get Runtime::API::Request.new(@api_client, + "/personal_access_tokens?user_id=#{ENV['USER_ID']}", + page: page_no.to_s, per_page: "100").url + + fetched_tokens + .concat(JSON.parse(tokens_response.body) + .select { |token| Date.parse(token["created_at"]) < @revoke_before && token['active'] } + .map { |token| { id: token["id"], name: token["name"], created_at: token["created_at"] } } + ) + + page_no = tokens_response.headers[:x_next_page].to_i + end + + fetched_tokens + end + + def revoke_tokens(tokens, api_client, dry_run = false) + if dry_run + $stdout.puts "Following #{tokens.count} tokens would be revoked:" + else + $stdout.puts "Revoking #{tokens.count} tokens..." + end + + tokens.each do |token| + if dry_run + $stdout.puts "Token name: #{token[:name]}, id: #{token[:id]}, created at: #{token[:created_at]}" + else + request_url = Runtime::API::Request.new(api_client, "/personal_access_tokens/#{token[:id]}").url + + $stdout.puts "\nRevoking token with name: #{token[:name]}, " \ + "id: #{token[:id]}, created at: #{token[:created_at]}" + + delete_response = delete(request_url) + dot_or_f = delete_response.code == 204 ? "\e[32m.\e[0m" : "\e[31mF - #{delete_response}\e[0m" + print dot_or_f + end + end + end + end + end +end diff --git a/qa/qa/tools/test_resources_handler.rb b/qa/qa/tools/test_resources_handler.rb index 0030a47ed55..60c6dbfc16c 100644 --- a/qa/qa/tools/test_resources_handler.rb +++ b/qa/qa/tools/test_resources_handler.rb @@ -27,7 +27,6 @@ module QA include Support::API IGNORED_RESOURCES = [ - 'QA::Resource::PersonalAccessToken', 'QA::Resource::CiVariable', 'QA::Resource::Repository::Commit', 'QA::EE::Resource::GroupIteration', |