From 201dd19f6de266b3ae5663bc026cb763fc4f77ae Mon Sep 17 00:00:00 2001
From: Maxime Orefice <morefice@gitlab.com>
Date: Wed, 5 Aug 2020 16:13:19 -0400
Subject: [PATCH] Add pipeline create artifact service

This new service will be used to persist generated
coverage report once a pipeline has been completed.
---
 app/models/ci/pipeline.rb                     | 10 +++
 app/models/ci/pipeline_artifact.rb            | 10 ++-
 .../ci/pipelines/create_artifact_service.rb   | 33 +++++++++
 app/workers/all_queues.yml                    |  8 +++
 .../ci/pipelines/create_artifact_worker.rb    | 18 +++++
 lib/carrier_wave_string_file.rb               |  8 +++
 lib/gitlab/ci/features.rb                     |  4 ++
 spec/factories/ci/pipeline_artifacts.rb       | 11 +++
 spec/models/ci/pipeline_artifact_spec.rb      | 46 +++++++------
 spec/models/ci/pipeline_spec.rb               | 32 +++++++++
 .../pipelines/create_artifact_service_spec.rb | 67 +++++++++++++++++++
 .../pipelines/create_artifact_worker_spec.rb  | 32 +++++++++
 12 files changed, 257 insertions(+), 22 deletions(-)
 create mode 100644 app/services/ci/pipelines/create_artifact_service.rb
 create mode 100644 app/workers/ci/pipelines/create_artifact_worker.rb
 create mode 100644 spec/services/ci/pipelines/create_artifact_service_spec.rb
 create mode 100644 spec/workers/ci/pipelines/create_artifact_worker_spec.rb

diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 27cc72dc1b3d..289417327e8c 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -228,6 +228,12 @@ class Pipeline < ApplicationRecord
         end
       end
 
+      after_transition any => ::Ci::Pipeline.completed_statuses do |pipeline|
+        pipeline.run_after_commit do
+          ::Ci::Pipelines::CreateArtifactWorker.perform_async(pipeline.id)
+        end
+      end
+
       after_transition any => ::Ci::Pipeline.completed_statuses do |pipeline|
         next unless pipeline.bridge_triggered?
         next unless pipeline.bridge_waiting?
@@ -856,6 +862,10 @@ def has_reports?(reports_scope)
       complete? && latest_report_builds(reports_scope).exists?
     end
 
+    def has_coverage_reports?
+      self.has_reports?(Ci::JobArtifact.coverage_reports)
+    end
+
     def test_report_summary
       Gitlab::Ci::Reports::TestReportSummary.new(latest_builds_report_results)
     end
diff --git a/app/models/ci/pipeline_artifact.rb b/app/models/ci/pipeline_artifact.rb
index e7f51977ccd4..5a3bf52a43da 100644
--- a/app/models/ci/pipeline_artifact.rb
+++ b/app/models/ci/pipeline_artifact.rb
@@ -14,6 +14,11 @@ class PipelineArtifact < ApplicationRecord
     ].freeze
 
     FILE_SIZE_LIMIT = 10.megabytes.freeze
+    EXPIRATION_DATE = 1.week.freeze
+
+    DEFAULT_FILE_NAMES = {
+      code_coverage: 'code_coverage.json'
+    }.freeze
 
     belongs_to :project, class_name: "Project", inverse_of: :pipeline_artifacts
     belongs_to :pipeline, class_name: "Ci::Pipeline", inverse_of: :pipeline_artifacts
@@ -24,14 +29,13 @@ class PipelineArtifact < ApplicationRecord
     validates :file_type, presence: true
 
     mount_file_store_uploader Ci::PipelineArtifactUploader
-    before_save :set_size, if: :file_changed?
 
     enum file_type: {
       code_coverage: 1
     }
 
-    def set_size
-      self.size = file.size
+    def self.has_code_coverage?
+      where(file_type: :code_coverage).exists?
     end
   end
 end
