summaryrefslogtreecommitdiff
path: root/app/models/ci
diff options
context:
space:
mode:
Diffstat (limited to 'app/models/ci')
-rw-r--r--app/models/ci/build.rb27
-rw-r--r--app/models/ci/job_artifact.rb9
-rw-r--r--app/models/ci/namespace_mirror.rb37
-rw-r--r--app/models/ci/pending_build.rb12
-rw-r--r--app/models/ci/pipeline.rb62
-rw-r--r--app/models/ci/project_mirror.rb16
-rw-r--r--app/models/ci/runner.rb77
-rw-r--r--app/models/ci/runner_namespace.rb1
-rw-r--r--app/models/ci/runner_project.rb1
-rw-r--r--app/models/ci/stage.rb1
10 files changed, 179 insertions, 64 deletions
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 3fdc44bccf3..428e440afba 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -10,6 +10,7 @@ module Ci
include Presentable
include Importable
include Ci::HasRef
+ extend ::Gitlab::Utils::Override
BuildArchivedError = Class.new(StandardError)
@@ -58,7 +59,7 @@ module Ci
has_one :runner_session, class_name: 'Ci::BuildRunnerSession', validate: true, inverse_of: :build
has_one :trace_metadata, class_name: 'Ci::BuildTraceMetadata', inverse_of: :build
- has_many :terraform_state_versions, class_name: 'Terraform::StateVersion', dependent: :nullify, inverse_of: :build, foreign_key: :ci_build_id # rubocop:disable Cop/ActiveRecordDependent
+ has_many :terraform_state_versions, class_name: 'Terraform::StateVersion', inverse_of: :build, foreign_key: :ci_build_id
accepts_nested_attributes_for :runner_session, update_only: true
accepts_nested_attributes_for :job_variables
@@ -164,6 +165,7 @@ module Ci
scope :with_artifacts_not_expired, -> { with_downloadable_artifacts.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.current) }
scope :with_expired_artifacts, -> { with_downloadable_artifacts.where('artifacts_expire_at < ?', Time.current) }
+ scope :with_pipeline_locked_artifacts, -> { joins(:pipeline).where('pipeline.locked': Ci::Pipeline.lockeds[:artifacts_locked]) }
scope :last_month, -> { where('created_at > ?', Date.today - 1.month) }
scope :manual_actions, -> { where(when: :manual, status: COMPLETED_STATUSES + %i[manual]) }
scope :scheduled_actions, -> { where(when: :delayed, status: COMPLETED_STATUSES + %i[scheduled]) }
@@ -188,8 +190,6 @@ module Ci
scope :without_coverage, -> { where(coverage: nil) }
scope :with_coverage_regex, -> { where.not(coverage_regex: nil) }
- scope :for_project, -> (project_id) { where(project_id: project_id) }
-
acts_as_taggable
add_authentication_token_field :token, encrypted: :required
@@ -286,6 +286,7 @@ module Ci
build.run_after_commit do
BuildQueueWorker.perform_async(id)
+ BuildHooksWorker.perform_async(id)
end
end
@@ -451,7 +452,7 @@ module Ci
end
def retryable?
- return false if retried? || archived?
+ return false if retried? || archived? || deployment_rejected?
success? || failed? || canceled?
end
@@ -722,6 +723,14 @@ module Ci
self.token && ActiveSupport::SecurityUtils.secure_compare(token, self.token)
end
+ # acts_as_taggable uses this method create/remove tags with contexts
+ # defined by taggings and to get those contexts it executes a query.
+ # We don't use any other contexts except `tags`, so we don't need it.
+ override :custom_contexts
+ def custom_contexts
+ []
+ end
+
def tag_list
if tags.loaded?
tags.map(&:name)
@@ -1074,6 +1083,16 @@ module Ci
runner&.instance_type?
end
+ def job_variables_attributes
+ strong_memoize(:job_variables_attributes) do
+ job_variables.internal_source.map do |variable|
+ variable.attributes.except('id', 'job_id', 'encrypted_value', 'encrypted_value_iv').tap do |attrs|
+ attrs[:value] = variable.value
+ end
+ end
+ end
+ end
+
protected
def run_status_commit_hooks!
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index ec1137920ef..e6dd62fab34 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -2,6 +2,7 @@
module Ci
class JobArtifact < Ci::ApplicationRecord
+ include IgnorableColumns
include AfterCommitQueue
include ObjectStorage::BackgroundMove
include UpdateProjectStatistics
@@ -120,6 +121,9 @@ module Ci
belongs_to :project
belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id
+ # We will start using this column once we complete https://gitlab.com/gitlab-org/gitlab/-/issues/285597
+ ignore_column :original_filename, remove_with: '14.7', remove_after: '2022-11-22'
+
mount_file_store_uploader JobArtifactUploader
skip_callback :save, :after, :store_file!, if: :store_after_commit?
@@ -133,6 +137,7 @@ module Ci
scope :not_expired, -> { where('expire_at IS NULL OR expire_at > ?', Time.current) }
scope :for_sha, ->(sha, project_id) { joins(job: :pipeline).where(ci_pipelines: { sha: sha, project_id: project_id }) }
+ scope :for_job_ids, ->(job_ids) { where(job_id: job_ids) }
scope :for_job_name, ->(name) { joins(:job).where(ci_builds: { name: name }) }
scope :with_job, -> { joins(:job).includes(:job) }
@@ -266,6 +271,10 @@ module Ci
self.where(project: project).sum(:size)
end
+ def self.distinct_job_ids
+ distinct.pluck(:job_id)
+ end
+
##
# FastDestroyAll concerns
# rubocop: disable CodeReuse/ServiceClass
diff --git a/app/models/ci/namespace_mirror.rb b/app/models/ci/namespace_mirror.rb
new file mode 100644
index 00000000000..8a4be3139e8
--- /dev/null
+++ b/app/models/ci/namespace_mirror.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Ci
+ # This model represents a record in a shadow table of the main database's namespaces table.
+ # It allows us to navigate the namespace hierarchy on the ci database without resorting to a JOIN.
+ class NamespaceMirror < ApplicationRecord
+ belongs_to :namespace
+
+ scope :contains_namespace, -> (id) do
+ where('traversal_ids @> ARRAY[?]::int[]', id)
+ end
+
+ class << self
+ def sync!(event)
+ namespace = event.namespace
+ traversal_ids = namespace.self_and_ancestor_ids(hierarchy_order: :desc)
+
+ upsert({ namespace_id: event.namespace_id, traversal_ids: traversal_ids },
+ unique_by: :namespace_id)
+
+ # It won't be necessary once we remove `sync_traversal_ids`.
+ # More info: https://gitlab.com/gitlab-org/gitlab/-/issues/347541
+ sync_children_namespaces!(event.namespace_id, traversal_ids)
+ end
+
+ private
+
+ def sync_children_namespaces!(namespace_id, traversal_ids)
+ contains_namespace(namespace_id)
+ .where.not(namespace_id: namespace_id)
+ .update_all(
+ "traversal_ids = ARRAY[#{sanitize_sql(traversal_ids.join(','))}]::int[] || traversal_ids[array_position(traversal_ids, #{sanitize_sql(namespace_id)}) + 1:]"
+ )
+ end
+ end
+ end
+end
diff --git a/app/models/ci/pending_build.rb b/app/models/ci/pending_build.rb
index ccad6290fac..41dc74ef050 100644
--- a/app/models/ci/pending_build.rb
+++ b/app/models/ci/pending_build.rb
@@ -30,6 +30,10 @@ module Ci
self.upsert(entry.attributes.compact, returning: %w[build_id], unique_by: :build_id)
end
+ def maintain_denormalized_data?
+ ::Feature.enabled?(:ci_pending_builds_maintain_denormalized_data, default_enabled: :yaml)
+ end
+
private
def args_from_build(build)
@@ -42,15 +46,9 @@ module Ci
namespace: project.namespace
}
- if Feature.enabled?(:ci_pending_builds_maintain_tags_data, type: :development, default_enabled: :yaml)
+ if maintain_denormalized_data?
args.store(:tag_ids, build.tags_ids)
- end
-
- if Feature.enabled?(:ci_pending_builds_maintain_shared_runners_data, type: :development, default_enabled: :yaml)
args.store(:instance_runners_enabled, shared_runners_enabled?(project))
- end
-
- if Feature.enabled?(:ci_pending_builds_maintain_namespace_traversal_ids, type: :development, default_enabled: :yaml)
args.store(:namespace_traversal_ids, project.namespace.traversal_ids) if group_runners_enabled?(project)
end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index a29aa756e38..a90bd739741 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -63,6 +63,7 @@ module Ci
has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
has_many :latest_statuses_ordered_by_stage, -> { latest.order(:stage_idx, :stage) }, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
has_many :latest_statuses, -> { latest }, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
+ has_many :statuses_order_id_desc, -> { order_id_desc }, class_name: 'CommitStatus', foreign_key: :commit_id
has_many :processables, class_name: 'Ci::Processable', foreign_key: :commit_id, inverse_of: :pipeline
has_many :bridges, class_name: 'Ci::Bridge', foreign_key: :commit_id, inverse_of: :pipeline
has_many :builds, foreign_key: :commit_id, inverse_of: :pipeline
@@ -82,8 +83,6 @@ module Ci
# Merge requests for which the current pipeline is running against
# the merge request's latest commit.
has_many :merge_requests_as_head_pipeline, foreign_key: "head_pipeline_id", class_name: 'MergeRequest'
- has_many :package_build_infos, class_name: 'Packages::BuildInfo', dependent: :nullify, inverse_of: :pipeline # rubocop:disable Cop/ActiveRecordDependent
- has_many :package_file_build_infos, class_name: 'Packages::PackageFileBuildInfo', dependent: :nullify, inverse_of: :pipeline # rubocop:disable Cop/ActiveRecordDependent
has_many :pending_builds, -> { pending }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline
has_many :failed_builds, -> { latest.failed }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline
has_many :retryable_builds, -> { latest.failed_or_canceled.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline
@@ -236,7 +235,18 @@ module Ci
pipeline.run_after_commit do
PipelineHooksWorker.perform_async(pipeline.id)
- ExpirePipelineCacheWorker.perform_async(pipeline.id)
+
+ if pipeline.project.jira_subscription_exists?
+ # Passing the seq-id ensures this is idempotent
+ seq_id = ::Atlassian::JiraConnect::Client.generate_update_sequence_id
+ ::JiraConnect::SyncBuildsWorker.perform_async(pipeline.id, seq_id)
+ end
+
+ if Feature.enabled?(:expire_job_and_pipeline_cache_synchronously, pipeline.project, default_enabled: :yaml)
+ Ci::ExpirePipelineCacheService.new.execute(pipeline) # rubocop: disable CodeReuse/ServiceClass
+ else
+ ExpirePipelineCacheWorker.perform_async(pipeline.id)
+ end
end
end
@@ -271,14 +281,6 @@ module Ci
end
end
- after_transition any => any do |pipeline|
- pipeline.run_after_commit do
- # Passing the seq-id ensures this is idempotent
- seq_id = ::Atlassian::JiraConnect::Client.generate_update_sequence_id
- ::JiraConnect::SyncBuildsWorker.perform_async(pipeline.id, seq_id)
- end
- end
-
after_transition any => ::Ci::Pipeline.completed_statuses do |pipeline|
pipeline.run_after_commit do
::Ci::TestFailureHistoryService.new(pipeline).async.perform_if_needed # rubocop: disable CodeReuse/ServiceClass
@@ -643,7 +645,7 @@ module Ci
def coverage
coverage_array = latest_statuses.map(&:coverage).compact
if coverage_array.size >= 1
- '%.2f' % (coverage_array.reduce(:+) / coverage_array.size)
+ coverage_array.reduce(:+) / coverage_array.size
end
end
@@ -947,22 +949,16 @@ module Ci
end
def environments_in_self_and_descendants
- if ::Feature.enabled?(:avoid_cross_joins_environments_in_self_and_descendants, default_enabled: :yaml)
- # We limit to 100 unique environments for application safety.
- # See: https://gitlab.com/gitlab-org/gitlab/-/issues/340781#note_699114700
- expanded_environment_names =
- builds_in_self_and_descendants.joins(:metadata)
- .where.not('ci_builds_metadata.expanded_environment_name' => nil)
- .distinct('ci_builds_metadata.expanded_environment_name')
- .limit(100)
- .pluck(:expanded_environment_name)
-
- Environment.where(project: project, name: expanded_environment_names)
- else
- environment_ids = self_and_descendants.joins(:deployments).select(:'deployments.environment_id')
+ # We limit to 100 unique environments for application safety.
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/340781#note_699114700
+ expanded_environment_names =
+ builds_in_self_and_descendants.joins(:metadata)
+ .where.not('ci_builds_metadata.expanded_environment_name' => nil)
+ .distinct('ci_builds_metadata.expanded_environment_name')
+ .limit(100)
+ .pluck(:expanded_environment_name)
- Environment.where(id: environment_ids)
- end
+ Environment.where(project: project, name: expanded_environment_names).with_deployment(sha)
end
# With multi-project and parent-child pipelines
@@ -1276,18 +1272,18 @@ module Ci
self.builds.latest.build_matchers(project)
end
- def predefined_vars_in_builder_enabled?
- strong_memoize(:predefined_vars_in_builder_enabled) do
- Feature.enabled?(:ci_predefined_vars_in_builder, project, default_enabled: :yaml)
- end
- end
-
def authorized_cluster_agents
strong_memoize(:authorized_cluster_agents) do
::Clusters::AgentAuthorizationsFinder.new(project).execute.map(&:agent)
end
end
+ def create_deployment_in_separate_transaction?
+ strong_memoize(:create_deployment_in_separate_transaction) do
+ ::Feature.enabled?(:create_deployment_in_separate_transaction, project, default_enabled: :yaml)
+ end
+ end
+
private
def add_message(severity, content)
diff --git a/app/models/ci/project_mirror.rb b/app/models/ci/project_mirror.rb
new file mode 100644
index 00000000000..d6aaa3f50c1
--- /dev/null
+++ b/app/models/ci/project_mirror.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Ci
+ # This model represents a shadow table of the main database's projects table.
+ # It allows us to navigate the project and namespace hierarchy on the ci database.
+ class ProjectMirror < ApplicationRecord
+ belongs_to :project
+
+ class << self
+ def sync!(event)
+ upsert({ project_id: event.project_id, namespace_id: event.project.namespace_id },
+ unique_by: :project_id)
+ end
+ end
+ end
+end
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index 8a3025e5608..a80fd02080f 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -12,7 +12,6 @@ module Ci
include Gitlab::Utils::StrongMemoize
include TaggableQueries
include Presentable
- include LooseForeignKey
add_authentication_token_field :token, encrypted: :optional
@@ -27,6 +26,21 @@ module Ci
project_type: 3
}
+ enum executor_type: {
+ unknown: 0,
+ custom: 1,
+ shell: 2,
+ docker: 3,
+ docker_windows: 4,
+ docker_ssh: 5,
+ ssh: 6,
+ parallels: 7,
+ virtualbox: 8,
+ docker_machine: 9,
+ docker_ssh_machine: 10,
+ kubernetes: 11
+ }, _suffix: true
+
# This `ONLINE_CONTACT_TIMEOUT` needs to be larger than
# `RUNNER_QUEUE_EXPIRY_TIME+UPDATE_CONTACT_COLUMN_EVERY`
#
@@ -40,9 +54,12 @@ module Ci
# The `UPDATE_CONTACT_COLUMN_EVERY` defines how often the Runner DB entry can be updated
UPDATE_CONTACT_COLUMN_EVERY = (40.minutes..55.minutes).freeze
+ # The `STALE_TIMEOUT` constant defines the how far past the last contact or creation date a runner will be considered stale
+ STALE_TIMEOUT = 3.months
+
AVAILABLE_TYPES_LEGACY = %w[specific shared].freeze
AVAILABLE_TYPES = runner_types.keys.freeze
- AVAILABLE_STATUSES = %w[active paused online offline not_connected].freeze
+ AVAILABLE_STATUSES = %w[active paused online offline not_connected never_contacted stale].freeze # TODO: Remove in %15.0: active, paused, not_connected. Relevant issues: https://gitlab.com/gitlab-org/gitlab/-/issues/347303, https://gitlab.com/gitlab-org/gitlab/-/issues/347305, https://gitlab.com/gitlab-org/gitlab/-/issues/344648
AVAILABLE_SCOPES = (AVAILABLE_TYPES_LEGACY + AVAILABLE_TYPES + AVAILABLE_STATUSES).freeze
FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level maximum_timeout_human_readable].freeze
@@ -58,12 +75,14 @@ module Ci
before_save :ensure_token
- scope :active, -> { where(active: true) }
- scope :paused, -> { where(active: false) }
+ scope :active, -> (value = true) { where(active: value) }
+ scope :paused, -> { active(false) }
scope :online, -> { where('contacted_at > ?', online_contact_time_deadline) }
- scope :recent, -> { where('ci_runners.created_at > :date OR ci_runners.contacted_at > :date', date: 3.months.ago) }
+ scope :recent, -> { where('ci_runners.created_at >= :date OR ci_runners.contacted_at >= :date', date: stale_deadline) }
+ scope :stale, -> { where('ci_runners.created_at < :date AND (ci_runners.contacted_at IS NULL OR ci_runners.contacted_at < :date)', date: stale_deadline) }
scope :offline, -> { where(arel_table[:contacted_at].lteq(online_contact_time_deadline)) }
- scope :not_connected, -> { where(contacted_at: nil) }
+ scope :not_connected, -> { where(contacted_at: nil) } # TODO: Remove in 15.0
+ scope :never_contacted, -> { where(contacted_at: nil) }
scope :ordered, -> { order(id: :desc) }
scope :with_recent_runner_queue, -> { where('contacted_at > ?', recent_queue_deadline) }
@@ -78,10 +97,7 @@ module Ci
scope :belonging_to_group, -> (group_id, include_ancestors: false) {
groups = ::Group.where(id: group_id)
-
- if include_ancestors
- groups = Gitlab::ObjectHierarchy.new(groups).base_and_ancestors
- end
+ groups = groups.self_and_ancestors if include_ancestors
joins(:runner_namespaces)
.where(ci_runner_namespaces: { namespace_id: groups })
@@ -102,10 +118,9 @@ module Ci
scope :belonging_to_parent_group_of_project, -> (project_id) {
project_groups = ::Group.joins(:projects).where(projects: { id: project_id })
- hierarchy_groups = Gitlab::ObjectHierarchy.new(project_groups).base_and_ancestors
joins(:groups)
- .where(namespaces: { id: hierarchy_groups })
+ .where(namespaces: { id: project_groups.self_and_ancestors.as_ids })
.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336433')
}
@@ -152,7 +167,7 @@ module Ci
after_destroy :cleanup_runner_queue
- cached_attr_reader :version, :revision, :platform, :architecture, :ip_address, :contacted_at
+ cached_attr_reader :version, :revision, :platform, :architecture, :ip_address, :contacted_at, :executor_type
chronic_duration_attr :maximum_timeout_human_readable, :maximum_timeout,
error_message: 'Maximum job timeout has a value which could not be accepted'
@@ -168,8 +183,6 @@ module Ci
validates :config, json_schema: { filename: 'ci_runner_config' }
- loose_foreign_key :clusters_applications_runners, :runner_id, on_delete: :async_nullify
-
# Searches for runners matching the given query.
#
# This method uses ILIKE on PostgreSQL for the description field and performs a full match on tokens.
@@ -185,6 +198,10 @@ module Ci
ONLINE_CONTACT_TIMEOUT.ago
end
+ def self.stale_deadline
+ STALE_TIMEOUT.ago
+ end
+
def self.recent_queue_deadline
# we add queue expiry + online
# - contacted_at can be updated at any time within this interval
@@ -273,8 +290,17 @@ module Ci
contacted_at && contacted_at > self.class.online_contact_time_deadline
end
- def status
- return :not_connected unless contacted_at
+ def stale?
+ return false unless created_at
+
+ [created_at, contacted_at].compact.max < self.class.stale_deadline
+ end
+
+ def status(legacy_mode = nil)
+ return deprecated_rest_status if legacy_mode == '14.5'
+
+ return :stale if stale?
+ return :never_contacted unless contacted_at
online? ? :online : :offline
end
@@ -387,8 +413,9 @@ module Ci
# database after heartbeat write happens.
#
::Gitlab::Database::LoadBalancing::Session.without_sticky_writes do
- values = values&.slice(:version, :revision, :platform, :architecture, :ip_address, :config) || {}
+ values = values&.slice(:version, :revision, :platform, :architecture, :ip_address, :config, :executor) || {}
values[:contacted_at] = Time.current
+ values[:executor_type] = EXECUTOR_NAME_TO_TYPES.fetch(values.delete(:executor), :unknown)
cache_attributes(values)
@@ -413,6 +440,20 @@ module Ci
private
+ EXECUTOR_NAME_TO_TYPES = {
+ 'custom' => :custom,
+ 'shell' => :shell,
+ 'docker' => :docker,
+ 'docker-windows' => :docker_windows,
+ 'docker-ssh' => :docker_ssh,
+ 'ssh' => :ssh,
+ 'parallels' => :parallels,
+ 'virtualbox' => :virtualbox,
+ 'docker+machine' => :docker_machine,
+ 'docker-ssh+machine' => :docker_ssh_machine,
+ 'kubernetes' => :kubernetes
+ }.freeze
+
def cleanup_runner_queue
Gitlab::Redis::SharedState.with do |redis|
redis.del(runner_queue_key)
diff --git a/app/models/ci/runner_namespace.rb b/app/models/ci/runner_namespace.rb
index 52a31863fb2..82390ccc538 100644
--- a/app/models/ci/runner_namespace.rb
+++ b/app/models/ci/runner_namespace.rb
@@ -7,7 +7,6 @@ module Ci
self.limit_name = 'ci_registered_group_runners'
self.limit_scope = :group
self.limit_relation = :recent_runners
- self.limit_feature_flag_for_override = :ci_runner_limits_override
belongs_to :runner, inverse_of: :runner_namespaces
belongs_to :namespace, inverse_of: :runner_namespaces, class_name: '::Namespace'
diff --git a/app/models/ci/runner_project.rb b/app/models/ci/runner_project.rb
index 148a29a0f8b..42c24c8c8d1 100644
--- a/app/models/ci/runner_project.rb
+++ b/app/models/ci/runner_project.rb
@@ -7,7 +7,6 @@ module Ci
self.limit_name = 'ci_registered_project_runners'
self.limit_scope = :project
self.limit_relation = :recent_runners
- self.limit_feature_flag_for_override = :ci_runner_limits_override
belongs_to :runner, inverse_of: :runner_projects
belongs_to :project, inverse_of: :runner_projects
diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb
index e2b15497638..8c4e97ac840 100644
--- a/app/models/ci/stage.rb
+++ b/app/models/ci/stage.rb
@@ -22,6 +22,7 @@ module Ci
scope :ordered, -> { order(position: :asc) }
scope :in_pipelines, ->(pipelines) { where(pipeline: pipelines) }
scope :by_name, ->(names) { where(name: names) }
+ scope :by_position, ->(positions) { where(position: positions) }
with_options unless: :importing? do
validates :project, presence: true