From c8e8bb000c2a7add814f8123a64786a4e829c215 Mon Sep 17 00:00:00 2001
From: Harsha Muralidhar <hmuralidhar@gitlab.com>
Date: Mon, 17 Jun 2024 16:28:16 +0000
Subject: [PATCH] Introduce coverband gem for mapping source code paths

---
 .gitlab/ci/qa-common/main.gitlab-ci.yml       |  11 ++
 .gitlab/ci/rules.gitlab-ci.yml                |  10 +-
 .gitlab/ci/test-on-gdk/main.gitlab-ci.yml     |   8 +
 Gemfile                                       |   3 +
 Gemfile.checksum                              |   1 +
 Gemfile.lock                                  |   3 +
 config/initializers/coverband.rb              |  19 +++
 lib/api/api.rb                                |   1 +
 lib/api/internal/coverage.rb                  |  36 ++++
 qa/qa/runtime/env.rb                          |   4 +
 qa/qa/specs/spec_helper.rb                    |   1 +
 .../support/formatters/coverband_formatter.rb |  97 +++++++++++
 qa/qa/tools/ci/export_code_paths_mapping.rb   |  69 ++++++++
 .../formatters/coverband_formatter_spec.rb    | 155 ++++++++++++++++++
 .../ci/export_code_paths_mapping_spec.rb      |  55 +++++++
 qa/tasks/ci.rake                              |   7 +
 scripts/generate-e2e-pipeline                 |   1 +
 spec/requests/api/internal/coverage_spec.rb   |  55 +++++++
 18 files changed, 535 insertions(+), 1 deletion(-)
 create mode 100644 config/initializers/coverband.rb
 create mode 100644 lib/api/internal/coverage.rb
 create mode 100644 qa/qa/support/formatters/coverband_formatter.rb
 create mode 100644 qa/qa/tools/ci/export_code_paths_mapping.rb
 create mode 100644 qa/spec/support/formatters/coverband_formatter_spec.rb
 create mode 100644 qa/spec/tools/ci/export_code_paths_mapping_spec.rb
 create mode 100644 spec/requests/api/internal/coverage_spec.rb

diff --git a/.gitlab/ci/qa-common/main.gitlab-ci.yml b/.gitlab/ci/qa-common/main.gitlab-ci.yml
index 8e0cec312a523..d4260ae38e2ea 100644
--- a/.gitlab/ci/qa-common/main.gitlab-ci.yml
+++ b/.gitlab/ci/qa-common/main.gitlab-ci.yml
@@ -127,6 +127,17 @@ stages:
   script:
     - bundle exec rake "ci:export_test_metrics[$QA_METRICS_REPORT_FILE_PATTERN]"
 
+.export-code-paths-mapping:
+  extends:
+    - .qa-install
+    - .ruby-image
+  stage: report
+  when: always
+  variables:
+    QA_CODE_PATHS_MAPPING_FILE_PATTERN: $CI_PROJECT_DIR/gitlab-qa-run-*/**/test-code-paths-mapping-*.json
+  script:
+    - bundle exec rake "ci:export_code_paths_mapping[$QA_CODE_PATHS_MAPPING_FILE_PATTERN]"
+
 .generate-test-session:
   extends:
     - .qa-install
diff --git a/.gitlab/ci/rules.gitlab-ci.yml b/.gitlab/ci/rules.gitlab-ci.yml
index bab08b5765565..a6b3ea7935192 100644
--- a/.gitlab/ci/rules.gitlab-ci.yml
+++ b/.gitlab/ci/rules.gitlab-ci.yml
@@ -1738,7 +1738,15 @@
     # Certain components trigger a rebuild of the e2e GDK image so we want to test it too
     - <<: *if-merge-request
       changes: *gdk-component-patterns