diff --git a/app/services/ci/pipelines/create_artifact_service.rb b/app/services/ci/pipelines/create_artifact_service.rb
new file mode 100644
index 000000000000..179e18f22e8c
--- /dev/null
+++ b/app/services/ci/pipelines/create_artifact_service.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+module Ci
+  module Pipelines
+    class CreateArtifactService
+      def execute(pipeline)
+        return unless ::Gitlab::Ci::Features.coverage_report_view?(pipeline.project)
+        return unless pipeline.has_coverage_reports?
+        return if pipeline.pipeline_artifacts.has_code_coverage?
+
+        file = build_carrierwave_file(pipeline)
+
+        pipeline.pipeline_artifacts.create!(
+          project_id: pipeline.project_id,
+          file_type: :code_coverage,
+          file_format: :raw,
+          size: file["tempfile"].size,
+          file: file,
+          expire_at: Ci::PipelineArtifact::EXPIRATION_DATE.from_now
+        )
+      end
+
+      private
+
+      def build_carrierwave_file(pipeline)
+        CarrierWaveStringFile.new_file(
+          file_content: pipeline.coverage_reports.to_json,
+          filename: Ci::PipelineArtifact::DEFAULT_FILE_NAMES.fetch(:code_coverage),
+          content_type: 'application/json'
+        )
+      end
+    end
+  end
+end
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 2c871c55f0ac..e16032cdc50f 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -899,6 +899,14 @@
   :weight: 1
   :idempotent: true
   :tags: []
+- :name: pipeline_background:ci_pipelines_create_artifact
+  :feature_category: :continuous_integration
+  :has_external_dependencies:
+  :urgency: :low
+  :resource_boundary: :unknown
+  :weight: 1
+  :idempotent: true
+  :tags: []
 - :name: pipeline_background:ci_ref_delete_unlock_artifacts
   :feature_category: :continuous_integration
   :has_external_dependencies:
diff --git a/app/workers/ci/pipelines/create_artifact_worker.rb b/app/workers/ci/pipelines/create_artifact_worker.rb
new file mode 100644
index 000000000000..220df975503a
--- /dev/null
+++ b/app/workers/ci/pipelines/create_artifact_worker.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Ci
+  module Pipelines
+    class CreateArtifactWorker
+      include ApplicationWorker
+      include PipelineBackgroundQueue
+
+      idempotent!
+
+      def perform(pipeline_id)
+        Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline|
+          Ci::Pipelines::CreateArtifactService.new.execute(pipeline)
+        end
+      end
+    end
+  end
+end
diff --git a/lib/carrier_wave_string_file.rb b/lib/carrier_wave_string_file.rb
index c9a64d9e6318..b6bc3d986ca9 100644
--- a/lib/carrier_wave_string_file.rb
+++ b/lib/carrier_wave_string_file.rb
@@ -4,4 +4,12 @@ class CarrierWaveStringFile < StringIO
   def original_filename
     ""
   end
+
+  def self.new_file(file_content:, filename:, content_type: "application/octet-stream")
+    {
+      "tempfile" => StringIO.new(file_content),
+      "filename" => filename,
+      "content_type" => content_type
+    }
+  end
 end
diff --git a/lib/gitlab/ci/features.rb b/lib/gitlab/ci/features.rb
index 934d1a4c9f10..895daf65d0d6 100644
--- a/lib/gitlab/ci/features.rb
+++ b/lib/gitlab/ci/features.rb
@@ -83,6 +83,10 @@ def self.expand_names_for_cross_pipeline_artifacts?(project)
       def self.project_transactionless_destroy?(project)
         Feature.enabled?(:project_transactionless_destroy, project, default_enabled: false)
       end
+
+      def self.coverage_report_view?(project)
+        ::Feature.enabled?(:coverage_report_view, project)
+      end
     end
   end
 end
