summaryrefslogtreecommitdiff
path: root/lib/api/ci/runner.rb
blob: 08903dce3dc22971ebe09425421fc83db9312b47 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
# frozen_string_literal: true

module API
  module Ci
    class Runner < Grape::API::Instance
      helpers ::API::Helpers::Runner

      resource :runners do
        desc 'Registers a new Runner' do
          success Entities::RunnerRegistrationDetails
          http_codes [[201, 'Runner was created'], [403, 'Forbidden']]
        end
        params do
          requires :token, type: String, desc: 'Registration token'
          optional :description, type: String, desc: %q(Runner's description)
          optional :info, type: Hash, desc: %q(Runner's metadata)
          optional :active, type: Boolean, desc: 'Should Runner be active'
          optional :locked, type: Boolean, desc: 'Should Runner be locked for current project'
          optional :access_level, type: String, values: ::Ci::Runner.access_levels.keys,
                                  desc: 'The access_level of the runner'
          optional :run_untagged, type: Boolean, desc: 'Should Runner handle untagged jobs'
          optional :tag_list, type: Array[String], coerce_with: ::API::Validations::Types::CommaSeparatedToArray.coerce, desc: %q(List of Runner's tags)
          optional :maximum_timeout, type: Integer, desc: 'Maximum timeout set when this Runner will handle the job'
        end
        post '/' do
          attributes = attributes_for_keys([:description, :active, :locked, :run_untagged, :tag_list, :access_level, :maximum_timeout])
            .merge(get_runner_details_from_request)

          attributes =
            if runner_registration_token_valid?
              # Create shared runner. Requires admin access
              attributes.merge(runner_type: :instance_type)
            elsif project = Project.find_by_runners_token(params[:token])
              # Create a specific runner for the project
              attributes.merge(runner_type: :project_type, projects: [project])
            elsif group = Group.find_by_runners_token(params[:token])
              # Create a specific runner for the group
              attributes.merge(runner_type: :group_type, groups: [group])
            else
              forbidden!
            end

          runner = ::Ci::Runner.create(attributes)

          if runner.persisted?
            present runner, with: Entities::RunnerRegistrationDetails
          else
            render_validation_error!(runner)
          end
        end

        desc 'Deletes a registered Runner' do
          http_codes [[204, 'Runner was deleted'], [403, 'Forbidden']]
        end
        params do
          requires :token, type: String, desc: %q(Runner's authentication token)
        end
        delete '/' do
          authenticate_runner!

          runner = ::Ci::Runner.find_by_token(params[:token])

          destroy_conditionally!(runner)
        end

        desc 'Validates authentication credentials' do
          http_codes [[200, 'Credentials are valid'], [403, 'Forbidden']]
        end
        params do
          requires :token, type: String, desc: %q(Runner's authentication token)
        end
        post '/verify' do
          authenticate_runner!
          status 200
        end
      end

      resource :jobs do
        before do
          Gitlab::ApplicationContext.push(
            user: -> { current_job&.user },
            project: -> { current_job&.project }
          )
        end

        desc 'Request a job' do
          success Entities::JobRequest::Response
          http_codes [[201, 'Job was scheduled'],
                      [204, 'No job for Runner'],
                      [403, 'Forbidden']]
        end
        params do
          requires :token, type: String, desc: %q(Runner's authentication token)
          optional :last_update, type: String, desc: %q(Runner's queue last_update token)
          optional :info, type: Hash, desc: %q(Runner's metadata) do
            optional :name, type: String, desc: %q(Runner's name)
            optional :version, type: String, desc: %q(Runner's version)
            optional :revision, type: String, desc: %q(Runner's revision)
            optional :platform, type: String, desc: %q(Runner's platform)
            optional :architecture, type: String, desc: %q(Runner's architecture)
            optional :executor, type: String, desc: %q(Runner's executor)
            optional :features, type: Hash, desc: %q(Runner's features)
          end
          optional :session, type: Hash, desc: %q(Runner's session data) do
            optional :url, type: String, desc: %q(Session's url)
            optional :certificate, type: String, desc: %q(Session's certificate)
            optional :authorization, type: String, desc: %q(Session's authorization)
          end
          optional :job_age, type: Integer, desc: %q(Job should be older than passed age in seconds to be ran on runner)
        end

        # Since we serialize the build output ourselves to ensure Gitaly
        # gRPC calls succeed, we need a custom Grape format to handle
        # this:
        # 1. Grape will ordinarily call `JSON.dump` when Content-Type is set
        # to application/json. To avoid this, we need to define a custom type in
        # `content_type` and a custom formatter to go with it.
        # 2. Grape will parse the request input with the parser defined for
        # `content_type`. If no such parser exists, it will be treated as text. We
        # reuse the existing JSON parser to preserve the previous behavior.
        content_type :build_json, 'application/json'
        formatter :build_json, ->(object, _) { object }
        parser :build_json, ::Grape::Parser::Json

        post '/request' do
          authenticate_runner!

          unless current_runner.active?
            header 'X-GitLab-Last-Update', current_runner.ensure_runner_queue_value
            break no_content!
          end

          runner_params = declared_params(include_missing: false)

          if current_runner.runner_queue_value_latest?(runner_params[:last_update])
            header 'X-GitLab-Last-Update', runner_params[:last_update]
            Gitlab::Metrics.add_event(:build_not_found_cached)
            break no_content!
          end

          new_update = current_runner.ensure_runner_queue_value
          result = ::Ci::RegisterJobService.new(current_runner).execute(runner_params)

          if result.valid?
            if result.build_json
              Gitlab::Metrics.add_event(:build_found)
              env['api.format'] = :build_json
              body result.build_json
            else
              Gitlab::Metrics.add_event(:build_not_found)
              header 'X-GitLab-Last-Update', new_update
              no_content!
            end
          else
            # We received build that is invalid due to concurrency conflict
            Gitlab::Metrics.add_event(:build_invalid)
            conflict!
          end
        end

        desc 'Updates a job' do
          http_codes [[200, 'Job was updated'],
                      [202, 'Update accepted'],
                      [400, 'Unknown parameters'],
                      [403, 'Forbidden']]
        end
        params do
          requires :token, type: String, desc: %q(Runners's authentication token)
          requires :id, type: Integer, desc: %q(Job's ID)
          optional :trace, type: String, desc: %q(Job's full trace)
          optional :state, type: String, desc: %q(Job's status: success, failed)
          optional :checksum, type: String, desc: %q(Job's trace CRC32 checksum)
          optional :failure_reason, type: String, desc: %q(Job's failure_reason)
        end
        put '/:id' do
          job = authenticate_job!

          Gitlab::Metrics.add_event(:update_build)

          service = ::Ci::UpdateBuildStateService
            .new(job, declared_params(include_missing: false))

          service.execute.then do |result|
            status result.status
          end
        end

        desc 'Appends a patch to the job trace' do
          http_codes [[202, 'Trace was patched'],
                      [400, 'Missing Content-Range header'],
                      [403, 'Forbidden'],
                      [416, 'Range not satisfiable']]
        end
        params do
          requires :id, type: Integer, desc: %q(Job's ID)
          optional :token, type: String, desc: %q(Job's authentication token)
        end
        patch '/:id/trace' do
          job = authenticate_job!

          error!('400 Missing header Content-Range', 400) unless request.headers.key?('Content-Range')
          content_range = request.headers['Content-Range']
          content_range = content_range.split('-')

          # TODO:
          # it seems that `Content-Range` as formatted by runner is wrong,
          # the `byte_end` should point to final byte, but it points byte+1
          # that means that we have to calculate end of body,
          # as we cannot use `content_length[1]`
          # Issue: https://gitlab.com/gitlab-org/gitlab-runner/issues/3275

          body_data = request.body.read
          body_start = content_range[0].to_i
          body_end = body_start + body_data.bytesize

          stream_size = job.trace.append(body_data, body_start)
          unless stream_size == body_end
            break error!('416 Range Not Satisfiable', 416, { 'Range' => "0-#{stream_size}" })
          end

          status 202
          header 'Job-Status', job.status
          header 'Range', "0-#{stream_size}"
          header 'X-GitLab-Trace-Update-Interval', job.trace.update_interval.to_s
        end

        desc 'Authorize artifacts uploading for job' do
          http_codes [[200, 'Upload allowed'],
                      [403, 'Forbidden'],
                      [405, 'Artifacts support not enabled'],
                      [413, 'File too large']]
        end
        params do
          requires :id, type: Integer, desc: %q(Job's ID)
          optional :token, type: String, desc: %q(Job's authentication token)

          # NOTE:
          # In current runner, filesize parameter would be empty here. This is because archive is streamed by runner,
          # so the archive size is not known ahead of time. Streaming is done to not use additional I/O on
          # Runner to first save, and then send via Network.
          optional :filesize, type: Integer, desc: %q(Artifacts filesize)

          optional :artifact_type, type: String, desc: %q(The type of artifact),
                                  default: 'archive', values: ::Ci::JobArtifact.file_types.keys
        end
        post '/:id/artifacts/authorize' do
          not_allowed! unless Gitlab.config.artifacts.enabled
          require_gitlab_workhorse!

          job = authenticate_job!

          result = ::Ci::CreateJobArtifactsService.new(job).authorize(artifact_type: params[:artifact_type], filesize: params[:filesize])

          if result[:status] == :success
            content_type Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE
            status :ok
            result[:headers]
          else
            render_api_error!(result[:message], result[:http_status])
          end
        end

        desc 'Upload artifacts for job' do
          success Entities::JobRequest::Response
          http_codes [[201, 'Artifact uploaded'],
                      [400, 'Bad request'],
                      [403, 'Forbidden'],
                      [405, 'Artifacts support not enabled'],
                      [413, 'File too large']]
        end
        params do
          requires :id, type: Integer, desc: %q(Job's ID)
          requires :file, type: ::API::Validations::Types::WorkhorseFile, desc: %(The artifact file to store (generated by Multipart middleware))
          optional :token, type: String, desc: %q(Job's authentication token)
          optional :expire_in, type: String, desc: %q(Specify when artifacts should expire)
          optional :artifact_type, type: String, desc: %q(The type of artifact),
                                  default: 'archive', values: ::Ci::JobArtifact.file_types.keys
          optional :artifact_format, type: String, desc: %q(The format of artifact),
                                    default: 'zip', values: ::Ci::JobArtifact.file_formats.keys
          optional :metadata, type: ::API::Validations::Types::WorkhorseFile, desc: %(The artifact metadata to store (generated by Multipart middleware))
        end
        post '/:id/artifacts' do
          not_allowed! unless Gitlab.config.artifacts.enabled
          require_gitlab_workhorse!

          job = authenticate_job!

          artifacts = params[:file]
          metadata = params[:metadata]

          result = ::Ci::CreateJobArtifactsService.new(job).execute(artifacts, params, metadata_file: metadata)

          if result[:status] == :success
            status :created
          else
            render_api_error!(result[:message], result[:http_status])
          end
        end

        desc 'Download the artifacts file for job' do
          http_codes [[200, 'Upload allowed'],
                      [403, 'Forbidden'],
                      [404, 'Artifact not found']]
        end
        params do
          requires :id, type: Integer, desc: %q(Job's ID)
          optional :token, type: String, desc: %q(Job's authentication token)
          optional :direct_download, default: false, type: Boolean, desc: %q(Perform direct download from remote storage instead of proxying artifacts)
        end
        get '/:id/artifacts' do
          job = authenticate_job!(require_running: false)

          present_carrierwave_file!(job.artifacts_file, supports_direct_download: params[:direct_download])
        end
      end
    end
  end
end