-
+
({
isLoading: true,
+ isTreeLoaded: false,
isBatchLoading: false,
retrievingBatches: false,
addedLines: null,
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index 7e89d041c21..0d41f1c2178 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -323,6 +323,7 @@ export default {
[types.SET_TREE_DATA](state, { treeEntries, tree }) {
state.treeEntries = treeEntries;
state.tree = tree;
+ state.isTreeLoaded = true;
},
[types.SET_RENDER_TREE_LIST](state, renderTreeList) {
state.renderTreeList = renderTreeList;
diff --git a/app/assets/javascripts/pages/projects/product_analytics/graphs/index.js b/app/assets/javascripts/pages/projects/product_analytics/graphs/index.js
new file mode 100644
index 00000000000..0539d318471
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/product_analytics/graphs/index.js
@@ -0,0 +1,3 @@
+import initActivityCharts from '~/analytics/product_analytics/activity_charts_bundle';
+
+document.addEventListener('DOMContentLoaded', () => initActivityCharts());
diff --git a/app/controllers/groups/variables_controller.rb b/app/controllers/groups/variables_controller.rb
index 02b015e8e53..fb639f6e472 100644
--- a/app/controllers/groups/variables_controller.rb
+++ b/app/controllers/groups/variables_controller.rb
@@ -15,7 +15,12 @@ module Groups
end
def update
- if @group.update(group_variables_params)
+ update_result = Ci::ChangeVariablesService.new(
+ container: @group, current_user: current_user,
+ params: group_variables_params
+ ).execute
+
+ if update_result
respond_to do |format|
format.json { render_group_variables }
end
diff --git a/app/controllers/projects/product_analytics_controller.rb b/app/controllers/projects/product_analytics_controller.rb
index af2b2a502c3..badd7671dcf 100644
--- a/app/controllers/projects/product_analytics_controller.rb
+++ b/app/controllers/projects/product_analytics_controller.rb
@@ -16,6 +16,19 @@ class Projects::ProductAnalyticsController < Projects::ApplicationController
@event = product_analytics_events.try(:first)
end
+ def graphs
+ @graphs = []
+ @timerange = 30
+
+ requested_graphs = %w(platform os_timezone br_lang doc_charset)
+
+ requested_graphs.each do |graph|
+ @graphs << ProductAnalytics::BuildGraphService
+ .new(project, { graph: graph, timerange: @timerange })
+ .execute
+ end
+ end
+
private
def product_analytics_events
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index dbce6d24102..473087b7c2d 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -249,14 +249,6 @@ module Ci
pipeline.run_after_commit { AutoDevops::DisableWorker.perform_async(pipeline.id) }
end
-
- after_transition any => [:success] do |pipeline|
- next unless Gitlab::Ci::Features.keep_latest_artifacts_for_ref_enabled?(pipeline.project)
-
- pipeline.run_after_commit do
- Ci::PipelineSuccessUnlockArtifactsWorker.perform_async(pipeline.id)
- end
- end
end
scope :internal, -> { where(source: internal_sources) }
diff --git a/app/models/ci/ref.rb b/app/models/ci/ref.rb
index 356af32b0d7..3d8823728e7 100644
--- a/app/models/ci/ref.rb
+++ b/app/models/ci/ref.rb
@@ -3,6 +3,7 @@
module Ci
class Ref < ApplicationRecord
extend Gitlab::Ci::Model
+ include AfterCommitQueue
include Gitlab::OptimisticLocking
FAILING_STATUSES = %w[failed broken still_failing].freeze
@@ -15,6 +16,7 @@ module Ci
transition unknown: :success
transition fixed: :success
transition %i[failed broken still_failing] => :fixed
+ transition success: same
end
event :do_fail do
@@ -29,6 +31,14 @@ module Ci
state :fixed, value: 3
state :broken, value: 4
state :still_failing, value: 5
+
+ after_transition any => [:fixed, :success] do |ci_ref|
+ next unless ::Gitlab::Ci::Features.keep_latest_artifacts_for_ref_enabled?(ci_ref.project)
+
+ ci_ref.run_after_commit do
+ Ci::PipelineSuccessUnlockArtifactsWorker.perform_async(ci_ref.last_finished_pipeline_id)
+ end
+ end
end
class << self
diff --git a/app/models/commit_status_enums.rb b/app/models/commit_status_enums.rb
index caebff91022..f6b8c08b8e2 100644
--- a/app/models/commit_status_enums.rb
+++ b/app/models/commit_status_enums.rb
@@ -19,13 +19,14 @@ module CommitStatusEnums
scheduler_failure: 11,
data_integrity_failure: 12,
forward_deployment_failure: 13,
+ protected_environment_failure: 1_000,
insufficient_bridge_permissions: 1_001,
downstream_bridge_project_not_found: 1_002,
invalid_bridge_trigger: 1_003,
+ upstream_bridge_project_not_found: 1_004,
+ insufficient_upstream_permissions: 1_005,
bridge_pipeline_is_child_pipeline: 1_006,
downstream_pipeline_creation_failed: 1_007
}
end
end
-
-CommitStatusEnums.prepend_if_ee('EE::CommitStatusEnums')
diff --git a/app/models/external_pull_request.rb b/app/models/external_pull_request.rb
index 9c6d05f773a..1487a6387f0 100644
--- a/app/models/external_pull_request.rb
+++ b/app/models/external_pull_request.rb
@@ -63,6 +63,8 @@ class ExternalPullRequest < ApplicationRecord
def predefined_variables
Gitlab::Ci::Variables::Collection.new.tap do |variables|
variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_IID', value: pull_request_iid.to_s)
+ variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_SOURCE_REPOSITORY', value: source_repository)
+ variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_TARGET_REPOSITORY', value: target_repository)
variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_SOURCE_BRANCH_SHA', value: source_sha)
variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_TARGET_BRANCH_SHA', value: target_sha)
variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_SOURCE_BRANCH_NAME', value: source_branch)
diff --git a/app/models/product_analytics_event.rb b/app/models/product_analytics_event.rb
index 607646715e4..579ea88c272 100644
--- a/app/models/product_analytics_event.rb
+++ b/app/models/product_analytics_event.rb
@@ -20,6 +20,10 @@ class ProductAnalyticsEvent < ApplicationRecord
where('collector_tstamp BETWEEN ? AND ? ', today - duration + 1, today + 1)
}
+ def self.count_by_graph(graph, days)
+ group(graph).timerange(days).count
+ end
+
def as_json_wo_empty
as_json.compact
end
diff --git a/app/services/ci/change_variable_service.rb b/app/services/ci/change_variable_service.rb
new file mode 100644
index 00000000000..6588ad598f9
--- /dev/null
+++ b/app/services/ci/change_variable_service.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Ci
+ class ChangeVariableService < BaseContainerService
+ def execute
+ case params[:action]
+ when :create
+ container.variables.create(params[:variable_params])
+ when :update
+ variable.tap do |target_variable|
+ target_variable.update(params[:variable_params].except(:key))
+ end
+ when :destroy
+ variable.tap do |target_variable|
+ target_variable.destroy
+ end
+ end
+ end
+
+ private
+
+ def variable
+ container.variables.find_by!(params[:variable_params].slice(:key)) # rubocop:disable CodeReuse/ActiveRecord
+ end
+ end
+end
+
+::Ci::ChangeVariableService.prepend_if_ee('EE::Ci::ChangeVariableService')
diff --git a/app/services/ci/change_variables_service.rb b/app/services/ci/change_variables_service.rb
new file mode 100644
index 00000000000..3337eb09411
--- /dev/null
+++ b/app/services/ci/change_variables_service.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Ci
+ class ChangeVariablesService < BaseContainerService
+ def execute
+ container.update(params)
+ end
+ end
+end
+
+::Ci::ChangeVariablesService.prepend_if_ee('EE::Ci::ChangeVariablesService')
diff --git a/app/services/git/tag_push_service.rb b/app/services/git/tag_push_service.rb
index 120c4cde94b..641fe8e3916 100644
--- a/app/services/git/tag_push_service.rb
+++ b/app/services/git/tag_push_service.rb
@@ -26,9 +26,5 @@ module Git
def removing_tag?
Gitlab::Git.blank_ref?(newrev)
end
-
- def tag_name
- Gitlab::Git.ref_name(ref)
- end
end
end
diff --git a/app/services/product_analytics/build_graph_service.rb b/app/services/product_analytics/build_graph_service.rb
new file mode 100644
index 00000000000..31f9f093bb9
--- /dev/null
+++ b/app/services/product_analytics/build_graph_service.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module ProductAnalytics
+ class BuildGraphService
+ def initialize(project, params)
+ @project = project
+ @params = params
+ end
+
+ def execute
+ graph = @params[:graph].to_sym
+ timerange = @params[:timerange].days
+
+ results = product_analytics_events.count_by_graph(graph, timerange)
+
+ {
+ id: graph,
+ keys: results.keys,
+ values: results.values
+ }
+ end
+
+ private
+
+ def product_analytics_events
+ @project.product_analytics_events
+ end
+ end
+end
diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml
index bb278fbf311..0e4fcb8a191 100644
--- a/app/views/projects/_import_project_pane.html.haml
+++ b/app/views/projects/_import_project_pane.html.haml
@@ -56,8 +56,9 @@
- if git_import_enabled?
%div
- %button.btn.js-toggle-button.js-import-git-toggle-button{ type: "button", data: { toggle_open_class: 'active' }, **tracking_attrs(track_label, 'click_button', 'repo_url') }
- = icon('git', text: 'Repo by URL')
+ %button.btn.btn-svg.js-toggle-button.js-import-git-toggle-button{ type: "button", data: { toggle_open_class: 'active' }, **tracking_attrs(track_label, 'click_button', 'repo_url') }
+ = sprite_icon('link', size: 16, css_class: 'gl-icon')
+ = _('Repo by URL')
- if manifest_import_enabled?
%div
diff --git a/app/views/projects/product_analytics/_graph.html.haml b/app/views/projects/product_analytics/_graph.html.haml
new file mode 100644
index 00000000000..fd81a248005
--- /dev/null
+++ b/app/views/projects/product_analytics/_graph.html.haml
@@ -0,0 +1,6 @@
+- graph = local_assigns.fetch(:graph)
+
+%h3
+ = graph[:id]
+
+.js-project-analytics-chart{ "data-chart-data": graph.to_json, "data-chart-id": graph[:id] }
diff --git a/app/views/projects/product_analytics/_links.html.haml b/app/views/projects/product_analytics/_links.html.haml
index 35c4ec6aec5..0797c5baf91 100644
--- a/app/views/projects/product_analytics/_links.html.haml
+++ b/app/views/projects/product_analytics/_links.html.haml
@@ -2,6 +2,8 @@
%ul.nav-links
= nav_link(path: 'product_analytics#index') do
= link_to _('Events'), project_product_analytics_path(@project)
+ = nav_link(path: 'product_analytics#graphs') do
+ = link_to 'Graphs', graphs_project_product_analytics_path(@project)
= nav_link(path: 'product_analytics#test') do
= link_to _('Test'), test_project_product_analytics_path(@project)
= nav_link(path: 'product_analytics#setup') do
diff --git a/app/views/projects/product_analytics/graphs.html.haml b/app/views/projects/product_analytics/graphs.html.haml
new file mode 100644
index 00000000000..89286061594
--- /dev/null
+++ b/app/views/projects/product_analytics/graphs.html.haml
@@ -0,0 +1,12 @@
+- page_title _('Product Analytics')
+
+= render 'links'
+
+%p
+ = _('Showing graphs based on events of the last %{timerange} days.') % { timerange: @timerange }
+
+- @graphs.each_slice(2) do |pair|
+ .row.append-bottom-10
+ - pair.each do |graph|
+ .col-md-6{ id: graph[:id] }
+ = render 'graph', graph: graph
diff --git a/app/views/projects/product_analytics/index.html.haml b/app/views/projects/product_analytics/index.html.haml
index 20e44904f74..386f9265179 100644
--- a/app/views/projects/product_analytics/index.html.haml
+++ b/app/views/projects/product_analytics/index.html.haml
@@ -1,4 +1,4 @@
-- page_title 'Product Analytics'
+- page_title _('Product Analytics')
= render 'links'
diff --git a/app/views/projects/product_analytics/setup.html.haml b/app/views/projects/product_analytics/setup.html.haml
index 61fea8c91f7..e1819c7d74b 100644
--- a/app/views/projects/product_analytics/setup.html.haml
+++ b/app/views/projects/product_analytics/setup.html.haml
@@ -1,4 +1,7 @@
-= render "links"
+- page_title _('Product Analytics')
+
+= render 'links'
+
%p
= _('Copy the code below to implement tracking in your application:')
diff --git a/app/views/projects/product_analytics/test.html.haml b/app/views/projects/product_analytics/test.html.haml
index c1ce8ac8038..60d897ee138 100644
--- a/app/views/projects/product_analytics/test.html.haml
+++ b/app/views/projects/product_analytics/test.html.haml
@@ -1,3 +1,5 @@
+- page_title _('Product Analytics')
+
= render 'links'
%p
diff --git a/changelogs/unreleased/223831-pr-repository-env-var.yml b/changelogs/unreleased/223831-pr-repository-env-var.yml
new file mode 100644
index 00000000000..e518db3615c
--- /dev/null
+++ b/changelogs/unreleased/223831-pr-repository-env-var.yml
@@ -0,0 +1,5 @@
+---
+title: "Add ENV vars that expose source and target repository for CI Pipelines that run on an External Pull Requests."
+merge_request: 37616
+author: Rafael Dohms @rdohms
+type: added
diff --git a/changelogs/unreleased/225903-replace-fa-git-icons-with-gitlab-svg-link-icon.yml b/changelogs/unreleased/225903-replace-fa-git-icons-with-gitlab-svg-link-icon.yml
new file mode 100644
index 00000000000..dac6af93834
--- /dev/null
+++ b/changelogs/unreleased/225903-replace-fa-git-icons-with-gitlab-svg-link-icon.yml
@@ -0,0 +1,5 @@
+---
+title: Replace fa-git icons with link svg
+merge_request: 38078
+author:
+type: changed
diff --git a/changelogs/unreleased/227588-add-loading-to-mr-tree.yml b/changelogs/unreleased/227588-add-loading-to-mr-tree.yml
new file mode 100644
index 00000000000..5d9fadcc9b5
--- /dev/null
+++ b/changelogs/unreleased/227588-add-loading-to-mr-tree.yml
@@ -0,0 +1,5 @@
+---
+title: Keep large spinner while MR file tree is loading
+merge_request: 36446
+author:
+type: fixed
diff --git a/changelogs/unreleased/add-sign_in_count-to-user-api.yml b/changelogs/unreleased/add-sign_in_count-to-user-api.yml
new file mode 100644
index 00000000000..893295f93b1
--- /dev/null
+++ b/changelogs/unreleased/add-sign_in_count-to-user-api.yml
@@ -0,0 +1,5 @@
+---
+title: Add sign_in_count to /users/:id API for admins
+merge_request: 35726
+author: Luc Didry
+type: changed
diff --git a/config/initializers/stackprof.rb b/config/initializers/stackprof.rb
index 5497ff9a459..797efdb9bbd 100644
--- a/config/initializers/stackprof.rb
+++ b/config/initializers/stackprof.rb
@@ -8,94 +8,122 @@
# * timeout profile after 30 seconds
# * write to $TMPDIR/stackprof.$PID.$RAND.profile
-if Gitlab::Utils.to_boolean(ENV['STACKPROF_ENABLED'].to_s)
- Gitlab::Cluster::LifecycleEvents.on_worker_start do
- require 'stackprof'
- require 'tmpdir'
+module Gitlab
+ class StackProf
+ # this is a workaround for sidekiq, which defines its own SIGUSR2 handler.
+ # by defering to the sidekiq startup event, we get to set up our own
+ # handler late enough.
+ # see also: https://github.com/mperham/sidekiq/pull/4653
+ def self.install
+ require 'stackprof'
+ require 'tmpdir'
- Gitlab::AppJsonLogger.info "stackprof: listening on SIGUSR2 signal"
-
- # create a pipe in order to propagate signal out of the signal handler
- # see also: https://cr.yp.to/docs/selfpipe.html
- read, write = IO.pipe
-
- # create a separate thread that polls for signals on the pipe.
- #
- # this way we do not execute in signal handler context, which
- # lifts restrictions and also serializes the calls in a thread-safe
- # manner.
- #
- # it's very similar to a goroutine and channel design.
- #
- # another nice benefit of this method is that we can timeout the
- # IO.select call, allowing the profile to automatically stop after
- # a given interval (by default 30 seconds), avoiding unbounded memory
- # growth from a profile that was started and never stopped.
- t = Thread.new do
- timeout_s = ENV['STACKPROF_TIMEOUT_S']&.to_i || 30
- current_timeout_s = nil
- loop do
- got_value = IO.select([read], nil, nil, current_timeout_s)
- read.getbyte if got_value
-
- if StackProf.running?
- stackprof_file_prefix = ENV['STACKPROF_FILE_PREFIX'] || Dir.tmpdir
- stackprof_out_file = "#{stackprof_file_prefix}/stackprof.#{Process.pid}.#{SecureRandom.hex(6)}.profile"
-
- Gitlab::AppJsonLogger.info(
- event: "stackprof",
- message: "stopping profile",
- output_filename: stackprof_out_file,
- pid: Process.pid,
- timeout_s: timeout_s,
- timed_out: got_value.nil?
- )
-
- StackProf.stop
- StackProf.results(stackprof_out_file)
- current_timeout_s = nil
- else
- Gitlab::AppJsonLogger.info(
- event: "stackprof",
- message: "starting profile",
- pid: Process.pid
- )
-
- StackProf.start(
- mode: :cpu,
- raw: Gitlab::Utils.to_boolean(ENV['STACKPROF_RAW'] || 'true'),
- interval: ENV['STACKPROF_INTERVAL_US']&.to_i || 10_000
- )
- current_timeout_s = timeout_s
+ if Gitlab::Runtime.sidekiq?
+ Sidekiq.configure_server do |config|
+ config.on :startup do
+ on_worker_start
+ end
+ end
+ else
+ Gitlab::Cluster::LifecycleEvents.on_worker_start do
+ on_worker_start
end
end
end
- t.abort_on_exception = true
- # in the case of puma, this will override the existing SIGUSR2 signal handler
- # that can be used to trigger a restart.
- #
- # puma cluster has two types of restarts:
- # * SIGUSR1: phased restart
- # * SIGUSR2: restart
- #
- # phased restart is not supported in our configuration, because we use
- # preload_app. this means we will always perform a normal restart.
- # additionally, phased restart is not supported when sending a SIGUSR2
- # directly to a puma worker (as opposed to the master process).
- #
- # the result is that the behaviour of SIGUSR1 and SIGUSR2 is identical in
- # our configuration, and we can always use a SIGUSR1 to perform a restart.
- #
- # thus, it is acceptable for us to re-appropriate the SIGUSR2 signal, and
- # override the puma behaviour.
- #
- # see also:
- # * https://github.com/puma/puma/blob/master/docs/signals.md#puma-signals
- # * https://github.com/phusion/unicorn/blob/master/SIGNALS
- # * https://github.com/mperham/sidekiq/wiki/Signals
- Signal.trap('SIGUSR2') do
- write.write('.')
+ def self.on_worker_start
+ Gitlab::AppJsonLogger.info(
+ event: "stackprof",
+ message: "listening on SIGUSR2 signal",
+ pid: Process.pid
+ )
+
+ # create a pipe in order to propagate signal out of the signal handler
+ # see also: https://cr.yp.to/docs/selfpipe.html
+ read, write = IO.pipe
+
+ # create a separate thread that polls for signals on the pipe.
+ #
+ # this way we do not execute in signal handler context, which
+ # lifts restrictions and also serializes the calls in a thread-safe
+ # manner.
+ #
+ # it's very similar to a goroutine and channel design.
+ #
+ # another nice benefit of this method is that we can timeout the
+ # IO.select call, allowing the profile to automatically stop after
+ # a given interval (by default 30 seconds), avoiding unbounded memory
+ # growth from a profile that was started and never stopped.
+ t = Thread.new do
+ timeout_s = ENV['STACKPROF_TIMEOUT_S']&.to_i || 30
+ current_timeout_s = nil
+ loop do
+ got_value = IO.select([read], nil, nil, current_timeout_s)
+ read.getbyte if got_value
+
+ if ::StackProf.running?
+ stackprof_file_prefix = ENV['STACKPROF_FILE_PREFIX'] || Dir.tmpdir
+ stackprof_out_file = "#{stackprof_file_prefix}/stackprof.#{Process.pid}.#{SecureRandom.hex(6)}.profile"
+
+ Gitlab::AppJsonLogger.info(
+ event: "stackprof",
+ message: "stopping profile",
+ output_filename: stackprof_out_file,
+ pid: Process.pid,
+ timeout_s: timeout_s,
+ timed_out: got_value.nil?
+ )
+
+ ::StackProf.stop
+ ::StackProf.results(stackprof_out_file)
+ current_timeout_s = nil
+ else
+ Gitlab::AppJsonLogger.info(
+ event: "stackprof",
+ message: "starting profile",
+ pid: Process.pid
+ )
+
+ ::StackProf.start(
+ mode: :cpu,
+ raw: Gitlab::Utils.to_boolean(ENV['STACKPROF_RAW'] || 'true'),
+ interval: ENV['STACKPROF_INTERVAL_US']&.to_i || 10_000
+ )
+ current_timeout_s = timeout_s
+ end
+ end
+ end
+ t.abort_on_exception = true
+
+ # in the case of puma, this will override the existing SIGUSR2 signal handler
+ # that can be used to trigger a restart.
+ #
+ # puma cluster has two types of restarts:
+ # * SIGUSR1: phased restart
+ # * SIGUSR2: restart
+ #
+ # phased restart is not supported in our configuration, because we use
+ # preload_app. this means we will always perform a normal restart.
+ # additionally, phased restart is not supported when sending a SIGUSR2
+ # directly to a puma worker (as opposed to the master process).
+ #
+ # the result is that the behaviour of SIGUSR1 and SIGUSR2 is identical in
+ # our configuration, and we can always use a SIGUSR1 to perform a restart.
+ #
+ # thus, it is acceptable for us to re-appropriate the SIGUSR2 signal, and
+ # override the puma behaviour.
+ #
+ # see also:
+ # * https://github.com/puma/puma/blob/master/docs/signals.md#puma-signals
+ # * https://github.com/phusion/unicorn/blob/master/SIGNALS
+ # * https://github.com/mperham/sidekiq/wiki/Signals
+ Signal.trap('SIGUSR2') do
+ write.write('.')
+ end
end
end
end
+
+if Gitlab::Utils.to_boolean(ENV['STACKPROF_ENABLED'].to_s)
+ Gitlab::StackProf.install
+end
diff --git a/config/routes/project.rb b/config/routes/project.rb
index 89e761101b6..fd842770546 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -310,6 +310,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
collection do
get :setup
get :test
+ get :graphs
end
end
diff --git a/doc/api/users.md b/doc/api/users.md
index 0bd33a74771..a8339c3b61a 100644
--- a/doc/api/users.md
+++ b/doc/api/users.md
@@ -314,7 +314,8 @@ Example Responses:
"current_sign_in_ip": "196.165.1.102",
"last_sign_in_ip": "172.127.2.22",
"plan": "gold",
- "trial": true
+ "trial": true,
+ "sign_in_count": 1337
}
```
diff --git a/doc/ci/variables/predefined_variables.md b/doc/ci/variables/predefined_variables.md
index 98a2e7ae22f..ff80825c185 100644
--- a/doc/ci/variables/predefined_variables.md
+++ b/doc/ci/variables/predefined_variables.md
@@ -56,6 +56,8 @@ Kubernetes-specific environment variables are detailed in the
| `CI_ENVIRONMENT_SLUG` | 8.15 | all | A simplified version of the environment name, suitable for inclusion in DNS, URLs, Kubernetes labels, etc. Only present if [`environment:name`](../yaml/README.md#environmentname) is set. |
| `CI_ENVIRONMENT_URL` | 9.3 | all | The URL of the environment for this job. Only present if [`environment:url`](../yaml/README.md#environmenturl) is set. |
| `CI_EXTERNAL_PULL_REQUEST_IID` | 12.3 | all | Pull Request ID from GitHub if the [pipelines are for external pull requests](../ci_cd_for_external_repos/index.md#pipelines-for-external-pull-requests). Available only if `only: [external_pull_requests]` or [`rules`](../yaml/README.md#rules) syntax is used and the pull request is open. |
+| `CI_EXTERNAL_PULL_REQUEST_SOURCE_REPOSITORY` | 13.3 | all | The source repository name of the pull request if [the pipelines are for external pull requests](../ci_cd_for_external_repos/index.md#pipelines-for-external-pull-requests). Available only if `only: [external_pull_requests]` or [`rules`](../yaml/README.md#rules) syntax is used and the pull request is open. |
+| `CI_EXTERNAL_PULL_REQUEST_TARGET_REPOSITORY` | 13.3 | all | The target repository name of the pull request if [the pipelines are for external pull requests](../ci_cd_for_external_repos/index.md#pipelines-for-external-pull-requests). Available only if `only: [external_pull_requests]` or [`rules`](../yaml/README.md#rules) syntax is used and the pull request is open. |
| `CI_EXTERNAL_PULL_REQUEST_SOURCE_BRANCH_NAME` | 12.3 | all | The source branch name of the pull request if [the pipelines are for external pull requests](../ci_cd_for_external_repos/index.md#pipelines-for-external-pull-requests). Available only if `only: [external_pull_requests]` or [`rules`](../yaml/README.md#rules) syntax is used and the pull request is open. |
| `CI_EXTERNAL_PULL_REQUEST_SOURCE_BRANCH_SHA` | 12.3 | all | The HEAD SHA of the source branch of the pull request if [the pipelines are for external pull requests](../ci_cd_for_external_repos/index.md#pipelines-for-external-pull-requests). Available only if `only: [external_pull_requests]` or [`rules`](../yaml/README.md#rules) syntax is used and the pull request is open. |
| `CI_EXTERNAL_PULL_REQUEST_TARGET_BRANCH_NAME` | 12.3 | all | The target branch name of the pull request if [the pipelines are for external pull requests](../ci_cd_for_external_repos/index.md#pipelines-for-external-pull-requests). Available only if `only: [external_pull_requests]` or [`rules`](../yaml/README.md#rules) syntax is used and the pull request is open. |
diff --git a/doc/development/fe_guide/graphql.md b/doc/development/fe_guide/graphql.md
index 3d74ec94ae4..3f6ae556c23 100644
--- a/doc/development/fe_guide/graphql.md
+++ b/doc/development/fe_guide/graphql.md
@@ -75,7 +75,7 @@ their execution by clicking **Execute query** button on the top left:
## Apollo Client
To save duplicated clients getting created in different apps, we have a
-[default client](https://gitlab.com/gitlab-org/gitlab/blob/master/app/assets/javascripts/lib/graphql.js) that should be used. This setups the
+[default client](https://gitlab.com/gitlab-org/gitlab/blob/master/app/assets/javascripts/lib/graphql.js) that should be used. This sets up the
Apollo client with the correct URL and also sets the CSRF headers.
Default client accepts two parameters: `resolvers` and `config`.
diff --git a/doc/development/performance.md b/doc/development/performance.md
index 16ea1aa27ff..1dc7538c55b 100644
--- a/doc/development/performance.md
+++ b/doc/development/performance.md
@@ -281,6 +281,10 @@ This can be done via `pkill -USR2 puma:`. The `:` disambiguates between `puma
4.3.3.gitlab.2 ...` (the master process) from `puma: cluster worker 0: ...` (the
worker processes), selecting the latter.
+For Sidekiq, the signal can be sent to the `sidekiq-cluster` process via `pkill
+-USR2 bin/sidekiq-cluster`, which will forward the signal to all Sidekiq
+children. Alternatively, you can also select a specific pid of interest.
+
Production profiles can be especially noisy. It can be helpful to visualize them
as a [flamegraph](https://github.com/brendangregg/FlameGraph). This can be done
via:
diff --git a/doc/operations/metrics/alerts.md b/doc/operations/metrics/alerts.md
index 8923e99daec..b7513893912 100644
--- a/doc/operations/metrics/alerts.md
+++ b/doc/operations/metrics/alerts.md
@@ -82,15 +82,15 @@ Alerts can be used to trigger actions, like opening an issue automatically
1. Click **Save changes**.
After enabling, GitLab automatically opens an issue when an alert is triggered containing
-values extracted from [alert's payload](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config):
+values extracted from the [`alerts` field in webhook payload](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config):
- Issue author: `GitLab Alert Bot`
-- Issue title: Extract from `annotations/title`, `annotations/summary` or `labels/alertname`
-- Alert `Summary`: A list of properties
- - `starts_at`: Alert start time via `startsAt`
- - `full_query`: Alert query extracted from `generatorURL`
+- Issue title: Extracted from the alert payload fields `annotations/title`, `annotations/summary`, or `labels/alertname`.
+- Alert `Summary`: A list of properties from the alert's payload.
+ - `starts_at`: Alert start time from the payload's `startsAt` field
+ - `full_query`: Alert query extracted from the payload's `generatorURL` field
- Optional list of attached annotations extracted from `annotations/*`
-- Alert [GFM](../../user/markdown.md): GitLab Flavored Markdown from `annotations/gitlab_incident_markdown`
+- Alert [GFM](../../user/markdown.md): GitLab Flavored Markdown from the payload's `annotations/gitlab_incident_markdown` field.
When GitLab receives a **Recovery Alert**, it closes the associated issue.
This action is recorded as a system message on the issue indicating that it
diff --git a/lib/api/entities/user_details_with_admin.rb b/lib/api/entities/user_details_with_admin.rb
index 22a842983e2..e48b1da7859 100644
--- a/lib/api/entities/user_details_with_admin.rb
+++ b/lib/api/entities/user_details_with_admin.rb
@@ -6,6 +6,7 @@ module API
expose :highest_role
expose :current_sign_in_ip
expose :last_sign_in_ip
+ expose :sign_in_count
end
end
end
diff --git a/lib/api/group_variables.rb b/lib/api/group_variables.rb
index d3ca1c79e73..b5ff151f07d 100644
--- a/lib/api/group_variables.rb
+++ b/lib/api/group_variables.rb
@@ -51,9 +51,11 @@ module API
optional :variable_type, type: String, values: ::Ci::GroupVariable.variable_types.keys, desc: 'The type of variable, must be one of env_var or file. Defaults to env_var'
end
post ':id/variables' do
- variable_params = declared_params(include_missing: false)
-
- variable = user_group.variables.create(variable_params)
+ variable = ::Ci::ChangeVariableService.new(
+ container: user_group,
+ current_user: current_user,
+ params: { action: :create, variable_params: declared_params(include_missing: false) }
+ ).execute
if variable.valid?
present variable, with: Entities::Variable
@@ -74,17 +76,19 @@ module API
end
# rubocop: disable CodeReuse/ActiveRecord
put ':id/variables/:key' do
- variable = user_group.variables.find_by(key: params[:key])
+ variable = ::Ci::ChangeVariableService.new(
+ container: user_group,
+ current_user: current_user,
+ params: { action: :update, variable_params: declared_params(include_missing: false) }
+ ).execute
- break not_found!('GroupVariable') unless variable
-
- variable_params = declared_params(include_missing: false).except(:key)
-
- if variable.update(variable_params)
+ if variable.valid?
present variable, with: Entities::Variable
else
render_validation_error!(variable)
end
+ rescue ::ActiveRecord::RecordNotFound
+ not_found!('GroupVariable')
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -96,10 +100,17 @@ module API
end
# rubocop: disable CodeReuse/ActiveRecord
delete ':id/variables/:key' do
- variable = user_group.variables.find_by(key: params[:key])
- not_found!('GroupVariable') unless variable
+ variable = user_group.variables.find_by!(key: params[:key])
- destroy_conditionally!(variable)
+ destroy_conditionally!(variable) do |target_variable|
+ ::Ci::ChangeVariableService.new(
+ container: user_group,
+ current_user: current_user,
+ params: { action: :destroy, variable_params: declared_params(include_missing: false) }
+ ).execute
+ end
+ rescue ::ActiveRecord::RecordNotFound
+ not_found!('GroupVariable')
end
# rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 63fb91c9be5..e7b6387cb74 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -16406,6 +16406,9 @@ msgstr ""
msgid "Number of employees"
msgstr ""
+msgid "Number of events"
+msgstr ""
+
msgid "Number of events for this project: %{total_count}."
msgstr ""
@@ -17941,6 +17944,9 @@ msgstr ""
msgid "Product Analytics"
msgstr ""
+msgid "ProductAnalytics|There is no data for this type of chart currently. Please see the Setup tab if you have not configured the product analytics tool already."
+msgstr ""
+
msgid "Productivity"
msgstr ""
@@ -22018,6 +22024,9 @@ msgstr ""
msgid "Showing all issues"
msgstr ""
+msgid "Showing graphs based on events of the last %{timerange} days."
+msgstr ""
+
msgid "Showing last %{size} of log -"
msgstr ""
diff --git a/qa/qa/resource/kubernetes_cluster/project_cluster.rb b/qa/qa/resource/kubernetes_cluster/project_cluster.rb
index 5c61cc29236..78a24cdb677 100644
--- a/qa/qa/resource/kubernetes_cluster/project_cluster.rb
+++ b/qa/qa/resource/kubernetes_cluster/project_cluster.rb
@@ -5,7 +5,7 @@ module QA
module KubernetesCluster
class ProjectCluster < Base
attr_writer :cluster,
- :install_helm_tiller, :install_ingress, :install_prometheus, :install_runner, :domain
+ :install_ingress, :install_prometheus, :install_runner, :domain
attribute :project do
Resource::Project.fabricate!
@@ -36,33 +36,27 @@ module QA
cluster_page.add_cluster!
end
- if @install_helm_tiller
- Page::Project::Operations::Kubernetes::Show.perform do |show|
- # We must wait a few seconds for permissions to be set up correctly for new cluster
- sleep 10
+ Page::Project::Operations::Kubernetes::Show.perform do |show|
+ # We must wait a few seconds for permissions to be set up correctly for new cluster
+ sleep 25
- # Open applications tab
- show.open_applications
+ # Open applications tab
+ show.open_applications
- # Helm must be installed before everything else
- show.install!(:helm)
- show.await_installed(:helm)
+ show.install!(:ingress) if @install_ingress
+ show.install!(:prometheus) if @install_prometheus
+ show.install!(:runner) if @install_runner
- show.install!(:ingress) if @install_ingress
- show.install!(:prometheus) if @install_prometheus
- show.install!(:runner) if @install_runner
+ show.await_installed(:ingress) if @install_ingress
+ show.await_installed(:prometheus) if @install_prometheus
+ show.await_installed(:runner) if @install_runner
- show.await_installed(:ingress) if @install_ingress
- show.await_installed(:prometheus) if @install_prometheus
- show.await_installed(:runner) if @install_runner
+ if @install_ingress
+ populate(:ingress_ip)
- if @install_ingress
- populate(:ingress_ip)
-
- show.open_details
- show.set_domain("#{ingress_ip}.nip.io")
- show.save_domain
- end
+ show.open_details
+ show.set_domain("#{ingress_ip}.nip.io")
+ show.save_domain
end
end
end
diff --git a/qa/qa/service/docker_run/k3s.rb b/qa/qa/service/docker_run/k3s.rb
index bb9b6ca5179..07211b220f1 100644
--- a/qa/qa/service/docker_run/k3s.rb
+++ b/qa/qa/service/docker_run/k3s.rb
@@ -38,7 +38,7 @@ module QA
--no-deploy traefik
CMD
- command.gsub!("--network #{network} ", '') unless QA::Runtime::Env.running_in_ci?
+ command.gsub!("--network #{network} --hostname #{host_name}", '') unless QA::Runtime::Env.running_in_ci?
shell command
end
diff --git a/qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb b/qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb
index b2d57e902cb..3e25ecfd45d 100644
--- a/qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb
+++ b/qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb
@@ -38,7 +38,6 @@ module QA
Resource::KubernetesCluster::ProjectCluster.fabricate! do |k8s_cluster|
k8s_cluster.project = project
k8s_cluster.cluster = cluster
- k8s_cluster.install_helm_tiller = true
k8s_cluster.install_ingress = true
k8s_cluster.install_prometheus = true
k8s_cluster.install_runner = true
diff --git a/qa/qa/specs/features/browser_ui/8_monitor/all_monitor_core_features_spec.rb b/qa/qa/specs/features/browser_ui/8_monitor/all_monitor_core_features_spec.rb
index 1eef6c06cd8..d9e5944b72c 100644
--- a/qa/qa/specs/features/browser_ui/8_monitor/all_monitor_core_features_spec.rb
+++ b/qa/qa/specs/features/browser_ui/8_monitor/all_monitor_core_features_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module QA
- RSpec.describe 'Monitor', quarantine: { issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/230927', type: :stale } do
+ RSpec.describe 'Monitor' do
describe 'with Prometheus in a Gitlab-managed cluster', :orchestrated, :kubernetes, :requires_admin do
before :all do
@cluster = Service::KubernetesCluster.new(provider_class: Service::ClusterProvider::K3s).create!
@@ -98,7 +98,6 @@ module QA
Resource::KubernetesCluster::ProjectCluster.fabricate! do |cluster_settings|
cluster_settings.project = @project
cluster_settings.cluster = @cluster
- cluster_settings.install_helm_tiller = true
cluster_settings.install_runner = true
cluster_settings.install_ingress = true
cluster_settings.install_prometheus = true
diff --git a/spec/controllers/projects/product_analytics_controller_spec.rb b/spec/controllers/projects/product_analytics_controller_spec.rb
index 818f50b2fc4..47f1d96c70b 100644
--- a/spec/controllers/projects/product_analytics_controller_spec.rb
+++ b/spec/controllers/projects/product_analytics_controller_spec.rb
@@ -66,6 +66,27 @@ RSpec.describe Projects::ProductAnalyticsController do
end
end
+ describe 'GET #graphs' do
+ it 'renders graphs with 200 status code' do
+ get :graphs, params: project_params
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template(:graphs)
+ end
+
+ context 'feature flag disabled' do
+ before do
+ stub_feature_flags(product_analytics: false)
+ end
+
+ it 'returns not found' do
+ get :graphs, params: project_params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
private
def project_params(opts = {})
diff --git a/spec/features/projects/product_analytics/graphs_spec.rb b/spec/features/projects/product_analytics/graphs_spec.rb
new file mode 100644
index 00000000000..e2293893589
--- /dev/null
+++ b/spec/features/projects/product_analytics/graphs_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Product Analytics > Graphs' do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
+
+ before do
+ project.add_maintainer(user)
+ sign_in(user)
+ end
+
+ it 'shows graphs', :js do
+ create(:product_analytics_event, project: project)
+
+ visit(graphs_project_product_analytics_path(project))
+
+ expect(page).to have_content('Showing graphs based on events')
+ expect(page).to have_content('platform')
+ expect(page).to have_content('os_timezone')
+ expect(page).to have_content('br_lang')
+ expect(page).to have_content('doc_charset')
+ end
+end
diff --git a/spec/frontend/analytics/components/activity_chart_spec.js b/spec/frontend/analytics/components/activity_chart_spec.js
new file mode 100644
index 00000000000..1f0f9a6c5d7
--- /dev/null
+++ b/spec/frontend/analytics/components/activity_chart_spec.js
@@ -0,0 +1,39 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlColumnChart } from '@gitlab/ui/dist/charts';
+import ActivityChart from '~/analytics/product_analytics/components/activity_chart.vue';
+
+describe('Activity Chart Bundle', () => {
+ let wrapper;
+ function mountComponent({ provide }) {
+ wrapper = shallowMount(ActivityChart, {
+ provide: {
+ formattedData: {},
+ ...provide,
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findChart = () => wrapper.find(GlColumnChart);
+ const findNoData = () => wrapper.find('[data-testid="noActivityChartData"]');
+
+ describe('Activity Chart', () => {
+ it('renders an warning message with no data', () => {
+ mountComponent({ provide: { formattedData: {} } });
+ expect(findNoData().exists()).toBe(true);
+ });
+
+ it('renders a chart with data', () => {
+ mountComponent({
+ provide: { formattedData: { keys: ['key1', 'key2'], values: [5038, 2241] } },
+ });
+
+ expect(findNoData().exists()).toBe(false);
+ expect(findChart().exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js
index b7f03f35dfb..ac046ddc203 100644
--- a/spec/frontend/diffs/components/app_spec.js
+++ b/spec/frontend/diffs/components/app_spec.js
@@ -41,6 +41,7 @@ describe('diffs/components/app', () => {
store = createDiffsStore();
store.state.diffs.isLoading = false;
+ store.state.diffs.isTreeLoaded = true;
extendStore(store);
diff --git a/spec/frontend/diffs/components/tree_list_spec.js b/spec/frontend/diffs/components/tree_list_spec.js
index f78c5f25ee7..14cb2a17aec 100644
--- a/spec/frontend/diffs/components/tree_list_spec.js
+++ b/spec/frontend/diffs/components/tree_list_spec.js
@@ -17,6 +17,7 @@ describe('Diffs tree list component', () => {
});
// Setup initial state
+ store.state.diffs.isTreeLoaded = true;
store.state.diffs.diffFiles.push('test');
store.state.diffs = {
addedLines: 10,
diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js
index c24d406fef3..70047899612 100644
--- a/spec/frontend/diffs/store/mutations_spec.js
+++ b/spec/frontend/diffs/store/mutations_spec.js
@@ -830,6 +830,7 @@ describe('DiffsStoreMutations', () => {
const state = {
treeEntries: {},
tree: [],
+ isTreeLoaded: false,
};
mutations[types.SET_TREE_DATA](state, {
@@ -844,6 +845,7 @@ describe('DiffsStoreMutations', () => {
});
expect(state.tree).toEqual(['tree']);
+ expect(state.isTreeLoaded).toEqual(true);
});
});
diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb
index 525639f6b98..c6d9dd8d555 100644
--- a/spec/models/ci/pipeline_spec.rb
+++ b/spec/models/ci/pipeline_spec.rb
@@ -813,6 +813,8 @@ RSpec.describe Ci::Pipeline, :mailer do
expect(subject.to_hash)
.to include(
'CI_EXTERNAL_PULL_REQUEST_IID' => pull_request.pull_request_iid.to_s,
+ 'CI_EXTERNAL_PULL_REQUEST_SOURCE_REPOSITORY' => pull_request.source_repository,
+ 'CI_EXTERNAL_PULL_REQUEST_TARGET_REPOSITORY' => pull_request.target_repository,
'CI_EXTERNAL_PULL_REQUEST_SOURCE_BRANCH_SHA' => pull_request.source_sha,
'CI_EXTERNAL_PULL_REQUEST_TARGET_BRANCH_SHA' => pull_request.target_sha,
'CI_EXTERNAL_PULL_REQUEST_SOURCE_BRANCH_NAME' => pull_request.source_branch,
@@ -3264,32 +3266,6 @@ RSpec.describe Ci::Pipeline, :mailer do
end
end
end
-
- context 'when transitioning to success' do
- context 'when feature is enabled' do
- before do
- stub_feature_flags(keep_latest_artifacts_for_ref: true)
- end
-
- it 'calls the PipelineSuccessUnlockArtifactsWorker' do
- expect(Ci::PipelineSuccessUnlockArtifactsWorker).to receive(:perform_async).with(pipeline.id)
-
- pipeline.succeed!
- end
- end
-
- context 'when feature is disabled' do
- before do
- stub_feature_flags(keep_latest_artifacts_for_ref: false)
- end
-
- it 'does not call the PipelineSuccessUnlockArtifactsWorker' do
- expect(Ci::PipelineSuccessUnlockArtifactsWorker).not_to receive(:perform_async)
-
- pipeline.succeed!
- end
- end
- end
end
describe '#default_branch?' do
diff --git a/spec/models/ci/ref_spec.rb b/spec/models/ci/ref_spec.rb
index dcb1b2d1f03..8bce3c10d8c 100644
--- a/spec/models/ci/ref_spec.rb
+++ b/spec/models/ci/ref_spec.rb
@@ -3,8 +3,69 @@
require 'spec_helper'
RSpec.describe Ci::Ref do
+ using RSpec::Parameterized::TableSyntax
+
it { is_expected.to belong_to(:project) }
+ describe 'state machine transitions' do
+ context 'unlock artifacts transition' do
+ let(:ci_ref) { create(:ci_ref) }
+ let(:unlock_artifacts_worker_spy) { class_spy(::Ci::PipelineSuccessUnlockArtifactsWorker) }
+
+ before do
+ stub_const('Ci::PipelineSuccessUnlockArtifactsWorker', unlock_artifacts_worker_spy)
+ end
+
+ context 'when keep latest artifact feature is enabled' do
+ before do
+ stub_feature_flags(keep_latest_artifacts_for_ref: true)
+ end
+
+ where(:initial_state, :action, :count) do
+ :unknown | :succeed! | 1
+ :unknown | :do_fail! | 0
+ :success | :succeed! | 1
+ :success | :do_fail! | 0
+ :failed | :succeed! | 1
+ :failed | :do_fail! | 0
+ :fixed | :succeed! | 1
+ :fixed | :do_fail! | 0
+ :broken | :succeed! | 1
+ :broken | :do_fail! | 0
+ :still_failing | :succeed | 1
+ :still_failing | :do_fail | 0
+ end
+
+ with_them do
+ context "when transitioning states" do
+ before do
+ status_value = Ci::Ref.state_machines[:status].states[initial_state].value
+ ci_ref.update!(status: status_value)
+ end
+
+ it 'calls unlock artifacts service' do
+ ci_ref.send(action)
+
+ expect(unlock_artifacts_worker_spy).to have_received(:perform_async).exactly(count).times
+ end
+ end
+ end
+ end
+
+ context 'when keep latest artifact feature is not enabled' do
+ before do
+ stub_feature_flags(keep_latest_artifacts_for_ref: false)
+ end
+
+ it 'does not call unlock artifacts service' do
+ ci_ref.succeed!
+
+ expect(unlock_artifacts_worker_spy).not_to have_received(:perform_async)
+ end
+ end
+ end
+ end
+
describe '.ensure_for' do
let_it_be(:project) { create(:project, :repository) }
diff --git a/spec/models/product_analytics_event_spec.rb b/spec/models/product_analytics_event_spec.rb
index 6058df9fa13..afdb5b690f8 100644
--- a/spec/models/product_analytics_event_spec.rb
+++ b/spec/models/product_analytics_event_spec.rb
@@ -21,4 +21,18 @@ RSpec.describe ProductAnalyticsEvent, type: :model do
it { expect(described_class.timerange(7.days)).to match_array([event_1, event_2]) }
it { expect(described_class.timerange(30.days)).to match_array([event_1, event_2, event_3]) }
end
+
+ describe '.count_by_graph' do
+ let_it_be(:events) do
+ [
+ create(:product_analytics_event, platform: 'web'),
+ create(:product_analytics_event, platform: 'web'),
+ create(:product_analytics_event, platform: 'app'),
+ create(:product_analytics_event, platform: 'mobile', collector_tstamp: Time.zone.now - 10.days)
+ ]
+ end
+
+ it { expect(described_class.count_by_graph('platform', 7.days)).to eq({ 'app' => 1, 'web' => 2 }) }
+ it { expect(described_class.count_by_graph('platform', 30.days)).to eq({ 'app' => 1, 'mobile' => 1, 'web' => 2 }) }
+ end
end
diff --git a/spec/requests/api/ci/runner/jobs_artifacts_spec.rb b/spec/requests/api/ci/runner/jobs_artifacts_spec.rb
new file mode 100644
index 00000000000..9be69d4c562
--- /dev/null
+++ b/spec/requests/api/ci/runner/jobs_artifacts_spec.rb
@@ -0,0 +1,891 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
+ include StubGitlabCalls
+ include RedisHelpers
+ include WorkhorseHelpers
+
+ let(:registration_token) { 'abcdefg123456' }
+
+ before do
+ stub_feature_flags(ci_enable_live_trace: true)
+ stub_gitlab_calls
+ stub_application_setting(runners_registration_token: registration_token)
+ allow_any_instance_of(::Ci::Runner).to receive(:cache_attributes)
+ end
+
+ describe '/api/v4/jobs' do
+ let(:root_namespace) { create(:namespace) }
+ let(:namespace) { create(:namespace, parent: root_namespace) }
+ let(:project) { create(:project, namespace: namespace, shared_runners_enabled: false) }
+ let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master') }
+ let(:runner) { create(:ci_runner, :project, projects: [project]) }
+ let(:user) { create(:user) }
+ let(:job) do
+ create(:ci_build, :artifacts, :extended_options,
+ pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0)
+ end
+
+ describe 'artifacts' do
+ let(:job) { create(:ci_build, :pending, user: user, project: project, pipeline: pipeline, runner_id: runner.id) }
+ let(:jwt) { JWT.encode({ 'iss' => 'gitlab-workhorse' }, Gitlab::Workhorse.secret, 'HS256') }
+ let(:headers) { { 'GitLab-Workhorse' => '1.0', Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER => jwt } }
+ let(:headers_with_token) { headers.merge(API::Helpers::Runner::JOB_TOKEN_HEADER => job.token) }
+ let(:file_upload) { fixture_file_upload('spec/fixtures/banana_sample.gif', 'image/gif') }
+ let(:file_upload2) { fixture_file_upload('spec/fixtures/dk.png', 'image/gif') }
+
+ before do
+ stub_artifacts_object_storage
+ job.run!
+ end
+
+ shared_examples_for 'rejecting artifacts that are too large' do
+ let(:filesize) { 100.megabytes.to_i }
+ let(:sample_max_size) { (filesize / 1.megabyte) - 10 } # Set max size to be smaller than file size to trigger error
+
+ shared_examples_for 'failed request' do
+ it 'responds with payload too large error' do
+ send_request
+
+ expect(response).to have_gitlab_http_status(:payload_too_large)
+ end
+ end
+
+ context 'based on plan limit setting' do
+ let(:application_max_size) { sample_max_size + 100 }
+ let(:limit_name) { "#{Ci::JobArtifact::PLAN_LIMIT_PREFIX}archive" }
+
+ before do
+ create(:plan_limits, :default_plan, limit_name => sample_max_size)
+ stub_application_setting(max_artifacts_size: application_max_size)
+ end
+
+ it_behaves_like 'failed request'
+ end
+
+ context 'based on application setting' do
+ before do
+ stub_application_setting(max_artifacts_size: sample_max_size)
+ end
+
+ it_behaves_like 'failed request'
+ end
+
+ context 'based on root namespace setting' do
+ let(:application_max_size) { sample_max_size + 10 }
+
+ before do
+ stub_application_setting(max_artifacts_size: application_max_size)
+ root_namespace.update!(max_artifacts_size: sample_max_size)
+ end
+
+ it_behaves_like 'failed request'
+ end
+
+ context 'based on child namespace setting' do
+ let(:application_max_size) { sample_max_size + 10 }
+ let(:root_namespace_max_size) { sample_max_size + 10 }
+
+ before do
+ stub_application_setting(max_artifacts_size: application_max_size)
+ root_namespace.update!(max_artifacts_size: root_namespace_max_size)
+ namespace.update!(max_artifacts_size: sample_max_size)
+ end
+
+ it_behaves_like 'failed request'
+ end
+
+ context 'based on project setting' do
+ let(:application_max_size) { sample_max_size + 10 }
+ let(:root_namespace_max_size) { sample_max_size + 10 }
+ let(:child_namespace_max_size) { sample_max_size + 10 }
+
+ before do
+ stub_application_setting(max_artifacts_size: application_max_size)
+ root_namespace.update!(max_artifacts_size: root_namespace_max_size)
+ namespace.update!(max_artifacts_size: child_namespace_max_size)
+ project.update!(max_artifacts_size: sample_max_size)
+ end
+
+ it_behaves_like 'failed request'
+ end
+ end
+
+ describe 'POST /api/v4/jobs/:id/artifacts/authorize' do
+ context 'when using token as parameter' do
+ context 'and the artifact is too large' do
+ it_behaves_like 'rejecting artifacts that are too large' do
+ let(:success_code) { :ok }
+ let(:send_request) { authorize_artifacts_with_token_in_params(filesize: filesize) }
+ end
+ end
+
+ context 'posting artifacts to running job' do
+ subject do
+ authorize_artifacts_with_token_in_params
+ end
+
+ it_behaves_like 'API::CI::Runner application context metadata', '/api/:version/jobs/:id/artifacts/authorize' do
+ let(:send_request) { subject }
+ end
+
+ it 'updates runner info' do
+ expect { subject }.to change { runner.reload.contacted_at }
+ end
+
+ shared_examples 'authorizes local file' do
+ it 'succeeds' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
+ expect(json_response['TempPath']).to eq(JobArtifactUploader.workhorse_local_upload_path)
+ expect(json_response['RemoteObject']).to be_nil
+ end
+ end
+
+ context 'when using local storage' do
+ it_behaves_like 'authorizes local file'
+ end
+
+ context 'when using remote storage' do
+ context 'when direct upload is enabled' do
+ before do
+ stub_artifacts_object_storage(enabled: true, direct_upload: true)
+ end
+
+ it 'succeeds' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
+ expect(json_response).not_to have_key('TempPath')
+ expect(json_response['RemoteObject']).to have_key('ID')
+ expect(json_response['RemoteObject']).to have_key('GetURL')
+ expect(json_response['RemoteObject']).to have_key('StoreURL')
+ expect(json_response['RemoteObject']).to have_key('DeleteURL')
+ expect(json_response['RemoteObject']).to have_key('MultipartUpload')
+ end
+ end
+
+ context 'when direct upload is disabled' do
+ before do
+ stub_artifacts_object_storage(enabled: true, direct_upload: false)
+ end
+
+ it_behaves_like 'authorizes local file'
+ end
+ end
+ end
+ end
+
+ context 'when using token as header' do
+ it 'authorizes posting artifacts to running job' do
+ authorize_artifacts_with_token_in_headers
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
+ expect(json_response['TempPath']).not_to be_nil
+ end
+
+ it 'fails to post too large artifact' do
+ stub_application_setting(max_artifacts_size: 0)
+
+ authorize_artifacts_with_token_in_headers(filesize: 100)
+
+ expect(response).to have_gitlab_http_status(:payload_too_large)
+ end
+ end
+
+ context 'when using runners token' do
+ it 'fails to authorize artifacts posting' do
+ authorize_artifacts(token: job.project.runners_token)
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ it 'reject requests that did not go through gitlab-workhorse' do
+ headers.delete(Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER)
+
+ authorize_artifacts
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+
+ context 'authorization token is invalid' do
+ it 'responds with forbidden' do
+ authorize_artifacts(token: 'invalid', filesize: 100 )
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'authorize uploading of an lsif artifact' do
+ before do
+ stub_feature_flags(code_navigation: job.project)
+ end
+
+ it 'adds ProcessLsif header' do
+ authorize_artifacts_with_token_in_headers(artifact_type: :lsif)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['ProcessLsif']).to be_truthy
+ end
+
+ it 'adds ProcessLsifReferences header' do
+ authorize_artifacts_with_token_in_headers(artifact_type: :lsif)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['ProcessLsifReferences']).to be_truthy
+ end
+
+ context 'code_navigation feature flag is disabled' do
+ it 'responds with a forbidden error' do
+ stub_feature_flags(code_navigation: false)
+ authorize_artifacts_with_token_in_headers(artifact_type: :lsif)
+
+ aggregate_failures do
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(json_response['ProcessLsif']).to be_falsy
+ expect(json_response['ProcessLsifReferences']).to be_falsy
+ end
+ end
+ end
+
+ context 'code_navigation_references feature flag is disabled' do
+ it 'sets ProcessLsifReferences header to false' do
+ stub_feature_flags(code_navigation_references: false)
+ authorize_artifacts_with_token_in_headers(artifact_type: :lsif)
+
+ aggregate_failures do
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['ProcessLsif']).to be_truthy
+ expect(json_response['ProcessLsifReferences']).to be_falsy
+ end
+ end
+ end
+ end
+
+ def authorize_artifacts(params = {}, request_headers = headers)
+ post api("/jobs/#{job.id}/artifacts/authorize"), params: params, headers: request_headers
+ end
+
+ def authorize_artifacts_with_token_in_params(params = {}, request_headers = headers)
+ params = params.merge(token: job.token)
+ authorize_artifacts(params, request_headers)
+ end
+
+ def authorize_artifacts_with_token_in_headers(params = {}, request_headers = headers_with_token)
+ authorize_artifacts(params, request_headers)
+ end
+ end
+
+ describe 'POST /api/v4/jobs/:id/artifacts' do
+ it_behaves_like 'API::CI::Runner application context metadata', '/api/:version/jobs/:id/artifacts' do
+ let(:send_request) do
+ upload_artifacts(file_upload, headers_with_token)
+ end
+ end
+
+ it 'updates runner info' do
+ expect { upload_artifacts(file_upload, headers_with_token) }.to change { runner.reload.contacted_at }
+ end
+
+ context 'when the artifact is too large' do
+ it_behaves_like 'rejecting artifacts that are too large' do
+ # This filesize validation also happens in non remote stored files,
+ # it's just that it's hard to stub the filesize in other cases to be
+ # more than a megabyte.
+ let!(:fog_connection) do
+ stub_artifacts_object_storage(direct_upload: true)
+ end
+
+ let(:file_upload) { fog_to_uploaded_file(object) }
+ let(:success_code) { :created }
+
+ let(:object) do
+ fog_connection.directories.new(key: 'artifacts').files.create( # rubocop:disable Rails/SaveBang
+ key: 'tmp/uploads/12312300',
+ body: 'content'
+ )
+ end
+
+ let(:send_request) do
+ upload_artifacts(file_upload, headers_with_token, 'file.remote_id' => '12312300')
+ end
+
+ before do
+ allow(object).to receive(:content_length).and_return(filesize)
+ end
+ end
+ end
+
+ context 'when artifacts are being stored inside of tmp path' do
+ before do
+ # by configuring this path we allow to pass temp file from any path
+ allow(JobArtifactUploader).to receive(:workhorse_upload_path).and_return('/')
+ end
+
+ context 'when job has been erased' do
+ let(:job) { create(:ci_build, erased_at: Time.now) }
+
+ before do
+ upload_artifacts(file_upload, headers_with_token)
+ end
+
+ it 'responds with forbidden' do
+ upload_artifacts(file_upload, headers_with_token)
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'when job is running' do
+ shared_examples 'successful artifacts upload' do
+ it 'updates successfully' do
+ expect(response).to have_gitlab_http_status(:created)
+ end
+ end
+
+ context 'when uses accelerated file post' do
+ context 'for file stored locally' do
+ before do
+ upload_artifacts(file_upload, headers_with_token)
+ end
+
+ it_behaves_like 'successful artifacts upload'
+ end
+
+ context 'for file stored remotely' do
+ let!(:fog_connection) do
+ stub_artifacts_object_storage(direct_upload: true)
+ end
+
+ let(:object) do
+ fog_connection.directories.new(key: 'artifacts').files.create( # rubocop:disable Rails/SaveBang
+ key: 'tmp/uploads/12312300',
+ body: 'content'
+ )
+ end
+
+ let(:file_upload) { fog_to_uploaded_file(object) }
+
+ before do
+ upload_artifacts(file_upload, headers_with_token, 'file.remote_id' => remote_id)
+ end
+
+ context 'when valid remote_id is used' do
+ let(:remote_id) { '12312300' }
+
+ it_behaves_like 'successful artifacts upload'
+ end
+
+ context 'when invalid remote_id is used' do
+ let(:remote_id) { 'invalid id' }
+
+ it 'responds with bad request' do
+ expect(response).to have_gitlab_http_status(:internal_server_error)
+ expect(json_response['message']).to eq("Missing file")
+ end
+ end
+ end
+ end
+
+ context 'when using runners token' do
+ it 'responds with forbidden' do
+ upload_artifacts(file_upload, headers.merge(API::Helpers::Runner::JOB_TOKEN_HEADER => job.project.runners_token))
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+ end
+
+ context 'when artifacts post request does not contain file' do
+ it 'fails to post artifacts without file' do
+ post api("/jobs/#{job.id}/artifacts"), params: {}, headers: headers_with_token
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+
+ context 'GitLab Workhorse is not configured' do
+ it 'fails to post artifacts without GitLab-Workhorse' do
+ post api("/jobs/#{job.id}/artifacts"), params: { token: job.token }, headers: {}
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+
+ context 'Is missing GitLab Workhorse token headers' do
+ let(:jwt) { JWT.encode({ 'iss' => 'invalid-header' }, Gitlab::Workhorse.secret, 'HS256') }
+
+ it 'fails to post artifacts without GitLab-Workhorse' do
+ expect(Gitlab::ErrorTracking).to receive(:track_exception).once
+
+ upload_artifacts(file_upload, headers_with_token)
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'when setting an expire date' do
+ let(:default_artifacts_expire_in) {}
+ let(:post_data) do
+ { file: file_upload,
+ expire_in: expire_in }
+ end
+
+ before do
+ stub_application_setting(default_artifacts_expire_in: default_artifacts_expire_in)
+
+ upload_artifacts(file_upload, headers_with_token, post_data)
+ end
+
+ context 'when an expire_in is given' do
+ let(:expire_in) { '7 days' }
+
+ it 'updates when specified' do
+ expect(response).to have_gitlab_http_status(:created)
+ expect(job.reload.artifacts_expire_at).to be_within(5.minutes).of(7.days.from_now)
+ end
+ end
+
+ context 'when no expire_in is given' do
+ let(:expire_in) { nil }
+
+ it 'ignores if not specified' do
+ expect(response).to have_gitlab_http_status(:created)
+ expect(job.reload.artifacts_expire_at).to be_nil
+ end
+
+ context 'with application default' do
+ context 'when default is 5 days' do
+ let(:default_artifacts_expire_in) { '5 days' }
+
+ it 'sets to application default' do
+ expect(response).to have_gitlab_http_status(:created)
+ expect(job.reload.artifacts_expire_at).to be_within(5.minutes).of(5.days.from_now)
+ end
+ end
+
+ context 'when default is 0' do
+ let(:default_artifacts_expire_in) { '0' }
+
+ it 'does not set expire_in' do
+ expect(response).to have_gitlab_http_status(:created)
+ expect(job.reload.artifacts_expire_at).to be_nil
+ end
+ end
+ end
+ end
+ end
+
+ context 'posts artifacts file and metadata file' do
+ let!(:artifacts) { file_upload }
+ let!(:artifacts_sha256) { Digest::SHA256.file(artifacts.path).hexdigest }
+ let!(:metadata) { file_upload2 }
+ let!(:metadata_sha256) { Digest::SHA256.file(metadata.path).hexdigest }
+
+ let(:stored_artifacts_file) { job.reload.artifacts_file }
+ let(:stored_metadata_file) { job.reload.artifacts_metadata }
+ let(:stored_artifacts_size) { job.reload.artifacts_size }
+ let(:stored_artifacts_sha256) { job.reload.job_artifacts_archive.file_sha256 }
+ let(:stored_metadata_sha256) { job.reload.job_artifacts_metadata.file_sha256 }
+ let(:file_keys) { post_data.keys }
+ let(:send_rewritten_field) { true }
+
+ before do
+ workhorse_finalize_with_multiple_files(
+ api("/jobs/#{job.id}/artifacts"),
+ method: :post,
+ file_keys: file_keys,
+ params: post_data,
+ headers: headers_with_token,
+ send_rewritten_field: send_rewritten_field
+ )
+ end
+
+ context 'when posts data accelerated by workhorse is correct' do
+ let(:post_data) { { file: artifacts, metadata: metadata } }
+
+ it 'stores artifacts and artifacts metadata' do
+ expect(response).to have_gitlab_http_status(:created)
+ expect(stored_artifacts_file.filename).to eq(artifacts.original_filename)
+ expect(stored_metadata_file.filename).to eq(metadata.original_filename)
+ expect(stored_artifacts_size).to eq(artifacts.size)
+ expect(stored_artifacts_sha256).to eq(artifacts_sha256)
+ expect(stored_metadata_sha256).to eq(metadata_sha256)
+ end
+ end
+
+ context 'with a malicious file.path param' do
+ let(:post_data) { {} }
+ let(:tmp_file) { Tempfile.new('crafted.file.path') }
+ let(:url) { "/jobs/#{job.id}/artifacts?file.path=#{tmp_file.path}" }
+
+ it 'rejects the request' do
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(stored_artifacts_size).to be_nil
+ end
+ end
+
+ context 'when workhorse header is missing' do
+ let(:post_data) { { file: artifacts, metadata: metadata } }
+ let(:send_rewritten_field) { false }
+
+ it 'rejects the request' do
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(stored_artifacts_size).to be_nil
+ end
+ end
+
+ context 'when there is no artifacts file in post data' do
+ let(:post_data) do
+ { metadata: metadata }
+ end
+
+ it 'is expected to respond with bad request' do
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+
+ it 'does not store metadata' do
+ expect(stored_metadata_file).to be_nil
+ end
+ end
+ end
+
+ context 'when artifact_type is archive' do
+ context 'when artifact_format is zip' do
+ let(:params) { { artifact_type: :archive, artifact_format: :zip } }
+
+ it 'stores junit test report' do
+ upload_artifacts(file_upload, headers_with_token, params)
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(job.reload.job_artifacts_archive).not_to be_nil
+ end
+ end
+
+ context 'when artifact_format is gzip' do
+ let(:params) { { artifact_type: :archive, artifact_format: :gzip } }
+
+ it 'returns an error' do
+ upload_artifacts(file_upload, headers_with_token, params)
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(job.reload.job_artifacts_archive).to be_nil
+ end
+ end
+ end
+
+ context 'when artifact_type is junit' do
+ context 'when artifact_format is gzip' do
+ let(:file_upload) { fixture_file_upload('spec/fixtures/junit/junit.xml.gz') }
+ let(:params) { { artifact_type: :junit, artifact_format: :gzip } }
+
+ it 'stores junit test report' do
+ upload_artifacts(file_upload, headers_with_token, params)
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(job.reload.job_artifacts_junit).not_to be_nil
+ end
+ end
+
+ context 'when artifact_format is raw' do
+ let(:file_upload) { fixture_file_upload('spec/fixtures/junit/junit.xml.gz') }
+ let(:params) { { artifact_type: :junit, artifact_format: :raw } }
+
+ it 'returns an error' do
+ upload_artifacts(file_upload, headers_with_token, params)
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(job.reload.job_artifacts_junit).to be_nil
+ end
+ end
+ end
+
+ context 'when artifact_type is metrics_referee' do
+ context 'when artifact_format is gzip' do
+ let(:file_upload) { fixture_file_upload('spec/fixtures/referees/metrics_referee.json.gz') }
+ let(:params) { { artifact_type: :metrics_referee, artifact_format: :gzip } }
+
+ it 'stores metrics_referee data' do
+ upload_artifacts(file_upload, headers_with_token, params)
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(job.reload.job_artifacts_metrics_referee).not_to be_nil
+ end
+ end
+
+ context 'when artifact_format is raw' do
+ let(:file_upload) { fixture_file_upload('spec/fixtures/referees/metrics_referee.json.gz') }
+ let(:params) { { artifact_type: :metrics_referee, artifact_format: :raw } }
+
+ it 'returns an error' do
+ upload_artifacts(file_upload, headers_with_token, params)
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(job.reload.job_artifacts_metrics_referee).to be_nil
+ end
+ end
+ end
+
+ context 'when artifact_type is network_referee' do
+ context 'when artifact_format is gzip' do
+ let(:file_upload) { fixture_file_upload('spec/fixtures/referees/network_referee.json.gz') }
+ let(:params) { { artifact_type: :network_referee, artifact_format: :gzip } }
+
+ it 'stores network_referee data' do
+ upload_artifacts(file_upload, headers_with_token, params)
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(job.reload.job_artifacts_network_referee).not_to be_nil
+ end
+ end
+
+ context 'when artifact_format is raw' do
+ let(:file_upload) { fixture_file_upload('spec/fixtures/referees/network_referee.json.gz') }
+ let(:params) { { artifact_type: :network_referee, artifact_format: :raw } }
+
+ it 'returns an error' do
+ upload_artifacts(file_upload, headers_with_token, params)
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(job.reload.job_artifacts_network_referee).to be_nil
+ end
+ end
+ end
+
+ context 'when artifact_type is dotenv' do
+ context 'when artifact_format is gzip' do
+ let(:file_upload) { fixture_file_upload('spec/fixtures/build.env.gz') }
+ let(:params) { { artifact_type: :dotenv, artifact_format: :gzip } }
+
+ it 'stores dotenv file' do
+ upload_artifacts(file_upload, headers_with_token, params)
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(job.reload.job_artifacts_dotenv).not_to be_nil
+ end
+
+ it 'parses dotenv file' do
+ expect do
+ upload_artifacts(file_upload, headers_with_token, params)
+ end.to change { job.job_variables.count }.from(0).to(2)
+ end
+
+ context 'when parse error happens' do
+ let(:file_upload) { fixture_file_upload('spec/fixtures/ci_build_artifacts_metadata.gz') }
+
+ it 'returns an error' do
+ upload_artifacts(file_upload, headers_with_token, params)
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['message']).to eq('Invalid Format')
+ end
+ end
+ end
+
+ context 'when artifact_format is raw' do
+ let(:file_upload) { fixture_file_upload('spec/fixtures/build.env.gz') }
+ let(:params) { { artifact_type: :dotenv, artifact_format: :raw } }
+
+ it 'returns an error' do
+ upload_artifacts(file_upload, headers_with_token, params)
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(job.reload.job_artifacts_dotenv).to be_nil
+ end
+ end
+ end
+ end
+
+ context 'when artifacts already exist for the job' do
+ let(:params) do
+ {
+ artifact_type: :archive,
+ artifact_format: :zip,
+ 'file.sha256' => uploaded_sha256
+ }
+ end
+
+ let(:existing_sha256) { '0' * 64 }
+
+ let!(:existing_artifact) do
+ create(:ci_job_artifact, :archive, file_sha256: existing_sha256, job: job)
+ end
+
+ context 'when sha256 is the same of the existing artifact' do
+ let(:uploaded_sha256) { existing_sha256 }
+
+ it 'ignores the new artifact' do
+ upload_artifacts(file_upload, headers_with_token, params)
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(job.reload.job_artifacts_archive).to eq(existing_artifact)
+ end
+ end
+
+ context 'when sha256 is different than the existing artifact' do
+ let(:uploaded_sha256) { '1' * 64 }
+
+ it 'logs and returns an error' do
+ expect(Gitlab::ErrorTracking).to receive(:track_exception)
+
+ upload_artifacts(file_upload, headers_with_token, params)
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(job.reload.job_artifacts_archive).to eq(existing_artifact)
+ end
+ end
+ end
+
+ context 'when object storage throws errors' do
+ let(:params) { { artifact_type: :archive, artifact_format: :zip } }
+
+ it 'does not store artifacts' do
+ allow_next_instance_of(JobArtifactUploader) do |uploader|
+ allow(uploader).to receive(:store!).and_raise(Errno::EIO)
+ end
+
+ upload_artifacts(file_upload, headers_with_token, params)
+
+ expect(response).to have_gitlab_http_status(:service_unavailable)
+ expect(job.reload.job_artifacts_archive).to be_nil
+ end
+ end
+
+ context 'when artifacts are being stored outside of tmp path' do
+ let(:new_tmpdir) { Dir.mktmpdir }
+
+ before do
+ # init before overwriting tmp dir
+ file_upload
+
+ # by configuring this path we allow to pass file from @tmpdir only
+ # but all temporary files are stored in system tmp directory
+ allow(Dir).to receive(:tmpdir).and_return(new_tmpdir)
+ end
+
+ after do
+ FileUtils.remove_entry(new_tmpdir)
+ end
+
+ it 'fails to post artifacts for outside of tmp path' do
+ upload_artifacts(file_upload, headers_with_token)
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+
+ def upload_artifacts(file, headers = {}, params = {})
+ workhorse_finalize(
+ api("/jobs/#{job.id}/artifacts"),
+ method: :post,
+ file_key: :file,
+ params: params.merge(file: file),
+ headers: headers,
+ send_rewritten_field: true
+ )
+ end
+ end
+
+ describe 'GET /api/v4/jobs/:id/artifacts' do
+ let(:token) { job.token }
+
+ it_behaves_like 'API::CI::Runner application context metadata', '/api/:version/jobs/:id/artifacts' do
+ let(:send_request) { download_artifact }
+ end
+
+ it 'updates runner info' do
+ expect { download_artifact }.to change { runner.reload.contacted_at }
+ end
+
+ context 'when job has artifacts' do
+ let(:job) { create(:ci_build) }
+ let(:store) { JobArtifactUploader::Store::LOCAL }
+
+ before do
+ create(:ci_job_artifact, :archive, file_store: store, job: job)
+ end
+
+ context 'when using job token' do
+ context 'when artifacts are stored locally' do
+ let(:download_headers) do
+ { 'Content-Transfer-Encoding' => 'binary',
+ 'Content-Disposition' => %q(attachment; filename="ci_build_artifacts.zip"; filename*=UTF-8''ci_build_artifacts.zip) }
+ end
+
+ before do
+ download_artifact
+ end
+
+ it 'download artifacts' do
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.headers.to_h).to include download_headers
+ end
+ end
+
+ context 'when artifacts are stored remotely' do
+ let(:store) { JobArtifactUploader::Store::REMOTE }
+ let!(:job) { create(:ci_build) }
+
+ context 'when proxy download is being used' do
+ before do
+ download_artifact(direct_download: false)
+ end
+
+ it 'uses workhorse send-url' do
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.headers.to_h).to include(
+ 'Gitlab-Workhorse-Send-Data' => /send-url:/)
+ end
+ end
+
+ context 'when direct download is being used' do
+ before do
+ download_artifact(direct_download: true)
+ end
+
+ it 'receive redirect for downloading artifacts' do
+ expect(response).to have_gitlab_http_status(:found)
+ expect(response.headers).to include('Location')
+ end
+ end
+ end
+ end
+
+ context 'when using runnners token' do
+ let(:token) { job.project.runners_token }
+
+ before do
+ download_artifact
+ end
+
+ it 'responds with forbidden' do
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+ end
+
+ context 'when job does not have artifacts' do
+ it 'responds with not found' do
+ download_artifact
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ def download_artifact(params = {}, request_headers = headers)
+ params = params.merge(token: token)
+ job.reload
+
+ get api("/jobs/#{job.id}/artifacts"), params: params, headers: request_headers
+ end
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/ci/runner/jobs_put_spec.rb b/spec/requests/api/ci/runner/jobs_put_spec.rb
new file mode 100644
index 00000000000..025747f2f0c
--- /dev/null
+++ b/spec/requests/api/ci/runner/jobs_put_spec.rb
@@ -0,0 +1,196 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
+ include StubGitlabCalls
+ include RedisHelpers
+ include WorkhorseHelpers
+
+ let(:registration_token) { 'abcdefg123456' }
+
+ before do
+ stub_feature_flags(ci_enable_live_trace: true)
+ stub_gitlab_calls
+ stub_application_setting(runners_registration_token: registration_token)
+ allow_any_instance_of(::Ci::Runner).to receive(:cache_attributes)
+ end
+
+ describe '/api/v4/jobs' do
+ let(:root_namespace) { create(:namespace) }
+ let(:namespace) { create(:namespace, parent: root_namespace) }
+ let(:project) { create(:project, namespace: namespace, shared_runners_enabled: false) }
+ let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master') }
+ let(:runner) { create(:ci_runner, :project, projects: [project]) }
+ let(:user) { create(:user) }
+ let(:job) do
+ create(:ci_build, :artifacts, :extended_options,
+ pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0)
+ end
+
+ describe 'PUT /api/v4/jobs/:id' do
+ let(:job) do
+ create(:ci_build, :pending, :trace_live, pipeline: pipeline, project: project, user: user, runner_id: runner.id)
+ end
+
+ before do
+ job.run!
+ end
+
+ it_behaves_like 'API::CI::Runner application context metadata', '/api/:version/jobs/:id' do
+ let(:send_request) { update_job(state: 'success') }
+ end
+
+ it 'updates runner info' do
+ expect { update_job(state: 'success') }.to change { runner.reload.contacted_at }
+ end
+
+ context 'when status is given' do
+ it 'mark job as succeeded' do
+ update_job(state: 'success')
+
+ job.reload
+ expect(job).to be_success
+ end
+
+ it 'mark job as failed' do
+ update_job(state: 'failed')
+
+ job.reload
+ expect(job).to be_failed
+ expect(job).to be_unknown_failure
+ end
+
+ context 'when failure_reason is script_failure' do
+ before do
+ update_job(state: 'failed', failure_reason: 'script_failure')
+ job.reload
+ end
+
+ it { expect(job).to be_script_failure }
+ end
+
+ context 'when failure_reason is runner_system_failure' do
+ before do
+ update_job(state: 'failed', failure_reason: 'runner_system_failure')
+ job.reload
+ end
+
+ it { expect(job).to be_runner_system_failure }
+ end
+
+ context 'when failure_reason is unrecognized value' do
+ before do
+ update_job(state: 'failed', failure_reason: 'what_is_this')
+ job.reload
+ end
+
+ it { expect(job).to be_unknown_failure }
+ end
+
+ context 'when failure_reason is job_execution_timeout' do
+ before do
+ update_job(state: 'failed', failure_reason: 'job_execution_timeout')
+ job.reload
+ end
+
+ it { expect(job).to be_job_execution_timeout }
+ end
+
+ context 'when failure_reason is unmet_prerequisites' do
+ before do
+ update_job(state: 'failed', failure_reason: 'unmet_prerequisites')
+ job.reload
+ end
+
+ it { expect(job).to be_unmet_prerequisites }
+ end
+ end
+
+ context 'when trace is given' do
+ it 'creates a trace artifact' do
+ allow(BuildFinishedWorker).to receive(:perform_async).with(job.id) do
+ ArchiveTraceWorker.new.perform(job.id)
+ end
+
+ update_job(state: 'success', trace: 'BUILD TRACE UPDATED')
+
+ job.reload
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(job.trace.raw).to eq 'BUILD TRACE UPDATED'
+ expect(job.job_artifacts_trace.open.read).to eq 'BUILD TRACE UPDATED'
+ end
+
+ context 'when concurrent update of trace is happening' do
+ before do
+ job.trace.write('wb') do
+ update_job(state: 'success', trace: 'BUILD TRACE UPDATED')
+ end
+ end
+
+ it 'returns that operation conflicts' do
+ expect(response).to have_gitlab_http_status(:conflict)
+ end
+ end
+ end
+
+ context 'when no trace is given' do
+ it 'does not override trace information' do
+ update_job
+
+ expect(job.reload.trace.raw).to eq 'BUILD TRACE'
+ end
+
+ context 'when running state is sent' do
+ it 'updates update_at value' do
+ expect { update_job_after_time }.to change { job.reload.updated_at }
+ end
+ end
+
+ context 'when other state is sent' do
+ it "doesn't update update_at value" do
+ expect { update_job_after_time(20.minutes, state: 'success') }.not_to change { job.reload.updated_at }
+ end
+ end
+ end
+
+ context 'when job has been erased' do
+ let(:job) { create(:ci_build, runner_id: runner.id, erased_at: Time.now) }
+
+ it 'responds with forbidden' do
+ update_job
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'when job has already been finished' do
+ before do
+ job.trace.set('Job failed')
+ job.drop!(:script_failure)
+ end
+
+ it 'does not update job status and job trace' do
+ update_job(state: 'success', trace: 'BUILD TRACE UPDATED')
+
+ job.reload
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(response.header['Job-Status']).to eq 'failed'
+ expect(job.trace.raw).to eq 'Job failed'
+ expect(job).to be_failed
+ end
+ end
+
+ def update_job(token = job.token, **params)
+ new_params = params.merge(token: token)
+ put api("/jobs/#{job.id}"), params: new_params
+ end
+
+ def update_job_after_time(update_interval = 20.minutes, state = 'running')
+ Timecop.travel(job.updated_at + update_interval) do
+ update_job(job.token, state: state)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/ci/runner/jobs_request_post_spec.rb b/spec/requests/api/ci/runner/jobs_request_post_spec.rb
new file mode 100644
index 00000000000..4fa95f8ebb2
--- /dev/null
+++ b/spec/requests/api/ci/runner/jobs_request_post_spec.rb
@@ -0,0 +1,861 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
+ include StubGitlabCalls
+ include RedisHelpers
+ include WorkhorseHelpers
+
+ let(:registration_token) { 'abcdefg123456' }
+
+ before do
+ stub_feature_flags(ci_enable_live_trace: true)
+ stub_gitlab_calls
+ stub_application_setting(runners_registration_token: registration_token)
+ allow_any_instance_of(::Ci::Runner).to receive(:cache_attributes)
+ end
+
+ describe '/api/v4/jobs' do
+ let(:root_namespace) { create(:namespace) }
+ let(:namespace) { create(:namespace, parent: root_namespace) }
+ let(:project) { create(:project, namespace: namespace, shared_runners_enabled: false) }
+ let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master') }
+ let(:runner) { create(:ci_runner, :project, projects: [project]) }
+ let(:user) { create(:user) }
+ let(:job) do
+ create(:ci_build, :artifacts, :extended_options,
+ pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0)
+ end
+
+ describe 'POST /api/v4/jobs/request' do
+ let!(:last_update) {}
+ let!(:new_update) { }
+ let(:user_agent) { 'gitlab-runner 9.0.0 (9-0-stable; go1.7.4; linux/amd64)' }
+
+ before do
+ job
+ stub_container_registry_config(enabled: false)
+ end
+
+ shared_examples 'no jobs available' do
+ before do
+ request_job
+ end
+
+ context 'when runner sends version in User-Agent' do
+ context 'for stable version' do
+ it 'gives 204 and set X-GitLab-Last-Update' do
+ expect(response).to have_gitlab_http_status(:no_content)
+ expect(response.header).to have_key('X-GitLab-Last-Update')
+ end
+ end
+
+ context 'when last_update is up-to-date' do
+ let(:last_update) { runner.ensure_runner_queue_value }
+
+ it 'gives 204 and set the same X-GitLab-Last-Update' do
+ expect(response).to have_gitlab_http_status(:no_content)
+ expect(response.header['X-GitLab-Last-Update']).to eq(last_update)
+ end
+ end
+
+ context 'when last_update is outdated' do
+ let(:last_update) { runner.ensure_runner_queue_value }
+ let(:new_update) { runner.tick_runner_queue }
+
+ it 'gives 204 and set a new X-GitLab-Last-Update' do
+ expect(response).to have_gitlab_http_status(:no_content)
+ expect(response.header['X-GitLab-Last-Update']).to eq(new_update)
+ end
+ end
+
+ context 'when beta version is sent' do
+ let(:user_agent) { 'gitlab-runner 9.0.0~beta.167.g2b2bacc (master; go1.7.4; linux/amd64)' }
+
+ it { expect(response).to have_gitlab_http_status(:no_content) }
+ end
+
+ context 'when pre-9-0 version is sent' do
+ let(:user_agent) { 'gitlab-ci-multi-runner 1.6.0 (1-6-stable; go1.6.3; linux/amd64)' }
+
+ it { expect(response).to have_gitlab_http_status(:no_content) }
+ end
+
+ context 'when pre-9-0 beta version is sent' do
+ let(:user_agent) { 'gitlab-ci-multi-runner 1.6.0~beta.167.g2b2bacc (master; go1.6.3; linux/amd64)' }
+
+ it { expect(response).to have_gitlab_http_status(:no_content) }
+ end
+ end
+ end
+
+ context 'when no token is provided' do
+ it 'returns 400 error' do
+ post api('/jobs/request')
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+
+ context 'when invalid token is provided' do
+ it 'returns 403 error' do
+ post api('/jobs/request'), params: { token: 'invalid' }
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'when valid token is provided' do
+ context 'when Runner is not active' do
+ let(:runner) { create(:ci_runner, :inactive) }
+ let(:update_value) { runner.ensure_runner_queue_value }
+
+ it 'returns 204 error' do
+ request_job
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ expect(response.header['X-GitLab-Last-Update']).to eq(update_value)
+ end
+ end
+
+ context 'when jobs are finished' do
+ before do
+ job.success
+ end
+
+ it_behaves_like 'no jobs available'
+ end
+
+ context 'when other projects have pending jobs' do
+ before do
+ job.success
+ create(:ci_build, :pending)
+ end
+
+ it_behaves_like 'no jobs available'
+ end
+
+ context 'when shared runner requests job for project without shared_runners_enabled' do
+ let(:runner) { create(:ci_runner, :instance) }
+
+ it_behaves_like 'no jobs available'
+ end
+
+ context 'when there is a pending job' do
+ let(:expected_job_info) do
+ { 'name' => job.name,
+ 'stage' => job.stage,
+ 'project_id' => job.project.id,
+ 'project_name' => job.project.name }
+ end
+
+ let(:expected_git_info) do
+ { 'repo_url' => job.repo_url,
+ 'ref' => job.ref,
+ 'sha' => job.sha,
+ 'before_sha' => job.before_sha,
+ 'ref_type' => 'branch',
+ 'refspecs' => ["+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}",
+ "+refs/heads/#{job.ref}:refs/remotes/origin/#{job.ref}"],
+ 'depth' => project.ci_default_git_depth }
+ end
+
+ let(:expected_steps) do
+ [{ 'name' => 'script',
+ 'script' => %w(echo),
+ 'timeout' => job.metadata_timeout,
+ 'when' => 'on_success',
+ 'allow_failure' => false },
+ { 'name' => 'after_script',
+ 'script' => %w(ls date),
+ 'timeout' => job.metadata_timeout,
+ 'when' => 'always',
+ 'allow_failure' => true }]
+ end
+
+ let(:expected_variables) do
+ [{ 'key' => 'CI_JOB_NAME', 'value' => 'spinach', 'public' => true, 'masked' => false },
+ { 'key' => 'CI_JOB_STAGE', 'value' => 'test', 'public' => true, 'masked' => false },
+ { 'key' => 'DB_NAME', 'value' => 'postgres', 'public' => true, 'masked' => false }]
+ end
+
+ let(:expected_artifacts) do
+ [{ 'name' => 'artifacts_file',
+ 'untracked' => false,
+ 'paths' => %w(out/),
+ 'when' => 'always',
+ 'expire_in' => '7d',
+ "artifact_type" => "archive",
+ "artifact_format" => "zip" }]
+ end
+
+ let(:expected_cache) do
+ [{ 'key' => 'cache_key',
+ 'untracked' => false,
+ 'paths' => ['vendor/*'],
+ 'policy' => 'pull-push' }]
+ end
+
+ let(:expected_features) { { 'trace_sections' => true } }
+
+ it 'picks a job' do
+ request_job info: { platform: :darwin }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(response.headers['Content-Type']).to eq('application/json')
+ expect(response.headers).not_to have_key('X-GitLab-Last-Update')
+ expect(runner.reload.platform).to eq('darwin')
+ expect(json_response['id']).to eq(job.id)
+ expect(json_response['token']).to eq(job.token)
+ expect(json_response['job_info']).to eq(expected_job_info)
+ expect(json_response['git_info']).to eq(expected_git_info)
+ expect(json_response['image']).to eq({ 'name' => 'ruby:2.7', 'entrypoint' => '/bin/sh', 'ports' => [] })
+ expect(json_response['services']).to eq([{ 'name' => 'postgres', 'entrypoint' => nil,
+ 'alias' => nil, 'command' => nil, 'ports' => [] },
+ { 'name' => 'docker:stable-dind', 'entrypoint' => '/bin/sh',
+ 'alias' => 'docker', 'command' => 'sleep 30', 'ports' => [] }])
+ expect(json_response['steps']).to eq(expected_steps)
+ expect(json_response['artifacts']).to eq(expected_artifacts)
+ expect(json_response['cache']).to eq(expected_cache)
+ expect(json_response['variables']).to include(*expected_variables)
+ expect(json_response['features']).to eq(expected_features)
+ end
+
+ it 'creates persistent ref' do
+ expect_any_instance_of(::Ci::PersistentRef).to receive(:create_ref)
+ .with(job.sha, "refs/#{Repository::REF_PIPELINES}/#{job.commit_id}")
+
+ request_job info: { platform: :darwin }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['id']).to eq(job.id)
+ end
+
+ context 'when job is made for tag' do
+ let!(:job) { create(:ci_build, :tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) }
+
+ it 'sets branch as ref_type' do
+ request_job
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['git_info']['ref_type']).to eq('tag')
+ end
+
+ context 'when GIT_DEPTH is specified' do
+ before do
+ create(:ci_pipeline_variable, key: 'GIT_DEPTH', value: 1, pipeline: pipeline)
+ end
+
+ it 'specifies refspecs' do
+ request_job
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['git_info']['refspecs']).to include("+refs/tags/#{job.ref}:refs/tags/#{job.ref}")
+ end
+ end
+
+ context 'when a Gitaly exception is thrown during response' do
+ before do
+ allow_next_instance_of(Ci::BuildRunnerPresenter) do |instance|
+ allow(instance).to receive(:artifacts).and_raise(GRPC::DeadlineExceeded)
+ end
+ end
+
+ it 'fails the job as a scheduler failure' do
+ request_job
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ expect(job.reload.failed?).to be_truthy
+ expect(job.failure_reason).to eq('scheduler_failure')
+ expect(job.runner_id).to eq(runner.id)
+ expect(job.runner_session).to be_nil
+ end
+ end
+
+ context 'when GIT_DEPTH is not specified and there is no default git depth for the project' do
+ before do
+ project.update!(ci_default_git_depth: nil)
+ end
+
+ it 'specifies refspecs' do
+ request_job
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['git_info']['refspecs'])
+ .to contain_exactly("+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}",
+ '+refs/tags/*:refs/tags/*',
+ '+refs/heads/*:refs/remotes/origin/*')
+ end
+ end
+ end
+
+ context 'when job filtered by job_age' do
+ let!(:job) { create(:ci_build, :tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0, queued_at: 60.seconds.ago) }
+
+ context 'job is queued less than job_age parameter' do
+ let(:job_age) { 120 }
+
+ it 'gives 204' do
+ request_job(job_age: job_age)
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+ end
+
+ context 'job is queued more than job_age parameter' do
+ let(:job_age) { 30 }
+
+ it 'picks a job' do
+ request_job(job_age: job_age)
+
+ expect(response).to have_gitlab_http_status(:created)
+ end
+ end
+ end
+
+ context 'when job is made for branch' do
+ it 'sets tag as ref_type' do
+ request_job
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['git_info']['ref_type']).to eq('branch')
+ end
+
+ context 'when GIT_DEPTH is specified' do
+ before do
+ create(:ci_pipeline_variable, key: 'GIT_DEPTH', value: 1, pipeline: pipeline)
+ end
+
+ it 'specifies refspecs' do
+ request_job
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['git_info']['refspecs']).to include("+refs/heads/#{job.ref}:refs/remotes/origin/#{job.ref}")
+ end
+ end
+
+ context 'when GIT_DEPTH is not specified and there is no default git depth for the project' do
+ before do
+ project.update!(ci_default_git_depth: nil)
+ end
+
+ it 'specifies refspecs' do
+ request_job
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['git_info']['refspecs'])
+ .to contain_exactly("+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}",
+ '+refs/tags/*:refs/tags/*',
+ '+refs/heads/*:refs/remotes/origin/*')
+ end
+ end
+ end
+
+ context 'when job is for a release' do
+ let!(:job) { create(:ci_build, :release_options, pipeline: pipeline) }
+
+ context 'when `multi_build_steps` is passed by the runner' do
+ it 'exposes release info' do
+ request_job info: { features: { multi_build_steps: true } }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(response.headers).not_to have_key('X-GitLab-Last-Update')
+ expect(json_response['steps']).to eq([
+ {
+ "name" => "script",
+ "script" => ["make changelog | tee release_changelog.txt"],
+ "timeout" => 3600,
+ "when" => "on_success",
+ "allow_failure" => false
+ },
+ {
+ "name" => "release",
+ "script" =>
+ ["release-cli create --name \"Release $CI_COMMIT_SHA\" --description \"Created using the release-cli $EXTRA_DESCRIPTION\" --tag-name \"release-$CI_COMMIT_SHA\" --ref \"$CI_COMMIT_SHA\""],
+ "timeout" => 3600,
+ "when" => "on_success",
+ "allow_failure" => false
+ }
+ ])
+ end
+ end
+
+ context 'when `multi_build_steps` is not passed by the runner' do
+ it 'drops the job' do
+ request_job
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+ end
+ end
+
+ context 'when job is made for merge request' do
+ let(:pipeline) { create(:ci_pipeline, source: :merge_request_event, project: project, ref: 'feature', merge_request: merge_request) }
+ let!(:job) { create(:ci_build, pipeline: pipeline, name: 'spinach', ref: 'feature', stage: 'test', stage_idx: 0) }
+ let(:merge_request) { create(:merge_request) }
+
+ it 'sets branch as ref_type' do
+ request_job
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['git_info']['ref_type']).to eq('branch')
+ end
+
+ context 'when GIT_DEPTH is specified' do
+ before do
+ create(:ci_pipeline_variable, key: 'GIT_DEPTH', value: 1, pipeline: pipeline)
+ end
+
+ it 'returns the overwritten git depth for merge request refspecs' do
+ request_job
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['git_info']['depth']).to eq(1)
+ end
+ end
+ end
+
+ it 'updates runner info' do
+ expect { request_job }.to change { runner.reload.contacted_at }
+ end
+
+ %w(version revision platform architecture).each do |param|
+ context "when info parameter '#{param}' is present" do
+ let(:value) { "#{param}_value" }
+
+ it "updates provided Runner's parameter" do
+ request_job info: { param => value }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(runner.reload.read_attribute(param.to_sym)).to eq(value)
+ end
+ end
+ end
+
+ it "sets the runner's ip_address" do
+ post api('/jobs/request'),
+ params: { token: runner.token },
+ headers: { 'User-Agent' => user_agent, 'X-Forwarded-For' => '123.222.123.222' }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(runner.reload.ip_address).to eq('123.222.123.222')
+ end
+
+ it "handles multiple X-Forwarded-For addresses" do
+ post api('/jobs/request'),
+ params: { token: runner.token },
+ headers: { 'User-Agent' => user_agent, 'X-Forwarded-For' => '123.222.123.222, 127.0.0.1' }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(runner.reload.ip_address).to eq('123.222.123.222')
+ end
+
+ context 'when concurrently updating a job' do
+ before do
+ expect_any_instance_of(::Ci::Build).to receive(:run!)
+ .and_raise(ActiveRecord::StaleObjectError.new(nil, nil))
+ end
+
+ it 'returns a conflict' do
+ request_job
+
+ expect(response).to have_gitlab_http_status(:conflict)
+ expect(response.headers).not_to have_key('X-GitLab-Last-Update')
+ end
+ end
+
+ context 'when project and pipeline have multiple jobs' do
+ let!(:job) { create(:ci_build, :tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) }
+ let!(:job2) { create(:ci_build, :tag, pipeline: pipeline, name: 'rubocop', stage: 'test', stage_idx: 0) }
+ let!(:test_job) { create(:ci_build, pipeline: pipeline, name: 'deploy', stage: 'deploy', stage_idx: 1) }
+
+ before do
+ job.success
+ job2.success
+ end
+
+ it 'returns dependent jobs' do
+ request_job
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['id']).to eq(test_job.id)
+ expect(json_response['dependencies'].count).to eq(2)
+ expect(json_response['dependencies']).to include(
+ { 'id' => job.id, 'name' => job.name, 'token' => job.token },
+ { 'id' => job2.id, 'name' => job2.name, 'token' => job2.token })
+ end
+ end
+
+ context 'when pipeline have jobs with artifacts' do
+ let!(:job) { create(:ci_build, :tag, :artifacts, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) }
+ let!(:test_job) { create(:ci_build, pipeline: pipeline, name: 'deploy', stage: 'deploy', stage_idx: 1) }
+
+ before do
+ job.success
+ end
+
+ it 'returns dependent jobs' do
+ request_job
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['id']).to eq(test_job.id)
+ expect(json_response['dependencies'].count).to eq(1)
+ expect(json_response['dependencies']).to include(
+ { 'id' => job.id, 'name' => job.name, 'token' => job.token,
+ 'artifacts_file' => { 'filename' => 'ci_build_artifacts.zip', 'size' => 107464 } })
+ end
+ end
+
+ context 'when explicit dependencies are defined' do
+ let!(:job) { create(:ci_build, :tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) }
+ let!(:job2) { create(:ci_build, :tag, pipeline: pipeline, name: 'rubocop', stage: 'test', stage_idx: 0) }
+ let!(:test_job) do
+ create(:ci_build, pipeline: pipeline, token: 'test-job-token', name: 'deploy',
+ stage: 'deploy', stage_idx: 1,
+ options: { script: ['bash'], dependencies: [job2.name] })
+ end
+
+ before do
+ job.success
+ job2.success
+ end
+
+ it 'returns dependent jobs' do
+ request_job
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['id']).to eq(test_job.id)
+ expect(json_response['dependencies'].count).to eq(1)
+ expect(json_response['dependencies'][0]).to include('id' => job2.id, 'name' => job2.name, 'token' => job2.token)
+ end
+ end
+
+ context 'when dependencies is an empty array' do
+ let!(:job) { create(:ci_build, :tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) }
+ let!(:job2) { create(:ci_build, :tag, pipeline: pipeline, name: 'rubocop', stage: 'test', stage_idx: 0) }
+ let!(:empty_dependencies_job) do
+ create(:ci_build, pipeline: pipeline, token: 'test-job-token', name: 'empty_dependencies_job',
+ stage: 'deploy', stage_idx: 1,
+ options: { script: ['bash'], dependencies: [] })
+ end
+
+ before do
+ job.success
+ job2.success
+ end
+
+ it 'returns an empty array' do
+ request_job
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['id']).to eq(empty_dependencies_job.id)
+ expect(json_response['dependencies'].count).to eq(0)
+ end
+ end
+
+ context 'when job has no tags' do
+ before do
+ job.update!(tags: [])
+ end
+
+ context 'when runner is allowed to pick untagged jobs' do
+ before do
+ runner.update_column(:run_untagged, true)
+ end
+
+ it 'picks job' do
+ request_job
+
+ expect(response).to have_gitlab_http_status(:created)
+ end
+ end
+
+ context 'when runner is not allowed to pick untagged jobs' do
+ before do
+ runner.update_column(:run_untagged, false)
+ end
+
+ it_behaves_like 'no jobs available'
+ end
+ end
+
+ context 'when triggered job is available' do
+ let(:expected_variables) do
+ [{ 'key' => 'CI_JOB_NAME', 'value' => 'spinach', 'public' => true, 'masked' => false },
+ { 'key' => 'CI_JOB_STAGE', 'value' => 'test', 'public' => true, 'masked' => false },
+ { 'key' => 'CI_PIPELINE_TRIGGERED', 'value' => 'true', 'public' => true, 'masked' => false },
+ { 'key' => 'DB_NAME', 'value' => 'postgres', 'public' => true, 'masked' => false },
+ { 'key' => 'SECRET_KEY', 'value' => 'secret_value', 'public' => false, 'masked' => false },
+ { 'key' => 'TRIGGER_KEY_1', 'value' => 'TRIGGER_VALUE_1', 'public' => false, 'masked' => false }]
+ end
+
+ let(:trigger) { create(:ci_trigger, project: project) }
+ let!(:trigger_request) { create(:ci_trigger_request, pipeline: pipeline, builds: [job], trigger: trigger) }
+
+ before do
+ project.variables << ::Ci::Variable.new(key: 'SECRET_KEY', value: 'secret_value')
+ end
+
+ shared_examples 'expected variables behavior' do
+ it 'returns variables for triggers' do
+ request_job
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['variables']).to include(*expected_variables)
+ end
+ end
+
+ context 'when variables are stored in trigger_request' do
+ before do
+ trigger_request.update_attribute(:variables, { TRIGGER_KEY_1: 'TRIGGER_VALUE_1' } )
+ end
+
+ it_behaves_like 'expected variables behavior'
+ end
+
+ context 'when variables are stored in pipeline_variables' do
+ before do
+ create(:ci_pipeline_variable, pipeline: pipeline, key: :TRIGGER_KEY_1, value: 'TRIGGER_VALUE_1')
+ end
+
+ it_behaves_like 'expected variables behavior'
+ end
+ end
+
+ describe 'registry credentials support' do
+ let(:registry_url) { 'registry.example.com:5005' }
+ let(:registry_credentials) do
+ { 'type' => 'registry',
+ 'url' => registry_url,
+ 'username' => 'gitlab-ci-token',
+ 'password' => job.token }
+ end
+
+ context 'when registry is enabled' do
+ before do
+ stub_container_registry_config(enabled: true, host_port: registry_url)
+ end
+
+ it 'sends registry credentials key' do
+ request_job
+
+ expect(json_response).to have_key('credentials')
+ expect(json_response['credentials']).to include(registry_credentials)
+ end
+ end
+
+ context 'when registry is disabled' do
+ before do
+ stub_container_registry_config(enabled: false, host_port: registry_url)
+ end
+
+ it 'does not send registry credentials' do
+ request_job
+
+ expect(json_response).to have_key('credentials')
+ expect(json_response['credentials']).not_to include(registry_credentials)
+ end
+ end
+ end
+
+ describe 'timeout support' do
+ context 'when project specifies job timeout' do
+ let(:project) { create(:project, shared_runners_enabled: false, build_timeout: 1234) }
+
+ it 'contains info about timeout taken from project' do
+ request_job
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['runner_info']).to include({ 'timeout' => 1234 })
+ end
+
+ context 'when runner specifies lower timeout' do
+ let(:runner) { create(:ci_runner, :project, maximum_timeout: 1000, projects: [project]) }
+
+ it 'contains info about timeout overridden by runner' do
+ request_job
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['runner_info']).to include({ 'timeout' => 1000 })
+ end
+ end
+
+ context 'when runner specifies bigger timeout' do
+ let(:runner) { create(:ci_runner, :project, maximum_timeout: 2000, projects: [project]) }
+
+ it 'contains info about timeout not overridden by runner' do
+ request_job
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['runner_info']).to include({ 'timeout' => 1234 })
+ end
+ end
+ end
+ end
+ end
+
+ describe 'port support' do
+ let(:job) { create(:ci_build, pipeline: pipeline, options: options) }
+
+ context 'when job image has ports' do
+ let(:options) do
+ {
+ image: {
+ name: 'ruby',
+ ports: [80]
+ },
+ services: ['mysql']
+ }
+ end
+
+ it 'returns the image ports' do
+ request_job
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response).to include(
+ 'id' => job.id,
+ 'image' => a_hash_including('name' => 'ruby', 'ports' => [{ 'number' => 80, 'protocol' => 'http', 'name' => 'default_port' }]),
+ 'services' => all(a_hash_including('name' => 'mysql')))
+ end
+ end
+
+ context 'when job services settings has ports' do
+ let(:options) do
+ {
+ image: 'ruby',
+ services: [
+ {
+ name: 'tomcat',
+ ports: [{ number: 8081, protocol: 'http', name: 'custom_port' }]
+ }
+ ]
+ }
+ end
+
+ it 'returns the service ports' do
+ request_job
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response).to include(
+ 'id' => job.id,
+ 'image' => a_hash_including('name' => 'ruby'),
+ 'services' => all(a_hash_including('name' => 'tomcat', 'ports' => [{ 'number' => 8081, 'protocol' => 'http', 'name' => 'custom_port' }])))
+ end
+ end
+ end
+
+ describe 'a job with excluded artifacts' do
+ context 'when excluded paths are defined' do
+ let(:job) do
+ create(:ci_build, pipeline: pipeline, token: 'test-job-token', name: 'test',
+ stage: 'deploy', stage_idx: 1,
+ options: { artifacts: { paths: ['abc'], exclude: ['cde'] } })
+ end
+
+ context 'when a runner supports this feature' do
+ it 'exposes excluded paths when the feature is enabled' do
+ stub_feature_flags(ci_artifacts_exclude: true)
+
+ request_job info: { features: { artifacts_exclude: true } }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response.dig('artifacts').first).to include('exclude' => ['cde'])
+ end
+
+ it 'does not expose excluded paths when the feature is disabled' do
+ stub_feature_flags(ci_artifacts_exclude: false)
+
+ request_job info: { features: { artifacts_exclude: true } }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response.dig('artifacts').first).not_to have_key('exclude')
+ end
+ end
+
+ context 'when a runner does not support this feature' do
+ it 'does not expose the build at all' do
+ stub_feature_flags(ci_artifacts_exclude: true)
+
+ request_job
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+ end
+ end
+
+ it 'does not expose excluded paths when these are empty' do
+ request_job
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response.dig('artifacts').first).not_to have_key('exclude')
+ end
+ end
+
+ def request_job(token = runner.token, **params)
+ new_params = params.merge(token: token, last_update: last_update)
+ post api('/jobs/request'), params: new_params.to_json, headers: { 'User-Agent' => user_agent, 'Content-Type': 'application/json' }
+ end
+ end
+
+ context 'for web-ide job' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :repository) }
+
+ let(:runner) { create(:ci_runner, :project, projects: [project]) }
+ let(:service) { ::Ci::CreateWebIdeTerminalService.new(project, user, ref: 'master').execute }
+ let(:pipeline) { service[:pipeline] }
+ let(:build) { pipeline.builds.first }
+ let(:job) { {} }
+ let(:config_content) do
+ 'terminal: { image: ruby, services: [mysql], before_script: [ls], tags: [tag-1], variables: { KEY: value } }'
+ end
+
+ before do
+ stub_webide_config_file(config_content)
+ project.add_maintainer(user)
+
+ pipeline
+ end
+
+ context 'when runner has matching tag' do
+ before do
+ runner.update!(tag_list: ['tag-1'])
+ end
+
+ it 'successfully picks job' do
+ request_job
+
+ build.reload
+
+ expect(build).to be_running
+ expect(build.runner).to eq(runner)
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response).to include(
+ "id" => build.id,
+ "variables" => include("key" => 'KEY', "value" => 'value', "public" => true, "masked" => false),
+ "image" => a_hash_including("name" => 'ruby'),
+ "services" => all(a_hash_including("name" => 'mysql')),
+ "job_info" => a_hash_including("name" => 'terminal', "stage" => 'terminal'))
+ end
+ end
+
+ context 'when runner does not have matching tags' do
+ it 'does not pick a job' do
+ request_job
+
+ build.reload
+
+ expect(build).to be_pending
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+ end
+
+ def request_job(token = runner.token, **params)
+ post api('/jobs/request'), params: params.merge(token: token)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/ci/runner/jobs_trace_spec.rb b/spec/requests/api/ci/runner/jobs_trace_spec.rb
new file mode 100644
index 00000000000..1980c1a9f51
--- /dev/null
+++ b/spec/requests/api/ci/runner/jobs_trace_spec.rb
@@ -0,0 +1,292 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
+ include StubGitlabCalls
+ include RedisHelpers
+ include WorkhorseHelpers
+
+ let(:registration_token) { 'abcdefg123456' }
+
+ before do
+ stub_feature_flags(ci_enable_live_trace: true)
+ stub_gitlab_calls
+ stub_application_setting(runners_registration_token: registration_token)
+ allow_any_instance_of(::Ci::Runner).to receive(:cache_attributes)
+ end
+
+ describe '/api/v4/jobs' do
+ let(:root_namespace) { create(:namespace) }
+ let(:namespace) { create(:namespace, parent: root_namespace) }
+ let(:project) { create(:project, namespace: namespace, shared_runners_enabled: false) }
+ let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master') }
+ let(:runner) { create(:ci_runner, :project, projects: [project]) }
+ let(:user) { create(:user) }
+ let(:job) do
+ create(:ci_build, :artifacts, :extended_options,
+ pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0)
+ end
+
+ describe 'PATCH /api/v4/jobs/:id/trace' do
+ let(:job) do
+ create(:ci_build, :running, :trace_live,
+ project: project, user: user, runner_id: runner.id, pipeline: pipeline)
+ end
+
+ let(:headers) { { API::Helpers::Runner::JOB_TOKEN_HEADER => job.token, 'Content-Type' => 'text/plain' } }
+ let(:headers_with_range) { headers.merge({ 'Content-Range' => '11-20' }) }
+ let(:update_interval) { 10.seconds.to_i }
+
+ before do
+ initial_patch_the_trace
+ end
+
+ it_behaves_like 'API::CI::Runner application context metadata', '/api/:version/jobs/:id/trace' do
+ let(:send_request) { patch_the_trace }
+ end
+
+ it 'updates runner info' do
+ runner.update!(contacted_at: 1.year.ago)
+
+ expect { patch_the_trace }.to change { runner.reload.contacted_at }
+ end
+
+ context 'when request is valid' do
+ it 'gets correct response' do
+ expect(response).to have_gitlab_http_status(:accepted)
+ expect(job.reload.trace.raw).to eq 'BUILD TRACE appended'
+ expect(response.header).to have_key 'Range'
+ expect(response.header).to have_key 'Job-Status'
+ expect(response.header).to have_key 'X-GitLab-Trace-Update-Interval'
+ end
+
+ context 'when job has been updated recently' do
+ it { expect { patch_the_trace }.not_to change { job.updated_at }}
+
+ it "changes the job's trace" do
+ patch_the_trace
+
+ expect(job.reload.trace.raw).to eq 'BUILD TRACE appended appended'
+ end
+
+ context 'when Runner makes a force-patch' do
+ it { expect { force_patch_the_trace }.not_to change { job.updated_at }}
+
+ it "doesn't change the build.trace" do
+ force_patch_the_trace
+
+ expect(job.reload.trace.raw).to eq 'BUILD TRACE appended'
+ end
+ end
+ end
+
+ context 'when job was not updated recently' do
+ let(:update_interval) { 15.minutes.to_i }
+
+ it { expect { patch_the_trace }.to change { job.updated_at } }
+
+ it 'changes the job.trace' do
+ patch_the_trace
+
+ expect(job.reload.trace.raw).to eq 'BUILD TRACE appended appended'
+ end
+
+ context 'when Runner makes a force-patch' do
+ it { expect { force_patch_the_trace }.to change { job.updated_at } }
+
+ it "doesn't change the job.trace" do
+ force_patch_the_trace
+
+ expect(job.reload.trace.raw).to eq 'BUILD TRACE appended'
+ end
+ end
+ end
+
+ context 'when project for the build has been deleted' do
+ let(:job) do
+ create(:ci_build, :running, :trace_live, runner_id: runner.id, pipeline: pipeline) do |job|
+ job.project.update!(pending_delete: true)
+ end
+ end
+
+ it 'responds with forbidden' do
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'when trace is patched' do
+ before do
+ patch_the_trace
+ end
+
+ it 'has valid trace' do
+ expect(response).to have_gitlab_http_status(:accepted)
+ expect(job.reload.trace.raw).to eq 'BUILD TRACE appended appended'
+ end
+
+ context 'when job is cancelled' do
+ before do
+ job.cancel
+ end
+
+ context 'when trace is patched' do
+ before do
+ patch_the_trace
+ end
+
+ it 'returns Forbidden ' do
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+ end
+
+ context 'when redis data are flushed' do
+ before do
+ redis_shared_state_cleanup!
+ end
+
+ it 'has empty trace' do
+ expect(job.reload.trace.raw).to eq ''
+ end
+
+ context 'when we perform partial patch' do
+ before do
+ patch_the_trace('hello', headers.merge({ 'Content-Range' => "28-32/5" }))
+ end
+
+ it 'returns an error' do
+ expect(response).to have_gitlab_http_status(:range_not_satisfiable)
+ expect(response.header['Range']).to eq('0-0')
+ end
+ end
+
+ context 'when we resend full trace' do
+ before do
+ patch_the_trace('BUILD TRACE appended appended hello', headers.merge({ 'Content-Range' => "0-34/35" }))
+ end
+
+ it 'succeeds with updating trace' do
+ expect(response).to have_gitlab_http_status(:accepted)
+ expect(job.reload.trace.raw).to eq 'BUILD TRACE appended appended hello'
+ end
+ end
+ end
+ end
+
+ context 'when concurrent update of trace is happening' do
+ before do
+ job.trace.write('wb') do
+ patch_the_trace
+ end
+ end
+
+ it 'returns that operation conflicts' do
+ expect(response).to have_gitlab_http_status(:conflict)
+ end
+ end
+
+ context 'when the job is canceled' do
+ before do
+ job.cancel
+ patch_the_trace
+ end
+
+ it 'receives status in header' do
+ expect(response.header['Job-Status']).to eq 'canceled'
+ end
+ end
+
+ context 'when build trace is being watched' do
+ before do
+ job.trace.being_watched!
+ end
+
+ it 'returns X-GitLab-Trace-Update-Interval as 3' do
+ patch_the_trace
+
+ expect(response).to have_gitlab_http_status(:accepted)
+ expect(response.header['X-GitLab-Trace-Update-Interval']).to eq('3')
+ end
+ end
+
+ context 'when build trace is not being watched' do
+ it 'returns X-GitLab-Trace-Update-Interval as 30' do
+ patch_the_trace
+
+ expect(response).to have_gitlab_http_status(:accepted)
+ expect(response.header['X-GitLab-Trace-Update-Interval']).to eq('30')
+ end
+ end
+ end
+
+ context 'when Runner makes a force-patch' do
+ before do
+ force_patch_the_trace
+ end
+
+ it 'gets correct response' do
+ expect(response).to have_gitlab_http_status(:accepted)
+ expect(job.reload.trace.raw).to eq 'BUILD TRACE appended'
+ expect(response.header).to have_key 'Range'
+ expect(response.header).to have_key 'Job-Status'
+ end
+ end
+
+ context 'when content-range start is too big' do
+ let(:headers_with_range) { headers.merge({ 'Content-Range' => '15-20/6' }) }
+
+ it 'gets 416 error response with range headers' do
+ expect(response).to have_gitlab_http_status(:range_not_satisfiable)
+ expect(response.header).to have_key 'Range'
+ expect(response.header['Range']).to eq '0-11'
+ end
+ end
+
+ context 'when content-range start is too small' do
+ let(:headers_with_range) { headers.merge({ 'Content-Range' => '8-20/13' }) }
+
+ it 'gets 416 error response with range headers' do
+ expect(response).to have_gitlab_http_status(:range_not_satisfiable)
+ expect(response.header).to have_key 'Range'
+ expect(response.header['Range']).to eq '0-11'
+ end
+ end
+
+ context 'when Content-Range header is missing' do
+ let(:headers_with_range) { headers }
+
+ it { expect(response).to have_gitlab_http_status(:bad_request) }
+ end
+
+ context 'when job has been errased' do
+ let(:job) { create(:ci_build, runner_id: runner.id, erased_at: Time.now) }
+
+ it { expect(response).to have_gitlab_http_status(:forbidden) }
+ end
+
+ def patch_the_trace(content = ' appended', request_headers = nil)
+ unless request_headers
+ job.trace.read do |stream|
+ offset = stream.size
+ limit = offset + content.length - 1
+ request_headers = headers.merge({ 'Content-Range' => "#{offset}-#{limit}" })
+ end
+ end
+
+ Timecop.travel(job.updated_at + update_interval) do
+ patch api("/jobs/#{job.id}/trace"), params: content, headers: request_headers
+ job.reload
+ end
+ end
+
+ def initial_patch_the_trace
+ patch_the_trace(' appended', headers_with_range)
+ end
+
+ def force_patch_the_trace
+ 2.times { patch_the_trace('') }
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/ci/runner/runners_delete_spec.rb b/spec/requests/api/ci/runner/runners_delete_spec.rb
new file mode 100644
index 00000000000..75960a1a1c0
--- /dev/null
+++ b/spec/requests/api/ci/runner/runners_delete_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
+ include StubGitlabCalls
+ include RedisHelpers
+ include WorkhorseHelpers
+
+ let(:registration_token) { 'abcdefg123456' }
+
+ before do
+ stub_feature_flags(ci_enable_live_trace: true)
+ stub_gitlab_calls
+ stub_application_setting(runners_registration_token: registration_token)
+ allow_any_instance_of(::Ci::Runner).to receive(:cache_attributes)
+ end
+
+ describe '/api/v4/runners' do
+ describe 'DELETE /api/v4/runners' do
+ context 'when no token is provided' do
+ it 'returns 400 error' do
+ delete api('/runners')
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+
+ context 'when invalid token is provided' do
+ it 'returns 403 error' do
+ delete api('/runners'), params: { token: 'invalid' }
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'when valid token is provided' do
+ let(:runner) { create(:ci_runner) }
+
+ it 'deletes Runner' do
+ delete api('/runners'), params: { token: runner.token }
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ expect(::Ci::Runner.count).to eq(0)
+ end
+
+ it_behaves_like '412 response' do
+ let(:request) { api('/runners') }
+ let(:params) { { token: runner.token } }
+ end
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/ci/runner/runners_post_spec.rb b/spec/requests/api/ci/runner/runners_post_spec.rb
new file mode 100644
index 00000000000..7c362fae7d2
--- /dev/null
+++ b/spec/requests/api/ci/runner/runners_post_spec.rb
@@ -0,0 +1,250 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
+ include StubGitlabCalls
+ include RedisHelpers
+ include WorkhorseHelpers
+
+ let(:registration_token) { 'abcdefg123456' }
+
+ before do
+ stub_feature_flags(ci_enable_live_trace: true)
+ stub_gitlab_calls
+ stub_application_setting(runners_registration_token: registration_token)
+ allow_any_instance_of(::Ci::Runner).to receive(:cache_attributes)
+ end
+
+ describe '/api/v4/runners' do
+ describe 'POST /api/v4/runners' do
+ context 'when no token is provided' do
+ it 'returns 400 error' do
+ post api('/runners')
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+
+ context 'when invalid token is provided' do
+ it 'returns 403 error' do
+ post api('/runners'), params: { token: 'invalid' }
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'when valid token is provided' do
+ it 'creates runner with default values' do
+ post api('/runners'), params: { token: registration_token }
+
+ runner = ::Ci::Runner.first
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['id']).to eq(runner.id)
+ expect(json_response['token']).to eq(runner.token)
+ expect(runner.run_untagged).to be true
+ expect(runner.active).to be true
+ expect(runner.token).not_to eq(registration_token)
+ expect(runner).to be_instance_type
+ end
+
+ context 'when project token is used' do
+ let(:project) { create(:project) }
+
+ it 'creates project runner' do
+ post api('/runners'), params: { token: project.runners_token }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(project.runners.size).to eq(1)
+ runner = ::Ci::Runner.first
+ expect(runner.token).not_to eq(registration_token)
+ expect(runner.token).not_to eq(project.runners_token)
+ expect(runner).to be_project_type
+ end
+ end
+
+ context 'when group token is used' do
+ let(:group) { create(:group) }
+
+ it 'creates a group runner' do
+ post api('/runners'), params: { token: group.runners_token }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(group.runners.reload.size).to eq(1)
+ runner = ::Ci::Runner.first
+ expect(runner.token).not_to eq(registration_token)
+ expect(runner.token).not_to eq(group.runners_token)
+ expect(runner).to be_group_type
+ end
+ end
+ end
+
+ context 'when runner description is provided' do
+ it 'creates runner' do
+ post api('/runners'), params: {
+ token: registration_token,
+ description: 'server.hostname'
+ }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(::Ci::Runner.first.description).to eq('server.hostname')
+ end
+ end
+
+ context 'when runner tags are provided' do
+ it 'creates runner' do
+ post api('/runners'), params: {
+ token: registration_token,
+ tag_list: 'tag1, tag2'
+ }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(::Ci::Runner.first.tag_list.sort).to eq(%w(tag1 tag2))
+ end
+ end
+
+ context 'when option for running untagged jobs is provided' do
+ context 'when tags are provided' do
+ it 'creates runner' do
+ post api('/runners'), params: {
+ token: registration_token,
+ run_untagged: false,
+ tag_list: ['tag']
+ }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(::Ci::Runner.first.run_untagged).to be false
+ expect(::Ci::Runner.first.tag_list.sort).to eq(['tag'])
+ end
+ end
+
+ context 'when tags are not provided' do
+ it 'returns 400 error' do
+ post api('/runners'), params: {
+ token: registration_token,
+ run_untagged: false
+ }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['message']).to include(
+ 'tags_list' => ['can not be empty when runner is not allowed to pick untagged jobs'])
+ end
+ end
+ end
+
+ context 'when option for locking Runner is provided' do
+ it 'creates runner' do
+ post api('/runners'), params: {
+ token: registration_token,
+ locked: true
+ }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(::Ci::Runner.first.locked).to be true
+ end
+ end
+
+ context 'when option for activating a Runner is provided' do
+ context 'when active is set to true' do
+ it 'creates runner' do
+ post api('/runners'), params: {
+ token: registration_token,
+ active: true
+ }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(::Ci::Runner.first.active).to be true
+ end
+ end
+
+ context 'when active is set to false' do
+ it 'creates runner' do
+ post api('/runners'), params: {
+ token: registration_token,
+ active: false
+ }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(::Ci::Runner.first.active).to be false
+ end
+ end
+ end
+
+ context 'when access_level is provided for Runner' do
+ context 'when access_level is set to ref_protected' do
+ it 'creates runner' do
+ post api('/runners'), params: {
+ token: registration_token,
+ access_level: 'ref_protected'
+ }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(::Ci::Runner.first.ref_protected?).to be true
+ end
+ end
+
+ context 'when access_level is set to not_protected' do
+ it 'creates runner' do
+ post api('/runners'), params: {
+ token: registration_token,
+ access_level: 'not_protected'
+ }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(::Ci::Runner.first.ref_protected?).to be false
+ end
+ end
+ end
+
+ context 'when maximum job timeout is specified' do
+ it 'creates runner' do
+ post api('/runners'), params: {
+ token: registration_token,
+ maximum_timeout: 9000
+ }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(::Ci::Runner.first.maximum_timeout).to eq(9000)
+ end
+
+ context 'when maximum job timeout is empty' do
+ it 'creates runner' do
+ post api('/runners'), params: {
+ token: registration_token,
+ maximum_timeout: ''
+ }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(::Ci::Runner.first.maximum_timeout).to be_nil
+ end
+ end
+ end
+
+ %w(name version revision platform architecture).each do |param|
+ context "when info parameter '#{param}' info is present" do
+ let(:value) { "#{param}_value" }
+
+ it "updates provided Runner's parameter" do
+ post api('/runners'), params: {
+ token: registration_token,
+ info: { param => value }
+ }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(::Ci::Runner.first.read_attribute(param.to_sym)).to eq(value)
+ end
+ end
+ end
+
+ it "sets the runner's ip_address" do
+ post api('/runners'),
+ params: { token: registration_token },
+ headers: { 'X-Forwarded-For' => '123.111.123.111' }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(::Ci::Runner.first.ip_address).to eq('123.111.123.111')
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/ci/runner/runners_verify_post_spec.rb b/spec/requests/api/ci/runner/runners_verify_post_spec.rb
new file mode 100644
index 00000000000..e2f5f9b2d68
--- /dev/null
+++ b/spec/requests/api/ci/runner/runners_verify_post_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
+ include StubGitlabCalls
+ include RedisHelpers
+ include WorkhorseHelpers
+
+ let(:registration_token) { 'abcdefg123456' }
+
+ before do
+ stub_feature_flags(ci_enable_live_trace: true)
+ stub_gitlab_calls
+ stub_application_setting(runners_registration_token: registration_token)
+ allow_any_instance_of(::Ci::Runner).to receive(:cache_attributes)
+ end
+
+ describe '/api/v4/runners' do
+ describe 'POST /api/v4/runners/verify' do
+ let(:runner) { create(:ci_runner) }
+
+ context 'when no token is provided' do
+ it 'returns 400 error' do
+ post api('/runners/verify')
+
+ expect(response).to have_gitlab_http_status :bad_request
+ end
+ end
+
+ context 'when invalid token is provided' do
+ it 'returns 403 error' do
+ post api('/runners/verify'), params: { token: 'invalid-token' }
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'when valid token is provided' do
+ it 'verifies Runner credentials' do
+ post api('/runners/verify'), params: { token: runner.token }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/ci/runner_spec.rb b/spec/requests/api/ci/runner_spec.rb
deleted file mode 100644
index 41c30ce383a..00000000000
--- a/spec/requests/api/ci/runner_spec.rb
+++ /dev/null
@@ -1,2456 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
- include StubGitlabCalls
- include RedisHelpers
- include WorkhorseHelpers
-
- let(:registration_token) { 'abcdefg123456' }
-
- before do
- stub_feature_flags(ci_enable_live_trace: true)
- stub_gitlab_calls
- stub_application_setting(runners_registration_token: registration_token)
- allow_any_instance_of(::Ci::Runner).to receive(:cache_attributes)
- end
-
- describe '/api/v4/runners' do
- describe 'POST /api/v4/runners' do
- context 'when no token is provided' do
- it 'returns 400 error' do
- post api('/runners')
-
- expect(response).to have_gitlab_http_status(:bad_request)
- end
- end
-
- context 'when invalid token is provided' do
- it 'returns 403 error' do
- post api('/runners'), params: { token: 'invalid' }
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
-
- context 'when valid token is provided' do
- it 'creates runner with default values' do
- post api('/runners'), params: { token: registration_token }
-
- runner = ::Ci::Runner.first
-
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response['id']).to eq(runner.id)
- expect(json_response['token']).to eq(runner.token)
- expect(runner.run_untagged).to be true
- expect(runner.active).to be true
- expect(runner.token).not_to eq(registration_token)
- expect(runner).to be_instance_type
- end
-
- context 'when project token is used' do
- let(:project) { create(:project) }
-
- it 'creates project runner' do
- post api('/runners'), params: { token: project.runners_token }
-
- expect(response).to have_gitlab_http_status(:created)
- expect(project.runners.size).to eq(1)
- runner = ::Ci::Runner.first
- expect(runner.token).not_to eq(registration_token)
- expect(runner.token).not_to eq(project.runners_token)
- expect(runner).to be_project_type
- end
- end
-
- context 'when group token is used' do
- let(:group) { create(:group) }
-
- it 'creates a group runner' do
- post api('/runners'), params: { token: group.runners_token }
-
- expect(response).to have_gitlab_http_status(:created)
- expect(group.runners.reload.size).to eq(1)
- runner = ::Ci::Runner.first
- expect(runner.token).not_to eq(registration_token)
- expect(runner.token).not_to eq(group.runners_token)
- expect(runner).to be_group_type
- end
- end
- end
-
- context 'when runner description is provided' do
- it 'creates runner' do
- post api('/runners'), params: {
- token: registration_token,
- description: 'server.hostname'
- }
-
- expect(response).to have_gitlab_http_status(:created)
- expect(::Ci::Runner.first.description).to eq('server.hostname')
- end
- end
-
- context 'when runner tags are provided' do
- it 'creates runner' do
- post api('/runners'), params: {
- token: registration_token,
- tag_list: 'tag1, tag2'
- }
-
- expect(response).to have_gitlab_http_status(:created)
- expect(::Ci::Runner.first.tag_list.sort).to eq(%w(tag1 tag2))
- end
- end
-
- context 'when option for running untagged jobs is provided' do
- context 'when tags are provided' do
- it 'creates runner' do
- post api('/runners'), params: {
- token: registration_token,
- run_untagged: false,
- tag_list: ['tag']
- }
-
- expect(response).to have_gitlab_http_status(:created)
- expect(::Ci::Runner.first.run_untagged).to be false
- expect(::Ci::Runner.first.tag_list.sort).to eq(['tag'])
- end
- end
-
- context 'when tags are not provided' do
- it 'returns 400 error' do
- post api('/runners'), params: {
- token: registration_token,
- run_untagged: false
- }
-
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(json_response['message']).to include(
- 'tags_list' => ['can not be empty when runner is not allowed to pick untagged jobs'])
- end
- end
- end
-
- context 'when option for locking Runner is provided' do
- it 'creates runner' do
- post api('/runners'), params: {
- token: registration_token,
- locked: true
- }
-
- expect(response).to have_gitlab_http_status(:created)
- expect(::Ci::Runner.first.locked).to be true
- end
- end
-
- context 'when option for activating a Runner is provided' do
- context 'when active is set to true' do
- it 'creates runner' do
- post api('/runners'), params: {
- token: registration_token,
- active: true
- }
-
- expect(response).to have_gitlab_http_status(:created)
- expect(::Ci::Runner.first.active).to be true
- end
- end
-
- context 'when active is set to false' do
- it 'creates runner' do
- post api('/runners'), params: {
- token: registration_token,
- active: false
- }
-
- expect(response).to have_gitlab_http_status(:created)
- expect(::Ci::Runner.first.active).to be false
- end
- end
- end
-
- context 'when access_level is provided for Runner' do
- context 'when access_level is set to ref_protected' do
- it 'creates runner' do
- post api('/runners'), params: {
- token: registration_token,
- access_level: 'ref_protected'
- }
-
- expect(response).to have_gitlab_http_status(:created)
- expect(::Ci::Runner.first.ref_protected?).to be true
- end
- end
-
- context 'when access_level is set to not_protected' do
- it 'creates runner' do
- post api('/runners'), params: {
- token: registration_token,
- access_level: 'not_protected'
- }
-
- expect(response).to have_gitlab_http_status(:created)
- expect(::Ci::Runner.first.ref_protected?).to be false
- end
- end
- end
-
- context 'when maximum job timeout is specified' do
- it 'creates runner' do
- post api('/runners'), params: {
- token: registration_token,
- maximum_timeout: 9000
- }
-
- expect(response).to have_gitlab_http_status(:created)
- expect(::Ci::Runner.first.maximum_timeout).to eq(9000)
- end
-
- context 'when maximum job timeout is empty' do
- it 'creates runner' do
- post api('/runners'), params: {
- token: registration_token,
- maximum_timeout: ''
- }
-
- expect(response).to have_gitlab_http_status(:created)
- expect(::Ci::Runner.first.maximum_timeout).to be_nil
- end
- end
- end
-
- %w(name version revision platform architecture).each do |param|
- context "when info parameter '#{param}' info is present" do
- let(:value) { "#{param}_value" }
-
- it "updates provided Runner's parameter" do
- post api('/runners'), params: {
- token: registration_token,
- info: { param => value }
- }
-
- expect(response).to have_gitlab_http_status(:created)
- expect(::Ci::Runner.first.read_attribute(param.to_sym)).to eq(value)
- end
- end
- end
-
- it "sets the runner's ip_address" do
- post api('/runners'),
- params: { token: registration_token },
- headers: { 'X-Forwarded-For' => '123.111.123.111' }
-
- expect(response).to have_gitlab_http_status(:created)
- expect(::Ci::Runner.first.ip_address).to eq('123.111.123.111')
- end
- end
-
- describe 'DELETE /api/v4/runners' do
- context 'when no token is provided' do
- it 'returns 400 error' do
- delete api('/runners')
-
- expect(response).to have_gitlab_http_status(:bad_request)
- end
- end
-
- context 'when invalid token is provided' do
- it 'returns 403 error' do
- delete api('/runners'), params: { token: 'invalid' }
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
-
- context 'when valid token is provided' do
- let(:runner) { create(:ci_runner) }
-
- it 'deletes Runner' do
- delete api('/runners'), params: { token: runner.token }
-
- expect(response).to have_gitlab_http_status(:no_content)
- expect(::Ci::Runner.count).to eq(0)
- end
-
- it_behaves_like '412 response' do
- let(:request) { api('/runners') }
- let(:params) { { token: runner.token } }
- end
- end
- end
-
- describe 'POST /api/v4/runners/verify' do
- let(:runner) { create(:ci_runner) }
-
- context 'when no token is provided' do
- it 'returns 400 error' do
- post api('/runners/verify')
-
- expect(response).to have_gitlab_http_status :bad_request
- end
- end
-
- context 'when invalid token is provided' do
- it 'returns 403 error' do
- post api('/runners/verify'), params: { token: 'invalid-token' }
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
-
- context 'when valid token is provided' do
- it 'verifies Runner credentials' do
- post api('/runners/verify'), params: { token: runner.token }
-
- expect(response).to have_gitlab_http_status(:ok)
- end
- end
- end
- end
-
- describe '/api/v4/jobs' do
- shared_examples 'application context metadata' do |api_route|
- it 'contains correct context metadata' do
- # Avoids popping the context from the thread so we can
- # check its content after the request.
- allow(Labkit::Context).to receive(:pop)
-
- send_request
-
- Labkit::Context.with_context do |context|
- expected_context = {
- 'meta.caller_id' => api_route,
- 'meta.user' => job.user.username,
- 'meta.project' => job.project.full_path,
- 'meta.root_namespace' => job.project.full_path_components.first
- }
-
- expect(context.to_h).to include(expected_context)
- end
- end
- end
-
- let(:root_namespace) { create(:namespace) }
- let(:namespace) { create(:namespace, parent: root_namespace) }
- let(:project) { create(:project, namespace: namespace, shared_runners_enabled: false) }
- let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master') }
- let(:runner) { create(:ci_runner, :project, projects: [project]) }
- let(:user) { create(:user) }
- let(:job) do
- create(:ci_build, :artifacts, :extended_options,
- pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0)
- end
-
- describe 'POST /api/v4/jobs/request' do
- let!(:last_update) {}
- let!(:new_update) { }
- let(:user_agent) { 'gitlab-runner 9.0.0 (9-0-stable; go1.7.4; linux/amd64)' }
-
- before do
- job
- stub_container_registry_config(enabled: false)
- end
-
- shared_examples 'no jobs available' do
- before do
- request_job
- end
-
- context 'when runner sends version in User-Agent' do
- context 'for stable version' do
- it 'gives 204 and set X-GitLab-Last-Update' do
- expect(response).to have_gitlab_http_status(:no_content)
- expect(response.header).to have_key('X-GitLab-Last-Update')
- end
- end
-
- context 'when last_update is up-to-date' do
- let(:last_update) { runner.ensure_runner_queue_value }
-
- it 'gives 204 and set the same X-GitLab-Last-Update' do
- expect(response).to have_gitlab_http_status(:no_content)
- expect(response.header['X-GitLab-Last-Update']).to eq(last_update)
- end
- end
-
- context 'when last_update is outdated' do
- let(:last_update) { runner.ensure_runner_queue_value }
- let(:new_update) { runner.tick_runner_queue }
-
- it 'gives 204 and set a new X-GitLab-Last-Update' do
- expect(response).to have_gitlab_http_status(:no_content)
- expect(response.header['X-GitLab-Last-Update']).to eq(new_update)
- end
- end
-
- context 'when beta version is sent' do
- let(:user_agent) { 'gitlab-runner 9.0.0~beta.167.g2b2bacc (master; go1.7.4; linux/amd64)' }
-
- it { expect(response).to have_gitlab_http_status(:no_content) }
- end
-
- context 'when pre-9-0 version is sent' do
- let(:user_agent) { 'gitlab-ci-multi-runner 1.6.0 (1-6-stable; go1.6.3; linux/amd64)' }
-
- it { expect(response).to have_gitlab_http_status(:no_content) }
- end
-
- context 'when pre-9-0 beta version is sent' do
- let(:user_agent) { 'gitlab-ci-multi-runner 1.6.0~beta.167.g2b2bacc (master; go1.6.3; linux/amd64)' }
-
- it { expect(response).to have_gitlab_http_status(:no_content) }
- end
- end
- end
-
- context 'when no token is provided' do
- it 'returns 400 error' do
- post api('/jobs/request')
-
- expect(response).to have_gitlab_http_status(:bad_request)
- end
- end
-
- context 'when invalid token is provided' do
- it 'returns 403 error' do
- post api('/jobs/request'), params: { token: 'invalid' }
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
-
- context 'when valid token is provided' do
- context 'when Runner is not active' do
- let(:runner) { create(:ci_runner, :inactive) }
- let(:update_value) { runner.ensure_runner_queue_value }
-
- it 'returns 204 error' do
- request_job
-
- expect(response).to have_gitlab_http_status(:no_content)
- expect(response.header['X-GitLab-Last-Update']).to eq(update_value)
- end
- end
-
- context 'when jobs are finished' do
- before do
- job.success
- end
-
- it_behaves_like 'no jobs available'
- end
-
- context 'when other projects have pending jobs' do
- before do
- job.success
- create(:ci_build, :pending)
- end
-
- it_behaves_like 'no jobs available'
- end
-
- context 'when shared runner requests job for project without shared_runners_enabled' do
- let(:runner) { create(:ci_runner, :instance) }
-
- it_behaves_like 'no jobs available'
- end
-
- context 'when there is a pending job' do
- let(:expected_job_info) do
- { 'name' => job.name,
- 'stage' => job.stage,
- 'project_id' => job.project.id,
- 'project_name' => job.project.name }
- end
-
- let(:expected_git_info) do
- { 'repo_url' => job.repo_url,
- 'ref' => job.ref,
- 'sha' => job.sha,
- 'before_sha' => job.before_sha,
- 'ref_type' => 'branch',
- 'refspecs' => ["+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}",
- "+refs/heads/#{job.ref}:refs/remotes/origin/#{job.ref}"],
- 'depth' => project.ci_default_git_depth }
- end
-
- let(:expected_steps) do
- [{ 'name' => 'script',
- 'script' => %w(echo),
- 'timeout' => job.metadata_timeout,
- 'when' => 'on_success',
- 'allow_failure' => false },
- { 'name' => 'after_script',
- 'script' => %w(ls date),
- 'timeout' => job.metadata_timeout,
- 'when' => 'always',
- 'allow_failure' => true }]
- end
-
- let(:expected_variables) do
- [{ 'key' => 'CI_JOB_NAME', 'value' => 'spinach', 'public' => true, 'masked' => false },
- { 'key' => 'CI_JOB_STAGE', 'value' => 'test', 'public' => true, 'masked' => false },
- { 'key' => 'DB_NAME', 'value' => 'postgres', 'public' => true, 'masked' => false }]
- end
-
- let(:expected_artifacts) do
- [{ 'name' => 'artifacts_file',
- 'untracked' => false,
- 'paths' => %w(out/),
- 'when' => 'always',
- 'expire_in' => '7d',
- "artifact_type" => "archive",
- "artifact_format" => "zip" }]
- end
-
- let(:expected_cache) do
- [{ 'key' => 'cache_key',
- 'untracked' => false,
- 'paths' => ['vendor/*'],
- 'policy' => 'pull-push' }]
- end
-
- let(:expected_features) { { 'trace_sections' => true } }
-
- it 'picks a job' do
- request_job info: { platform: :darwin }
-
- expect(response).to have_gitlab_http_status(:created)
- expect(response.headers['Content-Type']).to eq('application/json')
- expect(response.headers).not_to have_key('X-GitLab-Last-Update')
- expect(runner.reload.platform).to eq('darwin')
- expect(json_response['id']).to eq(job.id)
- expect(json_response['token']).to eq(job.token)
- expect(json_response['job_info']).to eq(expected_job_info)
- expect(json_response['git_info']).to eq(expected_git_info)
- expect(json_response['image']).to eq({ 'name' => 'ruby:2.7', 'entrypoint' => '/bin/sh', 'ports' => [] })
- expect(json_response['services']).to eq([{ 'name' => 'postgres', 'entrypoint' => nil,
- 'alias' => nil, 'command' => nil, 'ports' => [] },
- { 'name' => 'docker:stable-dind', 'entrypoint' => '/bin/sh',
- 'alias' => 'docker', 'command' => 'sleep 30', 'ports' => [] }])
- expect(json_response['steps']).to eq(expected_steps)
- expect(json_response['artifacts']).to eq(expected_artifacts)
- expect(json_response['cache']).to eq(expected_cache)
- expect(json_response['variables']).to include(*expected_variables)
- expect(json_response['features']).to eq(expected_features)
- end
-
- it 'creates persistent ref' do
- expect_any_instance_of(::Ci::PersistentRef).to receive(:create_ref)
- .with(job.sha, "refs/#{Repository::REF_PIPELINES}/#{job.commit_id}")
-
- request_job info: { platform: :darwin }
-
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response['id']).to eq(job.id)
- end
-
- context 'when job is made for tag' do
- let!(:job) { create(:ci_build, :tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) }
-
- it 'sets branch as ref_type' do
- request_job
-
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response['git_info']['ref_type']).to eq('tag')
- end
-
- context 'when GIT_DEPTH is specified' do
- before do
- create(:ci_pipeline_variable, key: 'GIT_DEPTH', value: 1, pipeline: pipeline)
- end
-
- it 'specifies refspecs' do
- request_job
-
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response['git_info']['refspecs']).to include("+refs/tags/#{job.ref}:refs/tags/#{job.ref}")
- end
- end
-
- context 'when a Gitaly exception is thrown during response' do
- before do
- allow_next_instance_of(Ci::BuildRunnerPresenter) do |instance|
- allow(instance).to receive(:artifacts).and_raise(GRPC::DeadlineExceeded)
- end
- end
-
- it 'fails the job as a scheduler failure' do
- request_job
-
- expect(response).to have_gitlab_http_status(:no_content)
- expect(job.reload.failed?).to be_truthy
- expect(job.failure_reason).to eq('scheduler_failure')
- expect(job.runner_id).to eq(runner.id)
- expect(job.runner_session).to be_nil
- end
- end
-
- context 'when GIT_DEPTH is not specified and there is no default git depth for the project' do
- before do
- project.update!(ci_default_git_depth: nil)
- end
-
- it 'specifies refspecs' do
- request_job
-
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response['git_info']['refspecs'])
- .to contain_exactly("+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}",
- '+refs/tags/*:refs/tags/*',
- '+refs/heads/*:refs/remotes/origin/*')
- end
- end
- end
-
- context 'when job filtered by job_age' do
- let!(:job) { create(:ci_build, :tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0, queued_at: 60.seconds.ago) }
-
- context 'job is queued less than job_age parameter' do
- let(:job_age) { 120 }
-
- it 'gives 204' do
- request_job(job_age: job_age)
-
- expect(response).to have_gitlab_http_status(:no_content)
- end
- end
-
- context 'job is queued more than job_age parameter' do
- let(:job_age) { 30 }
-
- it 'picks a job' do
- request_job(job_age: job_age)
-
- expect(response).to have_gitlab_http_status(:created)
- end
- end
- end
-
- context 'when job is made for branch' do
- it 'sets tag as ref_type' do
- request_job
-
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response['git_info']['ref_type']).to eq('branch')
- end
-
- context 'when GIT_DEPTH is specified' do
- before do
- create(:ci_pipeline_variable, key: 'GIT_DEPTH', value: 1, pipeline: pipeline)
- end
-
- it 'specifies refspecs' do
- request_job
-
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response['git_info']['refspecs']).to include("+refs/heads/#{job.ref}:refs/remotes/origin/#{job.ref}")
- end
- end
-
- context 'when GIT_DEPTH is not specified and there is no default git depth for the project' do
- before do
- project.update!(ci_default_git_depth: nil)
- end
-
- it 'specifies refspecs' do
- request_job
-
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response['git_info']['refspecs'])
- .to contain_exactly("+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}",
- '+refs/tags/*:refs/tags/*',
- '+refs/heads/*:refs/remotes/origin/*')
- end
- end
- end
-
- context 'when job is for a release' do
- let!(:job) { create(:ci_build, :release_options, pipeline: pipeline) }
-
- context 'when `multi_build_steps` is passed by the runner' do
- it 'exposes release info' do
- request_job info: { features: { multi_build_steps: true } }
-
- expect(response).to have_gitlab_http_status(:created)
- expect(response.headers).not_to have_key('X-GitLab-Last-Update')
- expect(json_response['steps']).to eq([
- {
- "name" => "script",
- "script" => ["make changelog | tee release_changelog.txt"],
- "timeout" => 3600,
- "when" => "on_success",
- "allow_failure" => false
- },
- {
- "name" => "release",
- "script" =>
- ["release-cli create --name \"Release $CI_COMMIT_SHA\" --description \"Created using the release-cli $EXTRA_DESCRIPTION\" --tag-name \"release-$CI_COMMIT_SHA\" --ref \"$CI_COMMIT_SHA\""],
- "timeout" => 3600,
- "when" => "on_success",
- "allow_failure" => false
- }
- ])
- end
- end
-
- context 'when `multi_build_steps` is not passed by the runner' do
- it 'drops the job' do
- request_job
-
- expect(response).to have_gitlab_http_status(:no_content)
- end
- end
- end
-
- context 'when job is made for merge request' do
- let(:pipeline) { create(:ci_pipeline, source: :merge_request_event, project: project, ref: 'feature', merge_request: merge_request) }
- let!(:job) { create(:ci_build, pipeline: pipeline, name: 'spinach', ref: 'feature', stage: 'test', stage_idx: 0) }
- let(:merge_request) { create(:merge_request) }
-
- it 'sets branch as ref_type' do
- request_job
-
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response['git_info']['ref_type']).to eq('branch')
- end
-
- context 'when GIT_DEPTH is specified' do
- before do
- create(:ci_pipeline_variable, key: 'GIT_DEPTH', value: 1, pipeline: pipeline)
- end
-
- it 'returns the overwritten git depth for merge request refspecs' do
- request_job
-
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response['git_info']['depth']).to eq(1)
- end
- end
- end
-
- it 'updates runner info' do
- expect { request_job }.to change { runner.reload.contacted_at }
- end
-
- %w(version revision platform architecture).each do |param|
- context "when info parameter '#{param}' is present" do
- let(:value) { "#{param}_value" }
-
- it "updates provided Runner's parameter" do
- request_job info: { param => value }
-
- expect(response).to have_gitlab_http_status(:created)
- expect(runner.reload.read_attribute(param.to_sym)).to eq(value)
- end
- end
- end
-
- it "sets the runner's ip_address" do
- post api('/jobs/request'),
- params: { token: runner.token },
- headers: { 'User-Agent' => user_agent, 'X-Forwarded-For' => '123.222.123.222' }
-
- expect(response).to have_gitlab_http_status(:created)
- expect(runner.reload.ip_address).to eq('123.222.123.222')
- end
-
- it "handles multiple X-Forwarded-For addresses" do
- post api('/jobs/request'),
- params: { token: runner.token },
- headers: { 'User-Agent' => user_agent, 'X-Forwarded-For' => '123.222.123.222, 127.0.0.1' }
-
- expect(response).to have_gitlab_http_status(:created)
- expect(runner.reload.ip_address).to eq('123.222.123.222')
- end
-
- context 'when concurrently updating a job' do
- before do
- expect_any_instance_of(::Ci::Build).to receive(:run!)
- .and_raise(ActiveRecord::StaleObjectError.new(nil, nil))
- end
-
- it 'returns a conflict' do
- request_job
-
- expect(response).to have_gitlab_http_status(:conflict)
- expect(response.headers).not_to have_key('X-GitLab-Last-Update')
- end
- end
-
- context 'when project and pipeline have multiple jobs' do
- let!(:job) { create(:ci_build, :tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) }
- let!(:job2) { create(:ci_build, :tag, pipeline: pipeline, name: 'rubocop', stage: 'test', stage_idx: 0) }
- let!(:test_job) { create(:ci_build, pipeline: pipeline, name: 'deploy', stage: 'deploy', stage_idx: 1) }
-
- before do
- job.success
- job2.success
- end
-
- it 'returns dependent jobs' do
- request_job
-
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response['id']).to eq(test_job.id)
- expect(json_response['dependencies'].count).to eq(2)
- expect(json_response['dependencies']).to include(
- { 'id' => job.id, 'name' => job.name, 'token' => job.token },
- { 'id' => job2.id, 'name' => job2.name, 'token' => job2.token })
- end
- end
-
- context 'when pipeline have jobs with artifacts' do
- let!(:job) { create(:ci_build, :tag, :artifacts, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) }
- let!(:test_job) { create(:ci_build, pipeline: pipeline, name: 'deploy', stage: 'deploy', stage_idx: 1) }
-
- before do
- job.success
- end
-
- it 'returns dependent jobs' do
- request_job
-
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response['id']).to eq(test_job.id)
- expect(json_response['dependencies'].count).to eq(1)
- expect(json_response['dependencies']).to include(
- { 'id' => job.id, 'name' => job.name, 'token' => job.token,
- 'artifacts_file' => { 'filename' => 'ci_build_artifacts.zip', 'size' => 107464 } })
- end
- end
-
- context 'when explicit dependencies are defined' do
- let!(:job) { create(:ci_build, :tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) }
- let!(:job2) { create(:ci_build, :tag, pipeline: pipeline, name: 'rubocop', stage: 'test', stage_idx: 0) }
- let!(:test_job) do
- create(:ci_build, pipeline: pipeline, token: 'test-job-token', name: 'deploy',
- stage: 'deploy', stage_idx: 1,
- options: { script: ['bash'], dependencies: [job2.name] })
- end
-
- before do
- job.success
- job2.success
- end
-
- it 'returns dependent jobs' do
- request_job
-
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response['id']).to eq(test_job.id)
- expect(json_response['dependencies'].count).to eq(1)
- expect(json_response['dependencies'][0]).to include('id' => job2.id, 'name' => job2.name, 'token' => job2.token)
- end
- end
-
- context 'when dependencies is an empty array' do
- let!(:job) { create(:ci_build, :tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) }
- let!(:job2) { create(:ci_build, :tag, pipeline: pipeline, name: 'rubocop', stage: 'test', stage_idx: 0) }
- let!(:empty_dependencies_job) do
- create(:ci_build, pipeline: pipeline, token: 'test-job-token', name: 'empty_dependencies_job',
- stage: 'deploy', stage_idx: 1,
- options: { script: ['bash'], dependencies: [] })
- end
-
- before do
- job.success
- job2.success
- end
-
- it 'returns an empty array' do
- request_job
-
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response['id']).to eq(empty_dependencies_job.id)
- expect(json_response['dependencies'].count).to eq(0)
- end
- end
-
- context 'when job has no tags' do
- before do
- job.update(tags: [])
- end
-
- context 'when runner is allowed to pick untagged jobs' do
- before do
- runner.update_column(:run_untagged, true)
- end
-
- it 'picks job' do
- request_job
-
- expect(response).to have_gitlab_http_status(:created)
- end
- end
-
- context 'when runner is not allowed to pick untagged jobs' do
- before do
- runner.update_column(:run_untagged, false)
- end
-
- it_behaves_like 'no jobs available'
- end
- end
-
- context 'when triggered job is available' do
- let(:expected_variables) do
- [{ 'key' => 'CI_JOB_NAME', 'value' => 'spinach', 'public' => true, 'masked' => false },
- { 'key' => 'CI_JOB_STAGE', 'value' => 'test', 'public' => true, 'masked' => false },
- { 'key' => 'CI_PIPELINE_TRIGGERED', 'value' => 'true', 'public' => true, 'masked' => false },
- { 'key' => 'DB_NAME', 'value' => 'postgres', 'public' => true, 'masked' => false },
- { 'key' => 'SECRET_KEY', 'value' => 'secret_value', 'public' => false, 'masked' => false },
- { 'key' => 'TRIGGER_KEY_1', 'value' => 'TRIGGER_VALUE_1', 'public' => false, 'masked' => false }]
- end
-
- let(:trigger) { create(:ci_trigger, project: project) }
- let!(:trigger_request) { create(:ci_trigger_request, pipeline: pipeline, builds: [job], trigger: trigger) }
-
- before do
- project.variables << ::Ci::Variable.new(key: 'SECRET_KEY', value: 'secret_value')
- end
-
- shared_examples 'expected variables behavior' do
- it 'returns variables for triggers' do
- request_job
-
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response['variables']).to include(*expected_variables)
- end
- end
-
- context 'when variables are stored in trigger_request' do
- before do
- trigger_request.update_attribute(:variables, { TRIGGER_KEY_1: 'TRIGGER_VALUE_1' } )
- end
-
- it_behaves_like 'expected variables behavior'
- end
-
- context 'when variables are stored in pipeline_variables' do
- before do
- create(:ci_pipeline_variable, pipeline: pipeline, key: :TRIGGER_KEY_1, value: 'TRIGGER_VALUE_1')
- end
-
- it_behaves_like 'expected variables behavior'
- end
- end
-
- describe 'registry credentials support' do
- let(:registry_url) { 'registry.example.com:5005' }
- let(:registry_credentials) do
- { 'type' => 'registry',
- 'url' => registry_url,
- 'username' => 'gitlab-ci-token',
- 'password' => job.token }
- end
-
- context 'when registry is enabled' do
- before do
- stub_container_registry_config(enabled: true, host_port: registry_url)
- end
-
- it 'sends registry credentials key' do
- request_job
-
- expect(json_response).to have_key('credentials')
- expect(json_response['credentials']).to include(registry_credentials)
- end
- end
-
- context 'when registry is disabled' do
- before do
- stub_container_registry_config(enabled: false, host_port: registry_url)
- end
-
- it 'does not send registry credentials' do
- request_job
-
- expect(json_response).to have_key('credentials')
- expect(json_response['credentials']).not_to include(registry_credentials)
- end
- end
- end
-
- describe 'timeout support' do
- context 'when project specifies job timeout' do
- let(:project) { create(:project, shared_runners_enabled: false, build_timeout: 1234) }
-
- it 'contains info about timeout taken from project' do
- request_job
-
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response['runner_info']).to include({ 'timeout' => 1234 })
- end
-
- context 'when runner specifies lower timeout' do
- let(:runner) { create(:ci_runner, :project, maximum_timeout: 1000, projects: [project]) }
-
- it 'contains info about timeout overridden by runner' do
- request_job
-
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response['runner_info']).to include({ 'timeout' => 1000 })
- end
- end
-
- context 'when runner specifies bigger timeout' do
- let(:runner) { create(:ci_runner, :project, maximum_timeout: 2000, projects: [project]) }
-
- it 'contains info about timeout not overridden by runner' do
- request_job
-
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response['runner_info']).to include({ 'timeout' => 1234 })
- end
- end
- end
- end
- end
-
- describe 'port support' do
- let(:job) { create(:ci_build, pipeline: pipeline, options: options) }
-
- context 'when job image has ports' do
- let(:options) do
- {
- image: {
- name: 'ruby',
- ports: [80]
- },
- services: ['mysql']
- }
- end
-
- it 'returns the image ports' do
- request_job
-
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response).to include(
- 'id' => job.id,
- 'image' => a_hash_including('name' => 'ruby', 'ports' => [{ 'number' => 80, 'protocol' => 'http', 'name' => 'default_port' }]),
- 'services' => all(a_hash_including('name' => 'mysql')))
- end
- end
-
- context 'when job services settings has ports' do
- let(:options) do
- {
- image: 'ruby',
- services: [
- {
- name: 'tomcat',
- ports: [{ number: 8081, protocol: 'http', name: 'custom_port' }]
- }
- ]
- }
- end
-
- it 'returns the service ports' do
- request_job
-
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response).to include(
- 'id' => job.id,
- 'image' => a_hash_including('name' => 'ruby'),
- 'services' => all(a_hash_including('name' => 'tomcat', 'ports' => [{ 'number' => 8081, 'protocol' => 'http', 'name' => 'custom_port' }])))
- end
- end
- end
-
- describe 'a job with excluded artifacts' do
- context 'when excluded paths are defined' do
- let(:job) do
- create(:ci_build, pipeline: pipeline, token: 'test-job-token', name: 'test',
- stage: 'deploy', stage_idx: 1,
- options: { artifacts: { paths: ['abc'], exclude: ['cde'] } })
- end
-
- context 'when a runner supports this feature' do
- it 'exposes excluded paths when the feature is enabled' do
- stub_feature_flags(ci_artifacts_exclude: true)
-
- request_job info: { features: { artifacts_exclude: true } }
-
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response.dig('artifacts').first).to include('exclude' => ['cde'])
- end
-
- it 'does not expose excluded paths when the feature is disabled' do
- stub_feature_flags(ci_artifacts_exclude: false)
-
- request_job info: { features: { artifacts_exclude: true } }
-
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response.dig('artifacts').first).not_to have_key('exclude')
- end
- end
-
- context 'when a runner does not support this feature' do
- it 'does not expose the build at all' do
- stub_feature_flags(ci_artifacts_exclude: true)
-
- request_job
-
- expect(response).to have_gitlab_http_status(:no_content)
- end
- end
- end
-
- it 'does not expose excluded paths when these are empty' do
- request_job
-
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response.dig('artifacts').first).not_to have_key('exclude')
- end
- end
-
- def request_job(token = runner.token, **params)
- new_params = params.merge(token: token, last_update: last_update)
- post api('/jobs/request'), params: new_params.to_json, headers: { 'User-Agent' => user_agent, 'Content-Type': 'application/json' }
- end
- end
-
- context 'for web-ide job' do
- let_it_be(:user) { create(:user) }
- let_it_be(:project) { create(:project, :repository) }
-
- let(:runner) { create(:ci_runner, :project, projects: [project]) }
- let(:service) { ::Ci::CreateWebIdeTerminalService.new(project, user, ref: 'master').execute }
- let(:pipeline) { service[:pipeline] }
- let(:build) { pipeline.builds.first }
- let(:job) { {} }
- let(:config_content) do
- 'terminal: { image: ruby, services: [mysql], before_script: [ls], tags: [tag-1], variables: { KEY: value } }'
- end
-
- before do
- stub_webide_config_file(config_content)
- project.add_maintainer(user)
-
- pipeline
- end
-
- context 'when runner has matching tag' do
- before do
- runner.update!(tag_list: ['tag-1'])
- end
-
- it 'successfully picks job' do
- request_job
-
- build.reload
-
- expect(build).to be_running
- expect(build.runner).to eq(runner)
-
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response).to include(
- "id" => build.id,
- "variables" => include("key" => 'KEY', "value" => 'value', "public" => true, "masked" => false),
- "image" => a_hash_including("name" => 'ruby'),
- "services" => all(a_hash_including("name" => 'mysql')),
- "job_info" => a_hash_including("name" => 'terminal', "stage" => 'terminal'))
- end
- end
-
- context 'when runner does not have matching tags' do
- it 'does not pick a job' do
- request_job
-
- build.reload
-
- expect(build).to be_pending
- expect(response).to have_gitlab_http_status(:no_content)
- end
- end
-
- def request_job(token = runner.token, **params)
- post api('/jobs/request'), params: params.merge(token: token)
- end
- end
- end
-
- describe 'PUT /api/v4/jobs/:id' do
- let(:job) do
- create(:ci_build, :pending, :trace_live, pipeline: pipeline, project: project, user: user, runner_id: runner.id)
- end
-
- before do
- job.run!
- end
-
- it_behaves_like 'application context metadata', '/api/:version/jobs/:id' do
- let(:send_request) { update_job(state: 'success') }
- end
-
- it 'updates runner info' do
- expect { update_job(state: 'success') }.to change { runner.reload.contacted_at }
- end
-
- context 'when status is given' do
- it 'mark job as succeeded' do
- update_job(state: 'success')
-
- job.reload
- expect(job).to be_success
- end
-
- it 'mark job as failed' do
- update_job(state: 'failed')
-
- job.reload
- expect(job).to be_failed
- expect(job).to be_unknown_failure
- end
-
- context 'when failure_reason is script_failure' do
- before do
- update_job(state: 'failed', failure_reason: 'script_failure')
- job.reload
- end
-
- it { expect(job).to be_script_failure }
- end
-
- context 'when failure_reason is runner_system_failure' do
- before do
- update_job(state: 'failed', failure_reason: 'runner_system_failure')
- job.reload
- end
-
- it { expect(job).to be_runner_system_failure }
- end
-
- context 'when failure_reason is unrecognized value' do
- before do
- update_job(state: 'failed', failure_reason: 'what_is_this')
- job.reload
- end
-
- it { expect(job).to be_unknown_failure }
- end
-
- context 'when failure_reason is job_execution_timeout' do
- before do
- update_job(state: 'failed', failure_reason: 'job_execution_timeout')
- job.reload
- end
-
- it { expect(job).to be_job_execution_timeout }
- end
-
- context 'when failure_reason is unmet_prerequisites' do
- before do
- update_job(state: 'failed', failure_reason: 'unmet_prerequisites')
- job.reload
- end
-
- it { expect(job).to be_unmet_prerequisites }
- end
- end
-
- context 'when trace is given' do
- it 'creates a trace artifact' do
- allow(BuildFinishedWorker).to receive(:perform_async).with(job.id) do
- ArchiveTraceWorker.new.perform(job.id)
- end
-
- update_job(state: 'success', trace: 'BUILD TRACE UPDATED')
-
- job.reload
- expect(response).to have_gitlab_http_status(:ok)
- expect(job.trace.raw).to eq 'BUILD TRACE UPDATED'
- expect(job.job_artifacts_trace.open.read).to eq 'BUILD TRACE UPDATED'
- end
-
- context 'when concurrent update of trace is happening' do
- before do
- job.trace.write('wb') do
- update_job(state: 'success', trace: 'BUILD TRACE UPDATED')
- end
- end
-
- it 'returns that operation conflicts' do
- expect(response).to have_gitlab_http_status(:conflict)
- end
- end
- end
-
- context 'when no trace is given' do
- it 'does not override trace information' do
- update_job
-
- expect(job.reload.trace.raw).to eq 'BUILD TRACE'
- end
-
- context 'when running state is sent' do
- it 'updates update_at value' do
- expect { update_job_after_time }.to change { job.reload.updated_at }
- end
- end
-
- context 'when other state is sent' do
- it "doesn't update update_at value" do
- expect { update_job_after_time(20.minutes, state: 'success') }.not_to change { job.reload.updated_at }
- end
- end
- end
-
- context 'when job has been erased' do
- let(:job) { create(:ci_build, runner_id: runner.id, erased_at: Time.now) }
-
- it 'responds with forbidden' do
- update_job
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
-
- context 'when job has already been finished' do
- before do
- job.trace.set('Job failed')
- job.drop!(:script_failure)
- end
-
- it 'does not update job status and job trace' do
- update_job(state: 'success', trace: 'BUILD TRACE UPDATED')
-
- job.reload
- expect(response).to have_gitlab_http_status(:forbidden)
- expect(response.header['Job-Status']).to eq 'failed'
- expect(job.trace.raw).to eq 'Job failed'
- expect(job).to be_failed
- end
- end
-
- def update_job(token = job.token, **params)
- new_params = params.merge(token: token)
- put api("/jobs/#{job.id}"), params: new_params
- end
-
- def update_job_after_time(update_interval = 20.minutes, state = 'running')
- Timecop.travel(job.updated_at + update_interval) do
- update_job(job.token, state: state)
- end
- end
- end
-
- describe 'PATCH /api/v4/jobs/:id/trace' do
- let(:job) do
- create(:ci_build, :running, :trace_live,
- project: project, user: user, runner_id: runner.id, pipeline: pipeline)
- end
- let(:headers) { { API::Helpers::Runner::JOB_TOKEN_HEADER => job.token, 'Content-Type' => 'text/plain' } }
- let(:headers_with_range) { headers.merge({ 'Content-Range' => '11-20' }) }
- let(:update_interval) { 10.seconds.to_i }
-
- before do
- initial_patch_the_trace
- end
-
- it_behaves_like 'application context metadata', '/api/:version/jobs/:id/trace' do
- let(:send_request) { patch_the_trace }
- end
-
- it 'updates runner info' do
- runner.update!(contacted_at: 1.year.ago)
-
- expect { patch_the_trace }.to change { runner.reload.contacted_at }
- end
-
- context 'when request is valid' do
- it 'gets correct response' do
- expect(response).to have_gitlab_http_status(:accepted)
- expect(job.reload.trace.raw).to eq 'BUILD TRACE appended'
- expect(response.header).to have_key 'Range'
- expect(response.header).to have_key 'Job-Status'
- expect(response.header).to have_key 'X-GitLab-Trace-Update-Interval'
- end
-
- context 'when job has been updated recently' do
- it { expect { patch_the_trace }.not_to change { job.updated_at }}
-
- it "changes the job's trace" do
- patch_the_trace
-
- expect(job.reload.trace.raw).to eq 'BUILD TRACE appended appended'
- end
-
- context 'when Runner makes a force-patch' do
- it { expect { force_patch_the_trace }.not_to change { job.updated_at }}
-
- it "doesn't change the build.trace" do
- force_patch_the_trace
-
- expect(job.reload.trace.raw).to eq 'BUILD TRACE appended'
- end
- end
- end
-
- context 'when job was not updated recently' do
- let(:update_interval) { 15.minutes.to_i }
-
- it { expect { patch_the_trace }.to change { job.updated_at } }
-
- it 'changes the job.trace' do
- patch_the_trace
-
- expect(job.reload.trace.raw).to eq 'BUILD TRACE appended appended'
- end
-
- context 'when Runner makes a force-patch' do
- it { expect { force_patch_the_trace }.to change { job.updated_at } }
-
- it "doesn't change the job.trace" do
- force_patch_the_trace
-
- expect(job.reload.trace.raw).to eq 'BUILD TRACE appended'
- end
- end
- end
-
- context 'when project for the build has been deleted' do
- let(:job) do
- create(:ci_build, :running, :trace_live, runner_id: runner.id, pipeline: pipeline) do |job|
- job.project.update(pending_delete: true)
- end
- end
-
- it 'responds with forbidden' do
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
-
- context 'when trace is patched' do
- before do
- patch_the_trace
- end
-
- it 'has valid trace' do
- expect(response).to have_gitlab_http_status(:accepted)
- expect(job.reload.trace.raw).to eq 'BUILD TRACE appended appended'
- end
-
- context 'when job is cancelled' do
- before do
- job.cancel
- end
-
- context 'when trace is patched' do
- before do
- patch_the_trace
- end
-
- it 'returns Forbidden ' do
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
- end
-
- context 'when redis data are flushed' do
- before do
- redis_shared_state_cleanup!
- end
-
- it 'has empty trace' do
- expect(job.reload.trace.raw).to eq ''
- end
-
- context 'when we perform partial patch' do
- before do
- patch_the_trace('hello', headers.merge({ 'Content-Range' => "28-32/5" }))
- end
-
- it 'returns an error' do
- expect(response).to have_gitlab_http_status(:range_not_satisfiable)
- expect(response.header['Range']).to eq('0-0')
- end
- end
-
- context 'when we resend full trace' do
- before do
- patch_the_trace('BUILD TRACE appended appended hello', headers.merge({ 'Content-Range' => "0-34/35" }))
- end
-
- it 'succeeds with updating trace' do
- expect(response).to have_gitlab_http_status(:accepted)
- expect(job.reload.trace.raw).to eq 'BUILD TRACE appended appended hello'
- end
- end
- end
- end
-
- context 'when concurrent update of trace is happening' do
- before do
- job.trace.write('wb') do
- patch_the_trace
- end
- end
-
- it 'returns that operation conflicts' do
- expect(response).to have_gitlab_http_status(:conflict)
- end
- end
-
- context 'when the job is canceled' do
- before do
- job.cancel
- patch_the_trace
- end
-
- it 'receives status in header' do
- expect(response.header['Job-Status']).to eq 'canceled'
- end
- end
-
- context 'when build trace is being watched' do
- before do
- job.trace.being_watched!
- end
-
- it 'returns X-GitLab-Trace-Update-Interval as 3' do
- patch_the_trace
-
- expect(response).to have_gitlab_http_status(:accepted)
- expect(response.header['X-GitLab-Trace-Update-Interval']).to eq('3')
- end
- end
-
- context 'when build trace is not being watched' do
- it 'returns X-GitLab-Trace-Update-Interval as 30' do
- patch_the_trace
-
- expect(response).to have_gitlab_http_status(:accepted)
- expect(response.header['X-GitLab-Trace-Update-Interval']).to eq('30')
- end
- end
- end
-
- context 'when Runner makes a force-patch' do
- before do
- force_patch_the_trace
- end
-
- it 'gets correct response' do
- expect(response).to have_gitlab_http_status(:accepted)
- expect(job.reload.trace.raw).to eq 'BUILD TRACE appended'
- expect(response.header).to have_key 'Range'
- expect(response.header).to have_key 'Job-Status'
- end
- end
-
- context 'when content-range start is too big' do
- let(:headers_with_range) { headers.merge({ 'Content-Range' => '15-20/6' }) }
-
- it 'gets 416 error response with range headers' do
- expect(response).to have_gitlab_http_status(:range_not_satisfiable)
- expect(response.header).to have_key 'Range'
- expect(response.header['Range']).to eq '0-11'
- end
- end
-
- context 'when content-range start is too small' do
- let(:headers_with_range) { headers.merge({ 'Content-Range' => '8-20/13' }) }
-
- it 'gets 416 error response with range headers' do
- expect(response).to have_gitlab_http_status(:range_not_satisfiable)
- expect(response.header).to have_key 'Range'
- expect(response.header['Range']).to eq '0-11'
- end
- end
-
- context 'when Content-Range header is missing' do
- let(:headers_with_range) { headers }
-
- it { expect(response).to have_gitlab_http_status(:bad_request) }
- end
-
- context 'when job has been errased' do
- let(:job) { create(:ci_build, runner_id: runner.id, erased_at: Time.now) }
-
- it { expect(response).to have_gitlab_http_status(:forbidden) }
- end
-
- def patch_the_trace(content = ' appended', request_headers = nil)
- unless request_headers
- job.trace.read do |stream|
- offset = stream.size
- limit = offset + content.length - 1
- request_headers = headers.merge({ 'Content-Range' => "#{offset}-#{limit}" })
- end
- end
-
- Timecop.travel(job.updated_at + update_interval) do
- patch api("/jobs/#{job.id}/trace"), params: content, headers: request_headers
- job.reload
- end
- end
-
- def initial_patch_the_trace
- patch_the_trace(' appended', headers_with_range)
- end
-
- def force_patch_the_trace
- 2.times { patch_the_trace('') }
- end
- end
-
- describe 'artifacts' do
- let(:job) { create(:ci_build, :pending, user: user, project: project, pipeline: pipeline, runner_id: runner.id) }
- let(:jwt) { JWT.encode({ 'iss' => 'gitlab-workhorse' }, Gitlab::Workhorse.secret, 'HS256') }
- let(:headers) { { 'GitLab-Workhorse' => '1.0', Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER => jwt } }
- let(:headers_with_token) { headers.merge(API::Helpers::Runner::JOB_TOKEN_HEADER => job.token) }
- let(:file_upload) { fixture_file_upload('spec/fixtures/banana_sample.gif', 'image/gif') }
- let(:file_upload2) { fixture_file_upload('spec/fixtures/dk.png', 'image/gif') }
-
- before do
- stub_artifacts_object_storage
- job.run!
- end
-
- shared_examples_for 'rejecting artifacts that are too large' do
- let(:filesize) { 100.megabytes.to_i }
- let(:sample_max_size) { (filesize / 1.megabyte) - 10 } # Set max size to be smaller than file size to trigger error
-
- shared_examples_for 'failed request' do
- it 'responds with payload too large error' do
- send_request
-
- expect(response).to have_gitlab_http_status(:payload_too_large)
- end
- end
-
- context 'based on plan limit setting' do
- let(:application_max_size) { sample_max_size + 100 }
- let(:limit_name) { "#{Ci::JobArtifact::PLAN_LIMIT_PREFIX}archive" }
-
- before do
- create(:plan_limits, :default_plan, limit_name => sample_max_size)
- stub_application_setting(max_artifacts_size: application_max_size)
- end
-
- it_behaves_like 'failed request'
- end
-
- context 'based on application setting' do
- before do
- stub_application_setting(max_artifacts_size: sample_max_size)
- end
-
- it_behaves_like 'failed request'
- end
-
- context 'based on root namespace setting' do
- let(:application_max_size) { sample_max_size + 10 }
-
- before do
- stub_application_setting(max_artifacts_size: application_max_size)
- root_namespace.update!(max_artifacts_size: sample_max_size)
- end
-
- it_behaves_like 'failed request'
- end
-
- context 'based on child namespace setting' do
- let(:application_max_size) { sample_max_size + 10 }
- let(:root_namespace_max_size) { sample_max_size + 10 }
-
- before do
- stub_application_setting(max_artifacts_size: application_max_size)
- root_namespace.update!(max_artifacts_size: root_namespace_max_size)
- namespace.update!(max_artifacts_size: sample_max_size)
- end
-
- it_behaves_like 'failed request'
- end
-
- context 'based on project setting' do
- let(:application_max_size) { sample_max_size + 10 }
- let(:root_namespace_max_size) { sample_max_size + 10 }
- let(:child_namespace_max_size) { sample_max_size + 10 }
-
- before do
- stub_application_setting(max_artifacts_size: application_max_size)
- root_namespace.update!(max_artifacts_size: root_namespace_max_size)
- namespace.update!(max_artifacts_size: child_namespace_max_size)
- project.update!(max_artifacts_size: sample_max_size)
- end
-
- it_behaves_like 'failed request'
- end
- end
-
- describe 'POST /api/v4/jobs/:id/artifacts/authorize' do
- context 'when using token as parameter' do
- context 'and the artifact is too large' do
- it_behaves_like 'rejecting artifacts that are too large' do
- let(:success_code) { :ok }
- let(:send_request) { authorize_artifacts_with_token_in_params(filesize: filesize) }
- end
- end
-
- context 'posting artifacts to running job' do
- subject do
- authorize_artifacts_with_token_in_params
- end
-
- it_behaves_like 'application context metadata', '/api/:version/jobs/:id/artifacts/authorize' do
- let(:send_request) { subject }
- end
-
- it 'updates runner info' do
- expect { subject }.to change { runner.reload.contacted_at }
- end
-
- shared_examples 'authorizes local file' do
- it 'succeeds' do
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
- expect(json_response['TempPath']).to eq(JobArtifactUploader.workhorse_local_upload_path)
- expect(json_response['RemoteObject']).to be_nil
- end
- end
-
- context 'when using local storage' do
- it_behaves_like 'authorizes local file'
- end
-
- context 'when using remote storage' do
- context 'when direct upload is enabled' do
- before do
- stub_artifacts_object_storage(enabled: true, direct_upload: true)
- end
-
- it 'succeeds' do
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
- expect(json_response).not_to have_key('TempPath')
- expect(json_response['RemoteObject']).to have_key('ID')
- expect(json_response['RemoteObject']).to have_key('GetURL')
- expect(json_response['RemoteObject']).to have_key('StoreURL')
- expect(json_response['RemoteObject']).to have_key('DeleteURL')
- expect(json_response['RemoteObject']).to have_key('MultipartUpload')
- end
- end
-
- context 'when direct upload is disabled' do
- before do
- stub_artifacts_object_storage(enabled: true, direct_upload: false)
- end
-
- it_behaves_like 'authorizes local file'
- end
- end
- end
- end
-
- context 'when using token as header' do
- it 'authorizes posting artifacts to running job' do
- authorize_artifacts_with_token_in_headers
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
- expect(json_response['TempPath']).not_to be_nil
- end
-
- it 'fails to post too large artifact' do
- stub_application_setting(max_artifacts_size: 0)
-
- authorize_artifacts_with_token_in_headers(filesize: 100)
-
- expect(response).to have_gitlab_http_status(:payload_too_large)
- end
- end
-
- context 'when using runners token' do
- it 'fails to authorize artifacts posting' do
- authorize_artifacts(token: job.project.runners_token)
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
-
- it 'reject requests that did not go through gitlab-workhorse' do
- headers.delete(Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER)
-
- authorize_artifacts
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
-
- context 'authorization token is invalid' do
- it 'responds with forbidden' do
- authorize_artifacts(token: 'invalid', filesize: 100 )
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
-
- context 'authorize uploading of an lsif artifact' do
- before do
- stub_feature_flags(code_navigation: job.project)
- end
-
- it 'adds ProcessLsif header' do
- authorize_artifacts_with_token_in_headers(artifact_type: :lsif)
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['ProcessLsif']).to be_truthy
- end
-
- it 'adds ProcessLsifReferences header' do
- authorize_artifacts_with_token_in_headers(artifact_type: :lsif)
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['ProcessLsifReferences']).to be_truthy
- end
-
- context 'code_navigation feature flag is disabled' do
- it 'responds with a forbidden error' do
- stub_feature_flags(code_navigation: false)
- authorize_artifacts_with_token_in_headers(artifact_type: :lsif)
-
- aggregate_failures do
- expect(response).to have_gitlab_http_status(:forbidden)
- expect(json_response['ProcessLsif']).to be_falsy
- expect(json_response['ProcessLsifReferences']).to be_falsy
- end
- end
- end
-
- context 'code_navigation_references feature flag is disabled' do
- it 'sets ProcessLsifReferences header to false' do
- stub_feature_flags(code_navigation_references: false)
- authorize_artifacts_with_token_in_headers(artifact_type: :lsif)
-
- aggregate_failures do
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['ProcessLsif']).to be_truthy
- expect(json_response['ProcessLsifReferences']).to be_falsy
- end
- end
- end
- end
-
- def authorize_artifacts(params = {}, request_headers = headers)
- post api("/jobs/#{job.id}/artifacts/authorize"), params: params, headers: request_headers
- end
-
- def authorize_artifacts_with_token_in_params(params = {}, request_headers = headers)
- params = params.merge(token: job.token)
- authorize_artifacts(params, request_headers)
- end
-
- def authorize_artifacts_with_token_in_headers(params = {}, request_headers = headers_with_token)
- authorize_artifacts(params, request_headers)
- end
- end
-
- describe 'POST /api/v4/jobs/:id/artifacts' do
- it_behaves_like 'application context metadata', '/api/:version/jobs/:id/artifacts' do
- let(:send_request) do
- upload_artifacts(file_upload, headers_with_token)
- end
- end
-
- it 'updates runner info' do
- expect { upload_artifacts(file_upload, headers_with_token) }.to change { runner.reload.contacted_at }
- end
-
- context 'when the artifact is too large' do
- it_behaves_like 'rejecting artifacts that are too large' do
- # This filesize validation also happens in non remote stored files,
- # it's just that it's hard to stub the filesize in other cases to be
- # more than a megabyte.
- let!(:fog_connection) do
- stub_artifacts_object_storage(direct_upload: true)
- end
- let(:object) do
- fog_connection.directories.new(key: 'artifacts').files.create(
- key: 'tmp/uploads/12312300',
- body: 'content'
- )
- end
- let(:file_upload) { fog_to_uploaded_file(object) }
- let(:send_request) do
- upload_artifacts(file_upload, headers_with_token, 'file.remote_id' => '12312300')
- end
- let(:success_code) { :created }
-
- before do
- allow(object).to receive(:content_length).and_return(filesize)
- end
- end
- end
-
- context 'when artifacts are being stored inside of tmp path' do
- before do
- # by configuring this path we allow to pass temp file from any path
- allow(JobArtifactUploader).to receive(:workhorse_upload_path).and_return('/')
- end
-
- context 'when job has been erased' do
- let(:job) { create(:ci_build, erased_at: Time.now) }
-
- before do
- upload_artifacts(file_upload, headers_with_token)
- end
-
- it 'responds with forbidden' do
- upload_artifacts(file_upload, headers_with_token)
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
-
- context 'when job is running' do
- shared_examples 'successful artifacts upload' do
- it 'updates successfully' do
- expect(response).to have_gitlab_http_status(:created)
- end
- end
-
- context 'when uses accelerated file post' do
- context 'for file stored locally' do
- before do
- upload_artifacts(file_upload, headers_with_token)
- end
-
- it_behaves_like 'successful artifacts upload'
- end
-
- context 'for file stored remotely' do
- let!(:fog_connection) do
- stub_artifacts_object_storage(direct_upload: true)
- end
- let(:object) do
- fog_connection.directories.new(key: 'artifacts').files.create(
- key: 'tmp/uploads/12312300',
- body: 'content'
- )
- end
- let(:file_upload) { fog_to_uploaded_file(object) }
-
- before do
- upload_artifacts(file_upload, headers_with_token, 'file.remote_id' => remote_id)
- end
-
- context 'when valid remote_id is used' do
- let(:remote_id) { '12312300' }
-
- it_behaves_like 'successful artifacts upload'
- end
-
- context 'when invalid remote_id is used' do
- let(:remote_id) { 'invalid id' }
-
- it 'responds with bad request' do
- expect(response).to have_gitlab_http_status(:internal_server_error)
- expect(json_response['message']).to eq("Missing file")
- end
- end
- end
- end
-
- context 'when using runners token' do
- it 'responds with forbidden' do
- upload_artifacts(file_upload, headers.merge(API::Helpers::Runner::JOB_TOKEN_HEADER => job.project.runners_token))
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
- end
-
- context 'when artifacts post request does not contain file' do
- it 'fails to post artifacts without file' do
- post api("/jobs/#{job.id}/artifacts"), params: {}, headers: headers_with_token
-
- expect(response).to have_gitlab_http_status(:bad_request)
- end
- end
-
- context 'GitLab Workhorse is not configured' do
- it 'fails to post artifacts without GitLab-Workhorse' do
- post api("/jobs/#{job.id}/artifacts"), params: { token: job.token }, headers: {}
-
- expect(response).to have_gitlab_http_status(:bad_request)
- end
- end
-
- context 'Is missing GitLab Workhorse token headers' do
- let(:jwt) { JWT.encode({ 'iss' => 'invalid-header' }, Gitlab::Workhorse.secret, 'HS256') }
-
- it 'fails to post artifacts without GitLab-Workhorse' do
- expect(Gitlab::ErrorTracking).to receive(:track_exception).once
-
- upload_artifacts(file_upload, headers_with_token)
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
-
- context 'when setting an expire date' do
- let(:default_artifacts_expire_in) {}
- let(:post_data) do
- { file: file_upload,
- expire_in: expire_in }
- end
-
- before do
- stub_application_setting(default_artifacts_expire_in: default_artifacts_expire_in)
-
- upload_artifacts(file_upload, headers_with_token, post_data)
- end
-
- context 'when an expire_in is given' do
- let(:expire_in) { '7 days' }
-
- it 'updates when specified' do
- expect(response).to have_gitlab_http_status(:created)
- expect(job.reload.artifacts_expire_at).to be_within(5.minutes).of(7.days.from_now)
- end
- end
-
- context 'when no expire_in is given' do
- let(:expire_in) { nil }
-
- it 'ignores if not specified' do
- expect(response).to have_gitlab_http_status(:created)
- expect(job.reload.artifacts_expire_at).to be_nil
- end
-
- context 'with application default' do
- context 'when default is 5 days' do
- let(:default_artifacts_expire_in) { '5 days' }
-
- it 'sets to application default' do
- expect(response).to have_gitlab_http_status(:created)
- expect(job.reload.artifacts_expire_at).to be_within(5.minutes).of(5.days.from_now)
- end
- end
-
- context 'when default is 0' do
- let(:default_artifacts_expire_in) { '0' }
-
- it 'does not set expire_in' do
- expect(response).to have_gitlab_http_status(:created)
- expect(job.reload.artifacts_expire_at).to be_nil
- end
- end
- end
- end
- end
-
- context 'posts artifacts file and metadata file' do
- let!(:artifacts) { file_upload }
- let!(:artifacts_sha256) { Digest::SHA256.file(artifacts.path).hexdigest }
- let!(:metadata) { file_upload2 }
- let!(:metadata_sha256) { Digest::SHA256.file(metadata.path).hexdigest }
-
- let(:stored_artifacts_file) { job.reload.artifacts_file }
- let(:stored_metadata_file) { job.reload.artifacts_metadata }
- let(:stored_artifacts_size) { job.reload.artifacts_size }
- let(:stored_artifacts_sha256) { job.reload.job_artifacts_archive.file_sha256 }
- let(:stored_metadata_sha256) { job.reload.job_artifacts_metadata.file_sha256 }
- let(:file_keys) { post_data.keys }
- let(:send_rewritten_field) { true }
-
- before do
- workhorse_finalize_with_multiple_files(
- api("/jobs/#{job.id}/artifacts"),
- method: :post,
- file_keys: file_keys,
- params: post_data,
- headers: headers_with_token,
- send_rewritten_field: send_rewritten_field
- )
- end
-
- context 'when posts data accelerated by workhorse is correct' do
- let(:post_data) { { file: artifacts, metadata: metadata } }
-
- it 'stores artifacts and artifacts metadata' do
- expect(response).to have_gitlab_http_status(:created)
- expect(stored_artifacts_file.filename).to eq(artifacts.original_filename)
- expect(stored_metadata_file.filename).to eq(metadata.original_filename)
- expect(stored_artifacts_size).to eq(artifacts.size)
- expect(stored_artifacts_sha256).to eq(artifacts_sha256)
- expect(stored_metadata_sha256).to eq(metadata_sha256)
- end
- end
-
- context 'with a malicious file.path param' do
- let(:post_data) { {} }
- let(:tmp_file) { Tempfile.new('crafted.file.path') }
- let(:url) { "/jobs/#{job.id}/artifacts?file.path=#{tmp_file.path}" }
-
- it 'rejects the request' do
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(stored_artifacts_size).to be_nil
- end
- end
-
- context 'when workhorse header is missing' do
- let(:post_data) { { file: artifacts, metadata: metadata } }
- let(:send_rewritten_field) { false }
-
- it 'rejects the request' do
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(stored_artifacts_size).to be_nil
- end
- end
-
- context 'when there is no artifacts file in post data' do
- let(:post_data) do
- { metadata: metadata }
- end
-
- it 'is expected to respond with bad request' do
- expect(response).to have_gitlab_http_status(:bad_request)
- end
-
- it 'does not store metadata' do
- expect(stored_metadata_file).to be_nil
- end
- end
- end
-
- context 'when artifact_type is archive' do
- context 'when artifact_format is zip' do
- let(:params) { { artifact_type: :archive, artifact_format: :zip } }
-
- it 'stores junit test report' do
- upload_artifacts(file_upload, headers_with_token, params)
-
- expect(response).to have_gitlab_http_status(:created)
- expect(job.reload.job_artifacts_archive).not_to be_nil
- end
- end
-
- context 'when artifact_format is gzip' do
- let(:params) { { artifact_type: :archive, artifact_format: :gzip } }
-
- it 'returns an error' do
- upload_artifacts(file_upload, headers_with_token, params)
-
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(job.reload.job_artifacts_archive).to be_nil
- end
- end
- end
-
- context 'when artifact_type is junit' do
- context 'when artifact_format is gzip' do
- let(:file_upload) { fixture_file_upload('spec/fixtures/junit/junit.xml.gz') }
- let(:params) { { artifact_type: :junit, artifact_format: :gzip } }
-
- it 'stores junit test report' do
- upload_artifacts(file_upload, headers_with_token, params)
-
- expect(response).to have_gitlab_http_status(:created)
- expect(job.reload.job_artifacts_junit).not_to be_nil
- end
- end
-
- context 'when artifact_format is raw' do
- let(:file_upload) { fixture_file_upload('spec/fixtures/junit/junit.xml.gz') }
- let(:params) { { artifact_type: :junit, artifact_format: :raw } }
-
- it 'returns an error' do
- upload_artifacts(file_upload, headers_with_token, params)
-
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(job.reload.job_artifacts_junit).to be_nil
- end
- end
- end
-
- context 'when artifact_type is metrics_referee' do
- context 'when artifact_format is gzip' do
- let(:file_upload) { fixture_file_upload('spec/fixtures/referees/metrics_referee.json.gz') }
- let(:params) { { artifact_type: :metrics_referee, artifact_format: :gzip } }
-
- it 'stores metrics_referee data' do
- upload_artifacts(file_upload, headers_with_token, params)
-
- expect(response).to have_gitlab_http_status(:created)
- expect(job.reload.job_artifacts_metrics_referee).not_to be_nil
- end
- end
-
- context 'when artifact_format is raw' do
- let(:file_upload) { fixture_file_upload('spec/fixtures/referees/metrics_referee.json.gz') }
- let(:params) { { artifact_type: :metrics_referee, artifact_format: :raw } }
-
- it 'returns an error' do
- upload_artifacts(file_upload, headers_with_token, params)
-
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(job.reload.job_artifacts_metrics_referee).to be_nil
- end
- end
- end
-
- context 'when artifact_type is network_referee' do
- context 'when artifact_format is gzip' do
- let(:file_upload) { fixture_file_upload('spec/fixtures/referees/network_referee.json.gz') }
- let(:params) { { artifact_type: :network_referee, artifact_format: :gzip } }
-
- it 'stores network_referee data' do
- upload_artifacts(file_upload, headers_with_token, params)
-
- expect(response).to have_gitlab_http_status(:created)
- expect(job.reload.job_artifacts_network_referee).not_to be_nil
- end
- end
-
- context 'when artifact_format is raw' do
- let(:file_upload) { fixture_file_upload('spec/fixtures/referees/network_referee.json.gz') }
- let(:params) { { artifact_type: :network_referee, artifact_format: :raw } }
-
- it 'returns an error' do
- upload_artifacts(file_upload, headers_with_token, params)
-
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(job.reload.job_artifacts_network_referee).to be_nil
- end
- end
- end
-
- context 'when artifact_type is dotenv' do
- context 'when artifact_format is gzip' do
- let(:file_upload) { fixture_file_upload('spec/fixtures/build.env.gz') }
- let(:params) { { artifact_type: :dotenv, artifact_format: :gzip } }
-
- it 'stores dotenv file' do
- upload_artifacts(file_upload, headers_with_token, params)
-
- expect(response).to have_gitlab_http_status(:created)
- expect(job.reload.job_artifacts_dotenv).not_to be_nil
- end
-
- it 'parses dotenv file' do
- expect do
- upload_artifacts(file_upload, headers_with_token, params)
- end.to change { job.job_variables.count }.from(0).to(2)
- end
-
- context 'when parse error happens' do
- let(:file_upload) { fixture_file_upload('spec/fixtures/ci_build_artifacts_metadata.gz') }
-
- it 'returns an error' do
- upload_artifacts(file_upload, headers_with_token, params)
-
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(json_response['message']).to eq('Invalid Format')
- end
- end
- end
-
- context 'when artifact_format is raw' do
- let(:file_upload) { fixture_file_upload('spec/fixtures/build.env.gz') }
- let(:params) { { artifact_type: :dotenv, artifact_format: :raw } }
-
- it 'returns an error' do
- upload_artifacts(file_upload, headers_with_token, params)
-
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(job.reload.job_artifacts_dotenv).to be_nil
- end
- end
- end
- end
-
- context 'when artifacts already exist for the job' do
- let(:params) do
- {
- artifact_type: :archive,
- artifact_format: :zip,
- 'file.sha256' => uploaded_sha256
- }
- end
-
- let(:existing_sha256) { '0' * 64 }
-
- let!(:existing_artifact) do
- create(:ci_job_artifact, :archive, file_sha256: existing_sha256, job: job)
- end
-
- context 'when sha256 is the same of the existing artifact' do
- let(:uploaded_sha256) { existing_sha256 }
-
- it 'ignores the new artifact' do
- upload_artifacts(file_upload, headers_with_token, params)
-
- expect(response).to have_gitlab_http_status(:created)
- expect(job.reload.job_artifacts_archive).to eq(existing_artifact)
- end
- end
-
- context 'when sha256 is different than the existing artifact' do
- let(:uploaded_sha256) { '1' * 64 }
-
- it 'logs and returns an error' do
- expect(Gitlab::ErrorTracking).to receive(:track_exception)
-
- upload_artifacts(file_upload, headers_with_token, params)
-
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(job.reload.job_artifacts_archive).to eq(existing_artifact)
- end
- end
- end
-
- context 'when object storage throws errors' do
- let(:params) { { artifact_type: :archive, artifact_format: :zip } }
-
- it 'does not store artifacts' do
- allow_next_instance_of(JobArtifactUploader) do |uploader|
- allow(uploader).to receive(:store!).and_raise(Errno::EIO)
- end
-
- upload_artifacts(file_upload, headers_with_token, params)
-
- expect(response).to have_gitlab_http_status(:service_unavailable)
- expect(job.reload.job_artifacts_archive).to be_nil
- end
- end
-
- context 'when artifacts are being stored outside of tmp path' do
- let(:new_tmpdir) { Dir.mktmpdir }
-
- before do
- # init before overwriting tmp dir
- file_upload
-
- # by configuring this path we allow to pass file from @tmpdir only
- # but all temporary files are stored in system tmp directory
- allow(Dir).to receive(:tmpdir).and_return(new_tmpdir)
- end
-
- after do
- FileUtils.remove_entry(new_tmpdir)
- end
-
- it 'fails to post artifacts for outside of tmp path' do
- upload_artifacts(file_upload, headers_with_token)
-
- expect(response).to have_gitlab_http_status(:bad_request)
- end
- end
-
- def upload_artifacts(file, headers = {}, params = {})
- workhorse_finalize(
- api("/jobs/#{job.id}/artifacts"),
- method: :post,
- file_key: :file,
- params: params.merge(file: file),
- headers: headers,
- send_rewritten_field: true
- )
- end
- end
-
- describe 'GET /api/v4/jobs/:id/artifacts' do
- let(:token) { job.token }
-
- it_behaves_like 'application context metadata', '/api/:version/jobs/:id/artifacts' do
- let(:send_request) { download_artifact }
- end
-
- it 'updates runner info' do
- expect { download_artifact }.to change { runner.reload.contacted_at }
- end
-
- context 'when job has artifacts' do
- let(:job) { create(:ci_build) }
- let(:store) { JobArtifactUploader::Store::LOCAL }
-
- before do
- create(:ci_job_artifact, :archive, file_store: store, job: job)
- end
-
- context 'when using job token' do
- context 'when artifacts are stored locally' do
- let(:download_headers) do
- { 'Content-Transfer-Encoding' => 'binary',
- 'Content-Disposition' => %q(attachment; filename="ci_build_artifacts.zip"; filename*=UTF-8''ci_build_artifacts.zip) }
- end
-
- before do
- download_artifact
- end
-
- it 'download artifacts' do
- expect(response).to have_gitlab_http_status(:ok)
- expect(response.headers.to_h).to include download_headers
- end
- end
-
- context 'when artifacts are stored remotely' do
- let(:store) { JobArtifactUploader::Store::REMOTE }
- let!(:job) { create(:ci_build) }
-
- context 'when proxy download is being used' do
- before do
- download_artifact(direct_download: false)
- end
-
- it 'uses workhorse send-url' do
- expect(response).to have_gitlab_http_status(:ok)
- expect(response.headers.to_h).to include(
- 'Gitlab-Workhorse-Send-Data' => /send-url:/)
- end
- end
-
- context 'when direct download is being used' do
- before do
- download_artifact(direct_download: true)
- end
-
- it 'receive redirect for downloading artifacts' do
- expect(response).to have_gitlab_http_status(:found)
- expect(response.headers).to include('Location')
- end
- end
- end
- end
-
- context 'when using runnners token' do
- let(:token) { job.project.runners_token }
-
- before do
- download_artifact
- end
-
- it 'responds with forbidden' do
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
- end
-
- context 'when job does not have artifacts' do
- it 'responds with not found' do
- download_artifact
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
-
- def download_artifact(params = {}, request_headers = headers)
- params = params.merge(token: token)
- job.reload
-
- get api("/jobs/#{job.id}/artifacts"), params: params, headers: request_headers
- end
- end
- end
- end
-end
diff --git a/spec/requests/api/group_variables_spec.rb b/spec/requests/api/group_variables_spec.rb
index c6d6ae1615b..41b013f49ee 100644
--- a/spec/requests/api/group_variables_spec.rb
+++ b/spec/requests/api/group_variables_spec.rb
@@ -169,6 +169,14 @@ RSpec.describe API::GroupVariables do
expect(response).to have_gitlab_http_status(:not_found)
end
+
+ it 'responds with 400 if the update fails' do
+ put api("/groups/#{group.id}/variables/#{variable.key}", user), params: { value: 'shrt', masked: true }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(variable.reload.masked).to eq(false)
+ expect(json_response['message']).to eq('value' => ['is invalid'])
+ end
end
context 'authorized user with invalid permissions' do
diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb
index 17f9112c1d5..6c6497a240b 100644
--- a/spec/requests/api/users_spec.rb
+++ b/spec/requests/api/users_spec.rb
@@ -64,6 +64,7 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do
expect(json_response).to have_key('note')
expect(json_response['note']).to eq(user.note)
+ expect(json_response).to have_key('sign_in_count')
end
end
@@ -72,6 +73,7 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do
get api("/users/#{user.id}", user)
expect(json_response).not_to have_key('note')
+ expect(json_response).not_to have_key('sign_in_count')
end
end
end
diff --git a/spec/services/ci/change_variable_service_spec.rb b/spec/services/ci/change_variable_service_spec.rb
new file mode 100644
index 00000000000..7acdd4e834f
--- /dev/null
+++ b/spec/services/ci/change_variable_service_spec.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::ChangeVariableService do
+ let(:service) { described_class.new(container: group, current_user: user, params: params) }
+
+ let_it_be(:user) { create(:user) }
+ let(:group) { create(:group) }
+
+ describe '#execute' do
+ subject(:execute) { service.execute }
+
+ context 'when creating a variable' do
+ let(:params) { { variable_params: { key: 'new_variable', value: 'variable_value' }, action: :create } }
+
+ it 'persists a variable' do
+ expect { execute }.to change(Ci::GroupVariable, :count).from(0).to(1)
+ end
+ end
+
+ context 'when updating a variable' do
+ let!(:variable) { create(:ci_group_variable, value: 'old_value') }
+ let(:params) { { variable_params: { key: variable.key, value: 'new_value' }, action: :update } }
+
+ before do
+ group.variables << variable
+ end
+
+ it 'updates a variable' do
+ expect { execute }.to change { variable.reload.value }.from('old_value').to('new_value')
+ end
+
+ context 'when the variable does not exist' do
+ before do
+ variable.destroy!
+ end
+
+ it 'raises a record not found error' do
+ expect { execute }.to raise_error(::ActiveRecord::RecordNotFound)
+ end
+ end
+ end
+
+ context 'when destroying a variable' do
+ let!(:variable) { create(:ci_group_variable) }
+ let(:params) { { variable_params: { key: variable.key }, action: :destroy } }
+
+ before do
+ group.variables << variable
+ end
+
+ it 'destroys a variable' do
+ expect { execute }.to change { Ci::GroupVariable.exists?(variable.id) }.from(true).to(false)
+ end
+
+ context 'when the variable does not exist' do
+ before do
+ variable.destroy!
+ end
+
+ it 'raises a record not found error' do
+ expect { execute }.to raise_error(::ActiveRecord::RecordNotFound)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/ci/change_variables_service_spec.rb b/spec/services/ci/change_variables_service_spec.rb
new file mode 100644
index 00000000000..5f1207eaf58
--- /dev/null
+++ b/spec/services/ci/change_variables_service_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::ChangeVariablesService do
+ let(:service) { described_class.new(container: group, current_user: user, params: params) }
+
+ let_it_be(:user) { create(:user) }
+ let(:group) { spy(:group, variables: []) }
+ let(:params) { { variables_attributes: [{ key: 'new_variable', value: 'variable_value' }] } }
+
+ describe '#execute' do
+ subject(:execute) { service.execute }
+
+ it 'delegates to ActiveRecord update' do
+ execute
+
+ expect(group).to have_received(:update).with(params)
+ end
+ end
+end
diff --git a/spec/services/product_analytics/build_graph_service_spec.rb b/spec/services/product_analytics/build_graph_service_spec.rb
new file mode 100644
index 00000000000..933a2bfee92
--- /dev/null
+++ b/spec/services/product_analytics/build_graph_service_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ProductAnalytics::BuildGraphService do
+ let_it_be(:project) { create(:project) }
+
+ let_it_be(:events) do
+ [
+ create(:product_analytics_event, project: project, platform: 'web'),
+ create(:product_analytics_event, project: project, platform: 'web'),
+ create(:product_analytics_event, project: project, platform: 'app'),
+ create(:product_analytics_event, project: project, platform: 'mobile'),
+ create(:product_analytics_event, project: project, platform: 'mobile', collector_tstamp: Time.zone.now - 60.days)
+ ]
+ end
+
+ let(:params) { { graph: 'platform', timerange: 5 } }
+
+ subject { described_class.new(project, params).execute }
+
+ it 'returns a valid graph hash' do
+ expect(subject[:id]).to eq(:platform)
+ expect(subject[:keys]).to eq(%w(app mobile web))
+ expect(subject[:values]).to eq([1, 1, 2])
+ end
+end
diff --git a/spec/support/shared_examples/lib/api/ci/runner_shared_examples.rb b/spec/support/shared_examples/lib/api/ci/runner_shared_examples.rb
new file mode 100644
index 00000000000..bdb0316bf5a
--- /dev/null
+++ b/spec/support/shared_examples/lib/api/ci/runner_shared_examples.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'API::CI::Runner application context metadata' do |api_route|
+ it 'contains correct context metadata' do
+ # Avoids popping the context from the thread so we can
+ # check its content after the request.
+ allow(Labkit::Context).to receive(:pop)
+
+ send_request
+
+ Labkit::Context.with_context do |context|
+ expected_context = {
+ 'meta.caller_id' => api_route,
+ 'meta.user' => job.user.username,
+ 'meta.project' => job.project.full_path,
+ 'meta.root_namespace' => job.project.full_path_components.first
+ }
+
+ expect(context.to_h).to include(expected_context)
+ end
+ end
+end