-    - !reference [".qa:rules:e2e-schedule-blocking", rules]
+    - <<: *if-dot-com-gitlab-org-schedule
+      variables:
+        CREATE_TEST_FAILURE_ISSUES: "true"
+        PROCESS_TEST_RESULTS: "true"
+        KNAPSACK_GENERATE_REPORT: "true"
+        QA_SAVE_TEST_METRICS: "true"
+        QA_EXPORT_TEST_METRICS: "false"
+        COVERBAND_ENABLED: "false" #Will open a separate MR to set to true
+
 
 .qa:rules:e2e:test-on-cng:
   rules:
diff --git a/.gitlab/ci/test-on-gdk/main.gitlab-ci.yml b/.gitlab/ci/test-on-gdk/main.gitlab-ci.yml
index de54fc007c857..2d3007f3fe8bc 100644
--- a/.gitlab/ci/test-on-gdk/main.gitlab-ci.yml
+++ b/.gitlab/ci/test-on-gdk/main.gitlab-ci.yml
@@ -206,6 +206,14 @@ export-test-metrics:
   variables:
     QA_METRICS_REPORT_FILE_PATTERN: $CI_PROJECT_DIR/qa/tmp/test-metrics-*.json
 
+export-code-paths-mapping:
+  extends:
+    - .export-code-paths-mapping
+  variables:
+    QA_CODE_PATHS_MAPPING_FILE_PATTERN: $CI_PROJECT_DIR/qa/tmp/test-code-paths-mapping-*.json
+  rules:
+    - if: '$COVERBAND_ENABLED == "true"'
+
 .gitlab-qa-report:
   variables:
     QA_RSPEC_JSON_FILE_PATTERN: $CI_PROJECT_DIR/qa/tmp/rspec-*.json
diff --git a/Gemfile b/Gemfile
index b628778538b58..d8f93f9280b18 100644
--- a/Gemfile
+++ b/Gemfile
@@ -64,6 +64,9 @@ gem 'marginalia', '~> 1.11.1' # rubocop:todo Gemfile/MissingFeatureCategory
 # Authorization
 gem 'declarative_policy', '~> 1.1.0' # rubocop:todo Gemfile/MissingFeatureCategory
 
+# For source code paths mapping
+gem 'coverband', '6.1.2', require: false, feature_category: :shared
+
 # Authentication libraries
 gem 'devise', '~> 4.9.3', feature_category: :system_access
 gem 'devise-pbkdf2-encryptable', '~> 0.0.0', path: 'vendor/gems/devise-pbkdf2-encryptable' # rubocop:todo Gemfile/MissingFeatureCategory
diff --git a/Gemfile.checksum b/Gemfile.checksum
index df217a86fc6a0..ddaa782552117 100644
--- a/Gemfile.checksum
+++ b/Gemfile.checksum
@@ -91,6 +91,7 @@
 {"name":"cork","version":"0.3.0","platform":"ruby","checksum":"a0a0ac50e262f8514d1abe0a14e95e71c98b24e3378690e5d044daf0013ad4bc"},
 {"name":"cose","version":"1.3.0","platform":"ruby","checksum":"63247c66a5bc76e53926756574fe3724cc0a88707e358c90532ae2a320e98601"},
 {"name":"countries","version":"4.0.1","platform":"ruby","checksum":"d32e8a3c0b22949f1a41ea6d9005f5168ffce226f8fe077d1d6be785fffa81c5"},
+{"name":"coverband","version":"6.1.2","platform":"ruby","checksum":"979f356b0f1ff63a78b6ffbbbd0dccb3674c83d74d7e6fbdc3ee98f1ef35ba32"},
 {"name":"crack","version":"0.4.3","platform":"ruby","checksum":"5318ba8cd9cf7e0b5feb38948048503ba4b1fdc1b6ff30a39f0a00feb6036b29"},
 {"name":"crass","version":"1.0.6","platform":"ruby","checksum":"dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d"},
 {"name":"creole","version":"0.5.0","platform":"ruby","checksum":"951701e2d80760f156b1cb2a93471ca97c076289becc067a33b745133ed32c03"},
