summaryrefslogtreecommitdiff
path: root/lib/api/ci/runner.rb
blob: d8f4b22879709f210330da0c90b19bb3900fcf76 (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
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
# frozen_string_literal: true

module API
  module Ci
    class Runner < ::API::Base
      helpers ::API::Ci::Helpers::Runner

      content_type :txt, 'text/plain'

      resource :runners do
        desc 'Register a new runner' do
          detail "Register a new runner for the instance"
          success Entities::Ci::RunnerRegistrationDetails
          failure [[400, 'Bad Request'], [403, 'Forbidden']]
        end
        params do
          requires :token, type: String, desc: 'Registration token'
          optional :description, type: String, desc: %q(Description of the runner)
          optional :maintainer_note, type: String, desc: %q(Deprecated: see `maintenance_note`)
          optional :maintenance_note, type: String,
                                      desc: %q(Free-form maintenance notes for the runner (1024 characters))
          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)
          end
          optional :active, type: Boolean,
                            desc: 'Deprecated: Use `paused` instead. Specifies if the runner is allowed ' \
                                  'to receive new jobs'
          optional :paused, type: Boolean, desc: 'Specifies if the runner should ignore new jobs'
          optional :locked, type: Boolean, desc: 'Specifies if the runner should be locked for the 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: 'Specifies if the runner should handle untagged jobs'
          optional :tag_list, type: Array[String], coerce_with: ::API::Validations::Types::CommaSeparatedToArray.coerce,
                              desc: %q(A list of runner tags)
          optional :maximum_timeout, type: Integer,
                                     desc: 'Maximum timeout that limits the amount of time (in seconds) ' \
                                           'that runners can run jobs'
          mutually_exclusive :maintainer_note, :maintenance_note
          mutually_exclusive :active, :paused
        end
        post '/', urgency: :low, feature_category: :runner do
          attributes = attributes_for_keys(%i[description maintainer_note maintenance_note active paused locked run_untagged tag_list access_level maximum_timeout])
            .merge(get_runner_details_from_request)

          # Pull in deprecated maintainer_note if that's the only note value available
          deprecated_note = attributes.delete(:maintainer_note)
          attributes[:maintenance_note] ||= deprecated_note if deprecated_note
          attributes[:active] = !attributes.delete(:paused) if attributes.include?(:paused)

          result = ::Ci::Runners::RegisterRunnerService.new(params[:token], attributes).execute
          @runner = result.success? ? result.payload[:runner] : nil
          forbidden!(result.message) unless @runner

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

        desc 'Delete a registered runner' do
          summary "Delete a runner by authentication token"
          failure [[403, 'Forbidden']]
        end
        params do
          requires :token, type: String, desc: %q(The runner's authentication token)
        end
        delete '/', urgency: :low, feature_category: :runner do
          authenticate_runner!

          destroy_conditionally!(current_runner) { ::Ci::Runners::UnregisterRunnerService.new(current_runner, params[:token]).execute }
        end

        desc 'Delete a registered runner manager' do
          summary 'Internal endpoint that deletes a runner manager by authentication token and system ID.'
          http_codes [[204, 'Runner manager was deleted'], [400, 'Bad Request'], [403, 'Forbidden'], [404, 'Not Found']]
        end
        params do
          requires :token, type: String, desc: %q(The runner's authentication token)
          requires :system_id, type: String, desc: %q(The runner's system identifier.)
        end
        delete '/managers', urgency: :low, feature_category: :runner_fleet do
          authenticate_runner!(ensure_runner_manager: false)

          destroy_conditionally!(current_runner) do
            ::Ci::Runners::UnregisterRunnerManagerService.new(
              current_runner,
              params[:token],
              system_id: params[:system_id]).execute
          end
        end

        desc 'Validate authentication credentials' do
          summary "Verify authentication for a registered runner"
          success Entities::Ci::RunnerRegistrationDetails
          http_codes [[200, 'Credentials are valid'], [403, 'Forbidden']]
        end
        params do
          requires :token, type: String, desc: %q(The runner's authentication token)
          optional :system_id, type: String, desc: %q(The runner's system identifier)
        end
        post '/verify', urgency: :low, feature_category: :runner do
          # For runners that were created in the UI, we want to update the contacted_at value
          # only when it starts polling for jobs
          registering_created_runner = params[:token].start_with?(::Ci::Runner::CREATED_RUNNER_TOKEN_PREFIX)

          authenticate_runner!(update_contacted_at: !registering_created_runner)
          status 200

          present current_runner, with: Entities::Ci::RunnerRegistrationDetails
        end

        desc 'Reset runner authentication token with current token' do
          success Entities::Ci::ResetTokenResult
          failure [[403, 'Forbidden']]
        end
        params do
          requires :token, type: String, desc: 'The current authentication token of the runner'
        end
        post '/reset_authentication_token', urgency: :low, feature_category: :runner do
          authenticate_runner!

          current_runner.reset_token!
          present current_runner.token_with_expiration, with: Entities::Ci::ResetTokenResult
        end
      end

      resource :jobs do
        before { set_application_context }

        desc 'Request a job' do
          success Entities::Ci::JobRequest::Response
          http_codes [[201, 'Job was scheduled'],
                      [204, 'No job for Runner'],
                      [403, 'Forbidden'],
                      [409, 'Conflict']]
        end
        params do
          requires :token, type: String, desc: %q(Runner's authentication token)
          optional :system_id, type: String, desc: %q(Runner's system identifier)
          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)
            optional :config, type: Hash, desc: %q(Runner's config) do
              optional :gpus, type: String, desc: %q(GPUs enabled)
            end
          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
        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', urgency: :low, feature_category: :continuous_integration 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, current_runner_manager).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 'Update 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(Job token)
          requires :id, type: Integer, desc: %q(Job's ID)
          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)
          optional :output, type: Hash, desc: %q(Build log state) do
            optional :checksum, type: String, desc: %q(Job's trace CRC32 checksum)
            optional :bytesize, type: Integer, desc: %q(Job's trace size in bytes)
          end
          optional :exit_code, type: Integer, desc: %q(Job's exit code)
        end
        put '/:id', urgency: :low, feature_category: :continuous_integration do
          job = authenticate_job!(heartbeat_runner: true)

          Gitlab::Metrics.add_event(:update_build)

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

          service.execute.then do |result|
            track_ci_minutes_usage!(job, current_runner)

            header 'X-GitLab-Trace-Update-Interval', result.backoff
            status result.status
            body result.status.to_s
          end
        end

        desc 'Append 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)
          optional :debug_trace, type: Boolean, desc: %q(Enable or Disable the debug trace)
        end
        patch '/:id/trace', urgency: :low, feature_category: :continuous_integration do
          job = authenticate_job!(heartbeat_runner: true)

          error!('400 Missing header Content-Range', 400) unless request.headers.key?('Content-Range')
          content_range = request.headers['Content-Range']
          debug_trace = Gitlab::Utils.to_boolean(params[:debug_trace])

          result = ::Ci::AppendBuildTraceService
            .new(job, content_range: content_range, debug_trace: debug_trace)
            .execute(request.body.read)

          if result.status == 403
            break error!('403 Forbidden', 403)
          end

          if result.status == 416
            break error!('416 Range Not Satisfiable', 416, { 'Range' => "0-#{result.stream_size}" })
          end

          track_ci_minutes_usage!(job, current_runner)

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

        desc 'Authorize uploading job artifact' 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(Size of artifact file)

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

          job = authenticate_job!

          result = ::Ci::JobArtifacts::CreateService.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 a job artifact' do
          success Entities::Ci::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)), documentation: { type: 'file' }
          optional :token, type: String, desc: %q(Job's authentication token)
          optional :expire_in, type: String, desc: %q(Specify when artifact 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)), documentation: { type: 'file' }
          optional :accessibility, type: String, desc: %q(Specify accessibility level of artifact private/public)
        end
        post '/:id/artifacts', feature_category: :build_artifacts, urgency: :low do
          not_allowed! unless Gitlab.config.artifacts.enabled
          require_gitlab_workhorse!

          job = authenticate_job!

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

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

          if result[:status] == :success
            log_artifacts_filesize(result[:artifact])

            status :created
            body "201"
          else
            render_api_error!(result[:message], result[:http_status])
          end
        end

        desc 'Download the artifacts file for job' do
          http_codes [[200, 'Download allowed'],
                      [401, 'Unauthorized'],
                      [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
        route_setting :authentication, job_token_allowed: true
        get '/:id/artifacts', feature_category: :build_artifacts do
          authenticate_job_via_dependent_job!

          present_artifacts_file!(current_job.artifacts_file, supports_direct_download: params[:direct_download])
        end
      end
    end
  end
end