Class: Ci::Runner

Inherits:
ApplicationRecord show all
Extended by:
Gitlab::Utils::Override
Includes:
ChronicDurationAttribute, BulkInsertableTags, HasRunnerStatus, Taggable, EachBatch, FeatureGate, FromUnion, Gitlab::SQL::Pattern, Gitlab::Utils::StrongMemoize, Presentable, RedisCacheable, TokenAuthenticatable
Defined in:
app/models/ci/runner.rb

Constant Summary collapse

CREATED_RUNNER_TOKEN_PREFIX =

Prefix assigned to runners created from the UI, instead of registered via the command line

'glrt-'
REGISTRATION_RUNNER_TOKEN_PREFIX =
'glrtr-'
RUNNER_SHORT_SHA_LENGTH =
8
ONLINE_CONTACT_TIMEOUT =

This ‘ONLINE_CONTACT_TIMEOUT` needs to be larger than

`RUNNER_QUEUE_EXPIRY_TIME+UPDATE_CONTACT_COLUMN_EVERY`
2.hours
RUNNER_QUEUE_EXPIRY_TIME =

The ‘RUNNER_QUEUE_EXPIRY_TIME` indicates the longest interval that

Runner request needs to be refreshed by Rails instead of being handled
by Workhorse
1.hour
UPDATE_CONTACT_COLUMN_EVERY =

The ‘UPDATE_CONTACT_COLUMN_EVERY` defines how often the Runner DB entry can be updated

((40.minutes)..(55.minutes))
STALE_TIMEOUT =

The ‘STALE_TIMEOUT` constant defines the how far past the last contact or creation date a runner will be considered stale

7.days
REGISTRATION_AVAILABILITY_TIME =

Only allow authentication token to be visible for a short while

1.hour
AVAILABLE_TYPES_LEGACY =
%w[specific shared].freeze
AVAILABLE_TYPES =
runner_types.keys.freeze
DEPRECATED_STATUSES =

TODO: Remove in REST v5. Relevant issue: gitlab.com/gitlab-org/gitlab/-/issues/344648

%w[active paused].freeze
AVAILABLE_STATUSES =
%w[online offline never_contacted stale].freeze
AVAILABLE_STATUSES_INCL_DEPRECATED =
(DEPRECATED_STATUSES + AVAILABLE_STATUSES).freeze
AVAILABLE_SCOPES =
(AVAILABLE_TYPES_LEGACY + AVAILABLE_TYPES + AVAILABLE_STATUSES_INCL_DEPRECATED).freeze
FORM_EDITABLE =
i[description tag_list active run_untagged locked access_level maximum_timeout_human_readable].freeze
MINUTES_COST_FACTOR_FIELDS =
i[public_projects_minutes_cost_factor private_projects_minutes_cost_factor].freeze
TAG_LIST_MAX_LENGTH =
50

Constants included from RedisCacheable

RedisCacheable::CACHED_ATTRIBUTES_EXPIRY_TIME

Constants included from Gitlab::SQL::Pattern

Gitlab::SQL::Pattern::MIN_CHARS_FOR_PARTIAL_MATCHING, Gitlab::SQL::Pattern::REGEX_QUOTED_TERM

Constants included from BulkInsertableTags

BulkInsertableTags::BULK_INSERT_TAG_THREAD_KEY

Constants inherited from ApplicationRecord

ApplicationRecord::MAX_PLUCK

Constants included from HasCheckConstraints

HasCheckConstraints::NOT_NULL_CHECK_PATTERN

Constants included from ResetOnColumnErrors

ResetOnColumnErrors::MAX_RESET_PERIOD

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Gitlab::Utils::Override

extended, extensions, included, method_added, override, prepended, queue_verification, verify!

Methods included from Taggable

#reload, #tag_list, #tag_list=

Methods included from HasRunnerStatus

#online?, #stale?, #status

Methods included from Presentable

#present

Methods included from FeatureGate

#flipper_id

Methods included from ChronicDurationAttribute

#chronic_duration_attributes, #output_chronic_duration_attribute

Methods included from RedisCacheable

#cache_attributes, #cached_attribute, #merge_cache_attributes

Methods included from Gitlab::SQL::Pattern

split_query_to_search_terms

Methods included from BulkInsertableTags

#save_tags, with_bulk_insert_tags

Methods inherited from ApplicationRecord

model_name, table_name_prefix

Methods inherited from ApplicationRecord

===, cached_column_list, #create_or_load_association, current_transaction, declarative_enum, default_select_columns, delete_all_returning, #deleted_from_database?, id_in, id_not_in, iid_in, nullable_column?, primary_key_in, #readable_by?, safe_ensure_unique, safe_find_or_create_by, safe_find_or_create_by!, #to_ability_name, underscore, where_exists, where_not_exists, with_fast_read_statement_timeout, without_order