diff --git a/Gemfile.lock b/Gemfile.lock
index e16cc0192235d..4ddf2b5443d1e 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -420,6 +420,8 @@ GEM
     countries (4.0.1)
       i18n_data (~> 0.13.0)
       sixarm_ruby_unaccent (~> 1.1)
+    coverband (6.1.2)
+      redis (>= 3.0)
     crack (0.4.3)
       safe_yaml (~> 1.0.0)
     crass (1.0.6)
@@ -1948,6 +1950,7 @@ DEPENDENCIES
   concurrent-ruby (~> 1.1)
   connection_pool (~> 2.4)
   countries (~> 4.0.0)
+  coverband (= 6.1.2)
   creole (~> 0.5.0)
   crystalball (~> 0.7.0)
   cssbundling-rails (= 1.4.0)
diff --git a/config/initializers/coverband.rb b/config/initializers/coverband.rb
new file mode 100644
index 0000000000000..aed72a3ff61e0
--- /dev/null
+++ b/config/initializers/coverband.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+# Configuration used by coverband gem when "COVERBAND_ENABLED" is set to "true"
+return unless Gitlab::Utils.to_boolean(ENV['COVERBAND_ENABLED'], default: false)
+
+require 'coverband'
+
+Coverband.configure do |config|
+  config.store = Coverband::Adapters::RedisStore.new(Gitlab::Redis::SharedState.redis)
+  config.background_reporting_sleep_seconds = 1
+  config.reporting_wiggle = nil # Since this is not run in production disable wiggle and report every second.
+  config.ignore += %w[spec/.* lib/tasks/.*
+    config/application.rb config/boot.rb config/initializers/.* db/post_migrate/.*
+    config/puma.rb bin/.* config/environments/.* db/migrate/.*]
+
+  config.verbose = true
+  config.csp_policy = true
+  config.logger = Gitlab::AppLogger
+end
diff --git a/lib/api/api.rb b/lib/api/api.rb
index 2a8f03892a828..1eb4c60af12c7 100644
--- a/lib/api/api.rb
+++ b/lib/api/api.rb
@@ -385,6 +385,7 @@ def initialize(location_url)
     end
 
     mount ::API::Internal::Base
+    mount ::API::Internal::Coverage if Gitlab::Utils.to_boolean(ENV['COVERBAND_ENABLED'], default: false)
     mount ::API::Internal::Lfs
     mount ::API::Internal::Pages
     mount ::API::Internal::Kubernetes
diff --git a/lib/api/internal/coverage.rb b/lib/api/internal/coverage.rb
new file mode 100644
index 0000000000000..cba50280e5d6e
--- /dev/null
+++ b/lib/api/internal/coverage.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+#  This api is for internal use only for the purpose of source code paths mapping to E2E specs.
+
+module API
+  module Internal
+    class Coverage < ::API::Base
+      feature_category :code_testing
+      urgency :low
+
+      before do
+        authenticated_as_admin!
+      end
+
+      namespace 'internal' do
+        namespace 'coverage' do
+          desc 'Source code paths coverage mapping' do
+            success code: 200, message: 'Success'
+            failure [
+              { code: 401, message: 'Unauthorized' }
+            ]
+          end
+
+          get do
+            coverage = ::Coverband.configuration.store.coverage
+            coverage&.keys
+          end
+
+          delete do
+            ::Coverband.configuration.store.clear!
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/qa/qa/runtime/env.rb b/qa/qa/runtime/env.rb
index 2328bd833d8e9..c01d0e795f58d 100644
--- a/qa/qa/runtime/env.rb
+++ b/qa/qa/runtime/env.rb
@@ -90,6 +90,10 @@ def ci_project_path
         ENV['CI_PROJECT_PATH']
       end
 
