diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index ffac3a22efc09eab3cd818fa82233bed8b236cc0..65dfe4f019005fc146899b3971c95c767a466a3c 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -15,6 +15,9 @@ module Ci scope :with_artifacts, ->() { where.not(artifacts_file: nil) } scope :with_expired_artifacts, ->() { with_artifacts.where('artifacts_expire_at < ?', Time.now) } scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) } + scope :latest_successful_with_artifacts, ->() do + with_artifacts.success.order(id: :desc) + end mount_uploader :artifacts_file, ArtifactUploader mount_uploader :artifacts_metadata, ArtifactUploader diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index a65a826536de8f4fdc8a43a5e25b238a664035c1..f5b4124d1ee7d18108d5def5545b7e94eae73903 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -20,6 +20,14 @@ module Ci after_touch :update_state after_save :keep_around_commits + # ref can't be HEAD, can only be branch/tag name or SHA + scope :latest_successful_for, ->(ref) do + table = quoted_table_name + # TODO: Use `where(ref: ref).or(sha: ref)` in Rails 5 + where("#{table}.ref = ? OR #{table}.sha = ?", ref, ref). + success.order(id: :desc) + end + def self.truncate_sha(sha) sha[0...8] end @@ -222,7 +230,7 @@ module Ci def keep_around_commits return unless project - + project.repository.keep_around(self.sha) project.repository.keep_around(self.before_sha) end diff --git a/app/models/project.rb b/app/models/project.rb index a805f5d97bc61bab97d16404cf5beb561bb220df..29aaaf5117f52a67f566f0b42e22fee22ff586f9 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -429,6 +429,13 @@ class Project < ActiveRecord::Base repository.commit(ref) end + # ref can't be HEAD, can only be branch/tag name or SHA + def latest_successful_builds_for(ref = 'master') + Ci::Build.joins(:pipeline). + merge(pipelines.latest_successful_for(ref)). + latest_successful_with_artifacts + end + def merge_base_commit(first_commit_id, second_commit_id) sha = repository.merge_base(first_commit_id, second_commit_id) repository.commit(sha) if sha diff --git a/lib/api/builds.rb b/lib/api/builds.rb index d36047acd1f65c03a11d39a67c1ded42f20ebd2d..bb9e8f1ae6ef7a23a6e32d3f4f1b3c9fdfa8f12e 100644 --- a/lib/api/builds.rb +++ b/lib/api/builds.rb @@ -52,8 +52,7 @@ module API get ':id/builds/:build_id' do authorize_read_builds! - build = get_build(params[:build_id]) - return not_found!(build) unless build + build = get_build!(params[:build_id]) present build, with: Entities::Build, user_can_download_artifacts: can?(current_user, :read_build, user_project) @@ -69,18 +68,25 @@ module API get ':id/builds/:build_id/artifacts' do authorize_read_builds! - build = get_build(params[:build_id]) - return not_found!(build) unless build + build = get_build!(params[:build_id]) - artifacts_file = build.artifacts_file - - unless artifacts_file.file_storage? - return redirect_to build.artifacts_file.url - end + present_artifact!(build.artifacts_file) + end - return not_found! unless artifacts_file.exists? + # Download the artifacts file from ref_name and job + # + # Parameters: + # id (required) - The ID of a project + # ref_name (required) - The ref from repository + # job (required) - The name for the build + # Example Request: + # GET /projects/:id/artifacts/:ref_name/download?job=name + get ':id/builds/artifacts/:ref_name/download', + requirements: { ref_name: /.+/ } do + builds = user_project.latest_successful_builds_for(params[:ref_name]) + latest_build = builds.find_by!(name: params[:job]) - present_file!(artifacts_file.path, artifacts_file.filename) + present_artifact!(latest_build.artifacts_file) end # Get a trace of a specific build of a project @@ -97,8 +103,7 @@ module API get ':id/builds/:build_id/trace' do authorize_read_builds! - build = get_build(params[:build_id]) - return not_found!(build) unless build + build = get_build!(params[:build_id]) header 'Content-Disposition', "infile; filename=\"#{build.id}.log\"" content_type 'text/plain' @@ -118,8 +123,7 @@ module API post ':id/builds/:build_id/cancel' do authorize_update_builds! - build = get_build(params[:build_id]) - return not_found!(build) unless build + build = get_build!(params[:build_id]) build.cancel @@ -137,8 +141,7 @@ module API post ':id/builds/:build_id/retry' do authorize_update_builds! - build = get_build(params[:build_id]) - return not_found!(build) unless build + build = get_build!(params[:build_id]) return forbidden!('Build is not retryable') unless build.retryable? build = Ci::Build.retry(build, current_user) @@ -157,8 +160,7 @@ module API post ':id/builds/:build_id/erase' do authorize_update_builds! - build = get_build(params[:build_id]) - return not_found!(build) unless build + build = get_build!(params[:build_id]) return forbidden!('Build is not erasable!') unless build.erasable? build.erase(erased_by: current_user) @@ -176,8 +178,8 @@ module API post ':id/builds/:build_id/artifacts/keep' do authorize_update_builds! - build = get_build(params[:build_id]) - return not_found!(build) unless build && build.artifacts? + build = get_build!(params[:build_id]) + return not_found!(build) unless build.artifacts? build.keep_artifacts! @@ -192,6 +194,20 @@ module API user_project.builds.find_by(id: id.to_i) end + def get_build!(id) + get_build(id) || not_found! + end + + def present_artifact!(artifacts_file) + if !artifacts_file.file_storage? + redirect_to(build.artifacts_file.url) + elsif artifacts_file.exists? + present_file!(artifacts_file.path, artifacts_file.filename) + else + not_found! + end + end + def filter_builds(builds, scope) return builds if scope.nil? || scope.empty? diff --git a/spec/models/build_spec.rb b/spec/models/build_spec.rb index 481416319dd9cd01ff8607045578839223679c09..355cb8fdfff1a034823352fb9d7992f24c8de5a1 100644 --- a/spec/models/build_spec.rb +++ b/spec/models/build_spec.rb @@ -5,7 +5,9 @@ describe Ci::Build, models: true do let(:pipeline) do create(:ci_pipeline, project: project, - sha: project.commit.id) + sha: project.commit.id, + ref: 'fix', + status: 'success') end let(:build) { create(:ci_build, pipeline: pipeline) } @@ -612,7 +614,7 @@ describe Ci::Build, models: true do describe '#erasable?' do subject { build.erasable? } - it { is_expected.to eq true } + it { is_expected.to be_truthy } end describe '#erased?' do @@ -620,7 +622,7 @@ describe Ci::Build, models: true do subject { build.erased? } context 'build has not been erased' do - it { is_expected.to be false } + it { is_expected.to be_falsey } end context 'build has been erased' do @@ -628,12 +630,13 @@ describe Ci::Build, models: true do build.erase end - it { is_expected.to be true } + it { is_expected.to be_truthy } end end context 'metadata and build trace are not available' do let!(:build) { create(:ci_build, :success, :artifacts) } + before do build.remove_artifacts_metadata! end @@ -655,18 +658,58 @@ describe Ci::Build, models: true do describe '#retryable?' do context 'when build is running' do - before { build.run! } + before do + build.run! + end - it 'should return false' do - expect(build.retryable?).to be false + it 'returns false' do + expect(build).not_to be_retryable end end context 'when build is finished' do - before { build.success! } + before do + build.success! + end + + it 'returns true' do + expect(build).to be_retryable + end + end + end + + describe 'Project#latest_successful_builds_for' do + let(:build) do + create(:ci_build, :artifacts, :success, pipeline: pipeline) + end + + before do + build + end + + context 'with succeed pipeline' do + it 'returns builds from ref' do + builds = project.latest_successful_builds_for('fix') + + expect(builds).to contain_exactly(build) + end + + it 'returns empty relation if the build cannot be found' do + builds = project.latest_successful_builds_for('TAIL').all + + expect(builds).to be_empty + end + end + + context 'with pending pipeline' do + before do + pipeline.update(status: 'pending') + end + + it 'returns empty relation' do + builds = project.latest_successful_builds_for('fix').all - it 'should return true' do - expect(build.retryable?).to be true + expect(builds).to be_empty end end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 9dc34276f188edbb0617ccb1434e3c67180e22bb..53b420d808fa8bdc59df0d51f29f4298e2f198f2 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -377,7 +377,7 @@ describe Project, models: true do describe '#repository' do let(:project) { create(:project) } - it 'should return valid repo' do + it 'returns valid repo' do expect(project.repository).to be_kind_of(Repository) end end diff --git a/spec/requests/api/builds_spec.rb b/spec/requests/api/builds_spec.rb index f5b39c3d69857663d00bc57e201d2eb76ad882a8..47bcc0eebddc663c8392c2a36a8cc0e9ee40cf20 100644 --- a/spec/requests/api/builds_spec.rb +++ b/spec/requests/api/builds_spec.rb @@ -1,21 +1,26 @@ require 'spec_helper' -describe API::API, api: true do +describe API::API, api: true do include ApiHelpers let(:user) { create(:user) } let(:api_user) { user } let(:user2) { create(:user) } - let!(:project) { create(:project, creator_id: user.id) } - let!(:developer) { create(:project_member, :developer, user: user, project: project) } - let!(:reporter) { create(:project_member, :reporter, user: user2, project: project) } - let!(:pipeline) { create(:ci_pipeline, project: project, sha: project.commit.id) } - let!(:build) { create(:ci_build, pipeline: pipeline) } + let(:project) { create(:project, creator_id: user.id) } + let(:developer) { create(:project_member, :developer, user: user, project: project) } + let(:reporter) { create(:project_member, :reporter, user: user2, project: project) } + let(:pipeline) { create(:ci_pipeline, project: project, sha: project.commit.id) } + let(:build) { create(:ci_build, pipeline: pipeline) } describe 'GET /projects/:id/builds ' do let(:query) { '' } - before { get api("/projects/#{project.id}/builds?#{query}", api_user) } + before do + developer + build + + get api("/projects/#{project.id}/builds?#{query}", api_user) + end context 'authorized user' do it 'should return project builds' do @@ -77,9 +82,9 @@ describe API::API, api: true do context 'when user is authorized' do context 'when pipeline has builds' do before do - create(:ci_pipeline, project: project, sha: project.commit.id) + developer + build create(:ci_build, pipeline: pipeline) - create(:ci_build) get api("/projects/#{project.id}/repository/commits/#{project.commit.id}/builds", api_user) end @@ -93,6 +98,8 @@ describe API::API, api: true do context 'when pipeline has no builds' do before do + developer + branch_head = project.commit('feature').id get api("/projects/#{project.id}/repository/commits/#{branch_head}/builds", api_user) end @@ -107,8 +114,7 @@ describe API::API, api: true do context 'when user is not authorized' do before do - create(:ci_pipeline, project: project, sha: project.commit.id) - create(:ci_build, pipeline: pipeline) + build get api("/projects/#{project.id}/repository/commits/#{project.commit.id}/builds", nil) end @@ -122,7 +128,11 @@ describe API::API, api: true do end describe 'GET /projects/:id/builds/:build_id' do - before { get api("/projects/#{project.id}/builds/#{build.id}", api_user) } + before do + developer + + get api("/projects/#{project.id}/builds/#{build.id}", api_user) + end context 'authorized user' do it 'should return specific build data' do @@ -141,7 +151,11 @@ describe API::API, api: true do end describe 'GET /projects/:id/builds/:build_id/artifacts' do - before { get api("/projects/#{project.id}/builds/#{build.id}/artifacts", api_user) } + before do + developer + + get api("/projects/#{project.id}/builds/#{build.id}/artifacts", api_user) + end context 'build with artifacts' do let(:build) { create(:ci_build, :artifacts, pipeline: pipeline) } @@ -172,10 +186,146 @@ describe API::API, api: true do end end + describe 'GET /projects/:id/artifacts/:ref_name/download?job=name' do + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:pipeline) do + create(:ci_pipeline, + project: project, + sha: project.commit('fix').sha, + ref: 'fix') + end + let(:build) { create(:ci_build, :success, :artifacts, pipeline: pipeline) } + + before do + project.team << [user, :developer] + end + + def path_from_ref(ref = pipeline.ref, job = build.name) + api("/projects/#{project.id}/builds/artifacts/#{ref}/download?job=#{job}", user) + end + + context 'not logging in' do + let(:user) { nil } + + before do + get path_from_ref + end + + it 'gives 401 for unauthorized user' do + expect(response).to have_http_status(401) + end + end + + context 'non-existing build' do + def verify + expect(response).to have_http_status(404) + end + + context 'has no such ref' do + before do + get path_from_ref('TAIL', build.name) + end + + it('gives 404') { verify } + end + + context 'has no such build' do + before do + get path_from_ref(pipeline.ref, 'NOBUILD') + end + + it('gives 404') { verify } + end + end + + context 'find proper build' do + def verify + download_headers = + { 'Content-Transfer-Encoding' => 'binary', + 'Content-Disposition' => + "attachment; filename=#{build.artifacts_file.filename}" } + + expect(response).to have_http_status(200) + expect(response.headers).to include(download_headers) + end + + def create_new_pipeline(status) + new_pipeline = create(:ci_pipeline, status: 'success') + create(:ci_build, status, :artifacts, pipeline: new_pipeline) + end + + context 'with sha' do + before do + get path_from_ref(pipeline.sha) + end + + it('gives the file') { verify } + end + + context 'with regular branch' do + before do + pipeline.update(ref: 'master', + sha: project.commit('master').sha) + end + + before do + get path_from_ref('master') + end + + it('gives the file') { verify } + end + + context 'with branch name containing slash' do + before do + pipeline.update(ref: 'improve/awesome', + sha: project.commit('improve/awesome').sha) + end + + before do + get path_from_ref('improve/awesome') + end + + it('gives the file') { verify } + end + + context 'with latest pipeline' do + before do + 3.times do # creating some old pipelines + create_new_pipeline(:success) + end + end + + before do + get path_from_ref + end + + it('gives the file') { verify } + end + + context 'with success pipeline' do + before do + build # make sure pipeline was old, but still the latest success one + create_new_pipeline(:pending) + end + + before do + get path_from_ref + end + + it('gives the file') { verify } + end + end + end + describe 'GET /projects/:id/builds/:build_id/trace' do let(:build) { create(:ci_build, :trace, pipeline: pipeline) } - before { get api("/projects/#{project.id}/builds/#{build.id}/trace", api_user) } + before do + developer + + get api("/projects/#{project.id}/builds/#{build.id}/trace", api_user) + end context 'authorized user' do it 'should return specific build trace' do @@ -194,7 +344,12 @@ describe API::API, api: true do end describe 'POST /projects/:id/builds/:build_id/cancel' do - before { post api("/projects/#{project.id}/builds/#{build.id}/cancel", api_user) } + before do + developer + reporter + + post api("/projects/#{project.id}/builds/#{build.id}/cancel", api_user) + end context 'authorized user' do context 'user with :update_build persmission' do @@ -225,7 +380,12 @@ describe API::API, api: true do describe 'POST /projects/:id/builds/:build_id/retry' do let(:build) { create(:ci_build, :canceled, pipeline: pipeline) } - before { post api("/projects/#{project.id}/builds/#{build.id}/retry", api_user) } + before do + developer + reporter + + post api("/projects/#{project.id}/builds/#{build.id}/retry", api_user) + end context 'authorized user' do context 'user with :update_build permission' do @@ -256,6 +416,8 @@ describe API::API, api: true do describe 'POST /projects/:id/builds/:build_id/erase' do before do + developer + post api("/projects/#{project.id}/builds/#{build.id}/erase", user) end @@ -286,6 +448,8 @@ describe API::API, api: true do describe 'POST /projects/:id/builds/:build_id/artifacts/keep' do before do + developer + post api("/projects/#{project.id}/builds/#{build.id}/artifacts/keep", user) end