Methods included from Organizations::Sharding

#sharding_organization

Methods included from ResetOnColumnErrors

#reset_on_union_error, #reset_on_unknown_attribute_error

Methods included from Gitlab::SensitiveSerializableHash

#serializable_hash

Class Method Details

.arel_tag_names_arrayObject



326
327
328
329
330
331
# File 'app/models/ci/runner.rb', line 326

def self.arel_tag_names_array
  taggings_join_model
    .scoped_taggables
    .joins(:tag)
    .select('COALESCE(array_agg(tags.name ORDER BY tags.name), ARRAY[]::text[])')
end

.created_runner_prefixObject



362
363
364
# File 'app/models/ci/runner.rb', line 362

def self.created_runner_prefix
  ::Authn::TokenField::PrefixHelper.prepend_instance_prefix(CREATED_RUNNER_TOKEN_PREFIX)
end

.online_contact_time_deadlineObject



293
294
295
# File 'app/models/ci/runner.rb', line 293

def self.online_contact_time_deadline
  ONLINE_CONTACT_TIMEOUT.ago
end

.order_by(order) ⇒ Object



309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
# File 'app/models/ci/runner.rb', line 309

def self.order_by(order)
  case order
  when 'contacted_asc'
    order_contacted_at_asc
  when 'contacted_desc'
    order_contacted_at_desc
  when 'created_at_asc'
    order_created_at_asc
  when 'token_expires_at_asc'
    order_token_expires_at_asc
  when 'token_expires_at_desc'
    order_token_expires_at_desc
  else
    order_created_at_desc
  end
end

.recent_queue_deadlineObject



301
302
303
304
305
306
307
# File 'app/models/ci/runner.rb', line 301

def self.recent_queue_deadline
  # we add queue expiry + online
  # - contacted_at can be updated at any time within this interval
  #   we have always accurate `contacted_at` but it is stored in Redis
  #   and not persisted in database
  (ONLINE_CONTACT_TIMEOUT + RUNNER_QUEUE_EXPIRY_TIME).ago
end

.runner_matchersObject



333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
# File 'app/models/ci/runner.rb', line 333

def self.runner_matchers
  unique_params = [
    :runner_type,
    :public_projects_minutes_cost_factor,
    :private_projects_minutes_cost_factor,
    :run_untagged,
    :access_level,
    Arel.sql("(#{arel_tag_names_array.to_sql})"),
    :allowed_plan_ids
  ]

  group(*unique_params).pluck('array_agg(ci_runners.id)', *unique_params).map do |values|
    Gitlab::Ci::Matching::RunnerMatcher.new({
      runner_ids: values[0],
      runner_type: values[1],
      public_projects_minutes_cost_factor: values[2],
      private_projects_minutes_cost_factor: values[3],
      run_untagged: values[4],
      access_level: values[5],
      tag_list: values[6],
      allowed_plan_ids: values[7]
    })
  end
end

.search(query) ⇒ Object

Searches for runners matching the given query.

This method uses ILIKE on PostgreSQL for the description field and performs a full match on tokens.

query - The search query as a String.

Returns an ActiveRecord::Relation.



289
290
291
# File 'app/models/ci/runner.rb', line 289

def self.search(query)
  with_encrypted_tokens(encode(query)).or(fuzzy_search(query, [:description]))
end

.stale_deadlineObject



297
298
299
# File 'app/models/ci/runner.rb', line 297

def self.stale_deadline
  STALE_TIMEOUT.ago
end

.taggings_join_modelObject



358
359
360
# File 'app/models/ci/runner.rb', line 358

def self.taggings_join_model
  ::Ci::RunnerTagging
end

Instance Method Details

#assign_to(project, current_user = nil) ⇒ Object



380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
# File 'app/models/ci/runner.rb', line 380

def assign_to(project, current_user = nil)
  if instance_type?
    raise ArgumentError, 'Transitioning an instance runner to a project runner is not supported'
  elsif group_type?
    raise ArgumentError, 'Transitioning a group runner to a project runner is not supported'
  end

  if self.runner_projects.empty?
    self.errors.add(:assign_to, 'Taking over an orphaned project runner is not allowed')
    return false
  end

  begin
    transaction do
      self.runner_projects << ::Ci::RunnerProject.new(project: project, runner: self)
      self.save!
    end
  rescue ActiveRecord::RecordInvalid => e
    self.errors.add(:assign_to, e.message)
    false
  end
end

#belongs_to_more_than_one_project?Boolean

Returns:

  • (Boolean)


439
440
441
# File 'app/models/ci/runner.rb', line 439

def belongs_to_more_than_one_project?
  runner_projects.many?