+      def coverband_enabled?
+        enabled?(ENV['COVERBAND_ENABLED'], default: false)
+      end
+
       def schedule_type
         ENV['SCHEDULE_TYPE']
       end
diff --git a/qa/qa/specs/spec_helper.rb b/qa/qa/specs/spec_helper.rb
index 2a2754133e677..915d7acff1995 100644
--- a/qa/qa/specs/spec_helper.rb
+++ b/qa/qa/specs/spec_helper.rb
@@ -37,6 +37,7 @@
   config.add_formatter QA::Support::Formatters::QuarantineFormatter
   config.add_formatter QA::Support::Formatters::FeatureFlagFormatter
   config.add_formatter QA::Support::Formatters::TestMetricsFormatter if QA::Runtime::Env.running_in_ci?
+  config.add_formatter QA::Support::Formatters::CoverbandFormatter if QA::Runtime::Env.coverband_enabled?
 
   config.example_status_persistence_file_path = ENV.fetch('RSPEC_LAST_RUN_RESULTS_FILE', 'tmp/examples.txt')
 
diff --git a/qa/qa/support/formatters/coverband_formatter.rb b/qa/qa/support/formatters/coverband_formatter.rb
new file mode 100644
index 0000000000000..d4c669744d1f7
--- /dev/null
+++ b/qa/qa/support/formatters/coverband_formatter.rb
@@ -0,0 +1,97 @@
+# frozen_string_literal: true
+
+require 'json'
+
+module QA
+  module Support
+    module Formatters
+      # RSpec formatter to map E2E specs to diff files
+      class CoverbandFormatter < ::RSpec::Core::Formatters::BaseFormatter
+        include Support::API
+
+        COVERAGE_API_PATH = '/api/v4/internal/coverage'
+
+        def initialize(output)
+          super
+          @test_mapping = Hash.new { |hsh, key| hsh[key] = [] }
+          @logger = Runtime::Logger.logger
+          @headers_access_token = { "PRIVATE-TOKEN" => Runtime::Env.admin_personal_access_token }
+        end
+
+        ::RSpec::Core::Formatters.register(
+          self,
+          :example_started,
+          :example_finished,
+          :stop
+        )
+
+        # Runs at the end of suite
+        #
+        # @param [RSpec::Core::Notifications::ExamplesNotification] notification
+        # @return [void]
+        def stop(_notification)
+          logger.info("Saving test coverage mapping json file")
+
+          save_test_mapping
+        end
+
+        # Example start event
+        def example_started(_example_notification)
+          QA::Support::Retrier.retry_until(max_attempts: 5, sleep_interval: 1, message: "Retry clear coverage") do
+            resp = delete("#{Runtime::Scenario.gitlab_address}#{COVERAGE_API_PATH}",
+              headers: headers_access_token)
+
+            logger.error("Failed to clear coverage, code: #{resp.code}, body: #{resp.body}") if resp.code != 200
+
+            resp.code == 200
+          end
+          logger.debug("Cleared coverage data before example starts")
+        rescue StandardError => e
+          logger.error("Failed to clear coverage. Exception trace: #{e}")
+        end
+
+        # Example finish event
+        def example_finished(example_notification)
+          cov_resp = nil
+          QA::Support::Retrier.retry_until(max_attempts: 10, sleep_interval: 2, message: "Retry fetch coverage") do
+            cov_resp = get("#{Runtime::Scenario.gitlab_address}/api/v4/internal/coverage",
+              headers: headers_access_token)
+
+            if cov_resp.code != 200
+              logger.error("Fetching coverage data failed, code: #{cov_resp.code}, body: #{cov_resp.body}")
+            end
+
+            cov_resp.code == 200 && !JSON.parse(cov_resp.body).empty?
+          end
+
+          example_path = example_notification.example.metadata[:location]
+          test_mapping[example_path] = JSON.parse(cov_resp.body) unless example_failed?(example_notification)
+          logger.debug("Coverage paths were stored in mapping hash")
+        rescue StandardError => e
+          logger.error("Failed to fetch coverage mapping. Trace: #{e}")
+        end
+
+        def example_failed?(example_notification)
+          example_notification.example.execution_result.status == :failed
+        end
+
+        # Save coverage test mapping file
+        #
+        # @return [void]
+        def save_test_mapping
+          file = "tmp/test-code-paths-mapping-#{ENV['CI_JOB_NAME_SLUG'] || 'local'}-#{SecureRandom.hex(6)}.json"
+          # To write two different files in case of failed specs being retried
+
+          File.write(file, test_mapping.to_json)
+          logger.debug("Saved test code paths mapping to #{file}")
+        rescue StandardError => e
+          logger.error("Failed to save test code paths mapping, error: #{e}")
+        end
+
+        private
+
+        attr_reader :test_mapping, :logger, :headers_access_token
+      end
+    end
+  end
+end
diff --git a/qa/qa/tools/ci/export_code_paths_mapping.rb b/qa/qa/tools/ci/export_code_paths_mapping.rb
new file mode 100644
index 0000000000000..2d681fe56d609
--- /dev/null
+++ b/qa/qa/tools/ci/export_code_paths_mapping.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+require "fog/google"
+
+module QA
+  module Tools
+    module Ci
+      class ExportCodePathsMapping
+        include Helpers
+
+        PROJECT = "gitlab-qa-resources"
+        BUCKET = "code-path-mappings"
+
+        def self.export(mapping_files_glob)
+          new(mapping_files_glob).export
+        end
+
+        def initialize(mapping_files_glob)
+          @mapping_files_glob = mapping_files_glob
+        end
+
+        # Export code path mappings to GCP
+        #
+        # @return [void]
+        def export
+          mapping_files = Dir.glob(mapping_files_glob)
+          return logger.warn("No files matched pattern") if mapping_files.empty?
+
+          logger.info("Number of mapping files found: #{mapping_files.size}")
+
+          mapping_data = mapping_files.flat_map { |file| JSON.parse(File.read(file)) }.reduce(:merge!)
+          file = "test-code-paths-mapping-merged-pipeline-#{ENV['CI_PIPELINE_ID'] || 'local'}.json"
+          File.write(file, mapping_data.to_json) && logger.debug("Saved test code paths mapping to #{file}")
+          upload_to_gcs(file, mapping_data)
+        end
+
+        private
+
+        attr_reader :mapping_files_glob
+
+        def upload_to_gcs(file_name, mapping_data)
+          client.put_object(BUCKET, file_name, JSON.pretty_generate(mapping_data))
+        rescue StandardError => e
+          logger.error("Failed to upload code paths mapping to GCS. Error: #{e}")
+        end
+
+        # GCS client
+        #
+        # @return [Fog::Storage::GoogleJSON]
+        def client
+          @client ||= Fog::Storage::Google.new(google_project: PROJECT, **gcs_credentials)
+        end
+
+        # GCS credentials json
+        #
+        # @return [Hash]
+        def gcs_credentials
+          json_key =  ENV.fetch("QA_CODE_PATH_MAPPINGS_GCS_CREDENTIALS") do
+            raise "QA_CODE_PATH_MAPPINGS_GCS_CREDENTIALS env variable is required!"
+          end
+
+          return { google_json_key_location: json_key } if File.exist?(json_key)
+
+          { google_json_key_string: json_key }
+        end
+      end
+    end
+  end
+end
diff --git a/qa/spec/support/formatters/coverband_formatter_spec.rb b/qa/spec/support/formatters/coverband_formatter_spec.rb
new file mode 100644
index 0000000000000..a10a44a65f557
--- /dev/null
+++ b/qa/spec/support/formatters/coverband_formatter_spec.rb
@@ -0,0 +1,155 @@
+# frozen_string_literal: true
+
+describe QA::Support::Formatters::CoverbandFormatter do
+  include QA::Support::Helpers::StubEnv
+
+  let(:formatter) { described_class.new(StringIO.new) }
+
+  let(:rspec_example_notification) do
+    instance_double(RSpec::Core::Notifications::ExampleNotification, example: rspec_example)
+  end
+
+  # rubocop:disable RSpec/VerifiedDoubles -- Custom object
+
+  let(:rspec_example) do
+    double(
+      RSpec::Core::Example,
+      file_path: 'create_issue_spec.rb',
+      execution_result: instance_double(RSpec::Core::Example::ExecutionResult, status: status),
+      metadata: {
+        testcase: 'testcase',
+        full_description: "Plan",
+        location: "./qa/specs/features/browser_ui/2_plan/issue/create_issue_spec.rb:5"
+      }
+    )
+  end
+
+  # rubocop:enable RSpec/VerifiedDoubles
+
+  let(:gitlab_address) { 'http://gitlab.test.com' }
+  let(:api_path) { "#{gitlab_address}/api/v4/internal/coverage" }
+
+  let(:status) { :failed }
+  let(:token_header) { { "PRIVATE-TOKEN" => 'token' } }
+  let(:api_response) do
+    instance_double(
+      ::RestClient::Response
+    )
+  end
+
+  let(:non_empty_response) do
+    '{"test mapping":1}'
+  end
+
+  let(:mapping) do
+    { "./qa/specs/features/browser_ui/2_plan/issue/create_issue_spec.rb:5": { "test mapping": 1 } }
+  end
+
+  before do
+    stub_env('GITLAB_QA_ADMIN_ACCESS_TOKEN', 'token')
+  end
+
+  context 'with example_started' do
+    before do
+      allow(QA::Runtime::Scenario).to receive(:gitlab_address).and_return(gitlab_address)
+      allow(api_response).to receive(:body).and_return({})
+      allow(::RestClient::Request).to receive(:execute).and_return(api_response)
+    end
+
+    context 'when success response' do
+      before do
+        allow(api_response).to receive(:code).and_return(200)
+      end
+
+      it 'logs coverage cleared', :aggregate_failures do
+        expect(QA::Runtime::Logger.logger).to receive(:debug).with("Cleared coverage data before example starts").once
+        formatter.example_started(rspec_example_notification)
+      end
+    end
+
+    context 'with failure response' do
+      before do
+        allow(api_response).to receive(:code).and_return(401)
+        allow(QA::Support::Retrier).to receive(:retry_until).and_wrap_original do |method|
+          method.call(max_attempts: 1)
+        end
+      end
+
+      it 'logs error message' do
+        expect(QA::Runtime::Logger.logger).to receive(:error).with(/Failed to clear coverage.*/).once
+        formatter.example_started(rspec_example_notification)
+      end
+    end
+  end
+
+  context 'when example finished' do
+    before do
+      allow(QA::Runtime::Scenario).to receive(:gitlab_address).and_return(gitlab_address)
+      allow(::RestClient::Request).to receive(:execute).and_return(api_response)
+    end
+
+    context 'with success response and non empty coverage' do
+      let(:status) { :passed }
+
+      before do
+        allow(api_response).to receive(:code).and_return(200)
+        allow(api_response).to receive(:body).and_return(non_empty_response)
+      end
+
+      it 'logs success message and does not log any errors' do
+        expect(QA::Runtime::Logger.logger).not_to receive(:error)
+        expect(QA::Runtime::Logger.logger).to receive(:debug).with("Coverage paths were stored in mapping hash").once
+        formatter.example_finished(rspec_example_notification)
+      end
+    end
+
+    context 'with failure response' do
+      let(:status) { :passed }
+
+      before do
+        allow(api_response).to receive(:code).and_return(401)
+        allow(api_response).to receive(:body).and_return({})
+        allow(QA::Support::Retrier).to receive(:retry_until).and_wrap_original do |method|
+          method.call(max_attempts: 1)
+        end
+      end
+
+      it 'logs error message' do
+        expect(QA::Runtime::Logger.logger).to receive(:error).with(/Failed to fetch coverage mapping.*/).once
+        formatter.example_finished(rspec_example_notification)
+      end
+    end
+  end
+
+  context 'when save_test_mapping is called' do
+    before do
+      stub_env('CI_JOB_NAME_SLUG', 'job-name')
+    end
+
+    let(:file_name_pattern) { "test-code-paths-mapping-job-name" }
+
+    context 'with mapping data present' do
+      before do
+        allow(formatter).to receive(:test_mapping).and_return(mapping)
+      end
+
+      it 'writes to file' do
+        expect(::File).to receive(:write).with(/#{file_name_pattern}/, mapping.to_json).once
+        expect(QA::Runtime::Logger.logger).to receive(:debug).with(/Saved test code paths mapping to.*/).once
+        formatter.save_test_mapping
+      end
+    end
+
+    context 'when writing to file throws an error' do
+      before do
+        allow(::File).to receive(:write).and_raise(StandardError)
+      end
+
+      it 'raises an error' do
+        expect(QA::Runtime::Logger.logger).to receive(:error)
+          .with(/Failed to save test code paths mapping, error:.*/).once
+        formatter.save_test_mapping
+      end
+    end
+  end
+end
diff --git a/qa/spec/tools/ci/export_code_paths_mapping_spec.rb b/qa/spec/tools/ci/export_code_paths_mapping_spec.rb
new file mode 100644
index 0000000000000..1b9b737f73929
--- /dev/null
+++ b/qa/spec/tools/ci/export_code_paths_mapping_spec.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+RSpec.describe QA::Tools::Ci::ExportCodePathsMapping do
+  include QA::Support::Helpers::StubEnv
+
+  let(:glob) { "test_code_paths/*.json" }
+  let(:file_paths) { ["/test_code/test_code_path_mappings.json"] }
+  let(:logger) { instance_double("Logger", info: true, warn: true, debug: true) }
+  let(:gcs_client_options) { { force: true, content_type: 'application/json' } }
+  let(:gcs_client) { double("Fog::Storage::GoogleJSON::Real", put_object: nil) } # rubocop:disable RSpec/VerifiedDoubles -- Class has `put_object` method but is not getting verified
+  let(:gcs_project) { 'gitlab-qa-resources' }
+  let(:gcs_bucket_name) { 'code-path-mappings' }
+  let(:gcs_credentials) { 'code-path-mappings-gcs-credentials' }
+  let(:mapping_json) { code_path_mappings_data.to_json }
+  let(:code_path_mappings_data) do
+    {
+      "path_to_spec:rb": ['lib/model.rb']
+    }
+  end
+
+  let(:pretty_generated_mapping_json) do
+    JSON.pretty_generate(code_path_mappings_data)
+  end
+
+  before do
+    allow(Fog::Storage::Google).to receive(:new)
+                                     .with(google_project: gcs_project,
+                                       google_json_key_string: gcs_credentials)
+                                     .and_return(gcs_client)
+    allow(Gitlab::QA::TestLogger).to receive(:logger) { logger }
+    allow(Dir).to receive(:glob).with(glob) { file_paths }
+    allow(::File).to receive(:read).with(anything).and_return(code_path_mappings_data.to_json)
+    stub_env('QA_CODE_PATH_MAPPINGS_GCS_CREDENTIALS', gcs_credentials)
+  end
+
+  context "with mapping files present" do
+    it "exports mapping json to GCS and writes it as job artifact", :aggregate_failures do
+      expect(logger).to receive(:info).with("Number of mapping files found: #{file_paths.size}")
+      expect(::File).to receive(:write).with(String, mapping_json).once
+      expect(gcs_client).to receive(:put_object).with(gcs_bucket_name,
+        String, pretty_generated_mapping_json)
+      described_class.export(glob)
+    end
+  end
+
+  context "with no mapping files present" do
+    let(:file_paths) { [] }
+
+    it "exits without any exception raised but logs the error", :aggregate_failures do
+      expect(logger).to receive(:warn).with(/No files matched pattern/).once
+      expect(::File).not_to receive(:write)
+      described_class.export(glob)
+    end
+  end
+end
diff --git a/qa/tasks/ci.rake b/qa/tasks/ci.rake
index 887b90b919cb0..1c58339193f32 100644
--- a/qa/tasks/ci.rake
+++ b/qa/tasks/ci.rake
@@ -83,4 +83,11 @@ namespace :ci do
 
     QA::Tools::Ci::TestMetrics.export(args[:glob])
   end
+
+  desc "Export code paths mapping to GCP"
+  task :export_code_paths_mapping, [:glob] do |_, args|
+    raise("Code paths mapping JSON glob pattern is required") unless args[:glob]
+
+    QA::Tools::Ci::ExportCodePathsMapping.export(args[:glob])
+  end
 end
diff --git a/scripts/generate-e2e-pipeline b/scripts/generate-e2e-pipeline
index ff56e065126df..d6e7107ac66a6 100755
--- a/scripts/generate-e2e-pipeline
+++ b/scripts/generate-e2e-pipeline
@@ -49,6 +49,7 @@ variables:
   QA_SUITES: "$QA_SUITES"
   QA_TESTS: "$QA_TESTS"
   KNAPSACK_TEST_FILE_PATTERN: "$KNAPSACK_TEST_FILE_PATTERN"
+  COVERBAND_ENABLED: "$COVERBAND_ENABLED"
 YML
 )
 
