diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-10-21 07:08:36 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-10-21 07:08:36 +0000 |
commit | 48aff82709769b098321c738f3444b9bdaa694c6 (patch) | |
tree | e00c7c43e2d9b603a5a6af576b1685e400410dee /app/models | |
parent | 879f5329ee916a948223f8f43d77fba4da6cd028 (diff) | |
download | gitlab-ce-48aff82709769b098321c738f3444b9bdaa694c6.tar.gz |
Add latest changes from gitlab-org/gitlab@13-5-stable-eev13.5.0-rc42
Diffstat (limited to 'app/models')
129 files changed, 1677 insertions, 462 deletions
diff --git a/app/models/alert_management/alert.rb b/app/models/alert_management/alert.rb index e9b89af45c6..61cc15a522e 100644 --- a/app/models/alert_management/alert.rb +++ b/app/models/alert_management/alert.rb @@ -20,18 +20,7 @@ module AlertManagement resolved: 2, ignored: 3 }.freeze - - STATUS_EVENTS = { - triggered: :trigger, - acknowledged: :acknowledge, - resolved: :resolve, - ignored: :ignore - }.freeze - - OPEN_STATUSES = [ - :triggered, - :acknowledged - ].freeze + private_constant :STATUSES belongs_to :project belongs_to :issue, optional: true @@ -49,12 +38,16 @@ module AlertManagement sha_attribute :fingerprint + TITLE_MAX_LENGTH = 200 + DESCRIPTION_MAX_LENGTH = 1_000 + SERVICE_MAX_LENGTH = 100 + TOOL_MAX_LENGTH = 100 HOSTS_MAX_LENGTH = 255 - validates :title, length: { maximum: 200 }, presence: true - validates :description, length: { maximum: 1_000 } - validates :service, length: { maximum: 100 } - validates :monitoring_tool, length: { maximum: 100 } + validates :title, length: { maximum: TITLE_MAX_LENGTH }, presence: true + validates :description, length: { maximum: DESCRIPTION_MAX_LENGTH } + validates :service, length: { maximum: SERVICE_MAX_LENGTH } + validates :monitoring_tool, length: { maximum: TOOL_MAX_LENGTH } validates :project, presence: true validates :events, presence: true validates :severity, presence: true @@ -65,7 +58,7 @@ module AlertManagement conditions: -> { not_resolved }, message: -> (object, data) { _('Cannot have multiple unresolved alerts') } }, unless: :resolved? - validate :hosts_length + validate :hosts_format enum severity: { critical: 0, @@ -121,12 +114,13 @@ module AlertManagement delegate :details_url, to: :present scope :for_iid, -> (iid) { where(iid: iid) } - scope :for_status, -> (status) { where(status: status) } + scope :for_status, -> (status) { with_status(status) } scope :for_fingerprint, -> (project, fingerprint) { where(project: project, fingerprint: fingerprint) } scope :for_environment, -> (environment) { where(environment: environment) } + scope :for_assignee_username, -> (assignee_username) { joins(:assignees).merge(User.by_username(assignee_username)) } scope :search, -> (query) { fuzzy_search(query, [:title, :description, :monitoring_tool, :service]) } - scope :open, -> { with_status(OPEN_STATUSES) } - scope :not_resolved, -> { where.not(status: STATUSES[:resolved]) } + scope :open, -> { with_status(open_statuses) } + scope :not_resolved, -> { without_status(:resolved) } scope :with_prometheus_alert, -> { includes(:prometheus_alert) } scope :order_start_time, -> (sort_order) { order(started_at: sort_order) } @@ -142,13 +136,33 @@ module AlertManagement # Ascending sort order sorts statuses: Ignored > Resolved > Acknowledged > Triggered # Descending sort order sorts statuses: Triggered > Acknowledged > Resolved > Ignored # https://gitlab.com/gitlab-org/gitlab/-/issues/221242#what-is-the-expected-correct-behavior - scope :order_status, -> (sort_order) { order(status: sort_order == :asc ? :desc : :asc) } + scope :order_status, -> (sort_order) { order(status: sort_order == :asc ? :desc : :asc) } - scope :counts_by_status, -> { group(:status).count } scope :counts_by_project_id, -> { group(:project_id).count } alias_method :state, :status_name + def self.state_machine_statuses + @state_machine_statuses ||= state_machines[:status].states.to_h { |s| [s.name, s.value] } + end + private_class_method :state_machine_statuses + + def self.status_value(name) + state_machine_statuses[name] + end + + def self.status_name(raw_status) + state_machine_statuses.key(raw_status) + end + + def self.counts_by_status + group(:status).count.transform_keys { |k| status_name(k) } + end + + def self.status_names + @status_names ||= state_machine_statuses.keys + end + def self.sort_by_attribute(method) case method.to_s when 'started_at_asc' then order_start_time(:asc) @@ -190,8 +204,25 @@ module AlertManagement reference.to_i > 0 && reference.to_i <= Gitlab::Database::MAX_INT_VALUE end + def self.open_statuses + [:triggered, :acknowledged] + end + + def self.open_status?(status) + open_statuses.include?(status) + end + + def status_event_for(status) + self.class.state_machines[:status].events.transitions_for(self, to: status.to_s.to_sym).first&.event + end + + def change_status_to(new_status) + event = status_event_for(new_status) + event && fire_status_event(event) + end + def prometheus? - monitoring_tool == Gitlab::AlertManagement::AlertParams::MONITORING_TOOLS[:prometheus] + monitoring_tool == Gitlab::AlertManagement::Payload::MONITORING_TOOLS[:prometheus] end def register_new_event! @@ -224,10 +255,11 @@ module AlertManagement Gitlab::DataBuilder::Alert.build(self) end - def hosts_length + def hosts_format return unless hosts errors.add(:hosts, "hosts array is over #{HOSTS_MAX_LENGTH} chars") if hosts.join.length > HOSTS_MAX_LENGTH + errors.add(:hosts, "hosts array cannot be nested") if hosts.flatten != hosts end end end diff --git a/app/models/alert_management/http_integration.rb b/app/models/alert_management/http_integration.rb new file mode 100644 index 00000000000..7f954e1d384 --- /dev/null +++ b/app/models/alert_management/http_integration.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module AlertManagement + class HttpIntegration < ApplicationRecord + belongs_to :project, inverse_of: :alert_management_http_integrations + + attr_encrypted :token, + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_truncated, + algorithm: 'aes-256-gcm' + + validates :project, presence: true + validates :active, inclusion: { in: [true, false] } + + validates :token, presence: true + validates :name, presence: true, length: { maximum: 255 } + validates :endpoint_identifier, presence: true, length: { maximum: 255 } + validates :endpoint_identifier, uniqueness: { scope: [:project_id, :active] }, if: :active? + + before_validation :prevent_token_assignment + before_validation :ensure_token + + private + + def prevent_token_assignment + if token.present? && token_changed? + self.token = nil + self.encrypted_token = encrypted_token_was + self.encrypted_token_iv = encrypted_token_iv_was + end + end + + def ensure_token + self.token = generate_token if token.blank? + end + + def generate_token + SecureRandom.hex + end + end +end diff --git a/app/models/analytics/instance_statistics/measurement.rb b/app/models/analytics/instance_statistics/measurement.rb index eaaf9e999b3..76cc1111e90 100644 --- a/app/models/analytics/instance_statistics/measurement.rb +++ b/app/models/analytics/instance_statistics/measurement.rb @@ -3,13 +3,19 @@ module Analytics module InstanceStatistics class Measurement < ApplicationRecord + EXPERIMENTAL_IDENTIFIERS = %i[pipelines_succeeded pipelines_failed pipelines_canceled pipelines_skipped].freeze + enum identifier: { projects: 1, users: 2, issues: 3, merge_requests: 4, groups: 5, - pipelines: 6 + pipelines: 6, + pipelines_succeeded: 7, + pipelines_failed: 8, + pipelines_canceled: 9, + pipelines_skipped: 10 } IDENTIFIER_QUERY_MAPPING = { @@ -18,7 +24,11 @@ module Analytics identifiers[:issues] => -> { Issue }, identifiers[:merge_requests] => -> { MergeRequest }, identifiers[:groups] => -> { Group }, - identifiers[:pipelines] => -> { Ci::Pipeline } + identifiers[:pipelines] => -> { Ci::Pipeline }, + identifiers[:pipelines_succeeded] => -> { Ci::Pipeline.success }, + identifiers[:pipelines_failed] => -> { Ci::Pipeline.failed }, + identifiers[:pipelines_canceled] => -> { Ci::Pipeline.canceled }, + identifiers[:pipelines_skipped] => -> { Ci::Pipeline.skipped } }.freeze validates :recorded_at, :identifier, :count, presence: true @@ -26,6 +36,14 @@ module Analytics scope :order_by_latest, -> { order(recorded_at: :desc) } scope :with_identifier, -> (identifier) { where(identifier: identifier) } + + def self.measurement_identifier_values + if Feature.enabled?(:store_ci_pipeline_counts_by_status, default_enabled: true) + identifiers.values + else + identifiers.values - EXPERIMENTAL_IDENTIFIERS.map { |identifier| identifiers[identifier] } + end + end end end end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 6ffb9b7642a..3542bb90dc0 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -52,6 +52,16 @@ class ApplicationRecord < ActiveRecord::Base end end + # Start a new transaction with a shorter-than-usual statement timeout. This is + # currently one third of the default 15-second timeout + def self.with_fast_statement_timeout + transaction(requires_new: true) do + connection.exec_query("SET LOCAL statement_timeout = 5000") + + yield + end + end + def self.safe_find_or_create_by(*args, &block) safe_ensure_unique(retries: 1) do find_or_create_by(*args, &block) @@ -61,4 +71,8 @@ class ApplicationRecord < ActiveRecord::Base def self.underscore Gitlab::SafeRequestStore.fetch("model:#{self}:underscore") { self.to_s.underscore } end + + def self.where_exists(query) + where('EXISTS (?)', query.select(1)) + end end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index e9a3dcf39df..d034630a085 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -11,6 +11,7 @@ class ApplicationSetting < ApplicationRecord ignore_column :instance_statistics_visibility_private, remove_with: '13.6', remove_after: '2020-10-22' ignore_column :snowplow_iglu_registry_url, remove_with: '13.6', remove_after: '2020-11-22' + INSTANCE_REVIEW_MIN_USERS = 50 GRAFANA_URL_ERROR_MESSAGE = 'Please check your Grafana URL setting in ' \ 'Admin Area > Settings > Metrics and profiling > Metrics - Grafana' @@ -91,11 +92,16 @@ class ApplicationSetting < ApplicationRecord addressable_url: true, if: :help_page_support_url_column_exists? + validates :help_page_documentation_base_url, + length: { maximum: 255, message: _("is too long (maximum is %{count} characters)") }, + allow_blank: true, + addressable_url: true + validates :after_sign_out_path, allow_blank: true, addressable_url: true - validates :admin_notification_email, + validates :abuse_notification_email, devise_email: true, allow_blank: true @@ -432,6 +438,14 @@ class ApplicationSetting < ApplicationRecord !!(sourcegraph_url =~ /\Ahttps:\/\/(www\.)?sourcegraph\.com/) end + def instance_review_permitted? + users_count = Rails.cache.fetch('limited_users_count', expires_in: 1.day) do + ::User.limit(INSTANCE_REVIEW_MIN_USERS + 1).count(:all) + end + + users_count >= INSTANCE_REVIEW_MIN_USERS + end + def self.create_from_defaults check_schema! diff --git a/app/models/application_setting/term.rb b/app/models/application_setting/term.rb index 723540c9b91..bab036f5697 100644 --- a/app/models/application_setting/term.rb +++ b/app/models/application_setting/term.rb @@ -14,6 +14,8 @@ class ApplicationSetting end def accepted_by_user?(user) + return true if user.project_bot? + user.accepted_term_id == id || term_agreements.accepted.where(user: user).exists? end diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index 7a869d16a31..8a7bd5a7ad9 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -82,6 +82,7 @@ module ApplicationSettingImplementation group_import_limit: 6, help_page_hide_commercial_content: false, help_page_text: nil, + help_page_documentation_base_url: nil, hide_third_party_offers: false, housekeeping_bitmaps_enabled: true, housekeeping_enabled: true, @@ -119,6 +120,7 @@ module ApplicationSettingImplementation repository_checks_enabled: true, repository_storages_weighted: { default: 100 }, repository_storages: ['default'], + require_admin_approval_after_user_signup: false, require_two_factor_authentication: false, restricted_visibility_levels: Settings.gitlab['restricted_visibility_levels'], rsa_key_restriction: 0, diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb index f46803be057..34f03e769a0 100644 --- a/app/models/audit_event.rb +++ b/app/models/audit_event.rb @@ -4,8 +4,15 @@ class AuditEvent < ApplicationRecord include CreatedAtFilterable include IgnorableColumns include BulkInsertSafe + include EachBatch - PARALLEL_PERSISTENCE_COLUMNS = [:author_name, :entity_path, :target_details, :target_type].freeze + PARALLEL_PERSISTENCE_COLUMNS = [ + :author_name, + :entity_path, + :target_details, + :target_type, + :target_id + ].freeze ignore_column :type, remove_with: '13.6', remove_after: '2020-11-22' @@ -16,6 +23,7 @@ class AuditEvent < ApplicationRecord validates :author_id, presence: true validates :entity_id, presence: true validates :entity_type, presence: true + validates :ip_address, ip_address: true scope :by_entity_type, -> (entity_type) { where(entity_type: entity_type) } scope :by_entity_id, -> (entity_id) { where(entity_id: entity_id) } @@ -47,7 +55,9 @@ class AuditEvent < ApplicationRecord end def initialize_details - self.details = {} if details.nil? + return unless self.has_attribute?(:details) + + self.details = {} if details&.nil? end def author_name @@ -59,8 +69,8 @@ class AuditEvent < ApplicationRecord end def lazy_author - BatchLoader.for(author_id).batch(default_value: default_author_value) do |author_ids, loader| - User.where(id: author_ids).find_each do |user| + BatchLoader.for(author_id).batch(default_value: default_author_value, replace_methods: false) do |author_ids, loader| + User.select(:id, :name, :username).where(id: author_ids).find_each do |user| loader.call(user.id, user) end end diff --git a/app/models/authentication_event.rb b/app/models/authentication_event.rb index 1ac3c5fbd9c..ac6e08caf50 100644 --- a/app/models/authentication_event.rb +++ b/app/models/authentication_event.rb @@ -1,12 +1,22 @@ # frozen_string_literal: true class AuthenticationEvent < ApplicationRecord + include UsageStatistics + belongs_to :user, optional: true validates :provider, :user_name, :result, presence: true + validates :ip_address, ip_address: true enum result: { failed: 0, success: 1 } + + scope :for_provider, ->(provider) { where(provider: provider) } + scope :ldap, -> { where('provider LIKE ?', 'ldap%')} + + def self.providers + distinct.pluck(:provider) + end end diff --git a/app/models/blob_viewer/balsamiq.rb b/app/models/blob_viewer/balsamiq.rb index 1af6c5474d7..6ab73730222 100644 --- a/app/models/blob_viewer/balsamiq.rb +++ b/app/models/blob_viewer/balsamiq.rb @@ -8,7 +8,7 @@ module BlobViewer self.partial_name = 'balsamiq' self.extensions = %w(bmpr) self.binary = true - self.switcher_icon = 'file-image-o' + self.switcher_icon = 'doc-image' self.switcher_title = 'preview' end end diff --git a/app/models/blob_viewer/markup.rb b/app/models/blob_viewer/markup.rb index f525180048e..37a8e01d0f1 100644 --- a/app/models/blob_viewer/markup.rb +++ b/app/models/blob_viewer/markup.rb @@ -9,5 +9,15 @@ module BlobViewer self.extensions = Gitlab::MarkupHelper::EXTENSIONS self.file_types = %i(readme) self.binary = false + + def banzai_render_context + {}.tap do |h| + h[:rendered] = blob.rendered_markup if blob.respond_to?(:rendered_markup) + + if Feature.enabled?(:cached_markdown_blob, blob.project, default_enabled: true) + h[:cache_key] = ['blob', blob.id, 'commit', blob.commit_id] + end + end + end end end diff --git a/app/models/blob_viewer/pdf.rb b/app/models/blob_viewer/pdf.rb index 2cf7752585c..e3542b91d5c 100644 --- a/app/models/blob_viewer/pdf.rb +++ b/app/models/blob_viewer/pdf.rb @@ -8,7 +8,7 @@ module BlobViewer self.partial_name = 'pdf' self.extensions = %w(pdf) self.binary = true - self.switcher_icon = 'file-pdf-o' + self.switcher_icon = 'document' self.switcher_title = 'PDF' end end diff --git a/app/models/blob_viewer/sketch.rb b/app/models/blob_viewer/sketch.rb index 659ab11f30b..90bc9be29f4 100644 --- a/app/models/blob_viewer/sketch.rb +++ b/app/models/blob_viewer/sketch.rb @@ -8,7 +8,7 @@ module BlobViewer self.partial_name = 'sketch' self.extensions = %w(sketch) self.binary = true - self.switcher_icon = 'file-image-o' + self.switcher_icon = 'doc-image' self.switcher_title = 'preview' end end diff --git a/app/models/bulk_import.rb b/app/models/bulk_import.rb new file mode 100644 index 00000000000..cabff86a9f9 --- /dev/null +++ b/app/models/bulk_import.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class BulkImport < ApplicationRecord + belongs_to :user, optional: false + + has_one :configuration, class_name: 'BulkImports::Configuration' + has_many :entities, class_name: 'BulkImports::Entity' + + validates :source_type, :status, presence: true + + enum source_type: { gitlab: 0 } + + state_machine :status, initial: :created do + state :created, value: 0 + end +end diff --git a/app/models/bulk_imports/configuration.rb b/app/models/bulk_imports/configuration.rb new file mode 100644 index 00000000000..8c3aff6f749 --- /dev/null +++ b/app/models/bulk_imports/configuration.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class BulkImports::Configuration < ApplicationRecord + self.table_name = 'bulk_import_configurations' + + belongs_to :bulk_import, inverse_of: :configuration, optional: false + + validates :url, :access_token, length: { maximum: 255 }, presence: true + validates :url, public_url: { schemes: %w[http https], enforce_sanitization: true, ascii_only: true }, + allow_nil: true + + attr_encrypted :url, + key: Settings.attr_encrypted_db_key_base_truncated, + mode: :per_attribute_iv, + algorithm: 'aes-256-gcm' + attr_encrypted :access_token, + key: Settings.attr_encrypted_db_key_base_truncated, + mode: :per_attribute_iv, + algorithm: 'aes-256-gcm' +end diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb new file mode 100644 index 00000000000..2d0bba7bccc --- /dev/null +++ b/app/models/bulk_imports/entity.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class BulkImports::Entity < ApplicationRecord + self.table_name = 'bulk_import_entities' + + belongs_to :bulk_import, optional: false + belongs_to :parent, class_name: 'BulkImports::Entity', optional: true + + belongs_to :project, optional: true + belongs_to :group, foreign_key: :namespace_id, optional: true + + validates :project, absence: true, if: :group + validates :group, absence: true, if: :project + validates :source_type, :source_full_path, :destination_name, + :destination_namespace, presence: true + + validate :validate_parent_is_a_group, if: :parent + validate :validate_imported_entity_type + + enum source_type: { group_entity: 0, project_entity: 1 } + + state_machine :status, initial: :created do + state :created, value: 0 + end + + private + + def validate_parent_is_a_group + unless parent.group_entity? + errors.add(:parent, s_('BulkImport|must be a group')) + end + end + + def validate_imported_entity_type + if group.present? && project_entity? + errors.add(:group, s_('BulkImport|expected an associated Project but has an associated Group')) + end + + if project.present? && group_entity? + errors.add(:project, s_('BulkImport|expected an associated Group but has an associated Project')) + end + end +end diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index 1697067f633..2e725e0baff 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -27,7 +27,7 @@ module Ci # rubocop:enable Cop/ActiveRecordSerialize state_machine :status do - after_transition created: :pending do |bridge| + after_transition [:created, :manual] => :pending do |bridge| next unless bridge.downstream_project bridge.run_after_commit do @@ -46,6 +46,10 @@ module Ci event :scheduled do transition all => :scheduled end + + event :actionize do + transition created: :manual + end end def self.retry(bridge, current_user) @@ -126,9 +130,27 @@ module Ci false end + def playable? + return false unless ::Gitlab::Ci::Features.manual_bridges_enabled?(project) + + action? && !archived? && manual? + end + def action? - false + return false unless ::Gitlab::Ci::Features.manual_bridges_enabled?(project) + + %w[manual].include?(self.when) + end + + # rubocop: disable CodeReuse/ServiceClass + # We don't need it but we are taking `job_variables_attributes` parameter + # to make it consistent with `Ci::Build#play` method. + def play(current_user, job_variables_attributes = nil) + Ci::PlayBridgeService + .new(project, current_user) + .execute(self) end + # rubocop: enable CodeReuse/ServiceClass def artifacts? false @@ -185,6 +207,10 @@ module Ci [] end + def target_revision_ref + downstream_pipeline_params.dig(:target_revision, :ref) + end + private def cross_project_params diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 99580a52e96..9ff70ece947 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -327,6 +327,8 @@ module Ci after_transition any => [:success, :failed, :canceled] do |build| build.run_after_commit do + build.run_status_commit_hooks! + BuildFinishedWorker.perform_async(id) end end @@ -524,7 +526,6 @@ module Ci .concat(job_jwt_variables) .concat(scoped_variables) .concat(job_variables) - .concat(environment_changed_page_variables) .concat(persisted_environment_variables) .to_runner_variables end @@ -561,15 +562,6 @@ module Ci end end - def environment_changed_page_variables - Gitlab::Ci::Variables::Collection.new.tap do |variables| - break variables unless environment_status && Feature.enabled?(:modifed_path_ci_variables, project) - - variables.append(key: 'CI_MERGE_REQUEST_CHANGED_PAGE_PATHS', value: environment_status.changed_paths.join(',')) - variables.append(key: 'CI_MERGE_REQUEST_CHANGED_PAGE_URLS', value: environment_status.changed_urls.join(',')) - end - end - def deploy_token_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| break variables unless gitlab_deploy_token @@ -780,6 +772,11 @@ module Ci end end + def has_expired_locked_archive_artifacts? + locked_artifacts? && + artifacts_expire_at.present? && artifacts_expire_at < Time.current + end + def has_expiring_archive_artifacts? has_expiring_artifacts? && job_artifacts_archive.present? end @@ -901,7 +898,11 @@ module Ci def collect_test_reports!(test_reports) test_reports.get_suite(group_name).tap do |test_suite| each_report(Ci::JobArtifact::TEST_REPORT_FILE_TYPES) do |file_type, blob| - Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, test_suite, job: self) + Gitlab::Ci::Parsers.fabricate!(file_type).parse!( + blob, + test_suite, + job: self + ) end end end @@ -963,8 +964,30 @@ module Ci pending_state.try(:delete) end + def run_on_status_commit(&block) + status_commit_hooks.push(block) + end + + def max_test_cases_per_report + # NOTE: This is temporary and will be replaced later by a value + # that would come from an actual application limit. + ::Gitlab.com? ? 500_000 : 0 + end + + protected + + def run_status_commit_hooks! + status_commit_hooks.reverse_each do |hook| + instance_eval(&hook) + end + end + private + def status_commit_hooks + @status_commit_hooks ||= [] + end + def auto_retry strong_memoize(:auto_retry) do Gitlab::Ci::Build::AutoRetry.new(self) diff --git a/app/models/ci/build_pending_state.rb b/app/models/ci/build_pending_state.rb index 45f323adec2..299c67f441d 100644 --- a/app/models/ci/build_pending_state.rb +++ b/app/models/ci/build_pending_state.rb @@ -9,4 +9,10 @@ class Ci::BuildPendingState < ApplicationRecord enum failure_reason: CommitStatus.failure_reasons validates :build, presence: true + + def crc32 + trace_checksum.try do |checksum| + checksum.to_s.split('crc32:').last.to_i(16) + end + end end diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb index 444742062d9..6926ccd9438 100644 --- a/app/models/ci/build_trace_chunk.rb +++ b/app/models/ci/build_trace_chunk.rb @@ -3,9 +3,11 @@ module Ci class BuildTraceChunk < ApplicationRecord extend ::Gitlab::Ci::Model + include ::Comparable include ::FastDestroyAll include ::Checksummable include ::Gitlab::ExclusiveLeaseHelpers + include ::Gitlab::OptimisticLocking belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id @@ -29,6 +31,7 @@ module Ci } scope :live, -> { redis } + scope :persisted, -> { not_redis.order(:chunk_index) } class << self def all_stores @@ -63,12 +66,24 @@ module Ci get_store_class(store).delete_keys(value) end end + + ## + # Sometimes we do not want to read raw data. This method makes it easier + # to find attributes that are just metadata excluding raw data. + # + def metadata_attributes + attribute_names - %w[raw_data] + end end def data @data ||= get_data.to_s end + def crc32 + checksum.to_i + end + def truncate(offset = 0) raise ArgumentError, 'Offset is out of range' if offset > size || offset < 0 return if offset == size # Skip the following process as it doesn't affect anything @@ -102,22 +117,47 @@ module Ci (start_offset...end_offset) end - def persist_data! - in_lock(*lock_params) { unsafe_persist_data! } - end - def schedule_to_persist! - return if persisted? + return if flushed? Ci::BuildTraceChunkFlushWorker.perform_async(id) end - def persisted? - !redis? - end + ## + # It is possible that we run into two concurrent migrations. It might + # happen that a chunk gets migrated after being loaded by another worker + # but before the worker acquires a lock to perform the migration. + # + # We are using Redis locking to ensure that we perform this operation + # inside an exclusive lock, but this does not prevent us from running into + # race conditions related to updating a model representation in the + # database. Optimistic locking is another mechanism that help here. + # + # We are using optimistic locking combined with Redis locking to ensure + # that a chunk gets migrated properly. + # + # We are catching an exception related to an exclusive lock not being + # acquired because it is creating a lot of noise, and is a result of + # duplicated workers running in parallel for the same build trace chunk. + # + def persist_data! + in_lock(*lock_params) do # exclusive Redis lock is acquired first + raise FailedToPersistDataError, 'Modifed build trace chunk detected' if has_changes_to_save? - def live? - redis? + self.reset.then do |chunk| # we ensure having latest lock_version + chunk.unsafe_persist_data! # we migrate the data and update data store + end + end + rescue FailedToObtainLockError + metrics.increment_trace_operation(operation: :stalled) + rescue ActiveRecord::StaleObjectError + raise FailedToPersistDataError, <<~MSG + Data migration race condition detected + + store: #{data_store} + build: #{build.id} + index: #{chunk_index} + MSG end ## @@ -126,11 +166,28 @@ module Ci # no chunk with higher index in the database. # def final? - build.pending_state.present? && - build.trace_chunks.maximum(:chunk_index).to_i == chunk_index + build.pending_state.present? && chunks_max_index == chunk_index end - private + def flushed? + !redis? + end + + def migrated? + flushed? + end + + def live? + redis? + end + + def <=>(other) + return unless self.build_id == other.build_id + + self.chunk_index <=> other.chunk_index + end + + protected def get_data # Redis / database return UTF-8 encoded string by default @@ -145,12 +202,19 @@ module Ci current_size = current_data&.bytesize.to_i unless current_size == CHUNK_SIZE || final? - raise FailedToPersistDataError, 'Data is not fulfilled in a bucket' + raise FailedToPersistDataError, <<~MSG + data is not fulfilled in a bucket + + size: #{current_size} + state: #{pending_state?} + max: #{chunks_max_index} + index: #{chunk_index} + MSG end self.raw_data = nil self.data_store = new_store - self.checksum = crc32(current_data) + self.checksum = self.class.crc32(current_data) ## # We need to so persist data then save a new store identifier before we @@ -199,10 +263,20 @@ module Ci size == CHUNK_SIZE end + private + + def pending_state? + build.pending_state.present? + end + def current_store self.class.get_store_class(data_store) end + def chunks_max_index + build.trace_chunks.maximum(:chunk_index).to_i + end + def lock_params ["trace_write:#{build_id}:chunks:#{chunk_index}", { ttl: WRITE_LOCK_TTL, diff --git a/app/models/ci/build_trace_chunks/database.rb b/app/models/ci/build_trace_chunks/database.rb index ea8072099c6..7448afba4c2 100644 --- a/app/models/ci/build_trace_chunks/database.rb +++ b/app/models/ci/build_trace_chunks/database.rb @@ -17,6 +17,8 @@ module Ci def data(model) model.raw_data + rescue ActiveModel::MissingAttributeError + model.reset.raw_data end def set_data(model, new_data) diff --git a/app/models/ci/deleted_object.rb b/app/models/ci/deleted_object.rb new file mode 100644 index 00000000000..e74946eda16 --- /dev/null +++ b/app/models/ci/deleted_object.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Ci + class DeletedObject < ApplicationRecord + extend Gitlab::Ci::Model + + mount_uploader :file, DeletedObjectUploader + + scope :ready_for_destruction, ->(limit) do + where('pick_up_at < ?', Time.current).limit(limit) + end + + scope :lock_for_destruction, ->(limit) do + ready_for_destruction(limit) + .select(:id) + .order(:pick_up_at) + .lock('FOR UPDATE SKIP LOCKED') + end + + def self.bulk_import(artifacts) + attributes = artifacts.each.with_object([]) do |artifact, accumulator| + record = artifact.to_deleted_object_attrs + accumulator << record if record[:store_dir] && record[:file] + end + + self.insert_all(attributes) if attributes.any? + end + + def delete_file_from_storage + file.remove! + true + rescue => exception + Gitlab::ErrorTracking.track_exception(exception) + false + end + end +end diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 8bbb92e319f..02e17afdab0 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -46,7 +46,8 @@ module Ci terraform: 'tfplan.json', cluster_applications: 'gl-cluster-applications.json', requirements: 'requirements.json', - coverage_fuzzing: 'gl-coverage-fuzzing.json' + coverage_fuzzing: 'gl-coverage-fuzzing.json', + api_fuzzing: 'gl-api-fuzzing-report.json' }.freeze INTERNAL_TYPES = { @@ -65,11 +66,8 @@ module Ci cluster_applications: :gzip, lsif: :zip, - # All these file formats use `raw` as we need to store them uncompressed - # for Frontend to fetch the files and do analysis - # When they will be only used by backend, they can be `gzipped`. - accessibility: :raw, - codequality: :raw, + # Security reports and license scanning reports are raw artifacts + # because they used to be fetched by the frontend, but this is not the case anymore. sast: :raw, secret_detection: :raw, dependency_scanning: :raw, @@ -77,16 +75,24 @@ module Ci dast: :raw, license_management: :raw, license_scanning: :raw, + + # All these file formats use `raw` as we need to store them uncompressed + # for Frontend to fetch the files and do analysis + # When they will be only used by backend, they can be `gzipped`. + accessibility: :raw, + codequality: :raw, performance: :raw, browser_performance: :raw, load_performance: :raw, terraform: :raw, requirements: :raw, - coverage_fuzzing: :raw + coverage_fuzzing: :raw, + api_fuzzing: :raw }.freeze DOWNLOADABLE_TYPES = %w[ accessibility + api_fuzzing archive cobertura codequality @@ -194,7 +200,8 @@ module Ci requirements: 22, ## EE-specific coverage_fuzzing: 23, ## EE-specific browser_performance: 24, ## EE-specific - load_performance: 25 ## EE-specific + load_performance: 25, ## EE-specific + api_fuzzing: 26 ## EE-specific } # `file_location` indicates where actual files are stored. @@ -283,6 +290,15 @@ module Ci max_size&.megabytes.to_i end + def to_deleted_object_attrs + { + file_store: file_store, + store_dir: file.store_dir.to_s, + file: file_identifier, + pick_up_at: expire_at || Time.current + } + end + private def set_size diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 47eba685afe..684b6387ab1 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -27,6 +27,13 @@ module Ci sha_attribute :source_sha sha_attribute :target_sha + # Ci::CreatePipelineService returns Ci::Pipeline so this is the only place + # where we can pass additional information from the service. This accessor + # is used for storing the processed CI YAML contents for linting purposes. + # There is an open issue to address this: + # https://gitlab.com/gitlab-org/gitlab/-/issues/259010 + attr_accessor :merged_yaml + belongs_to :project, inverse_of: :all_pipelines belongs_to :user belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline' @@ -42,6 +49,7 @@ module Ci has_many :stages, -> { order(position: :asc) }, inverse_of: :pipeline 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 :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 @@ -577,11 +585,11 @@ module Ci end def retried - @retried ||= (statuses.order(id: :desc) - statuses.latest) + @retried ||= (statuses.order(id: :desc) - latest_statuses) end def coverage - coverage_array = statuses.latest.map(&:coverage).compact + coverage_array = latest_statuses.map(&:coverage).compact if coverage_array.size >= 1 '%.2f' % (coverage_array.reduce(:+) / coverage_array.size) end @@ -821,16 +829,28 @@ module Ci end def same_family_pipeline_ids - if ::Gitlab::Ci::Features.child_of_child_pipeline_enabled?(project) - ::Gitlab::Ci::PipelineObjectHierarchy.new( - base_and_ancestors(same_project: true), options: { same_project: true } - ).base_and_descendants.select(:id) - else - # If pipeline is a child of another pipeline, include the parent - # and the siblings, otherwise return only itself and children. - parent = parent_pipeline || self - [parent.id] + parent.child_pipelines.pluck(:id) - end + ::Gitlab::Ci::PipelineObjectHierarchy.new( + base_and_ancestors(same_project: true), options: { same_project: true } + ).base_and_descendants.select(:id) + end + + def build_with_artifacts_in_self_and_descendants(name) + builds_in_self_and_descendants + .ordered_by_pipeline # find job in hierarchical order + .with_downloadable_artifacts + .find_by_name(name) + end + + def builds_in_self_and_descendants + Ci::Build.latest.where(pipeline: self_and_descendants) + end + + # Without using `unscoped`, caller scope is also included into the query. + # Using `unscoped` here will be redundant after Rails 6.1 + def self_and_descendants + ::Gitlab::Ci::PipelineObjectHierarchy + .new(self.class.unscoped.where(id: id), options: { same_project: true }) + .base_and_descendants end def bridge_triggered? @@ -875,7 +895,7 @@ module Ci end def builds_with_coverage - builds.with_coverage + builds.latest.with_coverage end def has_reports?(reports_scope) diff --git a/app/models/ci_platform_metric.rb b/app/models/ci_platform_metric.rb index 5e6e3eddce9..ac4ab391bbf 100644 --- a/app/models/ci_platform_metric.rb +++ b/app/models/ci_platform_metric.rb @@ -14,7 +14,7 @@ class CiPlatformMetric < ApplicationRecord numericality: { only_integer: true, greater_than: 0 } CI_VARIABLE_KEY = 'AUTO_DEVOPS_PLATFORM_TARGET' - ALLOWED_TARGETS = %w[ECS FARGATE].freeze + ALLOWED_TARGETS = %w[ECS FARGATE EC2].freeze def self.insert_auto_devops_platform_targets! recorded_at = Time.zone.now diff --git a/app/models/clusters/agent.rb b/app/models/clusters/agent.rb index 874415e7bf4..5feb3b0a1e6 100644 --- a/app/models/clusters/agent.rb +++ b/app/models/clusters/agent.rb @@ -8,6 +8,7 @@ module Clusters has_many :agent_tokens, class_name: 'Clusters::AgentToken' + scope :ordered_by_name, -> { order(:name) } scope :with_name, -> (name) { where(name: name) } validates :name, diff --git a/app/models/clusters/applications/fluentd.rb b/app/models/clusters/applications/fluentd.rb index 3fd6e870edc..c608d37be77 100644 --- a/app/models/clusters/applications/fluentd.rb +++ b/app/models/clusters/applications/fluentd.rb @@ -22,7 +22,11 @@ module Clusters validate :has_at_least_one_log_enabled? def chart - 'stable/fluentd' + 'fluentd/fluentd' + end + + def repository + 'https://gitlab-org.gitlab.io/cluster-integration/helm-stable-archive' end def install_command diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb index 1d08f38a2f1..d5412714858 100644 --- a/app/models/clusters/applications/ingress.rb +++ b/app/models/clusters/applications/ingress.rb @@ -46,7 +46,11 @@ module Clusters end def chart - 'stable/nginx-ingress' + "#{name}/nginx-ingress" + end + + def repository + 'https://gitlab-org.gitlab.io/cluster-integration/helm-stable-archive' end def values @@ -60,6 +64,7 @@ module Clusters def install_command Gitlab::Kubernetes::Helm::InstallCommand.new( name: name, + repository: repository, version: VERSION, rbac: cluster.platform_kubernetes_rbac?, chart: chart, diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb index dd6a4144608..7679296699f 100644 --- a/app/models/clusters/applications/prometheus.rb +++ b/app/models/clusters/applications/prometheus.rb @@ -51,7 +51,11 @@ module Clusters end def chart - 'stable/prometheus' + "#{name}/prometheus" + end + + def repository + 'https://gitlab-org.gitlab.io/cluster-integration/helm-stable-archive' end def service_name @@ -65,6 +69,7 @@ module Clusters def install_command Gitlab::Kubernetes::Helm::InstallCommand.new( name: name, + repository: repository, version: VERSION, rbac: cluster.platform_kubernetes_rbac?, chart: chart, @@ -76,6 +81,7 @@ module Clusters def patch_command(values) ::Gitlab::Kubernetes::Helm::PatchCommand.new( name: name, + repository: repository, version: version, rbac: cluster.platform_kubernetes_rbac?, chart: chart, diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index f0b3c11ba1d..d07ea7b71dc 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -3,7 +3,7 @@ module Clusters module Applications class Runner < ApplicationRecord - VERSION = '0.20.2' + VERSION = '0.21.1' self.table_name = 'clusters_applications_runners' diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb index 7af78960e35..b85a902d58b 100644 --- a/app/models/clusters/platforms/kubernetes.rb +++ b/app/models/clusters/platforms/kubernetes.rb @@ -11,6 +11,7 @@ module Clusters RESERVED_NAMESPACES = %w(gitlab-managed-apps).freeze self.table_name = 'cluster_platforms_kubernetes' + self.reactive_cache_work_type = :external_dependency belongs_to :cluster, inverse_of: :platform_kubernetes, class_name: 'Clusters::Cluster' @@ -101,7 +102,7 @@ module Clusters def terminals(environment, data) pods = filter_by_project_environment(data[:pods], environment.project.full_path_slug, environment.slug) terminals = pods.flat_map { |pod| terminals_for_pod(api_url, environment.deployment_namespace, pod) }.compact - terminals.each { |terminal| add_terminal_auth(terminal, terminal_auth) } + terminals.each { |terminal| add_terminal_auth(terminal, **terminal_auth) } end def kubeclient diff --git a/app/models/commit.rb b/app/models/commit.rb index 5e0fceb23a4..83400c9e533 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -29,12 +29,6 @@ class Commit delegate :repository, to: :container delegate :project, to: :repository, allow_nil: true - DIFF_SAFE_LINES = Gitlab::Git::DiffCollection::DEFAULT_LIMITS[:max_lines] - - # Commits above this size will not be rendered in HTML - DIFF_HARD_LIMIT_FILES = 1000 - DIFF_HARD_LIMIT_LINES = 50000 - MIN_SHA_LENGTH = Gitlab::Git::Commit::MIN_SHA_LENGTH COMMIT_SHA_PATTERN = /\h{#{MIN_SHA_LENGTH},40}/.freeze EXACT_COMMIT_SHA_PATTERN = /\A#{COMMIT_SHA_PATTERN}\z/.freeze @@ -80,10 +74,30 @@ class Commit sha[0..MIN_SHA_LENGTH] end - def max_diff_options + def diff_safe_lines(project: nil) + Gitlab::Git::DiffCollection.default_limits(project: project)[:max_lines] + end + + def diff_hard_limit_files(project: nil) + if Feature.enabled?(:increased_diff_limits, project) + 2000 + else + 1000 + end + end + + def diff_hard_limit_lines(project: nil) + if Feature.enabled?(:increased_diff_limits, project) + 75000 + else + 50000 + end + end + + def max_diff_options(project: nil) { - max_files: DIFF_HARD_LIMIT_FILES, - max_lines: DIFF_HARD_LIMIT_LINES + max_files: diff_hard_limit_files(project: project), + max_lines: diff_hard_limit_lines(project: project) } end diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 2f0596c93cc..4498e08d754 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -48,6 +48,7 @@ class CommitStatus < ApplicationRecord scope :ordered_by_stage, -> { order(stage_idx: :asc) } scope :latest_ordered, -> { latest.ordered.includes(project: :namespace) } scope :retried_ordered, -> { retried.ordered.includes(project: :namespace) } + scope :ordered_by_pipeline, -> { order(pipeline_id: :asc) } scope :before_stage, -> (index) { where('stage_idx < ?', index) } scope :for_stage, -> (index) { where(stage_idx: index) } scope :after_stage, -> (index) { where('stage_idx > ?', index) } @@ -204,8 +205,13 @@ class CommitStatus < ApplicationRecord # 'rspec:linux: 1/10' => 'rspec:linux' common_name = name.to_s.gsub(%r{\d+[\s:\/\\]+\d+\s*}, '') - # 'rspec:linux: [aws, max memory]' => 'rspec:linux' - common_name.gsub!(%r{: \[.*, .*\]\s*\z}, '') + if ::Gitlab::Ci::Features.one_dimensional_matrix_enabled? + # 'rspec:linux: [aws, max memory]' => 'rspec:linux', 'rspec:linux: [aws]' => 'rspec:linux' + common_name.gsub!(%r{: \[.*\]\s*\z}, '') + else + # 'rspec:linux: [aws, max memory]' => 'rspec:linux', 'rspec:linux: [aws]' => 'rspec:linux: [aws]' + common_name.gsub!(%r{: \[.*, .*\]\s*\z}, '') + end common_name.strip! common_name diff --git a/app/models/concerns/approvable_base.rb b/app/models/concerns/approvable_base.rb index d07c4ec43ac..c2d94b50f8d 100644 --- a/app/models/concerns/approvable_base.rb +++ b/app/models/concerns/approvable_base.rb @@ -2,10 +2,34 @@ module ApprovableBase extend ActiveSupport::Concern + include FromUnion included do has_many :approvals, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_many :approved_by_users, through: :approvals, source: :user + + scope :without_approvals, -> { left_outer_joins(:approvals).where(approvals: { id: nil }) } + scope :with_approvals, -> { joins(:approvals) } + scope :approved_by_users_with_ids, -> (*user_ids) do + with_approvals + .merge(Approval.with_user) + .where(users: { id: user_ids }) + .group(:id) + .having("COUNT(users.id) = ?", user_ids.size) + end + scope :approved_by_users_with_usernames, -> (*usernames) do + with_approvals + .merge(Approval.with_user) + .where(users: { username: usernames }) + .group(:id) + .having("COUNT(users.id) = ?", usernames.size) + end + end + + class_methods do + def select_from_union(relations) + where(id: from_union(relations)) + end end def approved_by?(user) diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb index 0dd55ab67b5..d342b526677 100644 --- a/app/models/concerns/avatarable.rb +++ b/app/models/concerns/avatarable.rb @@ -3,16 +3,11 @@ module Avatarable extend ActiveSupport::Concern - ALLOWED_IMAGE_SCALER_WIDTHS = [ - 400, - 200, - 64, - 48, - 40, - 26, - 20, - 16 - ].freeze + USER_AVATAR_SIZES = [16, 20, 23, 24, 26, 32, 36, 38, 40, 48, 60, 64, 90, 96, 120, 160].freeze + PROJECT_AVATAR_SIZES = [15, 40, 48, 64, 88].freeze + GROUP_AVATAR_SIZES = [15, 37, 38, 39, 40, 64, 96].freeze + + ALLOWED_IMAGE_SCALER_WIDTHS = (USER_AVATAR_SIZES | PROJECT_AVATAR_SIZES | GROUP_AVATAR_SIZES).freeze included do prepend ShadowMethods diff --git a/app/models/concerns/checksummable.rb b/app/models/concerns/checksummable.rb index d6d17bfc604..056abafd0ce 100644 --- a/app/models/concerns/checksummable.rb +++ b/app/models/concerns/checksummable.rb @@ -3,11 +3,11 @@ module Checksummable extend ActiveSupport::Concern - def crc32(data) - Zlib.crc32(data) - end - class_methods do + def crc32(data) + Zlib.crc32(data) + end + def hexdigest(path) ::Digest::SHA256.file(path).hexdigest end diff --git a/app/models/concerns/counter_attribute.rb b/app/models/concerns/counter_attribute.rb index a5c7393e8f7..b468415c4c7 100644 --- a/app/models/concerns/counter_attribute.rb +++ b/app/models/concerns/counter_attribute.rb @@ -20,6 +20,14 @@ # To increment the counter we can use the method: # delayed_increment_counter(:commit_count, 3) # +# It is possible to register callbacks to be executed after increments have +# been flushed to the database. Callbacks are not executed if there are no increments +# to flush. +# +# counter_attribute_after_flush do |statistic| +# Namespaces::ScheduleAggregationWorker.perform_async(statistic.namespace_id) +# end +# module CounterAttribute extend ActiveSupport::Concern extend AfterCommitQueue @@ -48,6 +56,15 @@ module CounterAttribute def counter_attributes @counter_attributes ||= Set.new end + + def after_flush_callbacks + @after_flush_callbacks ||= [] + end + + # perform registered callbacks after increments have been flushed to the database + def counter_attribute_after_flush(&callback) + after_flush_callbacks << callback + end end # This method must only be called by FlushCounterIncrementsWorker @@ -75,6 +92,8 @@ module CounterAttribute unsafe_update_counters(id, attribute => increment_value) redis_state { |redis| redis.del(flushed_key) } end + + execute_after_flush_callbacks end end @@ -108,13 +127,13 @@ module CounterAttribute counter_key(attribute) + ':lock' end - private - def counter_attribute_enabled?(attribute) Feature.enabled?(:efficient_counter_attribute, project) && self.class.counter_attributes.include?(attribute) end + private + def steal_increments(increment_key, flushed_key) redis_state do |redis| redis.eval(LUA_STEAL_INCREMENT_SCRIPT, keys: [increment_key, flushed_key]) @@ -129,6 +148,12 @@ module CounterAttribute self.class.update_counters(id, increments) end + def execute_after_flush_callbacks + self.class.after_flush_callbacks.each do |callback| + callback.call(self) + end + end + def redis_state(&block) Gitlab::Redis::SharedState.with(&block) end diff --git a/app/models/concerns/has_repository.rb b/app/models/concerns/has_repository.rb index d909b67d7ba..978a54bdee7 100644 --- a/app/models/concerns/has_repository.rb +++ b/app/models/concerns/has_repository.rb @@ -71,6 +71,10 @@ module HasRepository raise NotImplementedError end + def lfs_enabled? + false + end + def empty_repo? repository.empty? end @@ -80,7 +84,11 @@ module HasRepository end def default_branch_from_preferences - empty_repo? ? Gitlab::CurrentSettings.default_branch_name : nil + return unless empty_repo? + + group_branch_default_name = group&.default_branch_name if respond_to?(:group) + + group_branch_default_name || Gitlab::CurrentSettings.default_branch_name end def reload_default_branch diff --git a/app/models/concerns/has_user_type.rb b/app/models/concerns/has_user_type.rb index 8a238dc736c..468387115e5 100644 --- a/app/models/concerns/has_user_type.rb +++ b/app/models/concerns/has_user_type.rb @@ -11,16 +11,18 @@ module HasUserType service_user: 4, ghost: 5, project_bot: 6, - migration_bot: 7 + migration_bot: 7, + security_bot: 8 }.with_indifferent_access.freeze - BOT_USER_TYPES = %w[alert_bot project_bot support_bot visual_review_bot migration_bot].freeze + BOT_USER_TYPES = %w[alert_bot project_bot support_bot visual_review_bot migration_bot security_bot].freeze NON_INTERNAL_USER_TYPES = %w[human project_bot service_user].freeze INTERNAL_USER_TYPES = (USER_TYPES.keys - NON_INTERNAL_USER_TYPES).freeze included do scope :humans, -> { where(user_type: :human) } scope :bots, -> { where(user_type: BOT_USER_TYPES) } + scope :without_bots, -> { humans.or(where.not(user_type: BOT_USER_TYPES)) } scope :bots_without_project_bot, -> { where(user_type: BOT_USER_TYPES - ['project_bot']) } scope :non_internal, -> { humans.or(where(user_type: NON_INTERNAL_USER_TYPES)) } scope :without_ghosts, -> { humans.or(where.not(user_type: :ghost)) } diff --git a/app/models/concerns/integration.rb b/app/models/concerns/integration.rb index 34ff5bb1195..9d446841a9f 100644 --- a/app/models/concerns/integration.rb +++ b/app/models/concerns/integration.rb @@ -16,7 +16,7 @@ module Integration Project.where(id: custom_integration_project_ids) end - def ids_without_integration(integration, limit) + def without_integration(integration) services = Service .select('1') .where('services.project_id = projects.id') @@ -26,8 +26,6 @@ module Integration .where('NOT EXISTS (?)', services) .where(pending_delete: false) .where(archived: false) - .limit(limit) - .pluck(:id) end end end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 888e1b384a2..7624a1a4e80 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -182,7 +182,7 @@ module Issuable end def supports_time_tracking? - is_a?(TimeTrackable) && !incident? + is_a?(TimeTrackable) end def supports_severity? @@ -203,15 +203,6 @@ module Issuable issuable_severity&.severity || IssuableSeverity::DEFAULT end - def update_severity(severity) - return unless incident? - - severity = severity.to_s.downcase - severity = IssuableSeverity::DEFAULT unless IssuableSeverity.severities.key?(severity) - - (issuable_severity || build_issuable_severity(issue_id: id)).update(severity: severity) - end - private def description_max_length_for_new_records_is_valid diff --git a/app/models/concerns/issue_available_features.rb b/app/models/concerns/issue_available_features.rb new file mode 100644 index 00000000000..6efb8103b7b --- /dev/null +++ b/app/models/concerns/issue_available_features.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +# Verifies features availability based on issue type. +# This can be used, for example, for hiding UI elements or blocking specific +# quick actions for particular issue types; +module IssueAvailableFeatures + extend ActiveSupport::Concern + + # EE only features are listed on EE::IssueAvailableFeatures + def available_features_for_issue_types + {}.with_indifferent_access + end + + def issue_type_supports?(feature) + unless available_features_for_issue_types.has_key?(feature) + raise ArgumentError, 'invalid feature' + end + + available_features_for_issue_types[feature].include?(issue_type) + end +end + +IssueAvailableFeatures.prepend_if_ee('EE::IssueAvailableFeatures') diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index 7b4485376d4..b10e8547e86 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -81,13 +81,6 @@ module Mentionable end def store_mentions! - # if store_mentioned_users_to_db feature flag is not enabled then consider storing operation as succeeded - # because we wrap this method in transaction with with_transaction_returning_status, and we need the status to be - # successful if mentionable.save is successful. - # - # This line will get removed when we remove the feature flag. - return true unless store_mentioned_users_to_db_enabled? - refs = all_references(self.author) references = {} @@ -253,15 +246,6 @@ module Mentionable def model_user_mention user_mentions.where(note_id: nil).first_or_initialize end - - # We need this method to be checking that store_mentioned_users_to_db feature flag is enabled at the group level - # and not the project level as epics are defined at group level and we want to have epics store user mentions as well - # for the test period. - # During the test period the flag should be enabled at the group level. - def store_mentioned_users_to_db_enabled? - return Feature.enabled?(:store_mentioned_users_to_db, self.project&.group, default_enabled: true) if self.respond_to?(:project) - return Feature.enabled?(:store_mentioned_users_to_db, self.group, default_enabled: true) if self.respond_to?(:group) - end end Mentionable.prepend_if_ee('EE::Mentionable') diff --git a/app/models/concerns/presentable.rb b/app/models/concerns/presentable.rb index 06c300c2e41..1f05abff2f4 100644 --- a/app/models/concerns/presentable.rb +++ b/app/models/concerns/presentable.rb @@ -5,13 +5,13 @@ module Presentable class_methods do def present(attributes) - all.map { |klass_object| klass_object.present(attributes) } + all.map { |klass_object| klass_object.present(**attributes) } end end def present(**attributes) Gitlab::View::Presenter::Factory - .new(self, attributes) + .new(self, **attributes) .fabricate! end end diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb index 5f30fc0c36c..3470bdab5fb 100644 --- a/app/models/concerns/reactive_caching.rb +++ b/app/models/concerns/reactive_caching.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # The usage of the ReactiveCaching module is documented here: -# https://docs.gitlab.com/ee/development/utilities.html#reactivecaching +# https://docs.gitlab.com/ee/development/reactive_caching.md module ReactiveCaching extend ActiveSupport::Concern @@ -9,7 +9,7 @@ module ReactiveCaching ExceededReactiveCacheLimit = Class.new(StandardError) WORK_TYPE = { - default: ReactiveCachingWorker, + no_dependency: ReactiveCachingWorker, external_dependency: ExternalServiceReactiveCachingWorker }.freeze @@ -30,7 +30,6 @@ module ReactiveCaching self.reactive_cache_refresh_interval = 1.minute self.reactive_cache_lifetime = 10.minutes self.reactive_cache_hard_limit = nil # this value should be set in megabytes. E.g: 1.megabyte - self.reactive_cache_work_type = :default self.reactive_cache_worker_finder = ->(id, *_args) do find_by(primary_key => id) end diff --git a/app/models/concerns/reactive_service.rb b/app/models/concerns/reactive_service.rb index af69da24994..c444f238944 100644 --- a/app/models/concerns/reactive_service.rb +++ b/app/models/concerns/reactive_service.rb @@ -8,5 +8,6 @@ module ReactiveService # Default cache key: class name + project_id self.reactive_cache_key = ->(service) { [service.class.model_name.singular, service.project_id] } + self.reactive_cache_work_type = :external_dependency end end diff --git a/app/models/concerns/referable.rb b/app/models/concerns/referable.rb index 40edd3b3ead..9a17131c91c 100644 --- a/app/models/concerns/referable.rb +++ b/app/models/concerns/referable.rb @@ -85,7 +85,7 @@ module Referable \/#{route.is_a?(Regexp) ? route : Regexp.escape(route)} \/#{pattern} (?<path> - (\/[a-z0-9_=-]+)* + (\/[a-z0-9_=-]+)*\/* )? (?<query> \?[a-z0-9_=-]+ diff --git a/app/models/concerns/relative_positioning.rb b/app/models/concerns/relative_positioning.rb index 3cbc174536c..7f559f0a7ed 100644 --- a/app/models/concerns/relative_positioning.rb +++ b/app/models/concerns/relative_positioning.rb @@ -102,33 +102,16 @@ module RelativePositioning delta = at_end ? gap : -gap indexed = (at_end ? objects : objects.reverse).each_with_index - # Some classes are polymorphic, and not all siblings are in the same table. - by_model = indexed.group_by { |pair| pair.first.class } lower_bound, upper_bound = at_end ? [position, MAX_POSITION] : [MIN_POSITION, position] - by_model.each do |model, pairs| - model.transaction do - pairs.each_slice(100) do |batch| - # These are known to be integers, one from the DB, and the other - # calculated by us, and thus safe to interpolate - values = batch.map do |obj, i| - desired_pos = position + delta * (i + 1) - pos = desired_pos.clamp(lower_bound, upper_bound) - obj.relative_position = pos - "(#{obj.id}, #{pos})" - end.join(', ') - - model.connection.exec_query(<<~SQL, "UPDATE #{model.table_name} positions") - WITH cte(cte_id, new_pos) AS ( - SELECT * - FROM (VALUES #{values}) as t (id, pos) - ) - UPDATE #{model.table_name} - SET relative_position = cte.new_pos - FROM cte - WHERE cte_id = id - SQL + representative.model_class.transaction do + indexed.each_slice(100) do |batch| + mapping = batch.to_h.transform_values! do |i| + desired_pos = position + delta * (i + 1) + { relative_position: desired_pos.clamp(lower_bound, upper_bound) } end + + ::Gitlab::Database::BulkUpdate.execute([:relative_position], mapping, &:model_class) end end @@ -200,4 +183,16 @@ module RelativePositioning # Override if you want to be notified of failures to move def could_not_move(exception) end + + # Override if the implementing class is not a simple application record, for + # example if the record is loaded from a union. + def reset_relative_position + reset.relative_position + end + + # Override if the model class needs a more complicated computation (e.g. the + # object is a member of a union). + def model_class + self.class + end end diff --git a/app/models/concerns/shardable.rb b/app/models/concerns/shardable.rb index 57cd77b44b4..c0883c08289 100644 --- a/app/models/concerns/shardable.rb +++ b/app/models/concerns/shardable.rb @@ -5,6 +5,10 @@ module Shardable included do belongs_to :shard + + scope :for_repository_storage, -> (repository_storage) { joins(:shard).where(shards: { name: repository_storage }) } + scope :excluding_repository_storage, -> (repository_storage) { joins(:shard).where.not(shards: { name: repository_storage }) } + validates :shard, presence: true end diff --git a/app/models/concerns/timebox.rb b/app/models/concerns/timebox.rb index 3e2cf9031d0..23fd73f2904 100644 --- a/app/models/concerns/timebox.rb +++ b/app/models/concerns/timebox.rb @@ -73,6 +73,32 @@ module Timebox end end + # A timebox is within the timeframe (start_date, end_date) if it overlaps + # with that timeframe: + # + # [ timeframe ] + # ----| ................ # Not overlapping + # |--| ................ # Not overlapping + # ------|............... # Overlapping + # -----------------------| # Overlapping + # ---------|............ # Overlapping + # |-----|............ # Overlapping + # |--------------| # Overlapping + # |--------------------| # Overlapping + # ...|-----|...... # Overlapping + # .........|-----| # Overlapping + # .........|--------- # Overlapping + # |-------------------- # Overlapping + # .........|--------| # Overlapping + # ...............|--| # Overlapping + # ............... |-| # Not Overlapping + # ............... |-- # Not Overlapping + # + # where: . = in timeframe + # ---| no start + # |--- no end + # |--| defined start and end + # scope :within_timeframe, -> (start_date, end_date) do where('start_date is not NULL or due_date is not NULL') .where('start_date is NULL or start_date <= ?', end_date) diff --git a/app/models/concerns/update_project_statistics.rb b/app/models/concerns/update_project_statistics.rb index a7028e18451..586f1dbb65c 100644 --- a/app/models/concerns/update_project_statistics.rb +++ b/app/models/concerns/update_project_statistics.rb @@ -80,10 +80,7 @@ module UpdateProjectStatistics run_after_commit do ProjectStatistics.increment_statistic( - project_id, self.class.project_statistics_name, delta) - - Namespaces::ScheduleAggregationWorker.perform_async( - project.namespace_id) + project, self.class.project_statistics_name, delta) end end end diff --git a/app/models/container_expiration_policy.rb b/app/models/container_expiration_policy.rb index b1dd720d908..641d244b665 100644 --- a/app/models/container_expiration_policy.rb +++ b/app/models/container_expiration_policy.rb @@ -3,6 +3,7 @@ class ContainerExpirationPolicy < ApplicationRecord include Schedulable include UsageStatistics + include EachBatch belongs_to :project, inverse_of: :container_expiration_policy @@ -19,6 +20,16 @@ class ContainerExpirationPolicy < ApplicationRecord scope :active, -> { where(enabled: true) } scope :preloaded, -> { preload(project: [:route]) } + def self.executable + runnable_schedules.where( + 'EXISTS (?)', + ContainerRepository.select(1) + .where( + 'container_repositories.project_id = container_expiration_policies.project_id' + ) + ) + end + def self.keep_n_options { 1 => _('%{tags} tag per image name') % { tags: 1 }, diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb index b0f7edac2f3..d97b8776085 100644 --- a/app/models/container_repository.rb +++ b/app/models/container_repository.rb @@ -107,6 +107,14 @@ class ContainerRepository < ApplicationRecord client.delete_repository_tag_by_name(self.path, name) end + def reset_expiration_policy_started_at! + update!(expiration_policy_started_at: nil) + end + + def start_expiration_policy! + update!(expiration_policy_started_at: Time.zone.now) + end + def self.build_from_path(path) self.new(project: path.repository_project, name: path.repository_name) diff --git a/app/models/data_list.rb b/app/models/data_list.rb index 2cee3447886..adad8e3013e 100644 --- a/app/models/data_list.rb +++ b/app/models/data_list.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true class DataList - def initialize(batch_ids, data_fields_hash, klass) - @batch_ids = batch_ids + def initialize(batch, data_fields_hash, klass) + @batch = batch @data_fields_hash = data_fields_hash @klass = klass end @@ -13,15 +13,15 @@ class DataList private - attr_reader :batch_ids, :data_fields_hash, :klass + attr_reader :batch, :data_fields_hash, :klass def columns data_fields_hash.keys << 'service_id' end def values - batch_ids.map do |row| - data_fields_hash.values << row['id'] + batch.map do |record| + data_fields_hash.values << record['id'] end end end diff --git a/app/models/deploy_token.rb b/app/models/deploy_token.rb index 395260b5201..9355d73fae9 100644 --- a/app/models/deploy_token.rb +++ b/app/models/deploy_token.rb @@ -78,6 +78,20 @@ class DeployToken < ApplicationRecord end end + def group + strong_memoize(:group) do + groups.first + end + end + + def accessible_projects + if project_type? + projects + elsif group_type? + group.all_projects + end + end + def holder strong_memoize(:holder) do if project_type? diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 3978620c74d..2d0d98136ec 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -46,6 +46,8 @@ class Deployment < ApplicationRecord scope :older_than, -> (deployment) { where('id < ?', deployment.id) } scope :with_deployable, -> { includes(:deployable).where('deployable_id IS NOT NULL') } + FINISHED_STATUSES = %i[success failed canceled].freeze + state_machine :status, initial: :created do event :run do transition created: :running @@ -63,27 +65,41 @@ class Deployment < ApplicationRecord transition any - [:canceled] => :canceled end - before_transition any => [:success, :failed, :canceled] do |deployment| + before_transition any => FINISHED_STATUSES do |deployment| deployment.finished_at = Time.current end - after_transition any => :success do |deployment| + after_transition any => :running do |deployment| + next unless deployment.project.ci_forward_deployment_enabled? + deployment.run_after_commit do - Deployments::SuccessWorker.perform_async(id) + Deployments::DropOlderDeploymentsWorker.perform_async(id) end end - after_transition any => [:success, :failed, :canceled] do |deployment| + after_transition any => :running do |deployment| deployment.run_after_commit do - Deployments::FinishedWorker.perform_async(id) + next unless Feature.enabled?(:ci_send_deployment_hook_when_start, deployment.project) + + Deployments::ExecuteHooksWorker.perform_async(id) end end - after_transition any => :running do |deployment| - next unless deployment.project.forward_deployment_enabled? + after_transition any => :success do |deployment| + deployment.run_after_commit do + Deployments::UpdateEnvironmentWorker.perform_async(id) + end + end + + after_transition any => FINISHED_STATUSES do |deployment| + deployment.run_after_commit do + Deployments::LinkMergeRequestWorker.perform_async(id) + end + end + after_transition any => FINISHED_STATUSES do |deployment| deployment.run_after_commit do - Deployments::ForwardDeploymentWorker.perform_async(id) + Deployments::ExecuteHooksWorker.perform_async(id) end end end @@ -273,7 +289,7 @@ class Deployment < ApplicationRecord SQL end - # Changes the status of a deployment and triggers the correspinding state + # Changes the status of a deployment and triggers the corresponding state # machine events. def update_status(status) case status diff --git a/app/models/deployment_merge_request.rb b/app/models/deployment_merge_request.rb index ff4d9f66202..b67f96906f5 100644 --- a/app/models/deployment_merge_request.rb +++ b/app/models/deployment_merge_request.rb @@ -3,4 +3,25 @@ class DeploymentMergeRequest < ApplicationRecord belongs_to :deployment, optional: false belongs_to :merge_request, optional: false + + def self.join_deployments_for_merge_requests + joins(deployment: :environment) + .where('deployment_merge_requests.merge_request_id = merge_requests.id') + end + + def self.by_deployment_id(id) + where('deployments.id = ?', id) + end + + def self.deployed_to(name) + where('environments.name = ?', name) + end + + def self.deployed_after(time) + where('deployments.finished_at > ?', time) + end + + def self.deployed_before(time) + where('deployments.finished_at < ?', time) + end end diff --git a/app/models/design_management/design.rb b/app/models/design_management/design.rb index 57bb250829d..62e4bd6cebc 100644 --- a/app/models/design_management/design.rb +++ b/app/models/design_management/design.rb @@ -167,6 +167,10 @@ module DesignManagement end end + def self.build_full_path(issue, design) + File.join(DesignManagement.designs_directory, "issue-#{issue.iid}", design.filename) + end + def to_ability_name 'design' end @@ -180,7 +184,7 @@ module DesignManagement end def full_path - @full_path ||= File.join(DesignManagement.designs_directory, "issue-#{issue.iid}", filename) + @full_path ||= self.class.build_full_path(issue, self) end def diff_refs @@ -224,6 +228,10 @@ module DesignManagement !interloper.exists? end + def notes_with_associations + notes.includes(:author) + end + private def head_version diff --git a/app/models/design_management/design_at_version.rb b/app/models/design_management/design_at_version.rb index b4cafb93c2c..211211144f4 100644 --- a/app/models/design_management/design_at_version.rb +++ b/app/models/design_management/design_at_version.rb @@ -21,10 +21,6 @@ module DesignManagement @design, @version = design, version end - def self.instantiate(attrs) - new(attrs).tap { |obj| obj.validate! } - end - # The ID, needed by GraphQL types and as part of the Lazy-fetch # protocol, includes information about both the design and the version. # diff --git a/app/models/design_management/design_collection.rb b/app/models/design_management/design_collection.rb index c48b36588c9..6deba14a6ba 100644 --- a/app/models/design_management/design_collection.rb +++ b/app/models/design_management/design_collection.rb @@ -5,6 +5,7 @@ module DesignManagement attr_reader :issue delegate :designs, :project, to: :issue + delegate :empty?, to: :designs state_machine :copy_state, initial: :ready, namespace: :copy do after_transition any => any, do: :update_stored_copy_state! diff --git a/app/models/diff_viewer/rich.rb b/app/models/diff_viewer/rich.rb index 5caefa2031c..0d94d8f773b 100644 --- a/app/models/diff_viewer/rich.rb +++ b/app/models/diff_viewer/rich.rb @@ -6,7 +6,7 @@ module DiffViewer included do self.type = :rich - self.switcher_icon = 'file-text-o' + self.switcher_icon = 'doc-text' self.switcher_title = _('rendered diff') end end diff --git a/app/models/environment.rb b/app/models/environment.rb index cfdcb0499e6..66613869915 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -4,12 +4,15 @@ class Environment < ApplicationRecord include Gitlab::Utils::StrongMemoize include ReactiveCaching include FastDestroyAll::Helpers + include Presentable self.reactive_cache_refresh_interval = 1.minute self.reactive_cache_lifetime = 55.seconds self.reactive_cache_hard_limit = 10.megabytes self.reactive_cache_work_type = :external_dependency + PRODUCTION_ENVIRONMENT_IDENTIFIERS = %w[prod production].freeze + belongs_to :project, required: true use_fast_destroy :all_deployments @@ -67,6 +70,7 @@ class Environment < ApplicationRecord scope :order_by_last_deployed_at_desc, -> do order(Gitlab::Database.nulls_last_order("(#{max_deployment_id_sql})", 'DESC')) end + scope :order_by_name, -> { order('environments.name ASC') } scope :in_review_folder, -> { where(environment_type: "review") } scope :for_name, -> (name) { where(name: name) } @@ -86,6 +90,7 @@ class Environment < ApplicationRecord scope :with_rank, -> do select('environments.*, rank() OVER (PARTITION BY project_id ORDER BY id DESC)') end + scope :for_id, -> (id) { where(id: id) } state_machine :state, initial: :available do event :start do @@ -118,6 +123,10 @@ class Environment < ApplicationRecord pluck(:name) end + def self.pluck_unique_names + pluck('DISTINCT(environments.name)') + end + def self.find_or_create_by_name(name) find_or_create_by(name: name) end @@ -211,7 +220,7 @@ class Environment < ApplicationRecord end def update_merge_request_metrics? - folder_name == "production" + PRODUCTION_ENVIRONMENT_IDENTIFIERS.include?(folder_name.downcase) end def ref_path diff --git a/app/models/environment_status.rb b/app/models/environment_status.rb index 46e41c22139..55ea4e2fe18 100644 --- a/app/models/environment_status.rb +++ b/app/models/environment_status.rb @@ -72,14 +72,6 @@ class EnvironmentStatus .merge_request_diff_files.where(deleted_file: false) end - def changed_paths - changes.map { |change| change[:path] } - end - - def changed_urls - changes.map { |change| change[:external_url] } - end - def has_route_map? project.route_map_for(sha).present? end diff --git a/app/models/event.rb b/app/models/event.rb index 92609144576..671def16151 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -242,6 +242,8 @@ class Event < ApplicationRecord target if note? end + # rubocop: disable Metrics/CyclomaticComplexity + # rubocop: disable Metrics/PerceivedComplexity def action_name if push_action? push_action_name @@ -267,10 +269,14 @@ class Event < ApplicationRecord 'updated' elsif created_project_action? created_project_action_name + elsif approved_action? + 'approved' else "opened" end end + # rubocop: enable Metrics/CyclomaticComplexity + # rubocop: enable Metrics/PerceivedComplexity def target_iid target.respond_to?(:iid) ? target.iid : target_id @@ -323,14 +329,6 @@ class Event < ApplicationRecord end end - def note_target_type - if target.noteable_type.present? - target.noteable_type.titleize - else - "Wall" - end.downcase - end - def body? if push_action? push_with_commits? diff --git a/app/models/global_label.rb b/app/models/global_label.rb deleted file mode 100644 index 7c020dd3b3d..00000000000 --- a/app/models/global_label.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -class GlobalLabel - include Presentable - - attr_accessor :title, :labels - alias_attribute :name, :title - - delegate :color, :text_color, :description, :scoped_label?, to: :@first_label - - def for_display - @first_label - end - - def self.build_collection(labels) - labels = labels.group_by(&:title) - - labels.map do |title, labels| - new(title, labels) - end - end - - def initialize(title, labels) - @title = title - @labels = labels - @first_label = labels.find { |lbl| lbl.description.present? } || labels.first - end - - def present(attributes) - super(attributes.merge(presenter_class: ::LabelPresenter)) - end -end diff --git a/app/models/group.rb b/app/models/group.rb index c0f145997cc..74f7efd253d 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -15,11 +15,10 @@ class Group < Namespace include WithUploads include Gitlab::Utils::StrongMemoize include GroupAPICompatibility + include EachBatch ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT = 10 - UpdateSharedRunnersError = Class.new(StandardError) - has_many :all_group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source, class_name: 'GroupMember' # rubocop:disable Cop/ActiveRecordDependent has_many :group_members, -> { where(requested_at: nil).where.not(members: { access_level: Gitlab::Access::MINIMAL_ACCESS }) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent alias_method :members, :group_members @@ -77,6 +76,7 @@ class Group < Namespace validate :visibility_level_allowed_by_projects validate :visibility_level_allowed_by_sub_groups validate :visibility_level_allowed_by_parent + validate :two_factor_authentication_allowed validates :variables, variable_duplicates: true validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 } @@ -140,6 +140,15 @@ class Group < Namespace end end + def without_integration(integration) + services = Service + .select('1') + .where('services.group_id = namespaces.id') + .where(type: integration.type) + + where('NOT EXISTS (?)', services) + end + private def public_to_user_arel(user) @@ -348,6 +357,7 @@ class Group < Namespace end group_hierarchy_members = GroupMember.active_without_invites_and_requests + .non_minimal_access .where(source_id: source_ids) GroupMember.from_union([group_hierarchy_members, @@ -528,57 +538,37 @@ class Group < Namespace preloader.preload(self, shared_with_group_links: [shared_with_group: :route]) end - def shared_runners_allowed? - shared_runners_enabled? || allow_descendants_override_disabled_shared_runners? - end - - def parent_allows_shared_runners? - return true unless has_parent? + def update_shared_runners_setting!(state) + raise ArgumentError unless SHARED_RUNNERS_SETTINGS.include?(state) - parent.shared_runners_allowed? + case state + when 'disabled_and_unoverridable' then disable_shared_runners! # also disallows override + when 'disabled_with_override' then disable_shared_runners_and_allow_override! + when 'enabled' then enable_shared_runners! # set both to true + end end - def parent_enabled_shared_runners? - return true unless has_parent? - - parent.shared_runners_enabled? + def default_owner + owners.first || parent&.default_owner || owner end - def enable_shared_runners! - raise UpdateSharedRunnersError, 'Shared Runners disabled for the parent group' unless parent_enabled_shared_runners? - - update_column(:shared_runners_enabled, true) + def default_branch_name + namespace_settings&.default_branch_name end - def disable_shared_runners! - group_ids = self_and_descendants - return if group_ids.empty? - - Group.by_id(group_ids).update_all(shared_runners_enabled: false) - - all_projects.update_all(shared_runners_enabled: false) + def access_level_roles + GroupMember.access_level_roles end - def allow_descendants_override_disabled_shared_runners! - raise UpdateSharedRunnersError, 'Shared Runners enabled' if shared_runners_enabled? - raise UpdateSharedRunnersError, 'Group level shared Runners not allowed' unless parent_allows_shared_runners? - - update_column(:allow_descendants_override_disabled_shared_runners, true) + def access_level_values + access_level_roles.values end - def disallow_descendants_override_disabled_shared_runners! - raise UpdateSharedRunnersError, 'Shared Runners enabled' if shared_runners_enabled? - - group_ids = self_and_descendants - return if group_ids.empty? - - Group.by_id(group_ids).update_all(allow_descendants_override_disabled_shared_runners: false) - - all_projects.update_all(shared_runners_enabled: false) - end + def parent_allows_two_factor_authentication? + return true unless has_parent? - def default_owner - owners.first || parent&.default_owner || owner + ancestor_settings = ancestors.find_by(parent_id: nil).namespace_settings + ancestor_settings.allow_mfa_for_subgroups end private @@ -611,6 +601,15 @@ class Group < Namespace errors.add(:visibility_level, "#{visibility} is not allowed since there are sub-groups with higher visibility.") end + def two_factor_authentication_allowed + return unless has_parent? + return unless require_two_factor_authentication + + return if parent_allows_two_factor_authentication? + + errors.add(:require_two_factor_authentication, _('is forbidden by a top-level group')) + end + def members_from_self_and_ancestor_group_shares group_group_link_table = GroupGroupLink.arel_table group_member_table = GroupMember.arel_table @@ -658,6 +657,45 @@ class Group < Namespace .new(Group.where(id: group_ids)) .base_and_descendants end + + def disable_shared_runners! + update!( + shared_runners_enabled: false, + allow_descendants_override_disabled_shared_runners: false) + + group_ids = descendants + unless group_ids.empty? + Group.by_id(group_ids).update_all( + shared_runners_enabled: false, + allow_descendants_override_disabled_shared_runners: false) + end + + all_projects.update_all(shared_runners_enabled: false) + end + + def disable_shared_runners_and_allow_override! + # enabled -> disabled_with_override + if shared_runners_enabled? + update!( + shared_runners_enabled: false, + allow_descendants_override_disabled_shared_runners: true) + + group_ids = descendants + unless group_ids.empty? + Group.by_id(group_ids).update_all(shared_runners_enabled: false) + end + + all_projects.update_all(shared_runners_enabled: false) + + # disabled_and_unoverridable -> disabled_with_override + else + update!(allow_descendants_override_disabled_shared_runners: true) + end + end + + def enable_shared_runners! + update!(shared_runners_enabled: true) + end end Group.prepend_if_ee('EE::Group') diff --git a/app/models/group_import_state.rb b/app/models/group_import_state.rb index d22c1ac5550..89602e40357 100644 --- a/app/models/group_import_state.rb +++ b/app/models/group_import_state.rb @@ -4,8 +4,9 @@ class GroupImportState < ApplicationRecord self.primary_key = :group_id belongs_to :group, inverse_of: :import_state + belongs_to :user, optional: false - validates :group, :status, presence: true + validates :group, :status, :user, presence: true validates :jid, presence: true, if: -> { started? || finished? } state_machine :status, initial: :created do diff --git a/app/models/incident_management/project_incident_management_setting.rb b/app/models/incident_management/project_incident_management_setting.rb index c79acdb685f..4887265be88 100644 --- a/app/models/incident_management/project_incident_management_setting.rb +++ b/app/models/incident_management/project_incident_management_setting.rb @@ -51,3 +51,5 @@ module IncidentManagement end end end + +IncidentManagement::ProjectIncidentManagementSetting.prepend_if_ee('EE::IncidentManagement::ProjectIncidentManagementSetting') diff --git a/app/models/issuable_severity.rb b/app/models/issuable_severity.rb index d68b3dc48ee..35d03a544bd 100644 --- a/app/models/issuable_severity.rb +++ b/app/models/issuable_severity.rb @@ -2,6 +2,13 @@ class IssuableSeverity < ApplicationRecord DEFAULT = 'unknown' + SEVERITY_LABELS = { + unknown: 'Unknown', + low: 'Low - S4', + medium: 'Medium - S3', + high: 'High - S2', + critical: 'Critical - S1' + }.freeze belongs_to :issue diff --git a/app/models/issue.rb b/app/models/issue.rb index 5a5de371301..5291b7890b6 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -19,6 +19,8 @@ class Issue < ApplicationRecord include WhereComposite include StateEventable include IdInOrdered + include Presentable + include IssueAvailableFeatures DueDateStruct = Struct.new(:title, :name).freeze NoDueDate = DueDateStruct.new('No Due Date', '0').freeze @@ -54,6 +56,7 @@ class Issue < ApplicationRecord dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_many :issue_assignees + has_many :issue_email_participants has_many :assignees, class_name: "User", through: :issue_assignees has_many :zoom_meetings has_many :user_mentions, class_name: "IssueUserMention", dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent @@ -87,6 +90,7 @@ class Issue < ApplicationRecord alias_method :issuing_parent, :project scope :in_projects, ->(project_ids) { where(project_id: project_ids) } + scope :not_in_projects, ->(project_ids) { where.not(project_id: project_ids) } scope :with_due_date, -> { where.not(due_date: nil) } scope :without_due_date, -> { where(due_date: nil) } @@ -101,6 +105,8 @@ class Issue < ApplicationRecord scope :order_relative_position_asc, -> { reorder(::Gitlab::Database.nulls_last_order('relative_position', 'ASC')) } scope :order_closed_date_desc, -> { reorder(closed_at: :desc) } scope :order_created_at_desc, -> { reorder(created_at: :desc) } + scope :order_severity_asc, -> { includes(:issuable_severity).order('issuable_severities.severity ASC NULLS FIRST') } + scope :order_severity_desc, -> { includes(:issuable_severity).order('issuable_severities.severity DESC NULLS LAST') } scope :preload_associated_models, -> { preload(:assignees, :labels, project: :namespace) } scope :with_web_entity_associations, -> { preload(:author, :project) } @@ -122,6 +128,7 @@ class Issue < ApplicationRecord scope :counts_by_state, -> { reorder(nil).group(:state_id).count } scope :service_desk, -> { where(author: ::User.support_bot) } + scope :inc_relations_for_view, -> { includes(author: :status) } # An issue can be uniquely identified by project_id and iid # Takes one or more sets of composite IDs, expressed as hash-like records of @@ -145,6 +152,7 @@ class Issue < ApplicationRecord after_commit :expire_etag_cache, unless: :importing? after_save :ensure_metrics, unless: :importing? + after_create_commit :record_create_action, unless: :importing? attr_spammable :title, spam_title: true attr_spammable :description, spam_description: true @@ -232,6 +240,8 @@ class Issue < ApplicationRecord when 'due_date', 'due_date_asc' then order_due_date_asc.with_order_id_desc when 'due_date_desc' then order_due_date_desc.with_order_id_desc when 'relative_position', 'relative_position_asc' then order_relative_position_asc.with_order_id_desc + when 'severity_asc' then order_severity_asc.with_order_id_desc + when 'severity_desc' then order_severity_desc.with_order_id_desc else super end @@ -413,6 +423,10 @@ class Issue < ApplicationRecord IssueLink.inverse_link_type(type) end + def relocation_target + moved_to || duplicated_to + end + private def ensure_metrics @@ -420,6 +434,10 @@ class Issue < ApplicationRecord metrics.record! end + def record_create_action + Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_created_action(author: author) + end + # Returns `true` if the given User can read the current Issue. # # This method duplicates the same check of issue_policy.rb diff --git a/app/models/issue_assignee.rb b/app/models/issue_assignee.rb index e57acbae546..7f3d552b3d9 100644 --- a/app/models/issue_assignee.rb +++ b/app/models/issue_assignee.rb @@ -8,6 +8,7 @@ class IssueAssignee < ApplicationRecord scope :in_projects, ->(project_ids) { joins(:issue).where("issues.project_id in (?)", project_ids) } scope :on_issues, ->(issue_ids) { where(issue_id: issue_ids) } + scope :for_assignee, ->(user) { where(assignee: user) } end IssueAssignee.prepend_if_ee('EE::IssueAssignee') diff --git a/app/models/issue_email_participant.rb b/app/models/issue_email_participant.rb new file mode 100644 index 00000000000..8eb9b6a8152 --- /dev/null +++ b/app/models/issue_email_participant.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class IssueEmailParticipant < ApplicationRecord + belongs_to :issue + + validates :email, presence: true, uniqueness: { scope: [:issue_id] } + validates :issue, presence: true + validate :validate_email_format + + def validate_email_format + self.errors.add(:email, I18n.t(:invalid, scope: 'valid_email.validations.email')) unless ValidateEmail.valid?(self.email) + end +end diff --git a/app/models/iteration.rb b/app/models/iteration.rb index d223c80fca0..bd245de411c 100644 --- a/app/models/iteration.rb +++ b/app/models/iteration.rb @@ -94,13 +94,25 @@ class Iteration < ApplicationRecord private + def parent_group + group || project.group + end + def start_or_due_dates_changed? start_date_changed? || due_date_changed? end - # ensure dates do not overlap with other Iterations in the same group/project + # ensure dates do not overlap with other Iterations in the same group/project tree def dates_do_not_overlap - return unless resource_parent.iterations.where.not(id: self.id).within_timeframe(start_date, due_date).exists? + iterations = if parent_group.present? && resource_parent.is_a?(Project) + Iteration.where(group: parent_group.self_and_ancestors).or(project.iterations) + elsif parent_group.present? + Iteration.where(group: parent_group.self_and_ancestors) + else + project.iterations + end + + return unless iterations.where.not(id: self.id).within_timeframe(start_date, due_date).exists? errors.add(:base, s_("Iteration|Dates cannot overlap with other existing Iterations")) end diff --git a/app/models/member.rb b/app/models/member.rb index 7ea9caa45d3..498e03b2c1a 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -80,7 +80,10 @@ class Member < ApplicationRecord scope :request, -> { where.not(requested_at: nil) } scope :non_request, -> { where(requested_at: nil) } - scope :not_accepted_invitations_by_user, -> (user) { invite.where(invite_accepted_at: nil, created_by: user) } + scope :not_accepted_invitations, -> { invite.where(invite_accepted_at: nil) } + scope :not_accepted_invitations_by_user, -> (user) { not_accepted_invitations.where(created_by: user) } + scope :not_expired, -> (today = Date.current) { where(arel_table[:expires_at].gt(today).or(arel_table[:expires_at].eq(nil))) } + scope :last_ten_days_excluding_today, -> (today = Date.current) { where(created_at: (today - 10).beginning_of_day..(today - 1).end_of_day) } scope :has_access, -> { active.where('access_level > 0') } @@ -372,6 +375,14 @@ class Member < ApplicationRecord send_invite end + def send_invitation_reminder(reminder_index) + return unless invite? + + generate_invite_token! unless @raw_invite_token + + run_after_commit_or_now { notification_service.invite_member_reminder(self, @raw_invite_token, reminder_index) } + end + def create_notification_setting user.notification_settings.find_or_create_for(source) end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 3fdc501644d..24541ba3218 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -31,6 +31,7 @@ class MergeRequest < ApplicationRecord self.reactive_cache_key = ->(model) { [model.project.id, model.iid] } self.reactive_cache_refresh_interval = 10.minutes self.reactive_cache_lifetime = 10.minutes + self.reactive_cache_work_type = :no_dependency SORTING_PREFERENCE_FIELD = :merge_requests_sort @@ -121,6 +122,8 @@ class MergeRequest < ApplicationRecord # when creating new merge request attr_accessor :can_be_created, :compare_commits, :diff_options, :compare + participant :reviewers + # Keep states definition to be evaluated before the state_machine block to avoid spec failures. # If this gets evaluated after, the `merged` and `locked` states which are overrided can be nil. def self.available_state_names @@ -255,11 +258,7 @@ class MergeRequest < ApplicationRecord scope :join_project, -> { joins(:target_project) } scope :join_metrics, -> do query = joins(:metrics) - - if Feature.enabled?(:improved_mr_merged_at_queries, default_enabled: true) - query = query.where(MergeRequest.arel_table[:target_project_id].eq(MergeRequest::Metrics.arel_table[:target_project_id])) - end - + query = query.where(MergeRequest.arel_table[:target_project_id].eq(MergeRequest::Metrics.arel_table[:target_project_id])) query end scope :references_project, -> { references(:target_project) } @@ -271,6 +270,8 @@ class MergeRequest < ApplicationRecord metrics: [:latest_closed_by, :merged_by]) } + scope :with_csv_entity_associations, -> { preload(:assignees, :approved_by_users, :author, :milestone, metrics: [:merged_by]) } + scope :by_target_branch_wildcard, ->(wildcard_branch_name) do where("target_branch LIKE ?", ApplicationRecord.sanitize_sql_like(wildcard_branch_name).tr('*', '%')) end @@ -629,7 +630,7 @@ class MergeRequest < ApplicationRecord def diff_size # Calling `merge_request_diff.diffs.real_size` will also perform # highlighting, which we don't need here. - merge_request_diff&.real_size || diff_stats&.real_size || diffs.real_size + merge_request_diff&.real_size || diff_stats&.real_size(project: project) || diffs.real_size end def modified_paths(past_merge_request_diff: nil, fallback_on_overflow: false) @@ -928,7 +929,7 @@ class MergeRequest < ApplicationRecord # rubocop: enable CodeReuse/ServiceClass def diffable_merge_ref? - can_be_merged? && merge_ref_head.present? + merge_ref_head.present? && (Feature.enabled?(:display_merge_conflicts_in_diff, project) || can_be_merged?) end # Returns boolean indicating the merge_status should be rechecked in order to @@ -1301,6 +1302,14 @@ class MergeRequest < ApplicationRecord unlock_mr end + def update_and_mark_in_progress_merge_commit_sha(commit_id) + self.update(in_progress_merge_commit_sha: commit_id) + # Since another process checks for matching merge request, we need + # to make it possible to detect whether the query should go to the + # primary. + target_project.mark_primary_write_location + end + def diverged_commits_count cache = Rails.cache.read(:"merge_request_#{id}_diverged_commits") @@ -1375,8 +1384,6 @@ class MergeRequest < ApplicationRecord end def has_coverage_reports? - return false unless Feature.enabled?(:coverage_report_view, project, default_enabled: true) - actual_head_pipeline&.has_coverage_reports? end @@ -1511,6 +1518,7 @@ class MergeRequest < ApplicationRecord metrics&.merged_at || merge_event&.created_at || + resource_state_events.find_by(state: :merged)&.created_at || notes.system.reorder(nil).find_by(note: 'merged')&.created_at end end @@ -1591,6 +1599,12 @@ class MergeRequest < ApplicationRecord .find_by(sha: diff_base_sha, ref: target_branch) end + def merge_base_pipeline + @merge_base_pipeline ||= project.ci_pipelines + .order(id: :desc) + .find_by(sha: actual_head_pipeline.target_sha, ref: target_branch) + end + def discussions_rendered_on_frontend? true end @@ -1680,6 +1694,10 @@ class MergeRequest < ApplicationRecord Feature.enabled?(:merge_request_reviewers, project) end + def allows_multiple_reviewers? + false + end + private def with_rebase_lock diff --git a/app/models/merge_request_context_commit.rb b/app/models/merge_request_context_commit.rb index a2982a5dd73..59cc82cfaf5 100644 --- a/app/models/merge_request_context_commit.rb +++ b/app/models/merge_request_context_commit.rb @@ -22,8 +22,8 @@ class MergeRequestContextCommit < ApplicationRecord end # create MergeRequestContextCommit by given commit sha and it's diff file record - def self.bulk_insert(*args) - Gitlab::Database.bulk_insert('merge_request_context_commits', *args) # rubocop:disable Gitlab/BulkInsert + def self.bulk_insert(rows, **args) + Gitlab::Database.bulk_insert('merge_request_context_commits', rows, **args) # rubocop:disable Gitlab/BulkInsert end def to_commit diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 880e3cc1ba5..24809141570 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -106,6 +106,17 @@ class MergeRequestDiff < ApplicationRecord joins(merge_request: :metrics).where(condition) end + scope :latest_diff_for_merge_requests, -> (merge_requests) do + inner_select = MergeRequestDiff + .default_scoped + .distinct + .select("FIRST_VALUE(id) OVER (PARTITION BY merge_request_id ORDER BY created_at DESC) as id") + .where(merge_request: merge_requests) + + joins("INNER JOIN (#{inner_select.to_sql}) latest_diffs ON latest_diffs.id = merge_request_diffs.id") + .includes(:merge_request_diff_commits) + end + class << self def ids_for_external_storage_migration(limit:) return [] unless Gitlab.config.external_diffs.enabled @@ -280,7 +291,13 @@ class MergeRequestDiff < ApplicationRecord end def commit_shas(limit: nil) - merge_request_diff_commits.limit(limit).pluck(:sha) + if association(:merge_request_diff_commits).loaded? + sorted_diff_commits = merge_request_diff_commits.sort_by { |diff_commit| [diff_commit.id, diff_commit.relative_order] } + sorted_diff_commits = sorted_diff_commits.take(limit) if limit + sorted_diff_commits.map(&:sha) + else + merge_request_diff_commits.limit(limit).pluck(:sha) + end end def includes_any_commits?(shas) @@ -509,6 +526,8 @@ class MergeRequestDiff < ApplicationRecord end def encode_in_base64?(diff_text) + return false if diff_text.nil? + (diff_text.encoding == Encoding::BINARY && !diff_text.ascii_only?) || diff_text.include?("\0") end @@ -536,7 +555,7 @@ class MergeRequestDiff < ApplicationRecord rows.each do |row| data = row.delete(:diff) row[:external_diff_offset] = file.pos - row[:external_diff_size] = data.bytesize + row[:external_diff_size] = data&.bytesize || 0 file.write(data) end @@ -651,7 +670,7 @@ class MergeRequestDiff < ApplicationRecord if compare.commits.empty? new_attributes[:state] = :empty else - diff_collection = compare.diffs(Commit.max_diff_options) + diff_collection = compare.diffs(Commit.max_diff_options(project: merge_request.project)) new_attributes[:real_size] = diff_collection.real_size if diff_collection.any? diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 55326b9a282..0a315ba8db2 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -46,6 +46,10 @@ class Milestone < ApplicationRecord state :active end + def self.min_chars_for_partial_matching + 2 + end + def self.reference_prefix '%' end diff --git a/app/models/milestone_release.rb b/app/models/milestone_release.rb index 0a6165c8254..2f2bf91e436 100644 --- a/app/models/milestone_release.rb +++ b/app/models/milestone_release.rb @@ -11,6 +11,10 @@ class MilestoneRelease < ApplicationRecord def same_project_between_milestone_and_release return if milestone&.project_id == release&.project_id + return if milestone&.group_id + errors.add(:base, _('Release does not have the same project as the milestone')) end end + +MilestoneRelease.prepend_if_ee('EE::MilestoneRelease') diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 527fa9d52d0..fd31042c2f6 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -18,6 +18,8 @@ class Namespace < ApplicationRecord # Android repo (15) + some extra backup. NUMBER_OF_ANCESTORS_ALLOWED = 20 + SHARED_RUNNERS_SETTINGS = %w[disabled_and_unoverridable disabled_with_override enabled].freeze + cache_markdown_field :description, pipeline: :description has_many :projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent @@ -59,6 +61,8 @@ class Namespace < ApplicationRecord validates :max_artifacts_size, numericality: { only_integer: true, greater_than: 0, allow_nil: true } validate :nesting_level_allowed + validate :changing_shared_runners_enabled_is_allowed + validate :changing_allow_descendants_override_disabled_shared_runners_is_allowed validates_associated :runners @@ -79,6 +83,7 @@ class Namespace < ApplicationRecord scope :for_user, -> { where('type IS NULL') } scope :sort_by_type, -> { order(Gitlab::Database.nulls_first_order(:type)) } + scope :include_route, -> { includes(:route) } scope :with_statistics, -> do joins('LEFT JOIN project_statistics ps ON ps.namespace_id = namespaces.id') @@ -278,7 +283,11 @@ class Namespace < ApplicationRecord # Includes projects from this namespace and projects from all subgroups # that belongs to this namespace def all_projects - Project.inside_path(full_path) + if Feature.enabled?(:recursive_approach_for_all_projects) + Project.where(namespace: self_and_descendants) + else + Project.inside_path(full_path) + end end # Includes pipelines from this namespace and pipelines from all subgroups @@ -378,6 +387,52 @@ class Namespace < ApplicationRecord actual_plan.name end + def changing_shared_runners_enabled_is_allowed + return unless Feature.enabled?(:disable_shared_runners_on_group, default_enabled: true) + return unless new_record? || changes.has_key?(:shared_runners_enabled) + + if shared_runners_enabled && has_parent? && parent.shared_runners_setting == 'disabled_and_unoverridable' + errors.add(:shared_runners_enabled, _('cannot be enabled because parent group has shared Runners disabled')) + end + end + + def changing_allow_descendants_override_disabled_shared_runners_is_allowed + return unless Feature.enabled?(:disable_shared_runners_on_group, default_enabled: true) + return unless new_record? || changes.has_key?(:allow_descendants_override_disabled_shared_runners) + + if shared_runners_enabled && !new_record? + errors.add(:allow_descendants_override_disabled_shared_runners, _('cannot be changed if shared runners are enabled')) + end + + if allow_descendants_override_disabled_shared_runners && has_parent? && parent.shared_runners_setting == 'disabled_and_unoverridable' + errors.add(:allow_descendants_override_disabled_shared_runners, _('cannot be enabled because parent group does not allow it')) + end + end + + def shared_runners_setting + if shared_runners_enabled + 'enabled' + else + if allow_descendants_override_disabled_shared_runners + 'disabled_with_override' + else + 'disabled_and_unoverridable' + end + end + end + + def shared_runners_setting_higher_than?(other_setting) + if other_setting == 'enabled' + false + elsif other_setting == 'disabled_with_override' + shared_runners_setting == 'enabled' + elsif other_setting == 'disabled_and_unoverridable' + shared_runners_setting == 'enabled' || shared_runners_setting == 'disabled_with_override' + else + raise ArgumentError + end + end + private def all_projects_with_pages diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb index 53bfa3d979e..6f31208f28b 100644 --- a/app/models/namespace_setting.rb +++ b/app/models/namespace_setting.rb @@ -3,7 +3,26 @@ class NamespaceSetting < ApplicationRecord belongs_to :namespace, inverse_of: :namespace_settings + validate :default_branch_name_content + validate :allow_mfa_for_group + + NAMESPACE_SETTINGS_PARAMS = [:default_branch_name].freeze + self.primary_key = :namespace_id + + def default_branch_name_content + return if default_branch_name.nil? + + if default_branch_name.blank? + errors.add(:default_branch_name, "can not be an empty string") + end + end + + def allow_mfa_for_group + if namespace&.subgroup? && allow_mfa_for_subgroups == false + errors.add(:allow_mfa_for_subgroups, _('is not allowed since the group is not top-level group.')) + end + end end NamespaceSetting.prepend_if_ee('EE::NamespaceSetting') diff --git a/app/models/note.rb b/app/models/note.rb index 812d77d5f86..954843505d4 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -322,8 +322,6 @@ class Note < ApplicationRecord end def contributor? - return false unless ::Feature.enabled?(:show_contributor_on_note, project) - project&.team&.contributor?(self.author_id) end diff --git a/app/models/notification_reason.rb b/app/models/notification_reason.rb index a7967239417..c227626af9e 100644 --- a/app/models/notification_reason.rb +++ b/app/models/notification_reason.rb @@ -5,6 +5,7 @@ class NotificationReason OWN_ACTIVITY = 'own_activity' ASSIGNED = 'assigned' + REVIEW_REQUESTED = 'review_requested' MENTIONED = 'mentioned' SUBSCRIBED = 'subscribed' @@ -12,6 +13,7 @@ class NotificationReason REASON_PRIORITY = [ OWN_ACTIVITY, ASSIGNED, + REVIEW_REQUESTED, MENTIONED, SUBSCRIBED ].freeze diff --git a/app/models/notification_recipient.rb b/app/models/notification_recipient.rb index 6a6b2bb1b58..79a84231083 100644 --- a/app/models/notification_recipient.rb +++ b/app/models/notification_recipient.rb @@ -5,7 +5,7 @@ class NotificationRecipient attr_reader :user, :type, :reason - def initialize(user, type, **opts) + def initialize(user, type, opts = {}) unless NotificationSetting.levels.key?(type) || type == :subscription raise ArgumentError, "invalid type: #{type.inspect}" end diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index c003a20f0fc..6066046a722 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -43,6 +43,7 @@ class NotificationSetting < ApplicationRecord :reopen_merge_request, :close_merge_request, :reassign_merge_request, + :change_reviewer_merge_request, :merge_merge_request, :failed_pipeline, :fixed_pipeline, diff --git a/app/models/operations/feature_flags/strategy.rb b/app/models/operations/feature_flags/strategy.rb index ff68af9741e..c70e10c72d5 100644 --- a/app/models/operations/feature_flags/strategy.rb +++ b/app/models/operations/feature_flags/strategy.rb @@ -6,14 +6,17 @@ module Operations STRATEGY_DEFAULT = 'default' STRATEGY_GITLABUSERLIST = 'gitlabUserList' STRATEGY_GRADUALROLLOUTUSERID = 'gradualRolloutUserId' + STRATEGY_FLEXIBLEROLLOUT = 'flexibleRollout' STRATEGY_USERWITHID = 'userWithId' STRATEGIES = { STRATEGY_DEFAULT => [].freeze, STRATEGY_GITLABUSERLIST => [].freeze, STRATEGY_GRADUALROLLOUTUSERID => %w[groupId percentage].freeze, + STRATEGY_FLEXIBLEROLLOUT => %w[groupId rollout stickiness].freeze, STRATEGY_USERWITHID => ['userIds'].freeze }.freeze USERID_MAX_LENGTH = 256 + STICKINESS_SETTINGS = %w[DEFAULT USERID SESSIONID RANDOM].freeze self.table_name = 'operations_strategies' @@ -67,16 +70,25 @@ module Operations case name when STRATEGY_GRADUALROLLOUTUSERID gradual_rollout_user_id_parameters_validation + when STRATEGY_FLEXIBLEROLLOUT + flexible_rollout_parameters_validation when STRATEGY_USERWITHID FeatureFlagUserXidsValidator.validate_user_xids(self, :parameters, parameters['userIds'], 'userIds') end end + def within_range?(value, min, max) + return false unless value.is_a?(String) + return false unless value.match?(/\A\d+\z/) + + value.to_i.between?(min, max) + end + def gradual_rollout_user_id_parameters_validation percentage = parameters['percentage'] group_id = parameters['groupId'] - unless percentage.is_a?(String) && percentage.match(/\A[1-9]?[0-9]\z|\A100\z/) + unless within_range?(percentage, 0, 100) parameters_error('percentage must be a string between 0 and 100 inclusive') end @@ -85,6 +97,25 @@ module Operations end end + def flexible_rollout_parameters_validation + stickiness = parameters['stickiness'] + group_id = parameters['groupId'] + rollout = parameters['rollout'] + + unless STICKINESS_SETTINGS.include?(stickiness) + options = STICKINESS_SETTINGS.to_sentence(last_word_connector: ', or ') + parameters_error("stickiness parameter must be #{options}") + end + + unless group_id.is_a?(String) && group_id.match(/\A[a-z]{1,32}\z/) + parameters_error('groupId parameter is invalid') + end + + unless within_range?(rollout, 0, 100) + parameters_error('rollout must be a string between 0 and 100 inclusive') + end + end + def parameters_error(message) errors.add(:parameters, message) false diff --git a/app/models/packages/event.rb b/app/models/packages/event.rb new file mode 100644 index 00000000000..f1d0af64ccd --- /dev/null +++ b/app/models/packages/event.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class Packages::Event < ApplicationRecord + belongs_to :package, optional: true + + EVENT_SCOPES = ::Packages::Package.package_types.merge(container: 1000, tag: 1001).freeze + + enum event_scope: EVENT_SCOPES + + enum event_type: { + push_package: 0, + delete_package: 1, + pull_package: 2, + search_package: 3, + list_package: 4, + list_repositories: 5, + delete_repository: 6, + delete_tag: 7, + delete_tag_bulk: 8, + list_tags: 9, + cli_metadata: 10 + } + + enum originator_type: { user: 0, deploy_token: 1, guest: 2 } +end diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb index bda11160957..a57d640ddc0 100644 --- a/app/models/packages/package.rb +++ b/app/models/packages/package.rb @@ -26,7 +26,7 @@ class Packages::Package < ApplicationRecord validates :project, presence: true validates :name, presence: true - validates :name, format: { with: Gitlab::Regex.package_name_regex }, unless: :conan? + validates :name, format: { with: Gitlab::Regex.package_name_regex }, unless: -> { conan? || generic? } validates :name, uniqueness: { scope: %i[project_id version package_type] }, unless: :conan? @@ -35,20 +35,24 @@ class Packages::Package < ApplicationRecord validate :valid_npm_package_name, if: :npm? validate :valid_composer_global_name, if: :composer? validate :package_already_taken, if: :npm? - validates :version, format: { with: Gitlab::Regex.semver_regex }, if: -> { npm? || nuget? } validates :name, format: { with: Gitlab::Regex.conan_recipe_component_regex }, if: :conan? + validates :name, format: { with: Gitlab::Regex.generic_package_name_regex }, if: :generic? + validates :version, format: { with: Gitlab::Regex.semver_regex }, if: :npm? + validates :version, format: { with: Gitlab::Regex.nuget_version_regex }, if: :nuget? validates :version, format: { with: Gitlab::Regex.conan_recipe_component_regex }, if: :conan? validates :version, format: { with: Gitlab::Regex.maven_version_regex }, if: -> { version? && maven? } validates :version, format: { with: Gitlab::Regex.pypi_version_regex }, if: :pypi? + validates :version, format: { with: Gitlab::Regex.prefixed_semver_regex }, if: :golang? validates :version, presence: true, format: { with: Gitlab::Regex.generic_package_version_regex }, if: :generic? - enum package_type: { maven: 1, npm: 2, conan: 3, nuget: 4, pypi: 5, composer: 6, generic: 7 } + enum package_type: { maven: 1, npm: 2, conan: 3, nuget: 4, pypi: 5, composer: 6, generic: 7, golang: 8, debian: 9 } scope :with_name, ->(name) { where(name: name) } scope :with_name_like, ->(name) { where(arel_table[:name].matches(name)) } + scope :with_normalized_pypi_name, ->(name) { where("LOWER(regexp_replace(name, '[-_.]+', '-', 'g')) = ?", name.downcase) } scope :search_by_name, ->(query) { fuzzy_search(query, [:name], use_minimum_char_limit: false) } scope :with_version, ->(version) { where(version: version) } scope :without_version_like, -> (version) { where.not(arel_table[:version].matches(version)) } @@ -119,6 +123,10 @@ class Packages::Package < ApplicationRecord .where(packages_package_files: { file_name: file_name, file_sha256: sha256 }).last! end + def self.by_name_and_version!(name, version) + find_by!(name: name, version: version) + end + def self.pluck_names pluck(:name) end diff --git a/app/models/pages_deployment.rb b/app/models/pages_deployment.rb index 78e0f185a11..cd952c32046 100644 --- a/app/models/pages_deployment.rb +++ b/app/models/pages_deployment.rb @@ -2,10 +2,24 @@ # PagesDeployment stores a zip archive containing GitLab Pages web-site class PagesDeployment < ApplicationRecord + include FileStoreMounter + belongs_to :project, optional: false belongs_to :ci_build, class_name: 'Ci::Build', optional: true validates :file, presence: true validates :file_store, presence: true, inclusion: { in: ObjectStorage::SUPPORTED_STORES } validates :size, presence: true, numericality: { greater_than: 0, only_integer: true } + + before_validation :set_size, if: :file_changed? + + default_value_for(:file_store) { ::Pages::DeploymentUploader.default_store } + + mount_file_store_uploader ::Pages::DeploymentUploader + + private + + def set_size + self.size = file.size + end end diff --git a/app/models/postgresql/replication_slot.rb b/app/models/postgresql/replication_slot.rb index a4370eda5ba..c96786423e5 100644 --- a/app/models/postgresql/replication_slot.rb +++ b/app/models/postgresql/replication_slot.rb @@ -22,8 +22,8 @@ module Postgresql def self.lag_too_great?(max = 100.megabytes) return false unless in_use? - lag_function = "#{Gitlab::Database.pg_wal_lsn_diff}" \ - "(#{Gitlab::Database.pg_current_wal_insert_lsn}(), restart_lsn)::bigint" + lag_function = "pg_wal_lsn_diff" \ + "(pg_current_wal_insert_lsn(), restart_lsn)::bigint" # We force the use of a transaction here so the query always goes to the # primary, even when using the EE DB load balancer. diff --git a/app/models/preloaders/merge_request_diff_preloader.rb b/app/models/preloaders/merge_request_diff_preloader.rb new file mode 100644 index 00000000000..ee9995c497d --- /dev/null +++ b/app/models/preloaders/merge_request_diff_preloader.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Preloaders + # This class preloads the `merge_request_diff` association for the given merge request models. + # + # Usage: + # merge_requests = MergeRequest.where(...) + # Preloaders::MergeRequestDiffPreloader.new(merge_requests).preload_all + # merge_requests.first.merge_request_diff # won't fire any query + class MergeRequestDiffPreloader + def initialize(merge_requests) + @merge_requests = merge_requests + end + + def preload_all + merge_request_diffs = MergeRequestDiff.latest_diff_for_merge_requests(@merge_requests) + cache = merge_request_diffs.index_by { |diff| diff.merge_request_id } + + @merge_requests.each do |merge_request| + merge_request_diff = cache[merge_request.id] + + merge_request.association(:merge_request_diff).target = merge_request_diff + merge_request.association(:merge_request_diff).loaded! + end + end + end +end diff --git a/app/models/project.rb b/app/models/project.rb index 4db0eaa0442..dbedd6d120c 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -33,7 +33,9 @@ class Project < ApplicationRecord include FromUnion include IgnorableColumns include Integration + include EachBatch extend Gitlab::Cache::RequestCache + extend Gitlab::Utils::Override extend Gitlab::ConfigHelper @@ -198,6 +200,7 @@ class Project < ApplicationRecord has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :export_jobs, class_name: 'ProjectExportJob' has_one :project_repository, inverse_of: :project + has_one :tracing_setting, class_name: 'ProjectTracingSetting' has_one :incident_management_setting, inverse_of: :project, class_name: 'IncidentManagement::ProjectIncidentManagementSetting' has_one :error_tracking_setting, inverse_of: :project, class_name: 'ErrorTracking::ProjectErrorTrackingSetting' has_one :metrics_setting, inverse_of: :project, class_name: 'ProjectMetricsSetting' @@ -268,6 +271,7 @@ class Project < ApplicationRecord has_many :metrics_users_starred_dashboards, class_name: 'Metrics::UsersStarredDashboard', inverse_of: :project has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :project + has_many :alert_management_http_integrations, class_name: 'AlertManagement::HttpIntegration', inverse_of: :project # Container repositories need to remove data from the container registry, # which is not managed by the DB. Hence we're still using dependent: :destroy @@ -294,6 +298,7 @@ class Project < ApplicationRecord # bulk that doesn't involve loading the rows into memory. As a result we're # still using `dependent: :destroy` here. has_many :builds, class_name: 'Ci::Build', inverse_of: :project, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :processables, class_name: 'Ci::Processable', inverse_of: :project has_many :build_trace_section_names, class_name: 'Ci::BuildTraceSectionName' has_many :build_trace_chunks, class_name: 'Ci::BuildTraceChunk', through: :builds, source: :trace_chunks has_many :build_report_results, class_name: 'Ci::BuildReportResult', inverse_of: :project @@ -336,6 +341,8 @@ class Project < ApplicationRecord has_many :webide_pipelines, -> { webide_source }, class_name: 'Ci::Pipeline', inverse_of: :project has_many :reviews, inverse_of: :project + has_many :terraform_states, class_name: 'Terraform::State', inverse_of: :project + # GitLab Pages has_many :pages_domains has_one :pages_metadatum, class_name: 'ProjectPagesMetadatum', inverse_of: :project @@ -361,6 +368,7 @@ class Project < ApplicationRecord allow_destroy: true, reject_if: ->(attrs) { attrs[:id].blank? && attrs[:url].blank? } + accepts_nested_attributes_for :tracing_setting, update_only: true, allow_destroy: true accepts_nested_attributes_for :incident_management_setting, update_only: true accepts_nested_attributes_for :error_tracking_setting, update_only: true accepts_nested_attributes_for :metrics_setting, update_only: true, allow_destroy: true @@ -392,7 +400,7 @@ class Project < ApplicationRecord delegate :external_dashboard_url, to: :metrics_setting, allow_nil: true, prefix: true delegate :dashboard_timezone, to: :metrics_setting, allow_nil: true, prefix: true delegate :default_git_depth, :default_git_depth=, to: :ci_cd_settings, prefix: :ci - delegate :forward_deployment_enabled, :forward_deployment_enabled=, :forward_deployment_enabled?, to: :ci_cd_settings + delegate :forward_deployment_enabled, :forward_deployment_enabled=, :forward_deployment_enabled?, to: :ci_cd_settings, prefix: :ci delegate :actual_limits, :actual_plan_name, to: :namespace, allow_nil: true delegate :allow_merge_on_skipped_pipeline, :allow_merge_on_skipped_pipeline?, :allow_merge_on_skipped_pipeline=, :has_confluence?, @@ -432,6 +440,7 @@ class Project < ApplicationRecord validate :visibility_level_allowed_by_group, if: :should_validate_visibility_level? validate :visibility_level_allowed_as_fork, if: :should_validate_visibility_level? validate :validate_pages_https_only, if: -> { changes.has_key?(:pages_https_only) } + validate :changing_shared_runners_enabled_is_allowed validates :repository_storage, presence: true, inclusion: { in: ->(_object) { Gitlab.config.repositories.storages.keys } } @@ -560,6 +569,7 @@ class Project < ApplicationRecord } scope :imported_from, -> (type) { where(import_type: type) } + scope :with_tracing_enabled, -> { joins(:tracing_setting) } enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 } @@ -595,7 +605,7 @@ class Project < ApplicationRecord return public_to_user unless user if user.is_a?(DeployToken) - user.projects + user.accessible_projects else where('EXISTS (?) OR projects.visibility_level IN (?)', user.authorizations_for_projects(min_access_level: min_access_level), @@ -667,8 +677,6 @@ class Project < ApplicationRecord scope :joins_import_state, -> { joins("INNER JOIN project_mirror_data import_state ON import_state.project_id = projects.id") } scope :for_group, -> (group) { where(group: group) } scope :for_group_and_its_subgroups, ->(group) { where(namespace_id: group.self_and_descendants.select(:id)) } - scope :for_repository_storage, -> (repository_storage) { where(repository_storage: repository_storage) } - scope :excluding_repository_storage, -> (repository_storage) { where.not(repository_storage: repository_storage) } class << self # Searches for a list of projects based on the query given in `query`. @@ -842,6 +850,7 @@ class Project < ApplicationRecord end end + override :lfs_enabled? def lfs_enabled? return namespace.lfs_enabled? if self[:lfs_enabled].nil? @@ -942,7 +951,7 @@ class Project < ApplicationRecord latest_pipeline = ci_pipelines.latest_successful_for_ref(ref) return unless latest_pipeline - latest_pipeline.builds.latest.with_downloadable_artifacts.find_by(name: job_name) + latest_pipeline.build_with_artifacts_in_self_and_descendants(job_name) end def latest_successful_build_for_sha(job_name, sha) @@ -951,7 +960,7 @@ class Project < ApplicationRecord latest_pipeline = ci_pipelines.latest_successful_for_sha(sha) return unless latest_pipeline - latest_pipeline.builds.latest.with_downloadable_artifacts.find_by(name: job_name) + latest_pipeline.build_with_artifacts_in_self_and_descendants(job_name) end def latest_successful_build_for_ref!(job_name, ref = default_branch) @@ -991,9 +1000,6 @@ class Project < ApplicationRecord job_id = if forked? RepositoryForkWorker.perform_async(id) - elsif gitlab_project_import? - # Do not retry on Import/Export until https://gitlab.com/gitlab-org/gitlab-foss/issues/26189 is solved. - RepositoryImportWorker.set(retry: false).perform_async(self.id) else RepositoryImportWorker.perform_async(self.id) end @@ -1186,6 +1192,15 @@ class Project < ApplicationRecord end end + def changing_shared_runners_enabled_is_allowed + return unless Feature.enabled?(:disable_shared_runners_on_group, default_enabled: true) + return unless new_record? || changes.has_key?(:shared_runners_enabled) + + if shared_runners_enabled && group && group.shared_runners_setting == 'disabled_and_unoverridable' + errors.add(:shared_runners_enabled, _('cannot be enabled because parent group does not allow it')) + end + end + def to_param if persisted? && errors.include?(:path) path_was @@ -1325,7 +1340,8 @@ class Project < ApplicationRecord end def find_or_initialize_services - available_services_names = Service.available_services_names - disabled_services + available_services_names = + Service.available_services_names + Service.project_specific_services_names - disabled_services available_services_names.map do |service_name| find_or_initialize_service(service_name) @@ -2292,6 +2308,10 @@ class Project < ApplicationRecord [] end + def mark_primary_write_location + # Overriden in EE + end + def toggle_ci_cd_settings!(settings_attribute) ci_cd_settings.toggle!(settings_attribute) end @@ -2495,12 +2515,25 @@ class Project < ApplicationRecord ci_config_path.presence || Ci::Pipeline::DEFAULT_CONFIG_PATH end + def ci_config_for(sha) + repository.gitlab_ci_yml_for(sha, ci_config_path_or_default) + end + def enabled_group_deploy_keys return GroupDeployKey.none unless group GroupDeployKey.for_groups(group.self_and_ancestors_ids) end + def feature_flags_client_token + instance = operations_feature_flags_client || create_operations_feature_flags_client! + instance.token + end + + def tracing_external_url + tracing_setting&.external_url + end + private def find_service(services, name) @@ -2509,10 +2542,10 @@ class Project < ApplicationRecord def build_from_instance_or_template(name) instance = find_service(services_instances, name) - return Service.build_from_integration(id, instance) if instance + return Service.build_from_integration(instance, project_id: id) if instance template = find_service(services_templates, name) - return Service.build_from_integration(id, template) if template + return Service.build_from_integration(template, project_id: id) if template end def services_templates diff --git a/app/models/project_pages_metadatum.rb b/app/models/project_pages_metadatum.rb index 8a1db4a9acf..bd1919fe7ed 100644 --- a/app/models/project_pages_metadatum.rb +++ b/app/models/project_pages_metadatum.rb @@ -5,6 +5,7 @@ class ProjectPagesMetadatum < ApplicationRecord belongs_to :project, inverse_of: :pages_metadatum belongs_to :artifacts_archive, class_name: 'Ci::JobArtifact' + belongs_to :pages_deployment scope :deployed, -> { where(deployed: true) } end diff --git a/app/models/project_repository_storage_move.rb b/app/models/project_repository_storage_move.rb index 2b74d9ccd88..76f428fe925 100644 --- a/app/models/project_repository_storage_move.rb +++ b/app/models/project_repository_storage_move.rb @@ -20,6 +20,10 @@ class ProjectRepositoryStorageMove < ApplicationRecord inclusion: { in: ->(_) { Gitlab.config.repositories.storages.keys } } validate :project_repository_writable, on: :create + default_value_for(:destination_storage_name, allows_nil: false) do + pick_repository_storage + end + state_machine initial: :initial do event :schedule do transition initial: :scheduled @@ -77,6 +81,12 @@ class ProjectRepositoryStorageMove < ApplicationRecord scope :order_created_at_desc, -> { order(created_at: :desc) } scope :with_projects, -> { includes(project: :route) } + class << self + def pick_repository_storage + Project.pick_repository_storage + end + end + private def project_repository_writable diff --git a/app/models/project_services/chat_message/deployment_message.rb b/app/models/project_services/chat_message/deployment_message.rb index dae3a56116e..5deb757e60f 100644 --- a/app/models/project_services/chat_message/deployment_message.rb +++ b/app/models/project_services/chat_message/deployment_message.rb @@ -38,7 +38,11 @@ module ChatMessage private def message - "Deploy to #{environment} #{humanized_status}" + if running? + "Starting deploy to #{environment}" + else + "Deploy to #{environment} #{humanized_status}" + end end def color @@ -73,5 +77,9 @@ module ChatMessage def humanized_status status == 'success' ? 'succeeded' : status end + + def running? + status == 'running' + end end end diff --git a/app/models/project_services/chat_message/issue_message.rb b/app/models/project_services/chat_message/issue_message.rb index 0cdcfcf0237..c8e90b66bae 100644 --- a/app/models/project_services/chat_message/issue_message.rb +++ b/app/models/project_services/chat_message/issue_message.rb @@ -41,15 +41,11 @@ module ChatMessage private def message - if opened_issue? - "[#{project_link}] Issue #{state} by #{user_combined_name}" - else - "[#{project_link}] Issue #{issue_link} #{state} by #{user_combined_name}" - end + "[#{project_link}] Issue #{issue_link} #{state} by #{user_combined_name}" end def opened_issue? - action == "open" + action == 'open' end def description_message @@ -57,7 +53,7 @@ module ChatMessage title: issue_title, title_link: issue_url, text: format(description), - color: "#C95823" + color: '#C95823' }] end diff --git a/app/models/project_services/confluence_service.rb b/app/models/project_services/confluence_service.rb index dd44a0d1d56..6db446fc04c 100644 --- a/app/models/project_services/confluence_service.rb +++ b/app/models/project_services/confluence_service.rb @@ -27,7 +27,7 @@ class ConfluenceService < Service end def description - s_('ConfluenceService|Connect a Confluence Cloud Workspace to your GitLab project') + s_('ConfluenceService|Connect a Confluence Cloud Workspace to GitLab') end def detailed_description diff --git a/app/models/project_services/drone_ci_service.rb b/app/models/project_services/drone_ci_service.rb index 4e4955b45d8..5a49f780d46 100644 --- a/app/models/project_services/drone_ci_service.rb +++ b/app/models/project_services/drone_ci_service.rb @@ -42,7 +42,7 @@ class DroneCiService < CiService def commit_status_path(sha, ref) Gitlab::Utils.append_path( drone_url, - "gitlab/#{project.full_path}/commits/#{sha}?branch=#{URI.encode(ref.to_s)}&access_token=#{token}") + "gitlab/#{project.full_path}/commits/#{sha}?branch=#{Addressable::URI.encode_component(ref.to_s)}&access_token=#{token}") end def commit_status(sha, ref) @@ -75,7 +75,7 @@ class DroneCiService < CiService def build_page(sha, ref) Gitlab::Utils.append_path( drone_url, - "gitlab/#{project.full_path}/redirect/commits/#{sha}?branch=#{URI.encode(ref.to_s)}") + "gitlab/#{project.full_path}/redirect/commits/#{sha}?branch=#{Addressable::URI.encode_component(ref.to_s)}") end def title diff --git a/app/models/project_services/packagist_service.rb b/app/models/project_services/packagist_service.rb index 35dbedd1341..21f0a2b2463 100644 --- a/app/models/project_services/packagist_service.rb +++ b/app/models/project_services/packagist_service.rb @@ -16,7 +16,7 @@ class PackagistService < Service end def description - 'Update your project on Packagist, the main Composer repository' + s_('Integrations|Update your projects on Packagist, the main Composer repository') end def self.to_param diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb index 67ab2c0ce8a..0d2f89fb18d 100644 --- a/app/models/project_statistics.rb +++ b/app/models/project_statistics.rb @@ -2,6 +2,7 @@ class ProjectStatistics < ApplicationRecord include AfterCommitQueue + include CounterAttribute belongs_to :project belongs_to :namespace @@ -9,6 +10,13 @@ class ProjectStatistics < ApplicationRecord default_value_for :wiki_size, 0 default_value_for :snippets_size, 0 + counter_attribute :build_artifacts_size + counter_attribute :storage_size + + counter_attribute_after_flush do |project_statistic| + Namespaces::ScheduleAggregationWorker.perform_async(project_statistic.namespace_id) + end + before_save :update_storage_size COLUMNS_TO_REFRESH = [:repository_size, :wiki_size, :lfs_objects_size, :commit_count, :snippets_size].freeze @@ -29,6 +37,8 @@ class ProjectStatistics < ApplicationRecord end def refresh!(only: []) + return if Gitlab::Database.read_only? + COLUMNS_TO_REFRESH.each do |column, generator| if only.empty? || only.include?(column) public_send("update_#{column}") # rubocop:disable GitlabSecurity/PublicSend @@ -96,12 +106,27 @@ class ProjectStatistics < ApplicationRecord # Additional columns are updated depending on key => [columns], which allows # to update statistics which are and also those which aren't included in storage_size # or any other additional summary column in the future. - def self.increment_statistic(project_id, key, amount) + def self.increment_statistic(project, key, amount) raise ArgumentError, "Cannot increment attribute: #{key}" unless INCREMENTABLE_COLUMNS.key?(key) return if amount == 0 - where(project_id: project_id) - .columns_to_increment(key, amount) + project.statistics.try do |project_statistics| + if project_statistics.counter_attribute_enabled?(key) + statistics_to_increment = [key] + INCREMENTABLE_COLUMNS[key].to_a + statistics_to_increment.each do |statistic| + project_statistics.delayed_increment_counter(statistic, amount) + end + else + legacy_increment_statistic(project, key, amount) + end + end + end + + def self.legacy_increment_statistic(project, key, amount) + where(project_id: project.id).columns_to_increment(key, amount) + + Namespaces::ScheduleAggregationWorker.perform_async( # rubocop: disable CodeReuse/Worker + project.namespace_id) end def self.columns_to_increment(key, amount) diff --git a/app/models/project_tracing_setting.rb b/app/models/project_tracing_setting.rb new file mode 100644 index 00000000000..93fa80aed67 --- /dev/null +++ b/app/models/project_tracing_setting.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class ProjectTracingSetting < ApplicationRecord + belongs_to :project + + validates :external_url, length: { maximum: 255 }, public_url: true + + before_validation :sanitize_external_url + + private + + def sanitize_external_url + self.external_url = Rails::Html::FullSanitizer.new.sanitize(self.external_url) + end +end diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index bd570cf7ead..91fb3d4e4ba 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -1,10 +1,11 @@ # frozen_string_literal: true class ProjectWiki < Wiki + self.container_class = Project alias_method :project, :container # Project wikis are tied to the main project storage - delegate :storage, :repository_storage, :hashed_storage?, to: :container + delegate :storage, :repository_storage, :hashed_storage?, :lfs_enabled?, to: :container override :disk_path def disk_path(*args, &block) diff --git a/app/models/prometheus_alert.rb b/app/models/prometheus_alert.rb index f0441d4a3cb..684f50d5f58 100644 --- a/app/models/prometheus_alert.rb +++ b/app/models/prometheus_alert.rb @@ -4,6 +4,7 @@ class PrometheusAlert < ApplicationRecord include Sortable include UsageStatistics include Presentable + include EachBatch OPERATORS_MAP = { lt: "<", @@ -35,6 +36,7 @@ class PrometheusAlert < ApplicationRecord scope :for_metric, -> (metric) { where(prometheus_metric: metric) } scope :for_project, -> (project) { where(project_id: project) } scope :for_environment, -> (environment) { where(environment_id: environment) } + scope :get_environment_id, -> { select(:environment_id).pluck(:environment_id) } def self.distinct_projects sub_query = self.group(:project_id).select(1) diff --git a/app/models/prometheus_metric.rb b/app/models/prometheus_metric.rb index 9ddf66cd388..590eda62c11 100644 --- a/app/models/prometheus_metric.rb +++ b/app/models/prometheus_metric.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class PrometheusMetric < ApplicationRecord + include EachBatch + belongs_to :project, validate: true, inverse_of: :prometheus_metrics has_many :prometheus_alerts, inverse_of: :prometheus_metric diff --git a/app/models/release.rb b/app/models/release.rb index 4c9d89105d7..f2162a0f674 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -30,6 +30,12 @@ class Release < ApplicationRecord scope :with_project_and_namespace, -> { includes(project: :namespace) } scope :recent, -> { sorted.limit(MAX_NUMBER_TO_DISPLAY) } + # Sorting + scope :order_created, -> { reorder('created_at ASC') } + scope :order_created_desc, -> { reorder('created_at DESC') } + scope :order_released, -> { reorder('released_at ASC') } + scope :order_released_desc, -> { reorder('released_at DESC') } + delegate :repository, to: :project MAX_NUMBER_TO_DISPLAY = 3 @@ -92,6 +98,17 @@ class Release < ApplicationRecord def set_released_at self.released_at ||= created_at end + + def self.sort_by_attribute(method) + case method.to_s + when 'created_at_asc' then order_created + when 'created_at_desc' then order_created_desc + when 'released_at_asc' then order_released + when 'released_at_desc' then order_released_desc + else + order_created_desc + end + end end Release.prepend_if_ee('EE::Release') diff --git a/app/models/repository.rb b/app/models/repository.rb index ef17e010ba8..d4fd202b966 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -26,6 +26,7 @@ class Repository delegate :ref_name_for_sha, to: :raw_repository delegate :bundle_to_disk, to: :raw_repository + delegate :lfs_enabled?, to: :container CreateTreeError = Class.new(StandardError) AmbiguousRefError = Class.new(StandardError) @@ -853,16 +854,16 @@ class Repository def merge(user, source_sha, merge_request, message) with_cache_hooks do raw_repository.merge(user, source_sha, merge_request.target_branch, message) do |commit_id| - merge_request.update(in_progress_merge_commit_sha: commit_id) + merge_request.update_and_mark_in_progress_merge_commit_sha(commit_id) nil # Return value does not matter. end end end - def merge_to_ref(user, source_sha, merge_request, target_ref, message, first_parent_ref) + def merge_to_ref(user, source_sha, merge_request, target_ref, message, first_parent_ref, allow_conflicts = false) branch = merge_request.target_branch - raw.merge_to_ref(user, source_sha, branch, target_ref, message, first_parent_ref) + raw.merge_to_ref(user, source_sha, branch, target_ref, message, first_parent_ref, allow_conflicts) end def delete_refs(*ref_names) @@ -873,7 +874,7 @@ class Repository their_commit_id = commit(source)&.id raise 'Invalid merge source' if their_commit_id.nil? - merge_request&.update(in_progress_merge_commit_sha: their_commit_id) + merge_request&.update_and_mark_in_progress_merge_commit_sha(their_commit_id) with_cache_hooks { raw.ff_merge(user, their_commit_id, target_branch) } end @@ -1142,21 +1143,10 @@ class Repository end def project - if repo_type.snippet? - container.project - elsif container.is_a?(Project) - container - end - end - - # TODO: pass this in directly to `Blob` rather than delegating it to here - # - # https://gitlab.com/gitlab-org/gitlab/-/issues/201886 - def lfs_enabled? if container.is_a?(Project) - container.lfs_enabled? + container else - false # LFS is not supported for snippet or group repositories + container.try(:project) end end diff --git a/app/models/resource_label_event.rb b/app/models/resource_label_event.rb index cc96698be09..18e2944a9ca 100644 --- a/app/models/resource_label_event.rb +++ b/app/models/resource_label_event.rb @@ -15,6 +15,7 @@ class ResourceLabelEvent < ResourceEvent validate :exactly_one_issuable after_save :expire_etag_cache + after_save :usage_metrics after_destroy :expire_etag_cache enum action: { @@ -113,6 +114,16 @@ class ResourceLabelEvent < ResourceEvent def discussion_id_key [self.class.name, created_at, user_id] end + + def for_issue? + issue_id.present? + end + + def usage_metrics + return unless for_issue? + + Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_label_changed_action(author: user) + end end ResourceLabelEvent.prepend_if_ee('EE::ResourceLabelEvent') diff --git a/app/models/resource_state_event.rb b/app/models/resource_state_event.rb index 1ce4e14d289..6475633868a 100644 --- a/app/models/resource_state_event.rb +++ b/app/models/resource_state_event.rb @@ -11,6 +11,8 @@ class ResourceStateEvent < ResourceEvent # state is used for issue and merge request states. enum state: Issue.available_states.merge(MergeRequest.available_states).merge(reopened: 5) + after_save :usage_metrics + def self.issuable_attrs %i(issue merge_request).freeze end @@ -18,6 +20,29 @@ class ResourceStateEvent < ResourceEvent def issuable issue || merge_request end + + def for_issue? + issue_id.present? + end + + private + + def usage_metrics + return unless for_issue? + + case state + when 'closed' + issue_usage_counter.track_issue_closed_action(author: user) + when 'reopened' + issue_usage_counter.track_issue_reopened_action(author: user) + else + # no-op, nothing to do, not a state we're tracking + end + end + + def issue_usage_counter + Gitlab::UsageDataCounters::IssueActivityUniqueCounter + end end ResourceStateEvent.prepend_if_ee('EE::ResourceStateEvent') diff --git a/app/models/resource_timebox_event.rb b/app/models/resource_timebox_event.rb index 44f48915425..dbb2b428c7b 100644 --- a/app/models/resource_timebox_event.rb +++ b/app/models/resource_timebox_event.rb @@ -13,6 +13,8 @@ class ResourceTimeboxEvent < ResourceEvent remove: 2 } + after_save :usage_metrics + def self.issuable_attrs %i(issue merge_request).freeze end @@ -20,4 +22,17 @@ class ResourceTimeboxEvent < ResourceEvent def issuable issue || merge_request end + + private + + def usage_metrics + case self + when ResourceMilestoneEvent + Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_milestone_changed_action(author: user) + when ResourceIterationEvent + Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_iteration_changed_action(author: user) + else + # no-op + end + end end diff --git a/app/models/resource_weight_event.rb b/app/models/resource_weight_event.rb deleted file mode 100644 index bbabd54325e..00000000000 --- a/app/models/resource_weight_event.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -class ResourceWeightEvent < ResourceEvent - validates :issue, presence: true - - include IssueResourceEvent -end diff --git a/app/models/service.rb b/app/models/service.rb index e63e06bf46f..764f417362f 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -7,9 +7,7 @@ class Service < ApplicationRecord include Importable include ProjectServicesLoggable include DataFields - include IgnorableColumns - - ignore_columns %i[default], remove_with: '13.5', remove_after: '2020-10-22' + include FromUnion SERVICE_NAMES = %w[ alerts asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker discord @@ -65,6 +63,7 @@ class Service < ApplicationRecord scope :active, -> { where(active: true) } scope :by_type, -> (type) { where(type: type) } scope :by_active_flag, -> (flag) { where(active: flag) } + scope :inherit_from_id, -> (id) { where(inherit_from_id: id) } scope :for_group, -> (group) { where(group_id: group, type: available_services_types) } scope :for_template, -> { where(template: true, type: available_services_types) } scope :for_instance, -> { where(instance: true, type: available_services_types) } @@ -209,6 +208,10 @@ class Service < ApplicationRecord DEV_SERVICE_NAMES end + def self.project_specific_services_names + [] + end + def self.available_services_types available_services_names.map { |service_name| "#{service_name}_service".camelize } end @@ -217,7 +220,7 @@ class Service < ApplicationRecord services_names.map { |service_name| "#{service_name}_service".camelize } end - def self.build_from_integration(project_id, integration) + def self.build_from_integration(integration, project_id: nil, group_id: nil) service = integration.dup if integration.supports_data_fields? @@ -227,8 +230,9 @@ class Service < ApplicationRecord service.template = false service.instance = false - service.inherit_from_id = integration.id if integration.instance? service.project_id = project_id + service.group_id = group_id + service.inherit_from_id = integration.id if integration.instance? || integration.group service.active = false if service.invalid? service end @@ -245,7 +249,7 @@ class Service < ApplicationRecord group_ids = scope.ancestors.select(:id) array = group_ids.to_sql.present? ? "array(#{group_ids.to_sql})" : 'ARRAY[]' - where(type: type, group_id: group_ids) + where(type: type, group_id: group_ids, inherit_from_id: nil) .order(Arel.sql("array_position(#{array}::bigint[], services.group_id)")) .first end @@ -256,6 +260,19 @@ class Service < ApplicationRecord end private_class_method :instance_level_integration + def self.create_from_active_default_integrations(scope, association, with_templates: false) + group_ids = scope.ancestors.select(:id) + array = group_ids.to_sql.present? ? "array(#{group_ids.to_sql})" : 'ARRAY[]' + + from_union([ + with_templates ? active.where(template: true) : none, + active.where(instance: true), + active.where(group_id: group_ids, inherit_from_id: nil) + ]).order(Arel.sql("type ASC, array_position(#{array}::bigint[], services.group_id), instance DESC")).group_by(&:type).each do |type, records| + build_from_integration(records.first, association => scope.id).save! + end + end + def activated? active end diff --git a/app/models/service_list.rb b/app/models/service_list.rb index 9cbc5e68059..5eca5f2bda1 100644 --- a/app/models/service_list.rb +++ b/app/models/service_list.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true class ServiceList - def initialize(batch_ids, service_hash, association) - @batch_ids = batch_ids + def initialize(batch, service_hash, association) + @batch = batch @service_hash = service_hash @association = association end @@ -13,15 +13,15 @@ class ServiceList private - attr_reader :batch_ids, :service_hash, :association + attr_reader :batch, :service_hash, :association def columns - (service_hash.keys << "#{association}_id") + service_hash.keys << "#{association}_id" end def values - batch_ids.map do |id| - (service_hash.values << id) + batch.select(:id).map do |record| + service_hash.values << record.id end end end diff --git a/app/models/snippet.rb b/app/models/snippet.rb index 1cf3097861c..d71853e11cf 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -19,7 +19,6 @@ class Snippet < ApplicationRecord extend ::Gitlab::Utils::Override MAX_FILE_COUNT = 10 - MAX_SINGLE_FILE_COUNT = 1 cache_markdown_field :title, pipeline: :single_line cache_markdown_field :description @@ -175,8 +174,8 @@ class Snippet < ApplicationRecord Snippet.find_by(id: id, project: project) end - def self.max_file_limit(user) - Feature.enabled?(:snippet_multiple_files, user) ? MAX_FILE_COUNT : MAX_SINGLE_FILE_COUNT + def self.max_file_limit + MAX_FILE_COUNT end def initialize(attributes = {}) @@ -283,7 +282,8 @@ class Snippet < ApplicationRecord strong_memoize(:repository_size_checker) do ::Gitlab::RepositorySizeChecker.new( current_size_proc: -> { repository.size.megabytes }, - limit: Gitlab::CurrentSettings.snippet_size_limit + limit: Gitlab::CurrentSettings.snippet_size_limit, + namespace: nil ) end end diff --git a/app/models/snippet_input_action_collection.rb b/app/models/snippet_input_action_collection.rb index 38313e3a980..1e886e98083 100644 --- a/app/models/snippet_input_action_collection.rb +++ b/app/models/snippet_input_action_collection.rb @@ -8,7 +8,11 @@ class SnippetInputActionCollection delegate :empty?, :any?, :[], to: :actions def initialize(actions = [], allowed_actions: nil) - @actions = actions.map { |action| SnippetInputAction.new(action.merge(allowed_actions: allowed_actions)) } + @actions = actions.map do |action| + params = action.merge(allowed_actions: allowed_actions) + + SnippetInputAction.new(**params) + end end def to_commit_actions diff --git a/app/models/snippet_repository.rb b/app/models/snippet_repository.rb index 2cfb201191d..fa25a6f8441 100644 --- a/app/models/snippet_repository.rb +++ b/app/models/snippet_repository.rb @@ -12,7 +12,7 @@ class SnippetRepository < ApplicationRecord belongs_to :snippet, inverse_of: :snippet_repository - delegate :repository, to: :snippet + delegate :repository, :repository_storage, to: :snippet class << self def find_snippet(disk_path) diff --git a/app/models/snippet_statistics.rb b/app/models/snippet_statistics.rb index 8545296d076..6fb6f0ef713 100644 --- a/app/models/snippet_statistics.rb +++ b/app/models/snippet_statistics.rb @@ -34,6 +34,8 @@ class SnippetStatistics < ApplicationRecord end def refresh! + return if Gitlab::Database.read_only? + update_commit_count update_repository_size update_file_count diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb index 961212d0295..0ddf2c5fbcd 100644 --- a/app/models/system_note_metadata.rb +++ b/app/models/system_note_metadata.rb @@ -18,9 +18,9 @@ class SystemNoteMetadata < ApplicationRecord commit description merge confidential visible label assignee cross_reference designs_added designs_modified designs_removed designs_discussion_added title time_tracking branch milestone discussion task moved - opened closed merged duplicate locked unlocked outdated + opened closed merged duplicate locked unlocked outdated reviewer tag due_date pinned_embed cherry_pick health_status approved unapproved - status alert_issue_added relate unrelate new_alert_added + status alert_issue_added relate unrelate new_alert_added severity ].freeze validates :note, presence: true diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb index 419fffcb666..9d88db27449 100644 --- a/app/models/terraform/state.rb +++ b/app/models/terraform/state.rb @@ -4,6 +4,12 @@ module Terraform class State < ApplicationRecord include UsageStatistics include FileStoreMounter + include IgnorableColumns + # These columns are being removed since geo replication falls to the versioned state + # Tracking in https://gitlab.com/gitlab-org/gitlab/-/issues/258262 + ignore_columns %i[verification_failure verification_retry_at verified_at verification_retry_count verification_checksum], + remove_with: '13.7', + remove_after: '2020-12-22' HEX_REGEXP = %r{\A\h+\z}.freeze UUID_LENGTH = 32 @@ -15,6 +21,7 @@ module Terraform has_one :latest_version, -> { ordered_by_version_desc }, class_name: 'Terraform::StateVersion', foreign_key: :terraform_state_id scope :versioning_not_enabled, -> { where(versioning_enabled: false) } + scope :ordered_by_name, -> { order(:name) } validates :project_id, presence: true validates :uuid, presence: true, uniqueness: true, length: { is: UUID_LENGTH }, @@ -30,11 +37,11 @@ module Terraform end def latest_file - versioning_enabled ? latest_version&.file : file - end - - def local? - file_store == ObjectStorage::Store::LOCAL + if versioning_enabled? + latest_version&.file + else + latest_version&.file || file + end end def locked? @@ -43,15 +50,56 @@ module Terraform def update_file!(data, version:) if versioning_enabled? - new_version = versions.build(version: version) - new_version.assign_attributes(created_by_user: locked_by_user, file: data) - new_version.save! + create_new_version!(data: data, version: version) + elsif latest_version.present? + migrate_legacy_version!(data: data, version: version) else self.file = data save! end end + + private + + ## + # If a Terraform state was created before versioning support was + # introduced, it will have a single version record whose file + # uses a legacy naming scheme in object storage. To update + # these states and versions to use the new behaviour, we must do + # the following when creating the next version: + # + # * Read the current, non-versioned file from the old location. + # * Update the :versioning_enabled flag, which determines the + # naming scheme + # * Resave the existing file with the updated name and location, + # using a version number one prior to the new version + # * Create the new version as normal + # + # This migration only needs to happen once for each state, from + # then on the state will behave as if it was always versioned. + # + # The code can be removed in the next major version (14.0), after + # which any states that haven't been migrated will need to be + # recreated: https://gitlab.com/gitlab-org/gitlab/-/issues/258960 + def migrate_legacy_version!(data:, version:) + current_file = latest_version.file.read + current_version = parse_serial(current_file) || version - 1 + + update!(versioning_enabled: true) + + reload_latest_version.update!(version: current_version, file: CarrierWaveStringFile.new(current_file)) + create_new_version!(data: data, version: version) + end + + def create_new_version!(data:, version:) + new_version = versions.build(version: version, created_by_user: locked_by_user) + new_version.assign_attributes(file: data) + new_version.save! + end + + def parse_serial(file) + Gitlab::Json.parse(file)["serial"] + rescue JSON::ParserError + end end end - -Terraform::State.prepend_if_ee('EE::Terraform::State') diff --git a/app/models/terraform/state_version.rb b/app/models/terraform/state_version.rb index d5e315d18a1..eff44485401 100644 --- a/app/models/terraform/state_version.rb +++ b/app/models/terraform/state_version.rb @@ -14,5 +14,11 @@ module Terraform mount_file_store_uploader VersionedStateUploader delegate :project_id, :uuid, to: :terraform_state, allow_nil: true + + def local? + file_store == ObjectStorage::Store::LOCAL + end end end + +Terraform::StateVersion.prepend_if_ee('EE::Terraform::StateVersion') diff --git a/app/models/todo.rb b/app/models/todo.rb index 6c8e085762d..0d893b25253 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -227,7 +227,7 @@ class Todo < ApplicationRecord end def self_assigned? - assigned? && self_added? + self_added? && (assigned? || review_requested?) end private diff --git a/app/models/u2f_registration.rb b/app/models/u2f_registration.rb index 81415eb383b..1a389081913 100644 --- a/app/models/u2f_registration.rb +++ b/app/models/u2f_registration.rb @@ -4,6 +4,19 @@ class U2fRegistration < ApplicationRecord belongs_to :user + after_commit :schedule_webauthn_migration, on: :create + after_commit :update_webauthn_registration, on: :update, if: :counter_changed? + + def schedule_webauthn_migration + BackgroundMigrationWorker.perform_async('MigrateU2fWebauthn', [id, id]) + end + + def update_webauthn_registration + # When we update the sign count of this registration + # we need to update the sign count of the corresponding webauthn registration + # as well if it exists already + WebauthnRegistration.find_by_credential_xid(webauthn_credential_xid)&.update_attribute(:counter, counter) + end def self.register(user, app_id, params, challenges) u2f = U2F::U2F.new(app_id) @@ -40,4 +53,13 @@ class U2fRegistration < ApplicationRecord rescue JSON::ParserError, NoMethodError, ArgumentError, U2F::Error false end + + private + + def webauthn_credential_xid + # To find the corresponding webauthn registration, we use that + # the key handle of the u2f reg corresponds to the credential xid of the webauthn reg + # (with some base64 back and forth) + Base64.strict_encode64(Base64.urlsafe_decode64(key_handle)) + end end diff --git a/app/models/user.rb b/app/models/user.rb index 0a784b30d8f..ef77e207215 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -64,14 +64,7 @@ class User < ApplicationRecord # and should be added after Devise modules are initialized. include AsyncDeviseEmail - BLOCKED_MESSAGE = "Your account has been blocked. Please contact your GitLab " \ - "administrator if you think this is an error." - LOGIN_FORBIDDEN = "Your account does not have the required permission to login. Please contact your GitLab " \ - "administrator if you think this is an error." - - MINIMUM_INACTIVE_DAYS = 180 - - ignore_column :bio, remove_with: '13.4', remove_after: '2020-09-22' + MINIMUM_INACTIVE_DAYS = 90 # Override Devise::Models::Trackable#update_tracked_fields! # to limit database writes to at most once every hour @@ -134,6 +127,8 @@ class User < ApplicationRecord -> { where(members: { access_level: [Gitlab::Access::REPORTER, Gitlab::Access::DEVELOPER, Gitlab::Access::MAINTAINER, Gitlab::Access::OWNER] }) }, through: :group_members, source: :group + has_many :minimal_access_group_members, -> { where(access_level: [Gitlab::Access::MINIMAL_ACCESS]) }, source: 'GroupMember', class_name: 'GroupMember' + has_many :minimal_access_groups, through: :minimal_access_group_members, source: :group # Projects has_many :groups_projects, through: :groups, source: :projects @@ -172,6 +167,8 @@ class User < ApplicationRecord has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue has_many :assigned_merge_requests, class_name: "MergeRequest", through: :merge_request_assignees, source: :merge_request + has_many :bulk_imports + has_many :custom_attributes, class_name: 'UserCustomAttribute' has_many :callouts, class_name: 'UserCallout' has_many :term_agreements @@ -298,6 +295,7 @@ class User < ApplicationRecord transition active: :blocked transition deactivated: :blocked transition ldap_blocked: :blocked + transition blocked_pending_approval: :blocked end event :ldap_block do @@ -309,13 +307,18 @@ class User < ApplicationRecord transition deactivated: :active transition blocked: :active transition ldap_blocked: :active + transition blocked_pending_approval: :active + end + + event :block_pending_approval do + transition active: :blocked_pending_approval end event :deactivate do transition active: :deactivated end - state :blocked, :ldap_blocked do + state :blocked, :ldap_blocked, :blocked_pending_approval do def blocked? true end @@ -339,6 +342,7 @@ class User < ApplicationRecord # Scopes scope :admins, -> { where(admin: true) } scope :blocked, -> { with_states(:blocked, :ldap_blocked) } + scope :blocked_pending_approval, -> { with_states(:blocked_pending_approval) } scope :external, -> { where(external: true) } scope :confirmed, -> { where.not(confirmed_at: nil) } scope :active, -> { with_state(:active).non_internal } @@ -381,11 +385,14 @@ class User < ApplicationRecord super && can?(:log_in) end + # The messages for these keys are defined in `devise.en.yml` def inactive_message - if blocked? - BLOCKED_MESSAGE + if blocked_pending_approval? + :blocked_pending_approval + elsif blocked? + :blocked elsif internal? - LOGIN_FORBIDDEN + :forbidden else super end @@ -535,6 +542,8 @@ class User < ApplicationRecord admins when 'blocked' blocked + when 'blocked_pending_approval' + blocked_pending_approval when 'two_factor_disabled' without_two_factor when 'two_factor_enabled' @@ -687,6 +696,17 @@ class User < ApplicationRecord end end + def security_bot + email_pattern = "security-bot%s@#{Settings.gitlab.host}" + + unique_internal(where(user_type: :security_bot), 'GitLab-Security-Bot', email_pattern) do |u| + u.bio = 'System bot that monitors detected vulnerabilities for solutions and creates merge requests with the fixes.' + u.name = 'GitLab Security Bot' + u.website_url = Gitlab::Routing.url_helpers.help_page_url('user/application_security/security_bot/index.md') + u.avatar = bot_avatar(image: 'security-bot.png') + end + end + def support_bot email_pattern = "support%s@#{Settings.gitlab.host}" @@ -773,7 +793,7 @@ class User < ApplicationRecord end def two_factor_otp_enabled? - otp_required_for_login? + otp_required_for_login? || Feature.enabled?(:forti_authenticator, self) end def two_factor_u2f_enabled? @@ -1676,6 +1696,8 @@ class User < ApplicationRecord end def terms_accepted? + return true if project_bot? + accepted_term_id.present? end @@ -1706,7 +1728,7 @@ class User < ApplicationRecord end def can_be_deactivated? - active? && no_recent_activity? + active? && no_recent_activity? && !internal? end def last_active_at diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb index 0ba319aa444..e39ff8712fc 100644 --- a/app/models/user_callout.rb +++ b/app/models/user_callout.rb @@ -19,7 +19,7 @@ class UserCallout < ApplicationRecord webhooks_moved: 13, service_templates_deprecated: 14, admin_integrations_moved: 15, - web_ide_alert_dismissed: 16, + web_ide_alert_dismissed: 16, # no longer in use active_user_count_threshold: 18, # EE-only buy_pipeline_minutes_notification_dot: 19, # EE-only personal_access_token_expiry: 21, # EE-only diff --git a/app/models/user_interacted_project.rb b/app/models/user_interacted_project.rb index 1c615777018..7e7a387d3d4 100644 --- a/app/models/user_interacted_project.rb +++ b/app/models/user_interacted_project.rb @@ -21,7 +21,7 @@ class UserInteractedProject < ApplicationRecord user_id: event.author_id } - cached_exists?(attributes) do + cached_exists?(**attributes) do transaction(requires_new: true) do where(attributes).select(1).first || create!(attributes) true # not caching the whole record here for now diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb index d3b3a46bf74..c05bc80415a 100644 --- a/app/models/user_preference.rb +++ b/app/models/user_preference.rb @@ -8,6 +8,9 @@ class UserPreference < ApplicationRecord belongs_to :user + scope :with_user, -> { joins(:user) } + scope :gitpod_enabled, -> { where(gitpod_enabled: true) } + validates :issue_notes_filter, :merge_request_notes_filter, inclusion: { in: NOTES_FILTERS.values }, presence: true validates :tab_width, numericality: { only_integer: true, diff --git a/app/models/vulnerability.rb b/app/models/vulnerability.rb index 71d0b1db410..a4338c4e2bd 100644 --- a/app/models/vulnerability.rb +++ b/app/models/vulnerability.rb @@ -1,17 +1,7 @@ # frozen_string_literal: true # Placeholder class for model that is implemented in EE -# It reserves '+' as a reference prefix, but the table does not exist in FOSS class Vulnerability < ApplicationRecord - include IgnorableColumns - - def self.reference_prefix - '+' - end - - def self.reference_prefix_escaped - '+' - end end Vulnerability.prepend_if_ee('EE::Vulnerability') diff --git a/app/models/wiki.rb b/app/models/wiki.rb index 9462f7401c4..e329a094319 100644 --- a/app/models/wiki.rb +++ b/app/models/wiki.rb @@ -4,6 +4,7 @@ class Wiki extend ::Gitlab::Utils::Override include HasRepository include Gitlab::Utils::StrongMemoize + include GlobalID::Identification MARKUPS = { # rubocop:disable Style/MultilineIfModifier 'Markdown' => :markdown, @@ -28,14 +29,46 @@ class Wiki # an operation fails. attr_reader :error_message - def self.for_container(container, user = nil) - "#{container.class.name}Wiki".constantize.new(container, user) + # Support run_after_commit callbacks, since we don't have a DB record + # we delegate to the container. + delegate :run_after_commit, to: :container + + class << self + attr_accessor :container_class + + def for_container(container, user = nil) + "#{container.class.name}Wiki".constantize.new(container, user) + end + + # This is needed to support repository lookup through Gitlab::GlRepository::Identifier + def find_by_id(container_id) + container_class.find_by_id(container_id)&.wiki + end end def initialize(container, user = nil) + raise ArgumentError, "user must be a User, got #{user.class}" if user && !user.is_a?(User) + @container = container @user = user - raise ArgumentError, "user must be a User, got #{user.class}" if user && !user.is_a?(User) + end + + def ==(other) + other.is_a?(self.class) && container == other.container + end + + # This is needed in: + # - Storage::Hashed + # - Gitlab::GlRepository::RepoType#identifier_for_container + # + # We also need an `#id` to support `build_stubbed` in tests, where the + # value doesn't matter. + # + # NOTE: Wikis don't have a DB record, so this ID can be the same + # for two wikis in different containers and should not be expected to + # be unique. Use `to_global_id` instead if you need a unique ID. + def id + container.id end def path @@ -103,10 +136,10 @@ class Wiki limited = pages.size > limit pages = pages.first(limit) if limited - [WikiPage.group_by_directory(pages), limited] + [WikiDirectory.group_pages(pages), limited] end - # Finds a page within the repository based on a tile + # Finds a page within the repository based on a title # or slug. # # title - The human readable or parameterized title of @@ -183,7 +216,7 @@ class Wiki override :repository def repository - @repository ||= Gitlab::GlRepository::WIKI.repository_for(container) + @repository ||= Gitlab::GlRepository::WIKI.repository_for(self) end def repository_storage @@ -198,7 +231,6 @@ class Wiki def full_path container.full_path + '.wiki' end - alias_method :id, :full_path # @deprecated use full_path when you need it for an URL route or disk_path when you want to point to the filesystem alias_method :path_with_namespace, :full_path diff --git a/app/models/wiki_directory.rb b/app/models/wiki_directory.rb index df2fe25b08b..3a2613e15d9 100644 --- a/app/models/wiki_directory.rb +++ b/app/models/wiki_directory.rb @@ -3,13 +3,46 @@ class WikiDirectory include ActiveModel::Validations - attr_accessor :slug, :pages + attr_accessor :slug, :entries validates :slug, presence: true - def initialize(slug, pages = []) + # Groups a list of wiki pages into a nested collection of WikiPage and WikiDirectory objects, + # preserving the order of the passed pages. + # + # Returns an array with all entries for the toplevel directory. + # + # @param [Array<WikiPage>] pages + # @return [Array<WikiPage, WikiDirectory>] + # + def self.group_pages(pages) + # Build a hash to map paths to created WikiDirectory objects, + # and recursively create them for each level of the path. + # For the toplevel directory we use '' as path, as that's what WikiPage#directory returns. + directories = Hash.new do |_, path| + directories[path] = new(path).tap do |directory| + if path.present? + parent = File.dirname(path) + parent = '' if parent == '.' + directories[parent].entries << directory + end + end + end + + pages.each do |page| + directories[page.directory].entries << page + end + + directories[''].entries + end + + def initialize(slug, entries = []) @slug = slug - @pages = pages + @entries = entries + end + + def title + WikiPage.unhyphenize(File.basename(slug)) end # Relative path to the partial to be used when rendering collections diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index faf3d19d936..989128987d5 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -31,29 +31,6 @@ class WikiPage alias_method :==, :eql? - # Sorts and groups pages by directory. - # - # pages - an array of WikiPage objects. - # - # Returns an array of WikiPage and WikiDirectory objects. The entries are - # sorted by alphabetical order (directories and pages inside each directory). - # Pages at the root level come before everything. - def self.group_by_directory(pages) - return [] if pages.blank? - - pages.each_with_object([]) do |page, grouped_pages| - next grouped_pages << page unless page.directory.present? - - directory = grouped_pages.find do |obj| - obj.is_a?(WikiDirectory) && obj.slug == page.directory - end - - next directory.pages << page if directory - - grouped_pages << WikiDirectory.new(page.directory, [page]) - end - end - def self.unhyphenize(name) name.gsub(/-+/, ' ') end |