end

#belongs_to_one_project?Boolean

Returns:

  • (Boolean)


435
436
437
# File 'app/models/ci/runner.rb', line 435

def belongs_to_one_project?
  runner_projects.one?
end

#compute_token_expirationObject



550
551
552
553
554
555
556
557
558
559
# File 'app/models/ci/runner.rb', line 550

def compute_token_expiration
  case runner_type
  when 'instance_type'
    compute_token_expiration_instance
  when 'group_type'
    compute_token_expiration_group
  when 'project_type'
    compute_token_expiration_project
  end
end

#dedicated_gitlab_hosted?Boolean

false in FOSS

Returns:

  • (Boolean)


584
585
586
# File 'app/models/ci/runner.rb', line 584

def dedicated_gitlab_hosted?
  false
end

#deprecated_rest_statusObject

DEPRECATED TODO Remove in v5 in favor of ‘status` for REST calls, see gitlab.com/gitlab-org/gitlab/-/issues/344648



411
412
413
414
415
416
417
418
419
420
421
# File 'app/models/ci/runner.rb', line 411

def deprecated_rest_status
  return :stale if stale?

  if contacted_at.nil?
    :never_contacted
  elsif active?
    online? ? :online : :offline
  else
    :paused
  end
end

#display_nameObject



403
404
405
406
407
# File 'app/models/ci/runner.rb', line 403

def display_name
  return short_sha if description.blank?

  description
end

#dot_com_gitlab_hosted?Boolean

CI_JOB_JWT_V2 that uses this method is deprecated

On .com all instance runners are hosted so instance_type is used to distingish hosted from non-hosted

Returns:

  • (Boolean)


579
580
581
# File 'app/models/ci/runner.rb', line 579

def dot_com_gitlab_hosted?
  Gitlab.com? && instance_type?
end

#ensure_manager(system_xid) ⇒ Object



561
562
563
564
565
566
567
568
# File 'app/models/ci/runner.rb', line 561

def ensure_manager(system_xid)
  # rubocop: disable Performance/ActiveRecordSubtransactionMethods -- This is used only in API endpoints outside of transactions
  RunnerManager.safe_find_or_create_by!(runner_id: id, system_xid: system_xid.to_s) do |m|
    m.runner_type = runner_type
    m.organization_id = organization_id
  end
  # rubocop: enable Performance/ActiveRecordSubtransactionMethods
end

#ensure_runner_queue_valueObject



506
507
508
509
510
# File 'app/models/ci/runner.rb', line 506

def ensure_runner_queue_value
  new_value = SecureRandom.hex
  ::Gitlab::Workhorse.set_key_and_notify(runner_queue_key, new_value,
    expire: RUNNER_QUEUE_EXPIRY_TIME, overwrite: false)
end

#has_tags?Boolean

Returns:

  • (Boolean)


476
477
478
# File 'app/models/ci/runner.rb', line 476

def has_tags?
  tag_list.any?
end

#heartbeat(creation_state: nil) ⇒ Object



516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
# File 'app/models/ci/runner.rb', line 516

def heartbeat(creation_state: nil)
  ##
  # We can safely ignore writes performed by a runner heartbeat. We do
  # not want to upgrade database connection proxy to use the primary
  # database after heartbeat write happens.
  #
  ::Gitlab::Database::LoadBalancing::SessionMap.current(load_balancer).without_sticky_writes do
    values = { contacted_at: Time.current }
    values[:creation_state] = creation_state if creation_state.present?

    merge_cache_attributes(values)

    # We save data without validation, it will always change due to `contacted_at`
    update_columns(values) if persist_cached_data?
  end
end

#match_build_if_online?(build) ⇒ Boolean

Returns:

  • (Boolean)


443
444
445
# File 'app/models/ci/runner.rb', line 443

def match_build_if_online?(build)
  active? && online? && matches_build?(build)
end

#matches_build?(build) ⇒ Boolean

Returns:

  • (Boolean)


537
538
539
# File 'app/models/ci/runner.rb', line 537

def matches_build?(build)
  runner_matcher.matches?(build.build_matcher)
end

#namespace_idsObject



545
546
547
# File 'app/models/ci/runner.rb', line 545

def namespace_ids
  runner_namespaces.pluck(:namespace_id).compact
end

#only_for?(project) ⇒ Boolean

Returns:

  • (Boolean)


447
448
449
# File 'app/models/ci/runner.rb', line 447

def only_for?(project)
  runner_projects.where.not(project_id: project.id).empty?
end

#ownerObject



423
424
425
426
427
428
429
430
431
432
433
# File 'app/models/ci/runner.rb', line 423

def owner
  case runner_type
  when 'instance_type'
    memoize_owner { ::User.find_by_id(creator_id) }
  when 'group_type'
    persisted? ? memoize_owner { owner_runner_namespace&.namespace } : runner_namespaces.first&.namespace
  when 'project_type'
    # If runner projects are not yet saved (e.g. when calculating `routable_token`), use in-memory collection
    persisted? ? memoize_owner { owner_runner_project&.project } : runner_projects.first&.project
  end
end

#partition_idObject



588
589
590
# File 'app/models/ci/runner.rb', line 588

def partition_id
  self.class.runner_types[runner_type]
end

#pick_build!(build) ⇒ Object



533
534
535
# File 'app/models/ci/runner.rb', line 533

def pick_build!(build)
  tick_runner_queue if matches_build?(build)
end

#predefined_variablesObject



484
485
486
487
488
489
# File 'app/models/ci/runner.rb', line 484

def predefined_variables
  Gitlab::Ci::Variables::Collection.new
    .append(key: 'CI_RUNNER_ID', value: id.to_s)
    .append(key: 'CI_RUNNER_DESCRIPTION', value: description)
    .append(key: 'CI_RUNNER_TAGS', value: tag_list.to_a.to_s)
end

#registration_available?Boolean

Returns:

  • (Boolean)


570
571
572
573
574
# File 'app/models/ci/runner.rb', line 570

def registration_available?
  authenticated_user_registration_type? &&
    created_at > REGISTRATION_AVAILABILITY_TIME.ago &&
    creation_state == 'started' # NOTE: We can't use started_creation_state? here as we need to check cached value
end

#runner_matcherObject



366
367
368
369
370
371
372
373
374
375
376
377
# File 'app/models/ci/runner.rb', line 366

def runner_matcher
  Gitlab::Ci::Matching::RunnerMatcher.new({
    runner_ids: [id],
    runner_type: runner_type,
    public_projects_minutes_cost_factor: public_projects_minutes_cost_factor,
    private_projects_minutes_cost_factor: private_projects_minutes_cost_factor,
    run_untagged: run_untagged,
    access_level: access_level,
    tag_list: tag_list,
    allowed_plan_ids: allowed_plan_ids
  })
end

#runner_queue_value_latest?(value) ⇒ Boolean

Returns:

  • (Boolean)


512
513
514
# File 'app/models/ci/runner.rb', line 512

def runner_queue_value_latest?(value)
  ensure_runner_queue_value == value if value.present?
end

#short_shaObject



451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
# File 'app/models/ci/runner.rb', line 451

def short_sha
  return unless token

  # We want to show the first characters of the hash, so we need to bypass any fixed components of the token, such
  # as instance_prefix, CREATED_RUNNER_TOKEN_PREFIX, REGISTRATION_RUNNER_TOKEN_PREFIX or legacy_partition_id_prefix_in_16_bit_encode

  legacy_partition_prefix = legacy_partition_id_prefix_in_16_bit_encode
  start_index = if authenticated_user_registration_type?
                  instance_prefix = ::Authn::TokenField::PrefixHelper.instance_prefix
                  if instance_prefix.present? && token.starts_with?(instance_prefix)
                    CREATED_RUNNER_TOKEN_PREFIX.length + "#{instance_prefix}-".length
                  else
                    CREATED_RUNNER_TOKEN_PREFIX.length
                  end
                elsif token.start_with?(REGISTRATION_RUNNER_TOKEN_PREFIX)
                  REGISTRATION_RUNNER_TOKEN_PREFIX.length
                else
                  0
                end

  start_index += legacy_partition_prefix.length if token[start_index..].start_with?(legacy_partition_prefix)

  token[start_index..start_index + RUNNER_SHORT_SHA_LENGTH - 1]
end

#tagging_tag_idsObject



480
481
482
# File 'app/models/ci/runner.rb', line 480

def tagging_tag_ids
  taggings.pluck(:tag_id)
end

#tick_runner_queueObject



491
492
493
494
495
496
497
498
499
500
501
502
503
504
# File 'app/models/ci/runner.rb', line 491

def tick_runner_queue
  ##
  # We only stick a runner to primary database to be able to detect the
  # replication lag in `EE::Ci::RegisterJobService#execute`. The
  # intention here is not to execute `Ci::RegisterJobService#execute` on
  # the primary database.
  #
  ::Ci::Runner.sticking.stick(:runner, id)

  SecureRandom.hex.tap do |new_update|
    ::Gitlab::Workhorse.set_key_and_notify(runner_queue_key, new_update,
      expire: RUNNER_QUEUE_EXPIRY_TIME, overwrite: true)
  end
end

#uncached_contacted_atObject



541
542
543
# File 'app/models/ci/runner.rb', line 541

def uncached_contacted_at
  read_attribute(:contacted_at)
end