diff options
Diffstat (limited to 'app/models/concerns')
24 files changed, 576 insertions, 94 deletions
diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index 49fc780f372..45944401c2d 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -70,8 +70,12 @@ module CacheMarkdownField def refresh_markdown_cache! updates = refresh_markdown_cache - - save_markdown(updates) + if updates.present? && save_markdown(updates) + # save_markdown updates DB columns directly, so compute and save mentions + # by calling store_mentions! or we end-up with missing mentions although those + # would appear in the notes, descriptions, etc in the UI + store_mentions! if mentionable_attributes_changed?(updates) + end end def cached_html_up_to_date?(markdown_field) @@ -106,7 +110,19 @@ module CacheMarkdownField def updated_cached_html_for(markdown_field) return unless cached_markdown_fields.markdown_fields.include?(markdown_field) - refresh_markdown_cache! if attribute_invalidated?(cached_markdown_fields.html_field(markdown_field)) + if attribute_invalidated?(cached_markdown_fields.html_field(markdown_field)) + # Invalidated due to Markdown content change + # We should not persist the updated HTML here since this will depend on whether the + # Markdown content change will be persisted. Both will be persisted together when the model is saved. + if changed_attributes.key?(markdown_field) + refresh_markdown_cache + else + # Invalidated due to stale HTML cache + # This could happen when the Markdown cache version is bumped or when a model is imported and the HTML is empty. + # We persist the updated HTML here so that subsequent calls to this method do not have to regenerate the HTML again. + refresh_markdown_cache! + end + end cached_html_for(markdown_field) end @@ -140,6 +156,46 @@ module CacheMarkdownField nil end + def store_mentions! + refs = all_references(self.author) + + references = {} + references[:mentioned_users_ids] = refs.mentioned_users&.pluck(:id).presence + references[:mentioned_groups_ids] = refs.mentioned_groups&.pluck(:id).presence + references[:mentioned_projects_ids] = refs.mentioned_projects&.pluck(:id).presence + + # One retry is enough as next time `model_user_mention` should return the existing mention record, + # that threw the `ActiveRecord::RecordNotUnique` exception in first place. + self.class.safe_ensure_unique(retries: 1) do + user_mention = model_user_mention + + # this may happen due to notes polymorphism, so noteable_id may point to a record + # that no longer exists as we cannot have FK on noteable_id + break if user_mention.blank? + + user_mention.mentioned_users_ids = references[:mentioned_users_ids] + user_mention.mentioned_groups_ids = references[:mentioned_groups_ids] + user_mention.mentioned_projects_ids = references[:mentioned_projects_ids] + + if user_mention.has_mentions? + user_mention.save! + else + user_mention.destroy! + end + end + + true + end + + def mentionable_attributes_changed?(changes = saved_changes) + return false unless is_a?(Mentionable) + + self.class.mentionable_attrs.any? do |attr| + changes.key?(cached_markdown_fields.html_field(attr.first)) && + changes.fetch(cached_markdown_fields.html_field(attr.first)).last.present? + end + end + included do cattr_reader :cached_markdown_fields do Gitlab::MarkdownCache::FieldData.new diff --git a/app/models/concerns/can_move_repository_storage.rb b/app/models/concerns/can_move_repository_storage.rb new file mode 100644 index 00000000000..52c3a4106e3 --- /dev/null +++ b/app/models/concerns/can_move_repository_storage.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module CanMoveRepositoryStorage + extend ActiveSupport::Concern + + RepositoryReadOnlyError = Class.new(StandardError) + + # Tries to set repository as read_only, checking for existing Git transfers in + # progress beforehand. Setting a repository read-only will fail if it is + # already in that state. + # + # @return nil. Failures will raise an exception + def set_repository_read_only!(skip_git_transfer_check: false) + with_lock do + raise RepositoryReadOnlyError, _('Git transfer in progress') if + !skip_git_transfer_check && git_transfer_in_progress? + + raise RepositoryReadOnlyError, _('Repository already read-only') if + self.class.where(id: id).pick(:repository_read_only) + + raise ActiveRecord::RecordNotSaved, _('Database update failed') unless + update_column(:repository_read_only, true) + + nil + end + end + + # Set repository as writable again. Unlike setting it read-only, this will + # succeed if the repository is already writable. + def set_repository_writable! + with_lock do + raise ActiveRecord::RecordNotSaved, _('Database update failed') unless + update_column(:repository_read_only, false) + + nil + end + end + + def git_transfer_in_progress? + reference_counter(type: repository.repo_type).value > 0 + end + + def reference_counter(type:) + Gitlab::ReferenceCounter.new(type.identifier_for_container(self)) + end +end diff --git a/app/models/concerns/case_sensitivity.rb b/app/models/concerns/case_sensitivity.rb index abddbf1c7e3..31b5afd604d 100644 --- a/app/models/concerns/case_sensitivity.rb +++ b/app/models/concerns/case_sensitivity.rb @@ -11,12 +11,14 @@ module CaseSensitivity def iwhere(params) criteria = self - params.each do |key, value| + params.each do |column, value| + column = arel_table[column] unless column.is_a?(Arel::Attribute) + criteria = case value when Array - criteria.where(value_in(key, value)) + criteria.where(value_in(column, value)) else - criteria.where(value_equal(key, value)) + criteria.where(value_equal(column, value)) end end @@ -28,7 +30,7 @@ module CaseSensitivity def value_equal(column, value) lower_value = lower_value(value) - lower_column(arel_table[column]).eq(lower_value).to_sql + lower_column(column).eq(lower_value).to_sql end def value_in(column, values) @@ -36,7 +38,7 @@ module CaseSensitivity lower_value(value) end - lower_column(arel_table[column]).in(lower_values).to_sql + lower_column(column).in(lower_values).to_sql end def lower_value(value) diff --git a/app/models/concerns/enums/ci/pipeline.rb b/app/models/concerns/enums/ci/pipeline.rb index bb8df37f649..e1f07fa162c 100644 --- a/app/models/concerns/enums/ci/pipeline.rb +++ b/app/models/concerns/enums/ci/pipeline.rb @@ -9,7 +9,8 @@ module Enums { unknown_failure: 0, config_error: 1, - external_validation_failure: 2 + external_validation_failure: 2, + deployments_limit_exceeded: 23 } end @@ -24,8 +25,6 @@ module Enums schedule: 4, api: 5, external: 6, - # TODO: Rename `pipeline` to `cross_project_pipeline` in 13.0 - # https://gitlab.com/gitlab-org/gitlab/issues/195991 pipeline: 7, chat: 8, webide: 9, @@ -53,6 +52,10 @@ module Enums sources.except(*dangling_sources.keys) end + def self.ci_branch_sources + ci_sources.except(:merge_request_event) + end + def self.ci_and_parent_sources ci_sources.merge(sources.slice(:parent_pipeline)) end diff --git a/app/models/concerns/enums/data_visualization_palette.rb b/app/models/concerns/enums/data_visualization_palette.rb new file mode 100644 index 00000000000..25002e64ba6 --- /dev/null +++ b/app/models/concerns/enums/data_visualization_palette.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Enums + # These color palettes are part of the Pajamas Design System. + # See https://design.gitlab.com/data-visualization/color/#categorical-data + module DataVisualizationPalette + def self.colors + { + blue: 0, + orange: 1, + aqua: 2, + green: 3, + magenta: 4 + } + end + + def self.weights + { + '50' => 0, + '100' => 1, + '200' => 2, + '300' => 3, + '400' => 4, + '500' => 5, + '600' => 6, + '700' => 7, + '800' => 8, + '900' => 9, + '950' => 10 + } + end + end +end diff --git a/app/models/concerns/enums/internal_id.rb b/app/models/concerns/enums/internal_id.rb index f01bd60ef16..b08c05b1934 100644 --- a/app/models/concerns/enums/internal_id.rb +++ b/app/models/concerns/enums/internal_id.rb @@ -15,7 +15,8 @@ module Enums operations_user_lists: 7, alert_management_alerts: 8, sprints: 9, # iterations - design_management_designs: 10 + design_management_designs: 10, + incident_management_oncall_schedules: 11 } end end diff --git a/app/models/concerns/has_repository.rb b/app/models/concerns/has_repository.rb index 3dea4a9f5fb..9692941d8b2 100644 --- a/app/models/concerns/has_repository.rb +++ b/app/models/concerns/has_repository.rb @@ -88,7 +88,7 @@ module HasRepository group_branch_default_name = group&.default_branch_name if respond_to?(:group) - group_branch_default_name || Gitlab::CurrentSettings.default_branch_name + (group_branch_default_name || Gitlab::CurrentSettings.default_branch_name).presence end def reload_default_branch diff --git a/app/models/concerns/has_wiki_page_meta_attributes.rb b/app/models/concerns/has_wiki_page_meta_attributes.rb new file mode 100644 index 00000000000..136f2d00ce3 --- /dev/null +++ b/app/models/concerns/has_wiki_page_meta_attributes.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +module HasWikiPageMetaAttributes + extend ActiveSupport::Concern + include Gitlab::Utils::StrongMemoize + + CanonicalSlugConflictError = Class.new(ActiveRecord::RecordInvalid) + WikiPageInvalid = Class.new(ArgumentError) + + included do + has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent + + validates :title, length: { maximum: 255 }, allow_nil: false + validate :no_two_metarecords_in_same_container_can_have_same_canonical_slug + + scope :with_canonical_slug, ->(slug) do + slug_table_name = klass.reflect_on_association(:slugs).table_name + + joins(:slugs).where(slug_table_name => { canonical: true, slug: slug }) + end + end + + class_methods do + # Return the (updated) WikiPage::Meta record for a given wiki page + # + # If none is found, then a new record is created, and its fields are set + # to reflect the wiki_page passed. + # + # @param [String] last_known_slug + # @param [WikiPage] wiki_page + # + # This method raises errors on validation issues. + def find_or_create(last_known_slug, wiki_page) + raise WikiPageInvalid unless wiki_page.valid? + + container = wiki_page.wiki.container + known_slugs = [last_known_slug, wiki_page.slug].compact.uniq + raise 'No slugs found! This should not be possible.' if known_slugs.empty? + + transaction do + updates = wiki_page_updates(wiki_page) + found = find_by_canonical_slug(known_slugs, container) + meta = found || create!(updates.merge(container_attrs(container))) + + meta.update_state(found.nil?, known_slugs, wiki_page, updates) + + # We don't need to run validations here, since find_by_canonical_slug + # guarantees that there is no conflict in canonical_slug, and DB + # constraints on title and project_id/group_id enforce our other invariants + # This saves us a query. + meta + end + end + + def find_by_canonical_slug(canonical_slug, container) + meta, conflict = with_canonical_slug(canonical_slug) + .where(container_attrs(container)) + .limit(2) + + if conflict.present? + meta.errors.add(:canonical_slug, 'Duplicate value found') + raise CanonicalSlugConflictError.new(meta) + end + + meta + end + + private + + def wiki_page_updates(wiki_page) + last_commit_date = wiki_page.version_commit_timestamp || Time.now.utc + + { + title: wiki_page.title, + created_at: last_commit_date, + updated_at: last_commit_date + } + end + + def container_key + raise NotImplementedError + end + + def container_attrs(container) + { container_key => container.id } + end + end + + def canonical_slug + strong_memoize(:canonical_slug) { slugs.canonical.take&.slug } + end + + # rubocop:disable Gitlab/ModuleWithInstanceVariables + def canonical_slug=(slug) + return if @canonical_slug == slug + + if persisted? + transaction do + slugs.canonical.update_all(canonical: false) + page_slug = slugs.create_with(canonical: true).find_or_create_by(slug: slug) + page_slug.update_columns(canonical: true) unless page_slug.canonical? + end + else + slugs.new(slug: slug, canonical: true) + end + + @canonical_slug = slug + end + # rubocop:enable Gitlab/ModuleWithInstanceVariables + + def update_state(created, known_slugs, wiki_page, updates) + update_wiki_page_attributes(updates) + insert_slugs(known_slugs, created, wiki_page.slug) + self.canonical_slug = wiki_page.slug + end + + private + + def update_wiki_page_attributes(updates) + # Remove all unnecessary updates: + updates.delete(:updated_at) if updated_at == updates[:updated_at] + updates.delete(:created_at) if created_at <= updates[:created_at] + updates.delete(:title) if title == updates[:title] + + update_columns(updates) unless updates.empty? + end + + def insert_slugs(strings, is_new, canonical_slug) + creation = Time.current.utc + + slug_attrs = strings.map do |slug| + slug_attributes(slug, canonical_slug, is_new, creation) + end + slugs.insert_all(slug_attrs) unless !is_new && slug_attrs.size == 1 + + @canonical_slug = canonical_slug if is_new || strings.size == 1 # rubocop:disable Gitlab/ModuleWithInstanceVariables + end + + def slug_attributes(slug, canonical_slug, is_new, creation) + { + slug: slug, + canonical: (is_new && slug == canonical_slug), + created_at: creation, + updated_at: creation + }.merge(slug_meta_attributes) + end + + def slug_meta_attributes + { self.association(:slugs).reflection.foreign_key => id } + end + + def no_two_metarecords_in_same_container_can_have_same_canonical_slug + container_id = attributes[self.class.container_key.to_s] + + return unless container_id.present? && canonical_slug.present? + + offending = self.class.with_canonical_slug(canonical_slug).where(self.class.container_key => container_id) + offending = offending.where.not(id: id) if persisted? + + if offending.exists? + errors.add(:canonical_slug, 'each page in a wiki must have a distinct canonical slug') + end + end +end diff --git a/app/models/concerns/has_wiki_page_slug_attributes.rb b/app/models/concerns/has_wiki_page_slug_attributes.rb new file mode 100644 index 00000000000..3335eccbaf6 --- /dev/null +++ b/app/models/concerns/has_wiki_page_slug_attributes.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module HasWikiPageSlugAttributes + extend ActiveSupport::Concern + + included do + validates :slug, uniqueness: { scope: meta_foreign_key } + validates :slug, length: { maximum: 2048 }, allow_nil: false + validates :canonical, uniqueness: { + scope: meta_foreign_key, + if: :canonical?, + message: 'Only one slug can be canonical per wiki metadata record' + } + + scope :canonical, -> { where(canonical: true) } + + def update_columns(attrs = {}) + super(attrs.reverse_merge(updated_at: Time.current.utc)) + end + end + + def self.update_all(attrs = {}) + super(attrs.reverse_merge(updated_at: Time.current.utc)) + end +end diff --git a/app/models/concerns/ignorable_columns.rb b/app/models/concerns/ignorable_columns.rb index 744a1f0b5f3..4cbcb25406d 100644 --- a/app/models/concerns/ignorable_columns.rb +++ b/app/models/concerns/ignorable_columns.rb @@ -31,15 +31,13 @@ module IgnorableColumns alias_method :ignore_column, :ignore_columns def ignored_columns_details - unless defined?(@ignored_columns_details) - IGNORE_COLUMN_MUTEX.synchronize do - @ignored_columns_details ||= superclass.try(:ignored_columns_details)&.dup || {} - end - end + return @ignored_columns_details if defined?(@ignored_columns_details) - @ignored_columns_details + IGNORE_COLUMN_MONITOR.synchronize do + @ignored_columns_details ||= superclass.try(:ignored_columns_details)&.dup || {} + end end - IGNORE_COLUMN_MUTEX = Mutex.new + IGNORE_COLUMN_MONITOR = Monitor.new end end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 7624a1a4e80..c3a394c1ca5 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -84,7 +84,6 @@ module Issuable validate :description_max_length_for_new_records_is_valid, on: :update before_validation :truncate_description_on_import! - after_save :store_mentions!, if: :any_mentionable_attributes_changed? scope :authored, ->(user) { where(author_id: user) } scope :recent, -> { reorder(id: :desc) } @@ -198,7 +197,7 @@ module Issuable end def severity - return IssuableSeverity::DEFAULT unless incident? + return IssuableSeverity::DEFAULT unless supports_severity? issuable_severity&.severity || IssuableSeverity::DEFAULT end @@ -305,14 +304,12 @@ module Issuable end def order_labels_priority(direction = 'ASC', excluded_labels: [], extra_select_columns: [], with_cte: false) - params = { + highest_priority = highest_label_priority( target_type: name, target_column: "#{table_name}.id", project_column: "#{table_name}.#{project_foreign_key}", excluded_labels: excluded_labels - } - - highest_priority = highest_label_priority(params).to_sql + ).to_sql # When using CTE make sure to select the same columns that are on the group_by clause. # This prevents errors when ignored columns are present in the database. diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index b10e8547e86..5db077c178d 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -80,37 +80,6 @@ module Mentionable all_references(current_user).users end - def store_mentions! - refs = all_references(self.author) - - references = {} - references[:mentioned_users_ids] = refs.mentioned_users&.pluck(:id).presence - references[:mentioned_groups_ids] = refs.mentioned_groups&.pluck(:id).presence - references[:mentioned_projects_ids] = refs.mentioned_projects&.pluck(:id).presence - - # One retry should be enough as next time `model_user_mention` should return the existing mention record, that - # threw the `ActiveRecord::RecordNotUnique` exception in first place. - self.class.safe_ensure_unique(retries: 1) do - user_mention = model_user_mention - - # this may happen due to notes polymorphism, so noteable_id may point to a record that no longer exists - # as we cannot have FK on noteable_id - break if user_mention.blank? - - user_mention.mentioned_users_ids = references[:mentioned_users_ids] - user_mention.mentioned_groups_ids = references[:mentioned_groups_ids] - user_mention.mentioned_projects_ids = references[:mentioned_projects_ids] - - if user_mention.has_mentions? - user_mention.save! - else - user_mention.destroy! - end - end - - true - end - def referenced_users User.where(id: user_mentions.select("unnest(mentioned_users_ids)")) end @@ -216,12 +185,6 @@ module Mentionable source.select { |key, val| mentionable.include?(key) } end - def any_mentionable_attributes_changed? - self.class.mentionable_attrs.any? do |attr| - saved_changes.key?(attr.first) - end - end - # Determine whether or not a cross-reference Note has already been created between this Mentionable and # the specified target. def cross_reference_exists?(target) @@ -237,12 +200,12 @@ module Mentionable end # User mention that is parsed from model description rather then its related notes. - # Models that have a descriprion attribute like Issue, MergeRequest, Epic, Snippet may have such a user mention. + # Models that have a description attribute like Issue, MergeRequest, Epic, Snippet may have such a user mention. # Other mentionable models like Commit, DesignManagement::Design, will never have such record as those do not have # a description attribute. # # Using this method followed by a call to *save* may result in *ActiveRecord::RecordNotUnique* exception - # in a multithreaded environment. Make sure to use it within a *safe_ensure_unique* block. + # in a multi-threaded environment. Make sure to use it within a *safe_ensure_unique* block. def model_user_mention user_mentions.where(note_id: nil).first_or_initialize end diff --git a/app/models/concerns/optimized_issuable_label_filter.rb b/app/models/concerns/optimized_issuable_label_filter.rb index 7be4a26d4fa..82055822cfb 100644 --- a/app/models/concerns/optimized_issuable_label_filter.rb +++ b/app/models/concerns/optimized_issuable_label_filter.rb @@ -1,6 +1,15 @@ # frozen_string_literal: true module OptimizedIssuableLabelFilter + extend ActiveSupport::Concern + + prepended do + extend Gitlab::Cache::RequestCache + + # Avoid repeating label queries times when the finder is instantiated multiple times during the request. + request_cache(:find_label_ids) { [root_namespace.id, params.label_names] } + end + def by_label(items) return items unless params.labels? @@ -41,7 +50,7 @@ module OptimizedIssuableLabelFilter def issuables_with_selected_labels(items, target_model) if root_namespace - all_label_ids = find_label_ids(root_namespace) + all_label_ids = find_label_ids # Found less labels in the DB than we were searching for. Return nothing. return items.none if all_label_ids.size != params.label_names.size @@ -57,18 +66,20 @@ module OptimizedIssuableLabelFilter items end - def find_label_ids(root_namespace) - finder_params = { - include_subgroups: true, - include_ancestor_groups: true, - include_descendant_groups: true, - group: root_namespace, - title: params.label_names - } - - LabelsFinder - .new(nil, finder_params) - .execute(skip_authorization: true) + def find_label_ids + group_labels = Label + .where(project_id: nil) + .where(title: params.label_names) + .where(group_id: root_namespace.self_and_descendants.select(:id)) + + project_labels = Label + .where(group_id: nil) + .where(title: params.label_names) + .where(project_id: Project.select(:id).where(namespace_id: root_namespace.self_and_descendants.select(:id))) + + Label + .from_union([group_labels, project_labels], remove_duplicates: false) + .reorder(nil) .pluck(:title, :id) .group_by(&:first) .values diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb index b69fb2931c3..07bec07e556 100644 --- a/app/models/concerns/project_features_compatibility.rb +++ b/app/models/concerns/project_features_compatibility.rb @@ -70,6 +70,14 @@ module ProjectFeaturesCompatibility write_feature_attribute_string(:metrics_dashboard_access_level, value) end + def analytics_access_level=(value) + write_feature_attribute_string(:analytics_access_level, value) + end + + def operations_access_level=(value) + write_feature_attribute_string(:operations_access_level, value) + end + private def write_feature_attribute_boolean(field, value) diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb index cddca72f91f..65195a8d5aa 100644 --- a/app/models/concerns/protected_ref.rb +++ b/app/models/concerns/protected_ref.rb @@ -12,6 +12,10 @@ module ProtectedRef delegate :matching, :matches?, :wildcard?, to: :ref_matcher scope :for_project, ->(project) { where(project: project) } + + def allow_multiple?(type) + false + end end def commit @@ -29,7 +33,7 @@ module ProtectedRef # to fail. has_many :"#{type}_access_levels", inverse_of: self.model_name.singular - validates :"#{type}_access_levels", length: { is: 1, message: "are restricted to a single instance per #{self.model_name.human}." } + validates :"#{type}_access_levels", length: { is: 1, message: "are restricted to a single instance per #{self.model_name.human}." }, unless: -> { allow_multiple?(type) } accepts_nested_attributes_for :"#{type}_access_levels", allow_destroy: true end diff --git a/app/models/concerns/protected_ref_access.rb b/app/models/concerns/protected_ref_access.rb index 28dc3366e51..5e38ce7cad8 100644 --- a/app/models/concerns/protected_ref_access.rb +++ b/app/models/concerns/protected_ref_access.rb @@ -45,6 +45,7 @@ module ProtectedRefAccess end def check_access(user) + return false unless user return true if user.admin? user.can?(:push_code, project) && diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb index 3470bdab5fb..dbc70ac2218 100644 --- a/app/models/concerns/reactive_caching.rb +++ b/app/models/concerns/reactive_caching.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true # The usage of the ReactiveCaching module is documented here: -# https://docs.gitlab.com/ee/development/reactive_caching.md +# https://docs.gitlab.com/ee/development/reactive_caching.html +# module ReactiveCaching extend ActiveSupport::Concern diff --git a/app/models/concerns/repository_storage_movable.rb b/app/models/concerns/repository_storage_movable.rb new file mode 100644 index 00000000000..a45b4626628 --- /dev/null +++ b/app/models/concerns/repository_storage_movable.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +module RepositoryStorageMovable + extend ActiveSupport::Concern + include AfterCommitQueue + + included do + scope :order_created_at_desc, -> { order(created_at: :desc) } + + validates :container, presence: true + validates :state, presence: true + validates :source_storage_name, + on: :create, + presence: true, + inclusion: { in: ->(_) { Gitlab.config.repositories.storages.keys } } + validates :destination_storage_name, + on: :create, + presence: true, + inclusion: { in: ->(_) { Gitlab.config.repositories.storages.keys } } + validate :container_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 + end + + event :start do + transition scheduled: :started + end + + event :finish_replication do + transition started: :replicated + end + + event :finish_cleanup do + transition replicated: :finished + end + + event :do_fail do + transition [:initial, :scheduled, :started] => :failed + transition replicated: :cleanup_failed + end + + around_transition initial: :scheduled do |storage_move, block| + block.call + + begin + storage_move.container.set_repository_read_only!(skip_git_transfer_check: true) + rescue => err + storage_move.add_error(err.message) + next false + end + + storage_move.run_after_commit do + storage_move.schedule_repository_storage_update_worker + end + + true + end + + before_transition started: :replicated do |storage_move| + storage_move.container.set_repository_writable! + + storage_move.update_repository_storage(storage_move.destination_storage_name) + end + + before_transition started: :failed do |storage_move| + storage_move.container.set_repository_writable! + end + + state :initial, value: 1 + state :scheduled, value: 2 + state :started, value: 3 + state :finished, value: 4 + state :failed, value: 5 + state :replicated, value: 6 + state :cleanup_failed, value: 7 + end + end + + class_methods do + private + + def pick_repository_storage + container_klass = reflect_on_association(:container).class_name.constantize + + container_klass.pick_repository_storage + end + end + + # Projects, snippets, and group wikis has different db structure. In projects, + # we need to update some columns in this step, but we don't with the other resources. + # + # Therefore, we create this No-op method for snippets and wikis and let project + # overwrite it in their implementation. + def update_repository_storage(new_storage) + # No-op + end + + def schedule_repository_storage_update_worker + raise NotImplementedError + end + + def add_error(message) + errors.add(error_key, message) + end + + private + + def container_repository_writable + add_error(_('is read only')) if container&.repository_read_only? + end + + def error_key + raise NotImplementedError + end +end diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index c70ce9bebcc..71d8e06de76 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -4,6 +4,36 @@ # Object must have name and path db fields and respond to parent and parent_changed? methods. module Routable extend ActiveSupport::Concern + include CaseSensitivity + + # Finds a Routable object by its full path, without knowing the class. + # + # Usage: + # + # Routable.find_by_full_path('groupname') # -> Group + # Routable.find_by_full_path('groupname/projectname') # -> Project + # + # Returns a single object, or nil. + def self.find_by_full_path(path, follow_redirects: false, route_scope: Route, redirect_route_scope: RedirectRoute) + return unless path.present? + + # Case sensitive match first (it's cheaper and the usual case) + # If we didn't have an exact match, we perform a case insensitive search + # + # We need to qualify the columns with the table name, to support both direct lookups on + # Route/RedirectRoute, and scoped lookups through the Routable classes. + route = + route_scope.find_by(routes: { path: path }) || + route_scope.iwhere(Route.arel_table[:path] => path).take + + if follow_redirects + route ||= redirect_route_scope.iwhere(RedirectRoute.arel_table[:path] => path).take + end + + return unless route + + route.is_a?(Routable) ? route : route.source + end included do # Remove `inverse_of: source` when upgraded to rails 5.2 @@ -30,15 +60,14 @@ module Routable # # Returns a single object, or nil. def find_by_full_path(path, follow_redirects: false) - # Case sensitive match first (it's cheaper and the usual case) - # If we didn't have an exact match, we perform a case insensitive search - found = includes(:route).find_by(routes: { path: path }) || where_full_path_in([path]).take - - return found if found - - if follow_redirects - joins(:redirect_routes).find_by("LOWER(redirect_routes.path) = LOWER(?)", path) - end + # TODO: Optimize these queries by avoiding joins + # https://gitlab.com/gitlab-org/gitlab/-/issues/292252 + Routable.find_by_full_path( + path, + follow_redirects: follow_redirects, + route_scope: includes(:route).references(:routes), + redirect_route_scope: joins(:redirect_routes) + ) end # Builds a relation to find multiple objects by their full paths. diff --git a/app/models/concerns/shardable.rb b/app/models/concerns/shardable.rb index c0883c08289..4bebb99d195 100644 --- a/app/models/concerns/shardable.rb +++ b/app/models/concerns/shardable.rb @@ -8,6 +8,7 @@ module Shardable 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 }) } + scope :for_shard, -> (shard) { where(shard_id: shard) } validates :shard, presence: true end diff --git a/app/models/concerns/timebox.rb b/app/models/concerns/timebox.rb index 23fd73f2904..8273059b30c 100644 --- a/app/models/concerns/timebox.rb +++ b/app/models/concerns/timebox.rb @@ -12,10 +12,16 @@ module Timebox include FromUnion TimeboxStruct = Struct.new(:title, :name, :id) do + include GlobalID::Identification + # Ensure these models match the interface required for exporting def serializable_hash(_opts = {}) { title: title, name: name, id: id } end + + def self.declarative_policy_class + "TimeboxPolicy" + end end # Represents a "No Timebox" state used for filtering Issues and Merge diff --git a/app/models/concerns/token_authenticatable.rb b/app/models/concerns/token_authenticatable.rb index a1f83884f02..535cf25eb9d 100644 --- a/app/models/concerns/token_authenticatable.rb +++ b/app/models/concerns/token_authenticatable.rb @@ -57,6 +57,13 @@ module TokenAuthenticatable token = read_attribute(token_field) token.present? && ActiveSupport::SecurityUtils.secure_compare(other_token, token) end + + # Base strategy delegates to this method for formatting a token before + # calling set_token. Can be overridden in models to e.g. add a prefix + # to the tokens + mod.define_method("format_#{token_field}") do |token| + token + end end def token_authenticatable_module diff --git a/app/models/concerns/token_authenticatable_strategies/base.rb b/app/models/concerns/token_authenticatable_strategies/base.rb index aafd0b538a3..f72a41f06b1 100644 --- a/app/models/concerns/token_authenticatable_strategies/base.rb +++ b/app/models/concerns/token_authenticatable_strategies/base.rb @@ -18,10 +18,15 @@ module TokenAuthenticatableStrategies raise NotImplementedError end - def set_token(instance) + def set_token(instance, token) raise NotImplementedError end + # Default implementation returns the token as-is + def format_token(instance, token) + instance.send("format_#{@token_field}", token) # rubocop:disable GitlabSecurity/PublicSend + end + def ensure_token(instance) write_new_token(instance) unless token_set?(instance) get_token(instance) @@ -57,7 +62,8 @@ module TokenAuthenticatableStrategies def write_new_token(instance) new_token = generate_available_token - set_token(instance, new_token) + formatted_token = format_token(instance, new_token) + set_token(instance, formatted_token) end def unique diff --git a/app/models/concerns/triggerable_hooks.rb b/app/models/concerns/triggerable_hooks.rb index 325a5531926..473b430bb04 100644 --- a/app/models/concerns/triggerable_hooks.rb +++ b/app/models/concerns/triggerable_hooks.rb @@ -15,14 +15,13 @@ module TriggerableHooks wiki_page_hooks: :wiki_page_events, deployment_hooks: :deployment_events, feature_flag_hooks: :feature_flag_events, - release_hooks: :releases_events + release_hooks: :releases_events, + member_hooks: :member_events }.freeze extend ActiveSupport::Concern class_methods do - attr_reader :triggerable_hooks - attr_reader :triggers def hooks_for(trigger) |