diff --git a/spec/factories/ci/pipeline_artifacts.rb b/spec/factories/ci/pipeline_artifacts.rb
index ecfd1e79e789..e601b0bbf0e3 100644
--- a/spec/factories/ci/pipeline_artifacts.rb
+++ b/spec/factories/ci/pipeline_artifacts.rb
@@ -13,5 +13,16 @@
       artifact.file = fixture_file_upload(
         Rails.root.join('spec/fixtures/pipeline_artifacts/code_coverage.json'), 'application/json')
     end
+
+    trait :with_multibyte_characters do
+      size { { "utf8" => "✓" }.to_json.size }
+      after(:build) do |artifact, _evaluator|
+        artifact.file = CarrierWaveStringFile.new_file(
+          file_content: { "utf8" => "✓" }.to_json,
+          filename: 'filename',
+          content_type: 'application/json'
+        )
+      end
+    end
   end
 end
diff --git a/spec/models/ci/pipeline_artifact_spec.rb b/spec/models/ci/pipeline_artifact_spec.rb
index 9d63d74a6cc5..9d2172d75721 100644
--- a/spec/models/ci/pipeline_artifact_spec.rb
+++ b/spec/models/ci/pipeline_artifact_spec.rb
@@ -3,7 +3,7 @@
 require 'spec_helper'
 
 RSpec.describe Ci::PipelineArtifact, type: :model do
-  let_it_be(:coverage_report) { create(:ci_pipeline_artifact) }
+  let(:coverage_report) { create(:ci_pipeline_artifact) }
 
   describe 'associations' do
     it { is_expected.to belong_to(:pipeline) }
@@ -44,24 +44,6 @@
     end
   end
 
-  describe '#set_size' do
-    subject { create(:ci_pipeline_artifact) }
-
-    context 'when file is being created' do
-      it 'sets the size' do
-        expect(subject.size).to eq(85)
-      end
-    end
-
-    context 'when file is being updated' do
-      it 'updates the size' do
-        subject.update!(file: fixture_file_upload('spec/fixtures/dk.png'))
-
-        expect(subject.size).to eq(1062)
-      end
-    end
-  end
-
   describe 'file is being stored' do
     subject { create(:ci_pipeline_artifact) }
 
@@ -78,5 +60,31 @@
         it_behaves_like 'mounted file in object store'
       end
     end
+
+    context 'when file contains multi-byte characters' do
+      let(:coverage_report_multibyte) { create(:ci_pipeline_artifact, :with_multibyte_characters) }
+
+      it 'sets the size in bytesize' do
+        expect(coverage_report_multibyte.size).to eq(12)
+      end
+    end
+  end
+
+  describe '.has_code_coverage?' do
+    subject { Ci::PipelineArtifact.has_code_coverage? }
+
+    context 'when pipeline artifact has a code coverage' do
+      let!(:pipeline_artifact) { create(:ci_pipeline_artifact) }
+
+      it 'returns true' do
+        expect(subject).to be_truthy
+      end
+    end
+
+    context 'when pipeline artifact does not have a code coverage' do
+      it 'returns false' do
+        expect(subject).to be_falsey
+      end
+    end
   end
 end
diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb
index b4e80fa75881..54dad2e18400 100644
--- a/spec/models/ci/pipeline_spec.rb
+++ b/spec/models/ci/pipeline_spec.rb
@@ -2948,6 +2948,38 @@ def create_build(name, stage_idx)
     end
   end
 