diff --git a/spec/requests/api/internal/coverage_spec.rb b/spec/requests/api/internal/coverage_spec.rb
new file mode 100644
index 0000000000000..10731231761df
--- /dev/null
+++ b/spec/requests/api/internal/coverage_spec.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Internal::Coverage, feature_category: :code_testing do
+  let(:admin) { create(:admin) }
+
+  before_all do
+    ::API::API.mount ::API::Internal::Coverage
+  end
+
+  describe '/internal/coverage' do
+    let(:path) { "/internal/coverage" }
+
+    context 'when user is not admin' do
+      it 'GET returns 401' do
+        get api(path)
+        expect(response).to have_gitlab_http_status(:unauthorized)
+      end
+
+      it 'DELETE returns 401' do
+        delete api(path)
+        expect(response).to have_gitlab_http_status(:unauthorized)
+      end
+    end
+
+    context 'when user is admin' do
+      let(:coverage_hash) do
+        { "./lib/gitlab/database/load_balancing/load_balancer.rb" =>
+           { "first_updated_at" => 1718070708,
+             "last_updated_at" => 1718073948,
+             "file_hash" => "1d6368d5806dba4d4af79450d0df9b72" } }
+      end
+
+      let(:resp) { coverage_hash.keys }
+
+      before do
+        stub_const('Coverband', Class.new)
+        allow(Coverband).to receive_message_chain(:configuration, :store, :coverage).and_return(coverage_hash)
+        allow(Coverband).to receive_message_chain(:configuration, :store, :clear!).and_return({})
+      end
+
+      it 'GET returns 200', :aggregate_failures do
+        get api(path.to_s, admin, admin_mode: true)
+        expect(response).to have_gitlab_http_status(:success)
+        expect(json_response).to eq(resp)
+      end
+
+      it 'DELETE returns 200' do
+        delete api(path.to_s, admin, admin_mode: true)
+        expect(response).to have_gitlab_http_status(:success)
+      end
+    end
+  end
+end
-- 
GitLab