+  describe '#has_coverage_reports?' do
+    subject { pipeline.has_coverage_reports? }
+
+    context 'when pipeline has builds with coverage reports' do
+      before do
+        create(:ci_build, :coverage_reports, pipeline: pipeline, project: project)
+      end
+
+      context 'when pipeline status is running' do
+        let(:pipeline) { create(:ci_pipeline, :running, project: project) }
+
+        it { expect(subject).to be_falsey }
+      end
+
+      context 'when pipeline status is success' do
+        let(:pipeline) { create(:ci_pipeline, :success, project: project) }
+
+        it { expect(subject).to be_truthy }
+      end
+    end
+
+    context 'when pipeline does not have builds with coverage reports' do
+      before do
+        create(:ci_build, :artifacts, pipeline: pipeline, project: project)
+      end
+
+      let(:pipeline) { create(:ci_pipeline, :success, project: project) }
+
+      it { expect(subject).to be_falsey }
+    end
+  end
+
   describe '#test_report_summary' do
     subject { pipeline.test_report_summary }
 
diff --git a/spec/services/ci/pipelines/create_artifact_service_spec.rb b/spec/services/ci/pipelines/create_artifact_service_spec.rb
new file mode 100644
index 000000000000..d5e9cf83a6d0
--- /dev/null
+++ b/spec/services/ci/pipelines/create_artifact_service_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Ci::Pipelines::CreateArtifactService do
+  describe '#execute' do
+    subject { described_class.new.execute(pipeline) }
+
+    context 'when pipeline has coverage reports' do
+      let(:pipeline) { create(:ci_pipeline, :with_coverage_reports) }
+
+      context 'when pipeline is finished' do
+        it 'creates a pipeline artifact' do
+          subject
+
+          expect(Ci::PipelineArtifact.count).to eq(1)
+        end
+
+        it 'persists the default file name' do
+          subject
+
+          file = Ci::PipelineArtifact.first.file
+
+          expect(file.filename).to eq('code_coverage.json')
+        end
+
+        it 'sets expire_at to 1 week' do
+          freeze_time do
+            subject
+
+            pipeline_artifact = Ci::PipelineArtifact.first
+
+            expect(pipeline_artifact.expire_at).to eq(1.week.from_now)
+          end
+        end
+      end
+
+      context 'when feature is disabled' do
+        it 'does not create a pipeline artifact' do
+          stub_feature_flags(coverage_report_view: false)
+
+          subject
+
+          expect(Ci::PipelineArtifact.count).to eq(0)
+        end
+      end
+
+      context 'when pipeline artifact has already been created' do
+        it 'do not raise an error and do not persist the same artifact twice' do
+          expect { 2.times { described_class.new.execute(pipeline) } }.not_to raise_error(ActiveRecord::RecordNotUnique)
+
+          expect(Ci::PipelineArtifact.count).to eq(1)
+        end
+      end
+    end
+
+    context 'when pipeline is running and coverage report does not exist' do
+      let(:pipeline) { create(:ci_pipeline, :running) }
+
+      it 'does not persist data' do
+        subject
+
+        expect(Ci::PipelineArtifact.count).to eq(0)
+      end
+    end
+  end
+end
diff --git a/spec/workers/ci/pipelines/create_artifact_worker_spec.rb b/spec/workers/ci/pipelines/create_artifact_worker_spec.rb
new file mode 100644
index 000000000000..31d2c4e9559b
--- /dev/null
+++ b/spec/workers/ci/pipelines/create_artifact_worker_spec.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Ci::Pipelines::CreateArtifactWorker do
+  describe '#perform' do
+    subject { described_class.new.perform(pipeline_id) }
+
+    context 'when pipeline exists' do
+      let(:pipeline) { create(:ci_pipeline) }
+      let(:pipeline_id) { pipeline.id }
+
+      it 'calls pipeline report result service' do
+        expect_next_instance_of(::Ci::Pipelines::CreateArtifactService) do |create_artifact_service|
+          expect(create_artifact_service).to receive(:execute)
+        end
+
+        subject
+      end
+    end
+
+    context 'when pipeline does not exist' do
+      let(:pipeline_id) { non_existing_record_id }
+
+      it 'does not call pipeline create artifact service' do
+        expect(Ci::Pipelines::CreateArtifactService).not_to receive(:execute)
+
+        subject
+      end
+    end
+  end
+end
-- 
GitLab