From b595cb0c1dec83de5bdee18284abe86614bed33b Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Wed, 20 Jul 2022 15:40:28 +0000 Subject: Add latest changes from gitlab-org/gitlab@15-2-stable-ee --- app/models/ability.rb | 7 + app/models/application_setting.rb | 16 ++ app/models/application_setting_implementation.rb | 5 + app/models/authentication_event.rb | 5 + app/models/awareness_session.rb | 236 +++++++++++++++++++++ app/models/ci/build.rb | 47 ++-- app/models/ci/build_report_result.rb | 4 - app/models/ci/group.rb | 3 +- app/models/ci/group_variable.rb | 4 + app/models/ci/job_artifact.rb | 2 +- app/models/ci/legacy_stage.rb | 73 ------- app/models/ci/pending_build.rb | 14 +- app/models/ci/pipeline.rb | 85 +++++--- app/models/ci/pipeline_artifact.rb | 17 ++ app/models/ci/runner.rb | 20 +- app/models/ci/runner_version.rb | 34 +++ app/models/ci/stage.rb | 2 +- app/models/ci/trigger.rb | 3 + app/models/ci/variable.rb | 4 + app/models/clusters/agent.rb | 2 + app/models/clusters/applications/elastic_stack.rb | 113 ---------- app/models/clusters/cluster.rb | 21 -- .../clusters/concerns/elasticsearch_client.rb | 38 ---- app/models/clusters/integrations/elastic_stack.rb | 40 ---- app/models/clusters/integrations/prometheus.rb | 18 +- app/models/commit_status.rb | 9 +- app/models/concerns/awareness.rb | 41 ++++ app/models/concerns/cache_markdown_field.rb | 9 +- app/models/concerns/ci/artifactable.rb | 2 + app/models/concerns/ci/bulk_insertable_tags.rb | 24 +++ app/models/concerns/ci/has_status.rb | 6 +- app/models/concerns/each_batch.rb | 61 ++++++ app/models/concerns/enums/ci/commit_status.rb | 5 +- .../integrations/has_issue_tracker_fields.rb | 18 +- .../integrations/slack_mattermost_notifier.rb | 2 +- app/models/concerns/loose_index_scan.rb | 67 ++++++ app/models/concerns/milestoneable.rb | 2 +- .../concerns/notification_branch_selection.rb | 16 +- app/models/concerns/packages/fips.rb | 11 + app/models/concerns/participable.rb | 23 +- app/models/concerns/require_email_verification.rb | 52 +++++ .../concerns/vulnerability_finding_helpers.rb | 37 ++++ app/models/container_registry/event.rb | 11 +- app/models/container_repository.rb | 2 +- app/models/customer_relations/contact.rb | 17 +- app/models/deploy_token.rb | 3 - app/models/deployment.rb | 22 +- app/models/environment.rb | 8 +- app/models/error_tracking/client_key.rb | 2 +- app/models/group.rb | 20 +- app/models/hooks/project_hook.rb | 15 ++ app/models/hooks/system_hook.rb | 2 +- app/models/hooks/web_hook.rb | 39 ++++ .../issuable_escalation_status.rb | 2 +- app/models/integration.rb | 35 ++- app/models/integrations/asana.rb | 37 ++-- app/models/integrations/assembla.rb | 29 +-- app/models/integrations/bamboo.rb | 1 - app/models/integrations/base_chat_notification.rb | 46 ++-- app/models/integrations/base_issue_tracker.rb | 2 +- app/models/integrations/campfire.rb | 63 +++--- app/models/integrations/confluence.rb | 19 +- app/models/integrations/datadog.rb | 157 +++++++------- app/models/integrations/discord.rb | 6 +- app/models/integrations/drone_ci.rb | 3 +- app/models/integrations/emails_on_push.rb | 51 ++--- app/models/integrations/external_wiki.rb | 22 +- app/models/integrations/field.rb | 27 ++- app/models/integrations/flowdock.rb | 23 +- app/models/integrations/hangouts_chat.rb | 5 +- app/models/integrations/harbor.rb | 2 +- app/models/integrations/irker.rb | 93 ++++---- app/models/integrations/jira.rb | 3 +- app/models/integrations/mattermost.rb | 6 + app/models/integrations/microsoft_teams.rb | 5 +- app/models/integrations/mock_ci.rb | 2 +- app/models/integrations/packagist.rb | 51 ++--- app/models/integrations/pipelines_email.rb | 34 +-- app/models/integrations/pivotaltracker.rb | 35 ++- app/models/integrations/prometheus.rb | 66 +++--- app/models/integrations/pushover.rb | 141 ++++++------ app/models/integrations/shimo.rb | 18 +- app/models/integrations/slack.rb | 5 + app/models/integrations/teamcity.rb | 5 +- app/models/integrations/unify_circuit.rb | 8 +- app/models/integrations/webex_teams.rb | 7 +- app/models/integrations/youtrack.rb | 5 +- app/models/integrations/zentao.rb | 56 ++--- app/models/issue.rb | 72 +++++-- app/models/key.rb | 14 +- app/models/member.rb | 18 +- app/models/members/project_member.rb | 12 +- app/models/merge_request.rb | 5 + app/models/merge_request_diff.rb | 51 +++++ app/models/merge_request_diff_file.rb | 43 +++- app/models/namespace.rb | 15 +- app/models/namespace_setting.rb | 23 +- app/models/note.rb | 27 +++ app/models/notification_recipient.rb | 4 + app/models/oauth_access_token.rb | 2 + app/models/operations/feature_flags_client.rb | 24 +++ app/models/packages/cleanup/policy.rb | 15 ++ app/models/packages/debian/file_entry.rb | 3 + app/models/pages/virtual_domain.rb | 9 +- app/models/pages_domain.rb | 10 +- ...users_max_access_level_in_projects_preloader.rb | 8 + app/models/project.rb | 154 +++++++++++--- app/models/project_export_job.rb | 1 + app/models/project_feature.rb | 5 + app/models/project_import_state.rb | 9 +- app/models/project_setting.rb | 2 + app/models/project_team.rb | 18 +- app/models/project_tracing_setting.rb | 15 -- .../projects/import_export/relation_export.rb | 22 ++ .../import_export/relation_export_upload.rb | 19 ++ app/models/protected_branch.rb | 4 +- app/models/remote_mirror.rb | 5 +- app/models/repository.rb | 4 +- app/models/snippet.rb | 1 + app/models/ssh_host_key.rb | 10 +- app/models/terraform/state.rb | 1 + app/models/todo.rb | 4 + app/models/user.rb | 118 ++++++++++- app/models/users/callout.rb | 7 +- app/models/users/group_callout.rb | 3 +- app/models/users/in_product_marketing_email.rb | 2 +- app/models/users/namespace_callout.rb | 33 +++ app/models/wiki_page.rb | 3 +- app/models/work_item.rb | 9 +- app/models/work_items/parent_link.rb | 19 +- app/models/work_items/type.rb | 6 +- app/models/work_items/widgets/assignees.rb | 10 + app/models/work_items/widgets/description.rb | 4 - app/models/work_items/widgets/hierarchy.rb | 4 +- app/models/work_items/widgets/weight.rb | 9 + app/models/x509_certificate.rb | 2 +- app/models/x509_issuer.rb | 2 +- 137 files changed, 2082 insertions(+), 1187 deletions(-) create mode 100644 app/models/awareness_session.rb delete mode 100644 app/models/ci/legacy_stage.rb create mode 100644 app/models/ci/runner_version.rb delete mode 100644 app/models/clusters/applications/elastic_stack.rb delete mode 100644 app/models/clusters/concerns/elasticsearch_client.rb delete mode 100644 app/models/clusters/integrations/elastic_stack.rb create mode 100644 app/models/concerns/awareness.rb create mode 100644 app/models/concerns/ci/bulk_insertable_tags.rb create mode 100644 app/models/concerns/loose_index_scan.rb create mode 100644 app/models/concerns/packages/fips.rb create mode 100644 app/models/concerns/require_email_verification.rb delete mode 100644 app/models/project_tracing_setting.rb create mode 100644 app/models/projects/import_export/relation_export.rb create mode 100644 app/models/projects/import_export/relation_export_upload.rb create mode 100644 app/models/users/namespace_callout.rb create mode 100644 app/models/work_items/widgets/assignees.rb create mode 100644 app/models/work_items/widgets/weight.rb (limited to 'app/models') diff --git a/app/models/ability.rb b/app/models/ability.rb index a185448d5ea..b15143c8c9c 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -26,6 +26,13 @@ class Ability end end + # A list of users that can read confidential notes in a project + def users_that_can_read_internal_notes(users, note_parent) + DeclarativePolicy.subject_scope do + users.select { |u| allowed?(u, :reporter_access, note_parent) } + end + end + # Returns an Array of Issues that can be read by the given user. # # issues - The issues to reduce down to those readable by the user. diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 6acdc02c799..17b46f929c3 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -28,6 +28,7 @@ class ApplicationSetting < ApplicationRecord add_authentication_token_field :runners_registration_token, encrypted: -> { Feature.enabled?(:application_settings_tokens_optional_encryption) ? :optional : :required } add_authentication_token_field :health_check_access_token add_authentication_token_field :static_objects_external_storage_auth_token, encrypted: :required + add_authentication_token_field :error_tracking_access_token, encrypted: :required belongs_to :self_monitoring_project, class_name: "Project", foreign_key: 'instance_administration_project_id' belongs_to :push_rule @@ -171,6 +172,11 @@ class ApplicationSetting < ApplicationRecord validates :kroki_formats, json_schema: { filename: 'application_setting_kroki_formats' } + validates :metrics_method_call_threshold, + numericality: { greater_than_or_equal_to: 0 }, + presence: true, + if: :prometheus_metrics_enabled + validates :plantuml_url, presence: true, if: :plantuml_enabled @@ -393,6 +399,7 @@ class ApplicationSetting < ApplicationRecord numericality: { only_integer: true, greater_than_or_equal_to: 0 } validates :packages_cleanup_package_file_worker_capacity, + :package_registry_cleanup_policies_worker_capacity, allow_nil: false, numericality: { only_integer: true, greater_than_or_equal_to: 0 } @@ -589,6 +596,14 @@ class ApplicationSetting < ApplicationRecord presence: true, length: { maximum: 255 }, if: :sentry_enabled? + validates :error_tracking_enabled, + inclusion: { in: [true, false], message: _('must be a boolean value') } + validates :error_tracking_api_url, + presence: true, + addressable_url: true, + length: { maximum: 255 }, + if: :error_tracking_enabled? + validates :users_get_by_id_limit, numericality: { only_integer: true, greater_than_or_equal_to: 0 } validates :users_get_by_id_limit_allowlist, @@ -653,6 +668,7 @@ class ApplicationSetting < ApplicationRecord before_save :ensure_runners_registration_token before_save :ensure_health_check_access_token + before_save :ensure_error_tracking_access_token after_commit do reset_memoized_terms diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index a89ea05fb62..e9a0a156121 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -217,6 +217,7 @@ module ApplicationSettingImplementation user_show_add_ssh_key_message: true, valid_runner_registrars: VALID_RUNNER_REGISTRAR_TYPES, wiki_page_max_content_bytes: 50.megabytes, + package_registry_cleanup_policies_worker_capacity: 2, container_registry_delete_tags_service_timeout: 250, container_registry_expiration_policies_worker_capacity: 4, container_registry_cleanup_tags_service_max_list_size: 200, @@ -445,6 +446,10 @@ module ApplicationSettingImplementation ensure_health_check_access_token! end + def error_tracking_access_token + ensure_error_tracking_access_token! + end + def usage_ping_can_be_configured? Settings.gitlab.usage_ping_enabled end diff --git a/app/models/authentication_event.rb b/app/models/authentication_event.rb index 1e822629ba1..0ed197f32df 100644 --- a/app/models/authentication_event.rb +++ b/app/models/authentication_event.rb @@ -25,4 +25,9 @@ class AuthenticationEvent < ApplicationRecord def self.providers STATIC_PROVIDERS | Devise.omniauth_providers.map(&:to_s) end + + def self.initial_login_or_known_ip_address?(user, ip_address) + !where(user_id: user).exists? || + where(user_id: user, ip_address: ip_address).success.exists? + end end diff --git a/app/models/awareness_session.rb b/app/models/awareness_session.rb new file mode 100644 index 00000000000..a84a3454a27 --- /dev/null +++ b/app/models/awareness_session.rb @@ -0,0 +1,236 @@ +# frozen_string_literal: true + +# A Redis backed session store for real-time collaboration. A session is defined +# by its documents and the users that join this session. An online user can have +# two states within the session: "active" and "away". +# +# By design, session must eventually be cleaned up. If this doesn't happen +# explicitly, all keys used within the session model must have an expiry +# timestamp set. +class AwarenessSession # rubocop:disable Gitlab/NamespacedClass + # An awareness session expires automatically after 1 hour of no activity + SESSION_LIFETIME = 1.hour + private_constant :SESSION_LIFETIME + + # Expire user awareness keys after some time of inactivity + USER_LIFETIME = 1.hour + private_constant :USER_LIFETIME + + PRESENCE_LIFETIME = 10.minutes + private_constant :PRESENCE_LIFETIME + + KEY_NAMESPACE = "gitlab:awareness" + private_constant :KEY_NAMESPACE + + class << self + def for(value = nil) + # Creates a unique value for situations where we have no unique value to + # create a session with. This could be when creating a new issue, a new + # merge request, etc. + value = SecureRandom.uuid unless value.present? + + # We use SHA-256 based session identifiers (similar to abbreviated git + # hashes). There is always a chance for Hash collisions (birthday + # problem), we therefore have to pick a good tradeoff between the amount + # of data stored and the probability of a collision. + # + # The approximate probability for a collision can be calculated: + # + # p ~= n^2 / 2m + # ~= (2^18)^2 / (2 * 16^15) + # ~= 2^36 / 2^61 + # + # n is the number of awareness sessions and m the number of possibilities + # for each item. For a hex number, this is 16^c, where c is the number of + # characters. With 260k (~2^18) sessions, the probability for a collision + # is ~2^-25. + # + # The number of 15 is selected carefully. The integer representation fits + # nicely into a signed 64 bit integer and eventually allows Redis to + # optimize its memory usage. 16 chars would exceed the space for + # this datatype. + id = Digest::SHA256.hexdigest(value.to_s)[0, 15] + + AwarenessSession.new(id) + end + end + + def initialize(id) + @id = id + end + + def join(user) + user_key = user_sessions_key(user.id) + + with_redis do |redis| + redis.pipelined do |pipeline| + pipeline.sadd(user_key, id_i) + pipeline.expire(user_key, USER_LIFETIME.to_i) + + pipeline.zadd(users_key, timestamp.to_f, user.id) + + # We also mark for expiry when a session key is created (first user joins), + # because some users might never actively leave a session and the key could + # therefore become stale, w/o us noticing. + reset_session_expiry(pipeline) + end + end + + nil + end + + def leave(user) + user_key = user_sessions_key(user.id) + + with_redis do |redis| + redis.pipelined do |pipeline| + pipeline.srem(user_key, id_i) + pipeline.zrem(users_key, user.id) + end + + # cleanup orphan sessions and users + # + # this needs to be a second pipeline due to the delete operations being + # dependent on the result of the cardinality checks + user_sessions_count, session_users_count = redis.pipelined do |pipeline| + pipeline.scard(user_key) + pipeline.zcard(users_key) + end + + redis.pipelined do |pipeline| + pipeline.del(user_key) unless user_sessions_count > 0 + + unless session_users_count > 0 + pipeline.del(users_key) + @id = nil + end + end + end + + nil + end + + def present?(user, threshold: PRESENCE_LIFETIME) + with_redis do |redis| + user_timestamp = redis.zscore(users_key, user.id) + break false unless user_timestamp.present? + + timestamp - user_timestamp < threshold + end + end + + def away?(user, threshold: PRESENCE_LIFETIME) + !present?(user, threshold: threshold) + end + + # Updates the last_activity timestamp for a user in this session + def touch!(user) + with_redis do |redis| + redis.pipelined do |pipeline| + pipeline.zadd(users_key, timestamp.to_f, user.id) + + # extend the session lifetime due to user activity + reset_session_expiry(pipeline) + end + end + + nil + end + + def size + with_redis do |redis| + redis.zcard(users_key) + end + end + + def to_param + id&.to_s + end + + def to_s + "awareness_session=#{id}" + end + + def online_users_with_last_activity(threshold: PRESENCE_LIFETIME) + users_with_last_activity.filter do |_user, last_activity| + user_online?(last_activity, threshold: threshold) + end + end + + def users + User.where(id: user_ids) + end + + def users_with_last_activity + # where in (x, y, [...z]) is a set and does not maintain any order, we need + # to make sure to establish a stable order for both, the pairs returned from + # redis and the ActiveRecord query. Using IDs in ascending order. + user_ids, last_activities = user_ids_with_last_activity + .sort_by(&:first) + .transpose + + return [] if user_ids.blank? + + users = User.where(id: user_ids).order(id: :asc) + users.zip(last_activities) + end + + private + + attr_reader :id + + def user_online?(last_activity, threshold:) + last_activity.to_i + threshold.to_i > Time.zone.now.to_i + end + + # converts session id from hex to integer representation + def id_i + Integer(id, 16) if id.present? + end + + def users_key + "#{KEY_NAMESPACE}:session:#{id}:users" + end + + def user_sessions_key(user_id) + "#{KEY_NAMESPACE}:user:#{user_id}:sessions" + end + + def with_redis + Gitlab::Redis::SharedState.with do |redis| + yield redis if block_given? + end + end + + def timestamp + Time.now.to_i + end + + def user_ids + with_redis do |redis| + redis.zrange(users_key, 0, -1) + end + end + + # Returns an array of tuples, where the first element in the tuple represents + # the user ID and the second part the last_activity timestamp. + def user_ids_with_last_activity + pairs = with_redis do |redis| + redis.zrange(users_key, 0, -1, with_scores: true) + end + + # map data type of score (float) to Time + pairs.map do |user_id, score| + [user_id, Time.zone.at(score.to_i)] + end + end + + # We want sessions to cleanup automatically after a certain period of + # inactivity. This sets the expiry timestamp for this session to + # [SESSION_LIFETIME]. + def reset_session_expiry(redis) + redis.expire(users_key, SESSION_LIFETIME) + + nil + end +end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index e35198ba31f..7f9697d0424 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -2,6 +2,7 @@ module Ci class Build < Ci::Processable + prepend Ci::BulkInsertableTags include Ci::Metadatable include Ci::Contextable include TokenAuthenticatable @@ -14,8 +15,6 @@ module Ci extend ::Gitlab::Utils::Override - BuildArchivedError = Class.new(StandardError) - belongs_to :project, inverse_of: :builds belongs_to :runner belongs_to :trigger_request @@ -30,10 +29,6 @@ module Ci return_exit_code: -> (build) { build.exit_codes_defined? } }.freeze - DEFAULT_RETRIES = { - scheduler_failure: 2 - }.freeze - DEGRADATION_THRESHOLD_VARIABLE_NAME = 'DEGRADATION_THRESHOLD' RUNNERS_STATUS_CACHE_EXPIRATION = 1.minute @@ -172,7 +167,6 @@ module Ci end scope :with_artifacts_not_expired, -> { with_downloadable_artifacts.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.current) } - scope :with_expired_artifacts, -> { with_downloadable_artifacts.where('artifacts_expire_at < ?', Time.current) } scope :with_pipeline_locked_artifacts, -> { joins(:pipeline).where('pipeline.locked': Ci::Pipeline.lockeds[:artifacts_locked]) } scope :last_month, -> { where('created_at > ?', Date.today - 1.month) } scope :manual_actions, -> { where(when: :manual, status: COMPLETED_STATUSES + %i[manual]) } @@ -187,13 +181,6 @@ module Ci joins(:metadata).where("ci_builds_metadata.config_options -> 'artifacts' -> 'reports' ?| array[:job_types]", job_types: job_types) end - scope :queued_before, ->(time) { where(arel_table[:queued_at].lt(time)) } - - scope :preload_project_and_pipeline_project, -> do - preload(Ci::Pipeline::PROJECT_ROUTE_AND_NAMESPACE_ROUTE, - pipeline: Ci::Pipeline::PROJECT_ROUTE_AND_NAMESPACE_ROUTE) - end - scope :with_coverage, -> { where.not(coverage: nil) } scope :without_coverage, -> { where(coverage: nil) } scope :with_coverage_regex, -> { where.not(coverage_regex: nil) } @@ -207,7 +194,7 @@ module Ci after_save :stick_build_if_status_changed after_create unless: :importing? do |build| - run_after_commit { BuildHooksWorker.perform_async(build.id) } + run_after_commit { BuildHooksWorker.perform_async(build) } end class << self @@ -217,10 +204,6 @@ module Ci ActiveModel::Name.new(self, nil, 'job') end - def first_pending - pending.unstarted.order('created_at ASC').first - end - def with_preloads preload(:job_artifacts_archive, :job_artifacts, :tags, project: [:namespace]) end @@ -302,7 +285,7 @@ module Ci build.run_after_commit do BuildQueueWorker.perform_async(id) - BuildHooksWorker.perform_async(id) + BuildHooksWorker.perform_async(build) end end @@ -330,7 +313,7 @@ module Ci build.run_after_commit do build.ensure_persistent_ref - BuildHooksWorker.perform_async(id) + BuildHooksWorker.perform_async(build) end end @@ -338,11 +321,7 @@ module Ci build.run_after_commit do build.run_status_commit_hooks! - if Feature.enabled?(:ci_build_finished_worker_namespace_changed, build.project) - Ci::BuildFinishedWorker.perform_async(id) - else - ::BuildFinishedWorker.perform_async(id) - end + Ci::BuildFinishedWorker.perform_async(id) end end @@ -446,10 +425,6 @@ module Ci true end - def save_tags - super unless Thread.current['ci_bulk_insert_tags'] - end - def archived? return true if degenerated? @@ -556,10 +531,6 @@ module Ci self.options.dig(:environment, :deployment_tier) if self.options end - def outdated_deployment? - success? && !deployment.try(:last?) - end - def triggered_by?(current_user) user == current_user end @@ -1162,6 +1133,14 @@ module Ci Gitlab::Utils::UsageData.track_usage_event('ci_users_executing_deployment_job', user_id) if user_id.present? && count_user_deployment? end + def track_verify_usage + Gitlab::Utils::UsageData.track_usage_event('ci_users_executing_verify_environment_job', user_id) if user_id.present? && count_user_verification? + end + + def count_user_verification? + has_environment? && environment_action == 'verify' + end + def each_report(report_types) job_artifacts_for_types(report_types).each do |report_artifact| report_artifact.each_blob do |blob| diff --git a/app/models/ci/build_report_result.rb b/app/models/ci/build_report_result.rb index 2c08fc4c8bf..b674c1b1a0e 100644 --- a/app/models/ci/build_report_result.rb +++ b/app/models/ci/build_report_result.rb @@ -39,9 +39,5 @@ module Ci def suite_error tests.dig("suite_error") end - - def tests_total - [tests_success, tests_failed, tests_errored, tests_skipped].sum - end end end diff --git a/app/models/ci/group.rb b/app/models/ci/group.rb index e5cb2026503..0105366d99b 100644 --- a/app/models/ci/group.rb +++ b/app/models/ci/group.rb @@ -50,8 +50,7 @@ module Ci def status_struct strong_memoize(:status_struct) do - Gitlab::Ci::Status::Composite - .new(@jobs, project: project) + Gitlab::Ci::Status::Composite.new(@jobs) end end diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb index 0af5533613f..e11edbda6dc 100644 --- a/app/models/ci/group_variable.rb +++ b/app/models/ci/group_variable.rb @@ -19,5 +19,9 @@ module Ci scope :unprotected, -> { where(protected: false) } scope :by_environment_scope, -> (environment_scope) { where(environment_scope: environment_scope) } scope :for_groups, ->(group_ids) { where(group_id: group_ids) } + + def audit_details + key + end end end diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 81943cfa651..ee7175a4f69 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -322,7 +322,7 @@ module Ci def expire_in=(value) self.expire_at = if value - ::Gitlab::Ci::Build::Artifacts::ExpireInParser.new(value).seconds_from_now + ::Gitlab::Ci::Build::DurationParser.new(value).seconds_from_now end end diff --git a/app/models/ci/legacy_stage.rb b/app/models/ci/legacy_stage.rb deleted file mode 100644 index ffd3d3fcd88..00000000000 --- a/app/models/ci/legacy_stage.rb +++ /dev/null @@ -1,73 +0,0 @@ -# frozen_string_literal: true - -module Ci - # Currently this is artificial object, constructed dynamically - # We should migrate this object to actual database record in the future - class LegacyStage - include StaticModel - include Presentable - - attr_reader :pipeline, :name - - delegate :project, to: :pipeline - - def initialize(pipeline, name:, status: nil, warnings: nil) - @pipeline = pipeline - @name = name - @status = status - # support ints and booleans - @has_warnings = ActiveRecord::Type::Boolean.new.cast(warnings) - end - - def groups - @groups ||= Ci::Group.fabricate(project, self) - end - - def to_param - name - end - - def statuses_count - @statuses_count ||= statuses.count - end - - def status - @status ||= statuses.latest.composite_status(project: project) - end - - def detailed_status(current_user) - Gitlab::Ci::Status::Stage::Factory - .new(self, current_user) - .fabricate! - end - - def latest_statuses - statuses.ordered.latest - end - - def statuses - @statuses ||= pipeline.statuses.where(stage: name) - end - - def builds - @builds ||= pipeline.builds.where(stage: name) - end - - def success? - status.to_s == 'success' - end - - def has_warnings? - # lazilly calculate the warnings - if @has_warnings.nil? - @has_warnings = statuses.latest.failed_but_allowed.any? - end - - @has_warnings - end - - def manual_playable? - %[manual scheduled skipped].include?(status.to_s) - end - end -end diff --git a/app/models/ci/pending_build.rb b/app/models/ci/pending_build.rb index d900a056242..0fa6a234a3d 100644 --- a/app/models/ci/pending_build.rb +++ b/app/models/ci/pending_build.rb @@ -30,10 +30,6 @@ module Ci self.upsert(entry.attributes.compact, returning: %w[build_id], unique_by: :build_id) end - def maintain_denormalized_data? - ::Feature.enabled?(:ci_pending_builds_maintain_denormalized_data) - end - private def args_from_build(build) @@ -43,13 +39,13 @@ module Ci build: build, project: project, protected: build.protected?, - namespace: project.namespace + namespace: project.namespace, + tag_ids: build.tags_ids, + instance_runners_enabled: shared_runners_enabled?(project) } - if maintain_denormalized_data? - args.store(:tag_ids, build.tags_ids) - args.store(:instance_runners_enabled, shared_runners_enabled?(project)) - args.store(:namespace_traversal_ids, project.namespace.traversal_ids) if group_runners_enabled?(project) + if group_runners_enabled?(project) + args.store(:namespace_traversal_ids, project.namespace.traversal_ids) end args diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 5d316906bd3..78b55680b5e 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -27,8 +27,6 @@ module Ci DEFAULT_CONFIG_PATH = CONFIG_EXTENSION CANCELABLE_STATUSES = (Ci::HasStatus::CANCELABLE_STATUSES + ['manual']).freeze - BridgeStatusError = Class.new(StandardError) - paginates_per 15 sha_attribute :source_sha @@ -133,6 +131,7 @@ module Ci validates :source, exclusion: { in: %w(unknown), unless: :importing? }, on: :create after_create :keep_around_commits, unless: :importing? + after_find :observe_age_in_minutes, unless: :importing? use_fast_destroy :job_artifacts use_fast_destroy :build_trace_chunks @@ -241,6 +240,13 @@ module Ci pipeline.run_after_commit do unless pipeline.user&.blocked? + Gitlab::AppLogger.info( + message: "Enqueuing hooks for Pipeline #{pipeline.id}: #{pipeline.status}", + class: self.class.name, + pipeline_id: pipeline.id, + project_id: pipeline.project_id, + pipeline_status: pipeline.status) + PipelineHooksWorker.perform_async(pipeline.id) end @@ -332,8 +338,8 @@ module Ci scope :for_id, -> (id) { where(id: id) } scope :for_iid, -> (iid) { where(iid: iid) } scope :for_project, -> (project_id) { where(project_id: project_id) } - scope :created_after, -> (time) { where('ci_pipelines.created_at > ?', time) } - scope :created_before_id, -> (id) { where('ci_pipelines.id < ?', id) } + scope :created_after, -> (time) { where(arel_table[:created_at].gt(time)) } + scope :created_before_id, -> (id) { where(arel_table[:id].lt(id)) } scope :before_pipeline, -> (pipeline) { created_before_id(pipeline.id).outside_pipeline_family(pipeline) } scope :with_pipeline_source, -> (source) { where(source: source) } @@ -490,40 +496,16 @@ module Ci .pluck(:stage, :stage_idx).map(&:first) end - def legacy_stage(name) - stage = Ci::LegacyStage.new(self, name: name) - stage unless stage.statuses_count == 0 - end - def ref_exists? project.repository.ref_exists?(git_ref) rescue Gitlab::Git::Repository::NoRepository false end - def legacy_stages_using_composite_status - stages = latest_statuses_ordered_by_stage.group_by(&:stage) - - stages.map do |stage_name, jobs| - composite_status = Gitlab::Ci::Status::Composite - .new(jobs) - - Ci::LegacyStage.new(self, - name: stage_name, - status: composite_status.status, - warnings: composite_status.warnings?) - end - end - def triggered_pipelines_with_preloads triggered_pipelines.preload(:source_job) end - # TODO: Remove usage of this method in templates - def legacy_stages - legacy_stages_using_composite_status - end - def valid_commit_sha if self.sha == Gitlab::Git::BLANK_SHA self.errors.add(:sha, " cant be 00000000 (branch removal)") @@ -1004,6 +986,10 @@ module Ci object_hierarchy(project_condition: :same).base_and_descendants end + def self_and_descendants_complete? + self_and_descendants.all?(&:complete?) + end + # Follow the parent-child relationships and return the top-level parent def root_ancestor return self unless child? @@ -1078,7 +1064,11 @@ module Ci end def has_reports?(reports_scope) - complete? && latest_report_builds(reports_scope).exists? + if Feature.enabled?(:mr_show_reports_immediately, project, type: :development) + latest_report_builds(reports_scope).exists? + else + complete? && latest_report_builds(reports_scope).exists? + end end def has_coverage_reports? @@ -1100,7 +1090,7 @@ module Ci end def test_reports - Gitlab::Ci::Reports::TestReports.new.tap do |test_reports| + Gitlab::Ci::Reports::TestReport.new.tap do |test_reports| latest_test_report_builds.find_each do |build| build.collect_test_reports!(test_reports) end @@ -1222,6 +1212,10 @@ module Ci Gitlab::Utils.slugify(source_ref.to_s) end + def stage(name) + stages.find_by(name: name) + end + def find_stage_by_name!(name) stages.find_by!(name: name) end @@ -1307,10 +1301,20 @@ module Ci end end - def has_expired_test_reports? - strong_memoize(:has_expired_test_reports) do - has_reports?(::Ci::JobArtifact.test_reports.expired) + def has_test_reports? + strong_memoize(:has_test_reports) do + has_reports?(::Ci::JobArtifact.test_reports) + end + end + + def age_in_minutes + return 0 unless persisted? + + unless has_attribute?(:created_at) + raise ArgumentError, 'pipeline not fully loaded' end + + (Time.current - created_at).ceil / 60 end private @@ -1363,6 +1367,21 @@ module Ci project.repository.keep_around(self.sha, self.before_sha) end + def observe_age_in_minutes + return unless age_metric_enabled? + return unless persisted? && has_attribute?(:created_at) + + ::Gitlab::Ci::Pipeline::Metrics + .pipeline_age_histogram + .observe({}, age_in_minutes) + end + + def age_metric_enabled? + ::Gitlab::SafeRequestStore.fetch(:age_metric_enabled) do + ::Feature.enabled?(:ci_pipeline_age_histogram, type: :ops) + end + end + # Without using `unscoped`, caller scope is also included into the query. # Using `unscoped` here will be redundant after Rails 6.1 def object_hierarchy(options = {}) diff --git a/app/models/ci/pipeline_artifact.rb b/app/models/ci/pipeline_artifact.rb index 2284a05bcc9..cdc3d69f754 100644 --- a/app/models/ci/pipeline_artifact.rb +++ b/app/models/ci/pipeline_artifact.rb @@ -51,6 +51,23 @@ module Ci def find_by_file_type(file_type) find_by(file_type: file_type) end + + def create_or_replace_for_pipeline!(pipeline:, file_type:, file:, size:) + transaction do + pipeline.pipeline_artifacts.find_by_file_type(file_type)&.destroy! + + pipeline.pipeline_artifacts.create!( + file_type: file_type, + project_id: pipeline.project_id, + size: size, + file: file, + file_format: REPORT_TYPES[file_type], + expire_at: EXPIRATION_DATE.from_now + ) + end + rescue ActiveRecord::ActiveRecordError => err + Gitlab::ErrorTracking.track_and_raise_exception(err, { pipeline_id: pipeline.id, file_type: file_type }) + end end def present diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 61194c9b7d1..f41ad890184 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -2,6 +2,7 @@ module Ci class Runner < Ci::ApplicationRecord + prepend Ci::BulkInsertableTags include Gitlab::SQL::Pattern include RedisCacheable include ChronicDurationAttribute @@ -14,6 +15,8 @@ module Ci include Presentable include EachBatch + ignore_column :semver, remove_with: '15.3', remove_after: '2022-07-22' + add_authentication_token_field :token, encrypted: :optional, expires_at: :compute_token_expiration, expiration_enforced?: :token_expiration_enforced? enum access_level: { @@ -75,9 +78,9 @@ module Ci has_many :groups, through: :runner_namespaces, disable_joins: true has_one :last_build, -> { order('id DESC') }, class_name: 'Ci::Build' + has_one :runner_version, primary_key: :version, foreign_key: :version, class_name: 'Ci::RunnerVersion' before_save :ensure_token - before_save :update_semver, if: -> { version_changed? } scope :active, -> (value = true) { where(active: value) } scope :paused, -> { active(false) } @@ -430,7 +433,6 @@ module Ci values = values&.slice(:version, :revision, :platform, :architecture, :ip_address, :config, :executor) || {} values[:contacted_at] = Time.current values[:executor_type] = EXECUTOR_NAME_TO_TYPES.fetch(values.delete(:executor), :unknown) - values[:semver] = semver_from_version(values[:version]) cache_attributes(values) @@ -451,16 +453,6 @@ module Ci read_attribute(:contacted_at) end - def semver_from_version(version) - parsed_runner_version = ::Gitlab::VersionInfo.parse(version) - - parsed_runner_version.valid? ? parsed_runner_version.to_s : nil - end - - def update_semver - self.semver = semver_from_version(self.version) - end - def namespace_ids strong_memoize(:namespace_ids) do runner_namespaces.pluck(:namespace_id).compact @@ -484,6 +476,10 @@ module Ci private + scope :with_upgrade_status, ->(upgrade_status) do + Ci::Runner.joins(:runner_version).where(runner_version: { status: upgrade_status }) + end + EXECUTOR_NAME_TO_TYPES = { 'unknown' => :unknown, 'custom' => :custom, diff --git a/app/models/ci/runner_version.rb b/app/models/ci/runner_version.rb new file mode 100644 index 00000000000..6b2d0060c9b --- /dev/null +++ b/app/models/ci/runner_version.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Ci + class RunnerVersion < Ci::ApplicationRecord + include EachBatch + include EnumWithNil + + enum_with_nil status: { + not_processed: nil, + invalid_version: -1, + unknown: 0, + not_available: 1, + available: 2, + recommended: 3 + } + + STATUS_DESCRIPTIONS = { + invalid_version: 'Runner version is not valid.', + unknown: 'Upgrade status is unknown.', + not_available: 'Upgrade is not available for the runner.', + available: 'Upgrade is available for the runner.', + recommended: 'Upgrade is available and recommended for the runner.' + }.freeze + + # Override auto generated negative scope (from available) so the scope has expected behavior + scope :not_available, -> { where(status: :not_available) } + + # This scope returns all versions that might need recalculating. For instance, once a version is considered + # :recommended, it normally doesn't change status even if the instance is upgraded + scope :potentially_outdated, -> { where(status: [nil, :not_available, :available, :unknown]) } + + validates :version, length: { maximum: 2048 } + end +end diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index 8c4e97ac840..f03d1e96a4b 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -142,7 +142,7 @@ module Ci end def latest_stage_status - statuses.latest.composite_status(project: project) || 'skipped' + statuses.latest.composite_status || 'skipped' end end end diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb index 5bf5ae51ec8..c4db4754c52 100644 --- a/app/models/ci/trigger.rb +++ b/app/models/ci/trigger.rb @@ -4,6 +4,9 @@ module Ci class Trigger < Ci::ApplicationRecord include Presentable include Limitable + include IgnorableColumns + + ignore_column :ref, remove_with: '15.4', remove_after: '2022-08-22' self.limit_name = 'pipeline_triggers' self.limit_scope = :project diff --git a/app/models/ci/variable.rb b/app/models/ci/variable.rb index 1e91f248fc4..c80c2ebe69a 100644 --- a/app/models/ci/variable.rb +++ b/app/models/ci/variable.rb @@ -18,5 +18,9 @@ module Ci scope :unprotected, -> { where(protected: false) } scope :by_environment_scope, -> (environment_scope) { where(environment_scope: environment_scope) } + + def audit_details + key + end end end diff --git a/app/models/clusters/agent.rb b/app/models/clusters/agent.rb index fb12ce7d292..3478bb69707 100644 --- a/app/models/clusters/agent.rb +++ b/app/models/clusters/agent.rb @@ -53,3 +53,5 @@ module Clusters end end end + +Clusters::Agent.prepend_mod_with('Clusters::Agent') diff --git a/app/models/clusters/applications/elastic_stack.rb b/app/models/clusters/applications/elastic_stack.rb deleted file mode 100644 index 73c731aab1a..00000000000 --- a/app/models/clusters/applications/elastic_stack.rb +++ /dev/null @@ -1,113 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Applications - class ElasticStack < ApplicationRecord - include ::Clusters::Concerns::ElasticsearchClient - - VERSION = '3.0.0' - - self.table_name = 'clusters_applications_elastic_stacks' - - include ::Clusters::Concerns::ApplicationCore - include ::Clusters::Concerns::ApplicationStatus - include ::Clusters::Concerns::ApplicationVersion - include ::Clusters::Concerns::ApplicationData - - default_value_for :version, VERSION - - after_destroy do - cluster&.find_or_build_integration_elastic_stack&.update(enabled: false, chart_version: nil) - end - - state_machine :status do - after_transition any => [:installed] do |application| - application.cluster&.find_or_build_integration_elastic_stack&.update(enabled: true, chart_version: application.version) - end - - after_transition any => [:uninstalled] do |application| - application.cluster&.find_or_build_integration_elastic_stack&.update(enabled: false, chart_version: nil) - end - end - - def chart - 'elastic-stack/elastic-stack' - end - - def repository - 'https://charts.gitlab.io' - end - - def install_command - helm_command_module::InstallCommand.new( - name: 'elastic-stack', - version: VERSION, - rbac: cluster.platform_kubernetes_rbac?, - chart: chart, - repository: repository, - files: files, - preinstall: migrate_to_3_script, - postinstall: post_install_script - ) - end - - def uninstall_command - helm_command_module::DeleteCommand.new( - name: 'elastic-stack', - rbac: cluster.platform_kubernetes_rbac?, - files: files, - postdelete: post_delete_script - ) - end - - def files - super.merge('wait-for-elasticsearch.sh': File.read("#{Rails.root}/vendor/elastic_stack/wait-for-elasticsearch.sh")) - end - - def chart_above_v2? - Gem::Version.new(version) >= Gem::Version.new('2.0.0') - end - - def chart_above_v3? - Gem::Version.new(version) >= Gem::Version.new('3.0.0') - end - - private - - def service_name - chart_above_v3? ? 'elastic-stack-elasticsearch-master' : 'elastic-stack-elasticsearch-client' - end - - def pvc_selector - chart_above_v3? ? "app=elastic-stack-elasticsearch-master" : "release=elastic-stack" - end - - def post_install_script - [ - "timeout 60 sh /data/helm/elastic-stack/config/wait-for-elasticsearch.sh http://elastic-stack-elasticsearch-master:9200" - ] - end - - def post_delete_script - [ - Gitlab::Kubernetes::KubectlCmd.delete("pvc", "--selector", pvc_selector, "--namespace", Gitlab::Kubernetes::Helm::NAMESPACE) - ] - end - - def migrate_to_3_script - return [] if !updating? || chart_above_v3? - - # Chart version 3.0.0 moves to our own chart at https://gitlab.com/gitlab-org/charts/elastic-stack - # and is not compatible with pre-existing resources. We first remove them. - [ - helm_command_module::DeleteCommand.new( - name: 'elastic-stack', - rbac: cluster.platform_kubernetes_rbac?, - files: files - ).delete_command, - Gitlab::Kubernetes::KubectlCmd.delete("pvc", "--selector", "release=elastic-stack", "--namespace", Gitlab::Kubernetes::Helm::NAMESPACE) - ] - end - end - end -end diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 014f7530357..ad1e7dc305f 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -20,7 +20,6 @@ module Clusters Clusters::Applications::Runner.application_name => Clusters::Applications::Runner, Clusters::Applications::Jupyter.application_name => Clusters::Applications::Jupyter, Clusters::Applications::Knative.application_name => Clusters::Applications::Knative, - Clusters::Applications::ElasticStack.application_name => Clusters::Applications::ElasticStack, Clusters::Applications::Cilium.application_name => Clusters::Applications::Cilium }.freeze DEFAULT_ENVIRONMENT = '*' @@ -51,7 +50,6 @@ module Clusters has_one :platform_kubernetes, class_name: 'Clusters::Platforms::Kubernetes', inverse_of: :cluster, autosave: true has_one :integration_prometheus, class_name: 'Clusters::Integrations::Prometheus', inverse_of: :cluster - has_one :integration_elastic_stack, class_name: 'Clusters::Integrations::ElasticStack', inverse_of: :cluster def self.has_one_cluster_application(name) # rubocop:disable Naming/PredicateName application = APPLICATIONS[name.to_s] @@ -66,7 +64,6 @@ module Clusters has_one_cluster_application :runner has_one_cluster_application :jupyter has_one_cluster_application :knative - has_one_cluster_application :elastic_stack has_one_cluster_application :cilium has_many :kubernetes_namespaces @@ -102,7 +99,6 @@ module Clusters delegate :available?, to: :application_helm, prefix: true, allow_nil: true delegate :available?, to: :application_ingress, prefix: true, allow_nil: true delegate :available?, to: :application_knative, prefix: true, allow_nil: true - delegate :available?, to: :integration_elastic_stack, prefix: true, allow_nil: true delegate :available?, to: :integration_prometheus, prefix: true, allow_nil: true delegate :external_ip, to: :application_ingress, prefix: true, allow_nil: true delegate :external_hostname, to: :application_ingress, prefix: true, allow_nil: true @@ -136,7 +132,6 @@ module Clusters scope :gcp_installed, -> { gcp_provided.joins(:provider_gcp).merge(Clusters::Providers::Gcp.with_status(:created)) } scope :aws_installed, -> { aws_provided.joins(:provider_aws).merge(Clusters::Providers::Aws.with_status(:created)) } - scope :with_available_elasticstack, -> { joins(:application_elastic_stack).merge(::Clusters::Applications::ElasticStack.available) } scope :distinct_with_deployed_environments, -> { joins(:environments).merge(::Deployment.success).distinct } scope :managed, -> { where(managed: true) } @@ -271,10 +266,6 @@ module Clusters integration_prometheus || build_integration_prometheus end - def find_or_build_integration_elastic_stack - integration_elastic_stack || build_integration_elastic_stack - end - def provider if gcp? provider_gcp @@ -309,18 +300,6 @@ module Clusters platform_kubernetes&.kubeclient if kubernetes? end - def elastic_stack_adapter - integration_elastic_stack - end - - def elasticsearch_client - elastic_stack_adapter&.elasticsearch_client - end - - def elastic_stack_available? - !!integration_elastic_stack_available? - end - def kubernetes_namespace_for(environment, deployable: environment.last_deployable) if deployable && environment.project_id != deployable.project_id raise ArgumentError, 'environment.project_id must match deployable.project_id' diff --git a/app/models/clusters/concerns/elasticsearch_client.rb b/app/models/clusters/concerns/elasticsearch_client.rb deleted file mode 100644 index e9aab7897a8..00000000000 --- a/app/models/clusters/concerns/elasticsearch_client.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Concerns - module ElasticsearchClient - include ::Gitlab::Utils::StrongMemoize - - ELASTICSEARCH_PORT = 9200 - ELASTICSEARCH_NAMESPACE = 'gitlab-managed-apps' - - def elasticsearch_client(timeout: nil) - strong_memoize(:elasticsearch_client) do - kube_client = cluster&.kubeclient&.core_client - next unless kube_client - - proxy_url = kube_client.proxy_url('service', service_name, ELASTICSEARCH_PORT, ELASTICSEARCH_NAMESPACE) - - Elasticsearch::Client.new(url: proxy_url, adapter: :net_http) do |faraday| - # ensures headers containing auth data are appended to original client options - faraday.headers.merge!(kube_client.headers) - # ensure TLS certs are properly verified - faraday.ssl[:verify] = kube_client.ssl_options[:verify_ssl] - faraday.ssl[:cert_store] = kube_client.ssl_options[:cert_store] - faraday.options.timeout = timeout unless timeout.nil? - end - - rescue Kubeclient::HttpError => error - # If users have mistakenly set parameters or removed the depended clusters, - # `proxy_url` could raise an exception because gitlab can not communicate with the cluster. - # We check for a nil client in downstream use and behaviour is equivalent to an empty state - log_exception(error, :failed_to_create_elasticsearch_client) - - nil - end - end - end - end -end diff --git a/app/models/clusters/integrations/elastic_stack.rb b/app/models/clusters/integrations/elastic_stack.rb deleted file mode 100644 index 97d73d252b9..00000000000 --- a/app/models/clusters/integrations/elastic_stack.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Integrations - class ElasticStack < ApplicationRecord - include ::Clusters::Concerns::ElasticsearchClient - include ::Clusters::Concerns::KubernetesLogger - - self.table_name = 'clusters_integration_elasticstack' - self.primary_key = :cluster_id - - belongs_to :cluster, class_name: 'Clusters::Cluster', foreign_key: :cluster_id - - validates :cluster, presence: true - validates :enabled, inclusion: { in: [true, false] } - - scope :enabled, -> { where(enabled: true) } - - def available? - enabled - end - - def service_name - chart_above_v3? ? 'elastic-stack-elasticsearch-master' : 'elastic-stack-elasticsearch-client' - end - - def chart_above_v2? - return true if chart_version.nil? - - Gem::Version.new(chart_version) >= Gem::Version.new('2.0.0') - end - - def chart_above_v3? - return true if chart_version.nil? - - Gem::Version.new(chart_version) >= Gem::Version.new('3.0.0') - end - end - end -end diff --git a/app/models/clusters/integrations/prometheus.rb b/app/models/clusters/integrations/prometheus.rb index 0d6177beae7..899529ff49f 100644 --- a/app/models/clusters/integrations/prometheus.rb +++ b/app/models/clusters/integrations/prometheus.rb @@ -55,23 +55,13 @@ module Clusters private def activate_project_integrations - if Feature.enabled?(:rename_integrations_workers) - ::Clusters::Applications::ActivateIntegrationWorker - .perform_async(cluster_id, ::Integrations::Prometheus.to_param) - else - ::Clusters::Applications::ActivateServiceWorker - .perform_async(cluster_id, ::Integrations::Prometheus.to_param) - end + ::Clusters::Applications::ActivateIntegrationWorker + .perform_async(cluster_id, ::Integrations::Prometheus.to_param) end def deactivate_project_integrations - if Feature.enabled?(:rename_integrations_workers) - ::Clusters::Applications::DeactivateIntegrationWorker - .perform_async(cluster_id, ::Integrations::Prometheus.to_param) - else - ::Clusters::Applications::DeactivateServiceWorker - .perform_async(cluster_id, ::Integrations::Prometheus.to_param) - end + ::Clusters::Applications::DeactivateIntegrationWorker + .perform_async(cluster_id, ::Integrations::Prometheus.to_param) end end end diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index ac9d8c39bd2..afe4927ee73 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -8,9 +8,12 @@ class CommitStatus < Ci::ApplicationRecord include EnumWithNil include BulkInsertableAssociations include TaggableQueries + include IgnorableColumns self.table_name = 'ci_builds' + ignore_column :token, remove_with: '15.4', remove_after: '2022-08-22' + belongs_to :user belongs_to :project belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :commit_id @@ -220,10 +223,6 @@ class CommitStatus < Ci::ApplicationRecord false end - def self.bulk_insert_tags!(statuses) - Gitlab::Ci::Tags::BulkInsert.new(statuses).insert! - end - def locking_enabled? will_save_change_to_status? end @@ -325,5 +324,3 @@ class CommitStatus < Ci::ApplicationRecord script_failure? || missing_dependency_failure? || archived_failure? || scheduler_failure? || data_integrity_failure? end end - -CommitStatus.prepend_mod_with('CommitStatus') diff --git a/app/models/concerns/awareness.rb b/app/models/concerns/awareness.rb new file mode 100644 index 00000000000..da87d87e838 --- /dev/null +++ b/app/models/concerns/awareness.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Awareness + extend ActiveSupport::Concern + + KEY_NAMESPACE = "gitlab:awareness" + private_constant :KEY_NAMESPACE + + def join(session) + session.join(self) + + nil + end + + def leave(session) + session.leave(self) + + nil + end + + def session_ids + with_redis do |redis| + redis + .smembers(user_sessions_key) + # converts session ids from (internal) integer to hex presentation + .map { |key| key.to_i.to_s(16) } + end + end + + private + + def user_sessions_key + "#{KEY_NAMESPACE}:user:#{id}:sessions" + end + + def with_redis + Gitlab::Redis::SharedState.with do |redis| + yield redis if block_given? + end + end +end diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index 99dbe464a7c..9ee0fd1db1d 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -172,7 +172,7 @@ module CacheMarkdownField refs = all_references(self.author) references = {} - references[:mentioned_users_ids] = refs.mentioned_user_ids.presence + references[:mentioned_users_ids] = mentioned_filtered_user_ids_for(refs) references[:mentioned_groups_ids] = refs.mentioned_group_ids.presence references[:mentioned_projects_ids] = refs.mentioned_project_ids.presence @@ -185,6 +185,13 @@ module CacheMarkdownField true end + # Overriden on objects that needs to filter + # mentioned users that cannot read them, for example, + # guest users that are referenced on a confidential note. + def mentioned_filtered_user_ids_for(refs) + refs.mentioned_user_ids.presence + end + def mentionable_attributes_changed?(changes = saved_changes) return false unless is_a?(Mentionable) diff --git a/app/models/concerns/ci/artifactable.rb b/app/models/concerns/ci/artifactable.rb index 78340cf967b..fb4ea4206f4 100644 --- a/app/models/concerns/ci/artifactable.rb +++ b/app/models/concerns/ci/artifactable.rb @@ -30,6 +30,8 @@ module Ci raise NotSupportedAdapterError, 'This file format requires a dedicated adapter' end + ::Gitlab::ApplicationContext.push(artifact: file.model) + file.open do |stream| file_format_adapter_class.new(stream).each_blob(&blk) end diff --git a/app/models/concerns/ci/bulk_insertable_tags.rb b/app/models/concerns/ci/bulk_insertable_tags.rb new file mode 100644 index 00000000000..453b3b3fbc9 --- /dev/null +++ b/app/models/concerns/ci/bulk_insertable_tags.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Ci + module BulkInsertableTags + extend ActiveSupport::Concern + + BULK_INSERT_TAG_THREAD_KEY = 'ci_bulk_insert_tags' + + class << self + def with_bulk_insert_tags + previous = Thread.current[BULK_INSERT_TAG_THREAD_KEY] + Thread.current[BULK_INSERT_TAG_THREAD_KEY] = true + yield + ensure + Thread.current[BULK_INSERT_TAG_THREAD_KEY] = previous + end + end + + # overrides save_tags from acts-as-taggable + def save_tags + super unless Thread.current[BULK_INSERT_TAG_THREAD_KEY] + end + end +end diff --git a/app/models/concerns/ci/has_status.rb b/app/models/concerns/ci/has_status.rb index cca66c3ec94..721cb14201f 100644 --- a/app/models/concerns/ci/has_status.rb +++ b/app/models/concerns/ci/has_status.rb @@ -23,11 +23,9 @@ module Ci UnknownStatusError = Class.new(StandardError) class_methods do - # The parameter `project` is only used for the feature flag check, and will be removed with - # https://gitlab.com/gitlab-org/gitlab/-/issues/321972 - def composite_status(project: nil) + def composite_status Gitlab::Ci::Status::Composite - .new(all, with_allow_failure: columns_hash.key?('allow_failure'), project: project) + .new(all, with_allow_failure: columns_hash.key?('allow_failure')) .status end diff --git a/app/models/concerns/each_batch.rb b/app/models/concerns/each_batch.rb index 443e1ab53b4..dbc0887dc97 100644 --- a/app/models/concerns/each_batch.rb +++ b/app/models/concerns/each_batch.rb @@ -2,6 +2,7 @@ module EachBatch extend ActiveSupport::Concern + include LooseIndexScan class_methods do # Iterates over the rows in a relation in batches, similar to Rails' @@ -100,5 +101,65 @@ module EachBatch break unless stop end end + + # Iterates over the rows in a relation in batches by skipping duplicated values in the column. + # Example: counting the number of distinct authors in `issues` + # + # - Table size: 100_000 + # - Column: author_id + # - Distinct author_ids in the table: 1000 + # + # The query will read maximum 1000 rows if we have index coverage on user_id. + # + # > count = 0 + # > Issue.distinct_each_batch(column: 'author_id', of: 1000) { |r| count += r.count(:author_id) } + def distinct_each_batch(column:, order: :asc, of: 1000) + start = except(:select) + .select(column) + .reorder(column => order) + + start = start.take + + return unless start + + start_id = start[column] + arel_table = self.arel_table + arel_column = arel_table[column.to_s] + + 1.step do |index| + stop = loose_index_scan(column: column, order: order) do |cte_query, inner_query| + if order == :asc + [cte_query.where(arel_column.gteq(start_id)), inner_query] + else + [cte_query.where(arel_column.lteq(start_id)), inner_query] + end + end.offset(of).take + + if stop + stop_id = stop[column] + + relation = loose_index_scan(column: column, order: order) do |cte_query, inner_query| + if order == :asc + [cte_query.where(arel_column.gteq(start_id)), inner_query.where(arel_column.lt(stop_id))] + else + [cte_query.where(arel_column.lteq(start_id)), inner_query.where(arel_column.gt(stop_id))] + end + end + start_id = stop_id + else + relation = loose_index_scan(column: column, order: order) do |cte_query, inner_query| + if order == :asc + [cte_query.where(arel_column.gteq(start_id)), inner_query] + else + [cte_query.where(arel_column.lteq(start_id)), inner_query] + end + end + end + + unscoped { yield relation, index } + + break unless stop + end + end end end diff --git a/app/models/concerns/enums/ci/commit_status.rb b/app/models/concerns/enums/ci/commit_status.rb index 445277a7a7c..ecb120d8013 100644 --- a/app/models/concerns/enums/ci/commit_status.rb +++ b/app/models/concerns/enums/ci/commit_status.rb @@ -29,9 +29,12 @@ module Enums builds_disabled: 20, environment_creation_failure: 21, deployment_rejected: 22, + protected_environment_failure: 1_000, insufficient_bridge_permissions: 1_001, downstream_bridge_project_not_found: 1_002, invalid_bridge_trigger: 1_003, + upstream_bridge_project_not_found: 1_004, + insufficient_upstream_permissions: 1_005, bridge_pipeline_is_child_pipeline: 1_006, # not used anymore, but cannot be deleted because of old data downstream_pipeline_creation_failed: 1_007, secrets_provider_not_found: 1_008, @@ -42,5 +45,3 @@ module Enums end end end - -Enums::Ci::CommitStatus.prepend_mod_with('Enums::Ci::CommitStatus') diff --git a/app/models/concerns/integrations/has_issue_tracker_fields.rb b/app/models/concerns/integrations/has_issue_tracker_fields.rb index b1def38d019..57f8e21c5a6 100644 --- a/app/models/concerns/integrations/has_issue_tracker_fields.rb +++ b/app/models/concerns/integrations/has_issue_tracker_fields.rb @@ -5,26 +5,32 @@ module Integrations extend ActiveSupport::Concern included do + self.field_storage = :data_fields + field :project_url, required: true, - storage: :data_fields, title: -> { _('Project URL') }, - help: -> { s_('IssueTracker|The URL to the project in the external issue tracker.') } + help: -> do + s_('IssueTracker|The URL to the project in the external issue tracker.') + end field :issues_url, required: true, - storage: :data_fields, title: -> { s_('IssueTracker|Issue URL') }, help: -> do - format s_('IssueTracker|The URL to view an issue in the external issue tracker. Must contain %{colon_id}.'), + ERB::Util.html_escape( + s_('IssueTracker|The URL to view an issue in the external issue tracker. Must contain %{colon_id}.') + ) % { colon_id: ':id'.html_safe + } end field :new_issue_url, required: true, - storage: :data_fields, title: -> { s_('IssueTracker|New issue URL') }, - help: -> { s_('IssueTracker|The URL to create an issue in the external issue tracker.') } + help: -> do + s_('IssueTracker|The URL to create an issue in the external issue tracker.') + end end end end diff --git a/app/models/concerns/integrations/slack_mattermost_notifier.rb b/app/models/concerns/integrations/slack_mattermost_notifier.rb index 3bdaa852ddf..142e62bb501 100644 --- a/app/models/concerns/integrations/slack_mattermost_notifier.rb +++ b/app/models/concerns/integrations/slack_mattermost_notifier.rb @@ -34,7 +34,7 @@ module Integrations class HTTPClient def self.post(uri, params = {}) params.delete(:http_options) # these are internal to the client and we do not want them - Gitlab::HTTP.post(uri, body: params, use_read_total_timeout: true) + Gitlab::HTTP.post(uri, body: params) end end end diff --git a/app/models/concerns/loose_index_scan.rb b/app/models/concerns/loose_index_scan.rb new file mode 100644 index 00000000000..5d37a30171a --- /dev/null +++ b/app/models/concerns/loose_index_scan.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module LooseIndexScan + extend ActiveSupport::Concern + + class_methods do + # Builds a recursive query to read distinct values from a column. + # + # Example 1: collect all distinct author ids for the `issues` table + # + # Bad: The DB reads all issues, sorts and dedups them in memory + # + # > Issue.select(:author_id).distinct.map(&:author_id) + # + # Good: Use loose index scan (skip index scan) + # + # > Issue.loose_index_scan(column: :author_id).map(&:author_id) + # + # Example 2: List of users for the DONE todos selector. Select all users who created a todo. + # + # Bad: Loads all DONE todos for the given user and extracts the author_ids + # + # > User.where(id: Todo.where(user_id: 4156052).done.select(:author_id)) + # + # Good: Loads distinct author_ids from todos and then loads users + # + # > distinct_authors = Todo.where(user_id: 4156052).done.loose_index_scan(column: :author_id).select(:author_id) + # > User.where(id: distinct_authors) + def loose_index_scan(column:, order: :asc) + arel_table = self.arel_table + arel_column = arel_table[column.to_s] + + cte = Gitlab::SQL::RecursiveCTE.new(:loose_index_scan_cte, union_args: { remove_order: false }) + + cte_query = except(:select) + .select(column) + .order(column => order) + .limit(1) + + inner_query = except(:select) + + cte_query, inner_query = yield([cte_query, inner_query]) if block_given? + cte << cte_query + + inner_query = if order == :asc + inner_query.where(arel_column.gt(cte.table[column.to_s])) + else + inner_query.where(arel_column.lt(cte.table[column.to_s])) + end + + inner_query = inner_query.order(column => order) + .select(column) + .limit(1) + + cte << cte.table + .project(Arel::Nodes::Grouping.new(Arel.sql(inner_query.to_sql)).as(column.to_s)) + + unscoped do + select(column) + .with + .recursive(cte.to_arel) + .from(cte.alias_to(arel_table)) + .where(arel_column.not_eq(nil)) # filtering out the last NULL value + end + end + end +end diff --git a/app/models/concerns/milestoneable.rb b/app/models/concerns/milestoneable.rb index 12041b103f6..14c54d99ef3 100644 --- a/app/models/concerns/milestoneable.rb +++ b/app/models/concerns/milestoneable.rb @@ -16,7 +16,7 @@ module Milestoneable scope :any_milestone, -> { where.not(milestone_id: nil) } scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) } - scope :without_particular_milestone, ->(title) { left_outer_joins(:milestone).where("milestones.title != ? OR milestone_id IS NULL", title) } + scope :without_particular_milestones, ->(titles) { left_outer_joins(:milestone).where("milestones.title NOT IN (?) OR milestone_id IS NULL", titles) } scope :any_release, -> { joins_milestone_releases } scope :with_release, -> (tag, project_id) { joins_milestone_releases.where( milestones: { releases: { tag: tag, project_id: project_id } } ) } scope :without_particular_release, -> (tag, project_id) { joins_milestone_releases.where.not(milestones: { releases: { tag: tag, project_id: project_id } }) } diff --git a/app/models/concerns/notification_branch_selection.rb b/app/models/concerns/notification_branch_selection.rb index 18ec996c3df..f2df7579a65 100644 --- a/app/models/concerns/notification_branch_selection.rb +++ b/app/models/concerns/notification_branch_selection.rb @@ -6,13 +6,15 @@ module NotificationBranchSelection extend ActiveSupport::Concern - def branch_choices - [ - [_('All branches'), 'all'].freeze, - [_('Default branch'), 'default'].freeze, - [_('Protected branches'), 'protected'].freeze, - [_('Default branch and protected branches'), 'default_and_protected'].freeze - ].freeze + class_methods do + def branch_choices + [ + [_('All branches'), 'all'].freeze, + [_('Default branch'), 'default'].freeze, + [_('Protected branches'), 'protected'].freeze, + [_('Default branch and protected branches'), 'default_and_protected'].freeze + ].freeze + end end def notify_for_branch?(data) diff --git a/app/models/concerns/packages/fips.rb b/app/models/concerns/packages/fips.rb new file mode 100644 index 00000000000..b8589cdc991 --- /dev/null +++ b/app/models/concerns/packages/fips.rb @@ -0,0 +1,11 @@ +# rubocop:disable Naming/FileName +# frozen_string_literal: true + +module Packages + module FIPS + extend ActiveSupport::Concern + + DisabledError = Class.new(StandardError) + end +end +# rubocop:enable Naming/FileName diff --git a/app/models/concerns/participable.rb b/app/models/concerns/participable.rb index 20743ebcb52..f59b5d1ecc8 100644 --- a/app/models/concerns/participable.rb +++ b/app/models/concerns/participable.rb @@ -92,7 +92,13 @@ module Participable end def raw_participants(current_user = nil, verify_access: false) - ext = Gitlab::ReferenceExtractor.new(project, current_user) + extractor = Gitlab::ReferenceExtractor.new(project, current_user) + + # Used to extract references from confidential notes. + # Referenced users that cannot read confidential notes are + # later removed from participants array. + internal_notes_extractor = Gitlab::ReferenceExtractor.new(project, current_user) + participants = Set.new process = [self] @@ -107,6 +113,8 @@ module Participable source.class.participant_attrs.each do |attr| if attr.respond_to?(:call) + ext = use_internal_notes_extractor_for?(source) ? internal_notes_extractor : extractor + source.instance_exec(current_user, ext, &attr) else process << source.__send__(attr) # rubocop:disable GitlabSecurity/PublicSend @@ -121,7 +129,18 @@ module Participable end end - participants.merge(ext.users) + participants.merge(users_that_can_read_internal_notes(internal_notes_extractor)) + participants.merge(extractor.users) + end + + def use_internal_notes_extractor_for?(source) + source.is_a?(Note) && source.confidential? + end + + def users_that_can_read_internal_notes(extractor) + return [] unless self.is_a?(Noteable) && self.try(:resource_parent) + + Ability.users_that_can_read_internal_notes(extractor.users, self.resource_parent) end def source_visible_to_user?(source, user) diff --git a/app/models/concerns/require_email_verification.rb b/app/models/concerns/require_email_verification.rb new file mode 100644 index 00000000000..cf6a31e6ebd --- /dev/null +++ b/app/models/concerns/require_email_verification.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +# == Require Email Verification module +# +# Contains functionality to handle email verification +module RequireEmailVerification + extend ActiveSupport::Concern + include Gitlab::Utils::StrongMemoize + + # This value is twice the amount we want it to be, because due to a bug in the devise-two-factor + # gem every failed login attempt increments the value of failed_attempts by 2 instead of 1. + # See: https://github.com/tinfoil/devise-two-factor/issues/127 + MAXIMUM_ATTEMPTS = 3 * 2 + UNLOCK_IN = 24.hours + + included do + # Virtual attribute for the email verification token form + attr_accessor :verification_token + end + + # When overridden, do not send Devise unlock instructions when locking access. + def lock_access!(opts = {}) + return super unless override_devise_lockable? + + super({ send_instructions: false }) + end + + protected + + # We cannot override the class methods `maximum_attempts` and `unlock_in`, because we want to + # check for 2FA being enabled on the instance. So instead override the Devise Lockable methods + # where those values are used. + def attempts_exceeded? + return super unless override_devise_lockable? + + failed_attempts >= MAXIMUM_ATTEMPTS + end + + def lock_expired? + return super unless override_devise_lockable? + + locked_at && locked_at < UNLOCK_IN.ago + end + + private + + def override_devise_lockable? + strong_memoize(:override_devise_lockable) do + Feature.enabled?(:require_email_verification, self) && !two_factor_enabled? + end + end +end diff --git a/app/models/concerns/vulnerability_finding_helpers.rb b/app/models/concerns/vulnerability_finding_helpers.rb index 7f96b3901f1..4cf36f83857 100644 --- a/app/models/concerns/vulnerability_finding_helpers.rb +++ b/app/models/concerns/vulnerability_finding_helpers.rb @@ -42,4 +42,41 @@ module VulnerabilityFindingHelpers ) end end + + def build_vulnerability_finding(security_finding) + report_finding = report_finding_for(security_finding) + return Vulnerabilities::Finding.new unless report_finding + + finding_data = report_finding.to_hash.except(:compare_key, :identifiers, :location, :scanner, :links, :signatures, + :flags, :evidence) + identifiers = report_finding.identifiers.map do |identifier| + Vulnerabilities::Identifier.new(identifier.to_hash) + end + signatures = report_finding.signatures.map do |signature| + Vulnerabilities::FindingSignature.new(signature.to_hash) + end + evidence = Vulnerabilities::Finding::Evidence.new(data: report_finding.evidence.data) if report_finding.evidence + + Vulnerabilities::Finding.new(finding_data).tap do |finding| + finding.location_fingerprint = report_finding.location.fingerprint + finding.vulnerability = vulnerability_for(security_finding.uuid) + finding.project = project + finding.sha = pipeline.sha + finding.scanner = security_finding.scanner + finding.finding_evidence = evidence + + if calculate_false_positive? + finding.vulnerability_flags = report_finding.flags.map do |flag| + Vulnerabilities::Flag.new(flag) + end + end + + finding.identifiers = identifiers + finding.signatures = signatures + end + end + + def calculate_false_positive? + project.licensed_feature_available?(:sast_fp_reduction) + end end diff --git a/app/models/container_registry/event.rb b/app/models/container_registry/event.rb index 47d21d21afd..d4075e1ff1b 100644 --- a/app/models/container_registry/event.rb +++ b/app/models/container_registry/event.rb @@ -6,6 +6,7 @@ module ContainerRegistry ALLOWED_ACTIONS = %w(push delete).freeze PUSH_ACTION = 'push' + DELETE_ACTION = 'delete' EVENT_TRACKING_CATEGORY = 'container_registry:notification' attr_reader :event @@ -41,6 +42,10 @@ module ContainerRegistry event['target'].has_key?('tag') end + def target_digest? + event['target'].has_key?('digest') + end + def target_repository? !target_tag? && event['target'].has_key?('repository') end @@ -53,6 +58,10 @@ module ContainerRegistry PUSH_ACTION == action end + def action_delete? + DELETE_ACTION == action + end + def container_repository_exists? return unless container_registry_path @@ -74,7 +83,7 @@ module ContainerRegistry def update_project_statistics return unless supported? - return unless target_tag? + return unless target_tag? || (action_delete? && target_digest?) return unless project Rails.cache.delete(project.root_ancestor.container_repositories_size_cache_key) diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb index c965d7cffe1..cdfd24e00aa 100644 --- a/app/models/container_repository.rb +++ b/app/models/container_repository.rb @@ -468,7 +468,7 @@ class ContainerRepository < ApplicationRecord def size strong_memoize(:size) do next unless Gitlab.com? - next if self.created_at.before?(MIGRATION_PHASE_1_STARTED_AT) + next if self.created_at.before?(MIGRATION_PHASE_1_STARTED_AT) && self.migration_state != 'import_done' next unless gitlab_api_client.supports_gitlab_api? gitlab_api_client.repository_details(self.path, sizing: :self)['size_bytes'] diff --git a/app/models/customer_relations/contact.rb b/app/models/customer_relations/contact.rb index ded6ab8687a..0f13c45b84d 100644 --- a/app/models/customer_relations/contact.rb +++ b/app/models/customer_relations/contact.rb @@ -57,7 +57,22 @@ class CustomerRelations::Contact < ApplicationRecord end def self.sort_by_name - order("last_name ASC, first_name ASC") + order(Gitlab::Pagination::Keyset::Order.build([ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'last_name', + order_expression: arel_table[:last_name].asc, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'first_name', + order_expression: arel_table[:first_name].asc, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'id', + order_expression: arel_table[:id].asc + ) + ])) end def self.find_ids_by_emails(group, emails) diff --git a/app/models/deploy_token.rb b/app/models/deploy_token.rb index 3c0f7d91a03..20d19ec9541 100644 --- a/app/models/deploy_token.rb +++ b/app/models/deploy_token.rb @@ -5,9 +5,6 @@ class DeployToken < ApplicationRecord include TokenAuthenticatable include PolicyActor include Gitlab::Utils::StrongMemoize - include IgnorableColumns - - ignore_column :token, remove_with: '15.2', remove_after: '2022-07-22' add_authentication_token_field :token, encrypted: :required diff --git a/app/models/deployment.rb b/app/models/deployment.rb index fc0dd7e00c7..c25ba6f9268 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -108,13 +108,9 @@ class Deployment < ApplicationRecord end end - after_transition any => :running do |deployment| + after_transition any => :running do |deployment, transition| deployment.run_after_commit do - if Feature.enabled?(:deployment_hooks_skip_worker, deployment.project) - deployment.execute_hooks(Time.current) - else - Deployments::HooksWorker.perform_async(deployment_id: id, status_changed_at: Time.current) - end + Deployments::HooksWorker.perform_async(deployment_id: id, status: transition.to, status_changed_at: Time.current) end end @@ -126,13 +122,9 @@ class Deployment < ApplicationRecord end end - after_transition any => FINISHED_STATUSES do |deployment| + after_transition any => FINISHED_STATUSES do |deployment, transition| deployment.run_after_commit do - if Feature.enabled?(:deployment_hooks_skip_worker, deployment.project) - deployment.execute_hooks(Time.current) - else - Deployments::HooksWorker.perform_async(deployment_id: id, status_changed_at: Time.current) - end + Deployments::HooksWorker.perform_async(deployment_id: id, status: transition.to, status_changed_at: Time.current) end end @@ -193,7 +185,7 @@ class Deployment < ApplicationRecord def self.last_deployment_group_for_environment(env) return self.none unless env.last_deployment_pipeline&.latest_successful_builds&.present? - BatchLoader.for(env).batch do |environments, loader| + BatchLoader.for(env).batch(default_value: self.none) do |environments, loader| latest_successful_build_ids = [] environments_hash = {} @@ -269,8 +261,8 @@ class Deployment < ApplicationRecord Commit.truncate_sha(sha) end - def execute_hooks(status_changed_at) - deployment_data = Gitlab::DataBuilder::Deployment.build(self, status_changed_at) + def execute_hooks(status, status_changed_at) + deployment_data = Gitlab::DataBuilder::Deployment.build(self, status, status_changed_at) project.execute_hooks(deployment_data, :deployment_hooks) project.execute_integrations(deployment_data, :deployment_hooks) end diff --git a/app/models/environment.rb b/app/models/environment.rb index da6ab5ed077..68540ce0f5c 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -451,13 +451,11 @@ class Environment < ApplicationRecord def auto_stop_in=(value) return unless value - return unless parsed_result = ChronicDuration.parse(value) - self.auto_stop_at = parsed_result.seconds.from_now - end + parser = ::Gitlab::Ci::Build::DurationParser.new(value) + return if parser.seconds_from_now.nil? - def elastic_stack_available? - !!deployment_platform&.cluster&.elastic_stack_available? + self.auto_stop_at = parser.seconds_from_now end def rollout_status diff --git a/app/models/error_tracking/client_key.rb b/app/models/error_tracking/client_key.rb index bbc57573aa9..d58a183f223 100644 --- a/app/models/error_tracking/client_key.rb +++ b/app/models/error_tracking/client_key.rb @@ -16,7 +16,7 @@ class ErrorTracking::ClientKey < ApplicationRecord end def sentry_dsn - @sentry_dsn ||= ErrorTracking::Collector::Dsn.build_url(public_key, project_id) + @sentry_dsn ||= ::Gitlab::ErrorTracking::ErrorRepository.build(project).dsn_url(public_key) end private diff --git a/app/models/group.rb b/app/models/group.rb index f5aad6e74ff..6d8f8bd7613 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -112,6 +112,8 @@ class Group < Namespace has_many :dependency_proxy_blobs, class_name: 'DependencyProxy::Blob' has_many :dependency_proxy_manifests, class_name: 'DependencyProxy::Manifest' + has_one :harbor_integration, class_name: 'Integrations::Harbor' + # debian_distributions and associated component_files must be destroyed by ruby code in order to properly remove carrierwave uploads has_many :debian_distributions, class_name: 'Packages::Debian::GroupDistribution', dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent @@ -361,8 +363,8 @@ class Group < Namespace owners.include?(user) end - def add_users(users, access_level, current_user: nil, expires_at: nil, tasks_to_be_done: [], tasks_project_id: nil) - Members::Groups::CreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass + def add_members(users, access_level, current_user: nil, expires_at: nil, tasks_to_be_done: [], tasks_project_id: nil) + Members::Groups::CreatorService.add_members( # rubocop:disable CodeReuse/ServiceClass self, users, access_level, @@ -373,8 +375,8 @@ class Group < Namespace ) end - def add_user(user, access_level, current_user: nil, expires_at: nil, ldap: false, blocking_refresh: true) - Members::Groups::CreatorService.add_user( # rubocop:disable CodeReuse/ServiceClass + def add_member(user, access_level, current_user: nil, expires_at: nil, ldap: false, blocking_refresh: true) + Members::Groups::CreatorService.add_member( # rubocop:disable CodeReuse/ServiceClass self, user, access_level, @@ -386,23 +388,23 @@ class Group < Namespace end def add_guest(user, current_user = nil) - add_user(user, :guest, current_user: current_user) + add_member(user, :guest, current_user: current_user) end def add_reporter(user, current_user = nil) - add_user(user, :reporter, current_user: current_user) + add_member(user, :reporter, current_user: current_user) end def add_developer(user, current_user = nil) - add_user(user, :developer, current_user: current_user) + add_member(user, :developer, current_user: current_user) end def add_maintainer(user, current_user = nil) - add_user(user, :maintainer, current_user: current_user) + add_member(user, :maintainer, current_user: current_user) end def add_owner(user, current_user = nil) - add_user(user, :owner, current_user: current_user) + add_member(user, :owner, current_user: current_user) end def member?(user, min_access_level = Gitlab::Access::GUEST) diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb index b7ace34141e..bcbf43ee38b 100644 --- a/app/models/hooks/project_hook.rb +++ b/app/models/hooks/project_hook.rb @@ -27,6 +27,8 @@ class ProjectHook < WebHook belongs_to :project validates :project, presence: true + scope :for_projects, ->(project) { where(project: project) } + def pluralized_name _('Webhooks') end @@ -41,6 +43,19 @@ class ProjectHook < WebHook project end + override :update_last_failure + def update_last_failure + return if executable? + + key = "web_hooks:last_failure:project-#{project_id}" + time = Time.current.utc.iso8601 + + Gitlab::Redis::SharedState.with do |redis| + prev = redis.get(key) + redis.set(key, time) if !prev || prev < time + end + end + private override :web_hooks_disable_failed? diff --git a/app/models/hooks/system_hook.rb b/app/models/hooks/system_hook.rb index c8a0cc05912..c0073f9a9b8 100644 --- a/app/models/hooks/system_hook.rb +++ b/app/models/hooks/system_hook.rb @@ -26,6 +26,6 @@ class SystemHook < WebHook end def help_path - 'system_hooks/system_hooks' + 'administration/system_hooks' end end diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index 37fd612e652..f428d07cd7f 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -3,6 +3,8 @@ class WebHook < ApplicationRecord include Sortable + InterpolationError = Class.new(StandardError) + MAX_FAILURES = 100 FAILURE_THRESHOLD = 3 # three strikes INITIAL_BACKOFF = 10.minutes @@ -36,6 +38,7 @@ class WebHook < ApplicationRecord validates :token, format: { without: /\n/ } validates :push_events_branch_filter, branch_filter: true validates :url_variables, json_schema: { filename: 'web_hooks_url_variables' } + validate :no_missing_url_variables after_initialize :initialize_url_variables @@ -45,6 +48,11 @@ class WebHook < ApplicationRecord where('recent_failures <= ? AND (disabled_until IS NULL OR disabled_until < ?)', FAILURE_THRESHOLD, Time.current) end + # Inverse of executable + scope :disabled, -> do + where('recent_failures > ? OR disabled_until >= ?', FAILURE_THRESHOLD, Time.current) + end + def executable? !temporarily_disabled? && !permanently_disabled? end @@ -164,6 +172,24 @@ class WebHook < ApplicationRecord super(options) end + # See app/validators/json_schemas/web_hooks_url_variables.json + VARIABLE_REFERENCE_RE = /\{([A-Za-z_][A-Za-z0-9_]+)\}/.freeze + + def interpolated_url + return url unless url.include?('{') + + vars = url_variables + url.gsub(VARIABLE_REFERENCE_RE) do + vars.fetch(_1.delete_prefix('{').delete_suffix('}')) + end + rescue KeyError => e + raise InterpolationError, "Invalid URL template. Missing key #{e.key}" + end + + def update_last_failure + # Overridden in child classes. + end + private def web_hooks_disable_failed? @@ -177,4 +203,17 @@ class WebHook < ApplicationRecord def rate_limiter @rate_limiter ||= Gitlab::WebHooks::RateLimiter.new(self) end + + def no_missing_url_variables + return if url.nil? + + variable_names = url_variables.keys + used_variables = url.scan(VARIABLE_REFERENCE_RE).map(&:first) + + missing = used_variables - variable_names + + return if missing.empty? + + errors.add(:url, "Invalid URL template. Missing keys: #{missing}") + end end diff --git a/app/models/incident_management/issuable_escalation_status.rb b/app/models/incident_management/issuable_escalation_status.rb index fc881e62efd..3c581f0489a 100644 --- a/app/models/incident_management/issuable_escalation_status.rb +++ b/app/models/incident_management/issuable_escalation_status.rb @@ -7,7 +7,7 @@ module IncidentManagement self.table_name = 'incident_management_issuable_escalation_statuses' belongs_to :issue - has_one :project, through: :issue, inverse_of: :incident_management_issuable_escalation_status + has_one :project, through: :issue, inverse_of: :incident_management_issuable_escalation_statuses validates :issue, presence: true, uniqueness: true diff --git a/app/models/integration.rb b/app/models/integration.rb index 726e95b7cbf..f5f701662e7 100644 --- a/app/models/integration.rb +++ b/app/models/integration.rb @@ -13,8 +13,6 @@ class Integration < ApplicationRecord include IgnorableColumns extend ::Gitlab::Utils::Override - ignore_column :properties, remove_with: '15.1', remove_after: '2022-05-22' - UnknownType = Class.new(StandardError) self.inheritance_column = :type_new @@ -154,6 +152,8 @@ class Integration < ApplicationRecord else raise ArgumentError, "Unknown field storage: #{storage}" end + + boolean_accessor(name) if attrs[:type] == 'checkbox' end # :nocov: @@ -200,14 +200,21 @@ class Integration < ApplicationRecord # Provide convenient boolean accessor methods for each serialized property. # Also keep track of updated properties in a similar way as ActiveModel::Dirty def self.boolean_accessor(*args) - prop_accessor(*args) - args.each do |arg| + # TODO: Allow legacy usage of `.boolean_accessor`, once all integrations + # are converted to the field DSL we can remove this and only call + # `.boolean_accessor` through `.field`. + # + # See https://gitlab.com/groups/gitlab-org/-/epics/7652 + prop_accessor(arg) unless method_defined?(arg) + class_eval <<~RUBY, __FILE__, __LINE__ + 1 - def #{arg} - return if properties.blank? + # Make the original getter available as a private method. + alias_method :#{arg}_before_type_cast, :#{arg} + private(:#{arg}_before_type_cast) - Gitlab::Utils.to_boolean(properties['#{arg}']) + def #{arg} + Gitlab::Utils.to_boolean(#{arg}_before_type_cast) end def #{arg}? @@ -494,16 +501,12 @@ class Integration < ApplicationRecord self.class.event_names end - def event_field(event) - nil - end - def api_field_names fields.reject { _1[:type] == 'password' }.pluck(:name) end - def global_fields - fields + def form_fields + fields.reject { _1[:api_only] == true } end def configurable_events @@ -574,11 +577,7 @@ class Integration < ApplicationRecord def async_execute(data) return unless supported_events.include?(data[:object_kind]) - if Feature.enabled?(:rename_integrations_workers) - Integrations::ExecuteWorker.perform_async(id, data) - else - ProjectServiceWorker.perform_async(id, data) - end + Integrations::ExecuteWorker.perform_async(id, data) end # override if needed diff --git a/app/models/integrations/asana.rb b/app/models/integrations/asana.rb index d25bf8b1b1e..2cfd71c9eb2 100644 --- a/app/models/integrations/asana.rb +++ b/app/models/integrations/asana.rb @@ -4,9 +4,22 @@ require 'asana' module Integrations class Asana < Integration - prop_accessor :api_key, :restrict_to_branch validates :api_key, presence: true, if: :activated? + field :api_key, + type: 'password', + title: 'API key', + help: -> { s_('AsanaService|User Personal Access Token. User must have access to the task. All comments are attributed to this user.') }, + non_empty_password_title: -> { s_('ProjectService|Enter new API key') }, + non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current API key.') }, + # Example Personal Access Token from Asana docs + placeholder: '0/68a9e79b868c6789e79a124c30b0', + required: true + + field :restrict_to_branch, + title: -> { s_('Integrations|Restrict to branch (optional)') }, + help: -> { s_('AsanaService|Comma-separated list of branches to be automatically inspected. Leave blank to include all branches.') } + def title 'Asana' end @@ -24,28 +37,6 @@ module Integrations 'asana' end - def fields - [ - { - type: 'password', - name: 'api_key', - title: 'API key', - help: s_('AsanaService|User Personal Access Token. User must have access to the task. All comments are attributed to this user.'), - non_empty_password_title: s_('ProjectService|Enter new API key'), - non_empty_password_help: s_('ProjectService|Leave blank to use your current API key.'), - # Example Personal Access Token from Asana docs - placeholder: '0/68a9e79b868c6789e79a124c30b0', - required: true - }, - { - type: 'text', - name: 'restrict_to_branch', - title: 'Restrict to branch (optional)', - help: s_('AsanaService|Comma-separated list of branches to be automatically inspected. Leave blank to include all branches.') - } - ] - end - def self.supported_events %w(push) end diff --git a/app/models/integrations/assembla.rb b/app/models/integrations/assembla.rb index ccd24c1fb2c..88dbf2915ef 100644 --- a/app/models/integrations/assembla.rb +++ b/app/models/integrations/assembla.rb @@ -2,9 +2,18 @@ module Integrations class Assembla < Integration - prop_accessor :token, :subdomain validates :token, presence: true, if: :activated? + field :token, + type: 'password', + non_empty_password_title: -> { s_('ProjectService|Enter new token') }, + non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, + placeholder: '', + required: true + + field :subdomain, + placeholder: '' + def title 'Assembla' end @@ -17,24 +26,6 @@ module Integrations 'assembla' end - def fields - [ - { - type: 'password', - name: 'token', - non_empty_password_title: s_('ProjectService|Enter new token'), - non_empty_password_help: s_('ProjectService|Leave blank to use your current token.'), - placeholder: '', - required: true - }, - { - type: 'text', - name: 'subdomain', - placeholder: '' - } - ] - end - def self.supported_events %w(push) end diff --git a/app/models/integrations/bamboo.rb b/app/models/integrations/bamboo.rb index 4e30c1ccc69..230dc6bb336 100644 --- a/app/models/integrations/bamboo.rb +++ b/app/models/integrations/bamboo.rb @@ -155,7 +155,6 @@ module Integrations query_params[:os_authType] = 'basic' params[:basic_auth] = basic_auth - params[:use_read_total_timeout] = true params end diff --git a/app/models/integrations/base_chat_notification.rb b/app/models/integrations/base_chat_notification.rb index 33d4eecbf49..c7992e4083c 100644 --- a/app/models/integrations/base_chat_notification.rb +++ b/app/models/integrations/base_chat_notification.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Base class for Chat notifications services +# Base class for Chat notifications integrations # This class is not meant to be used directly, but only to inherit from. module Integrations @@ -46,7 +46,7 @@ module Integrations # `notify_only_default_branch`. Now we have a string property named # `branches_to_be_notified`. Instead of doing a background migration, we # opted to set a value for the new property based on the old one, if - # users haven't specified one already. When users edit the service and + # users haven't specified one already. When users edit the integration and # select a value for this new property, it will override everything. self.branches_to_be_notified ||= notify_only_default_branch? ? "default" : "all" @@ -78,7 +78,7 @@ module Integrations type: 'select', name: 'branches_to_be_notified', title: s_('Integrations|Branches for which notifications are to be sent'), - choices: branch_choices + choices: self.class.branch_choices }.freeze, { type: 'text', @@ -118,7 +118,7 @@ module Integrations event_type = data[:event_type] || object_kind - channel_names = get_channel_field(event_type).presence || channel.presence + channel_names = event_channel_value(event_type).presence || channel.presence channels = channel_names&.split(',')&.map(&:strip) opts = {} @@ -134,15 +134,13 @@ module Integrations end def event_channel_names - supported_events.map { |event| event_channel_name(event) } - end + return [] unless configurable_channels? - def event_field(event) - fields.find { |field| field[:name] == event_channel_name(event) } + supported_events.map { |event| event_channel_name(event) } end - def global_fields - fields.reject { |field| field[:name].end_with?('channel') } + def form_fields + super.reject { |field| field[:name].end_with?('channel') } end def default_channel_placeholder @@ -153,6 +151,21 @@ module Integrations raise NotImplementedError end + # With some integrations the webhook is already tied to a specific channel, + # for others the channels are configurable for each event. + def configurable_channels? + false + end + + def event_channel_name(event) + EVENT_CHANNEL[event] + end + + def event_channel_value(event) + field_name = event_channel_name(event) + self.public_send(field_name) # rubocop:disable GitlabSecurity/PublicSend + end + private def log_usage(_, _) @@ -213,21 +226,12 @@ module Integrations end end - def get_channel_field(event) - field_name = event_channel_name(event) - self.public_send(field_name) # rubocop:disable GitlabSecurity/PublicSend - end - def build_event_channels - supported_events.reduce([]) do |channels, event| - channels << { type: 'text', name: event_channel_name(event), placeholder: default_channel_placeholder } + event_channel_names.map do |channel_field| + { type: 'text', name: channel_field, placeholder: default_channel_placeholder } end end - def event_channel_name(event) - EVENT_CHANNEL[event] - end - def project_name project.full_name end diff --git a/app/models/integrations/base_issue_tracker.rb b/app/models/integrations/base_issue_tracker.rb index bffe87c21ee..fe4a2f43b13 100644 --- a/app/models/integrations/base_issue_tracker.rb +++ b/app/models/integrations/base_issue_tracker.rb @@ -94,7 +94,7 @@ module Integrations result = false begin - response = Gitlab::HTTP.head(self.project_url, verify: true, use_read_total_timeout: true) + response = Gitlab::HTTP.head(self.project_url, verify: true) if response message = "#{self.type} received response #{response.code} when attempting to connect to #{self.project_url}" diff --git a/app/models/integrations/campfire.rb b/app/models/integrations/campfire.rb index 7889cd8f9a9..bf1358ac0f6 100644 --- a/app/models/integrations/campfire.rb +++ b/app/models/integrations/campfire.rb @@ -2,9 +2,34 @@ module Integrations class Campfire < Integration - prop_accessor :token, :subdomain, :room validates :token, presence: true, if: :activated? + field :token, + type: 'password', + title: -> { _('Campfire token') }, + help: -> { s_('CampfireService|API authentication token from Campfire.') }, + non_empty_password_title: -> { s_('ProjectService|Enter new token') }, + non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, + placeholder: '', + required: true + + field :subdomain, + title: -> { _('Campfire subdomain (optional)') }, + placeholder: '', + help: -> do + ERB::Util.html_escape( + s_('CampfireService|The %{code_open}.campfirenow.com%{code_close} subdomain.') + ) % { + code_open: ''.html_safe, + code_close: ''.html_safe + } + end + + field :room, + title: -> { _('Campfire room ID (optional)') }, + placeholder: '123456', + help: -> { s_('CampfireService|From the end of the room URL.') } + def title 'Campfire' end @@ -15,42 +40,18 @@ module Integrations def help docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('api/services', anchor: 'campfire'), target: '_blank', rel: 'noopener noreferrer' - s_('CampfireService|Send notifications about push events to Campfire chat rooms. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } + + ERB::Util.html_escape( + s_('CampfireService|Send notifications about push events to Campfire chat rooms. %{docs_link}') + ) % { + docs_link: docs_link.html_safe + } end def self.to_param 'campfire' end - def fields - [ - { - type: 'password', - name: 'token', - title: _('Campfire token'), - help: s_('CampfireService|API authentication token from Campfire.'), - non_empty_password_title: s_('ProjectService|Enter new token'), - non_empty_password_help: s_('ProjectService|Leave blank to use your current token.'), - placeholder: '', - required: true - }, - { - type: 'text', - name: 'subdomain', - title: _('Campfire subdomain (optional)'), - placeholder: '', - help: s_('CampfireService|The %{code_open}.campfirenow.com%{code_close} subdomain.') % { code_open: ''.html_safe, code_close: ''.html_safe } - }, - { - type: 'text', - name: 'room', - title: _('Campfire room ID (optional)'), - placeholder: '123456', - help: s_('CampfireService|From the end of the room URL.') - } - ] - end - def self.supported_events %w(push) end diff --git a/app/models/integrations/confluence.rb b/app/models/integrations/confluence.rb index 4e1d1993d02..c1c43af99bf 100644 --- a/app/models/integrations/confluence.rb +++ b/app/models/integrations/confluence.rb @@ -6,11 +6,14 @@ module Integrations VALID_HOST_MATCH = %r{\A.+\.atlassian\.net\Z}.freeze VALID_PATH_MATCH = %r{\A/wiki(/|\Z)}.freeze - prop_accessor :confluence_url - validates :confluence_url, presence: true, if: :activated? validate :validate_confluence_url_is_cloud, if: :activated? + field :confluence_url, + title: -> { s_('Confluence Cloud Workspace URL') }, + placeholder: 'https://example.atlassian.net/wiki', + required: true + def self.to_param 'confluence' end @@ -38,18 +41,6 @@ module Integrations end end - def fields - [ - { - type: 'text', - name: 'confluence_url', - title: s_('Confluence Cloud Workspace URL'), - placeholder: 'https://example.atlassian.net/wiki', - required: true - } - ] - end - def testable? false end diff --git a/app/models/integrations/datadog.rb b/app/models/integrations/datadog.rb index bb0fb6b9079..97e586c0662 100644 --- a/app/models/integrations/datadog.rb +++ b/app/models/integrations/datadog.rb @@ -15,7 +15,75 @@ module Integrations TAG_KEY_VALUE_RE = %r{\A [\w-]+ : .*\S.* \z}x.freeze - prop_accessor :datadog_site, :api_url, :api_key, :datadog_service, :datadog_env, :datadog_tags + field :datadog_site, + placeholder: DEFAULT_DOMAIN, + help: -> do + ERB::Util.html_escape( + s_('DatadogIntegration|The Datadog site to send data to. To send data to the EU site, use %{codeOpen}datadoghq.eu%{codeClose}.') + ) % { + codeOpen: ''.html_safe, + codeClose: ''.html_safe + } + end + + field :api_url, + title: -> { s_('DatadogIntegration|API URL') }, + help: -> { s_('DatadogIntegration|(Advanced) The full URL for your Datadog site.') } + + field :api_key, + type: 'password', + title: -> { _('API key') }, + non_empty_password_title: -> { s_('ProjectService|Enter new API key') }, + non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current API key') }, + help: -> do + ERB::Util.html_escape( + s_('DatadogIntegration|%{linkOpen}API key%{linkClose} used for authentication with Datadog.') + ) % { + linkOpen: %Q{}.html_safe, + linkClose: ''.html_safe + } + end, + required: true + + field :archive_trace_events, + type: 'checkbox', + title: -> { s_('Logs') }, + checkbox_label: -> { s_('Enable logs collection') }, + help: -> { s_('When enabled, job logs are collected by Datadog and displayed along with pipeline execution traces.') } + + field :datadog_service, + title: -> { s_('DatadogIntegration|Service') }, + placeholder: 'gitlab-ci', + help: -> { s_('DatadogIntegration|Tag all data from this GitLab instance in Datadog. Useful when managing several self-managed deployments.') } + + field :datadog_env, + title: -> { s_('DatadogIntegration|Environment') }, + placeholder: 'ci', + help: -> do + ERB::Util.html_escape( + s_('DatadogIntegration|For self-managed deployments, set the %{codeOpen}env%{codeClose} tag for all the data sent to Datadog. %{linkOpen}How do I use tags?%{linkClose}') + ) % { + codeOpen: ''.html_safe, + codeClose: ''.html_safe, + linkOpen: ''.html_safe, + linkClose: ''.html_safe + } + end + + field :datadog_tags, + type: 'textarea', + title: -> { s_('DatadogIntegration|Tags') }, + placeholder: "tag:value\nanother_tag:value", + help: -> do + ERB::Util.html_escape( + s_('DatadogIntegration|Custom tags in Datadog. Enter one tag per line in the %{codeOpen}key:value%{codeClose} format. %{linkOpen}How do I use tags?%{linkClose}') + ) % { + codeOpen: ''.html_safe, + codeClose: ''.html_safe, + linkOpen: ''.html_safe, + linkClose: ''.html_safe + } + end before_validation :strip_properties @@ -77,92 +145,11 @@ module Integrations end def fields - f = [ - { - type: 'text', - name: 'datadog_site', - placeholder: DEFAULT_DOMAIN, - help: ERB::Util.html_escape( - s_('DatadogIntegration|The Datadog site to send data to. To send data to the EU site, use %{codeOpen}datadoghq.eu%{codeClose}.') - ) % { - codeOpen: ''.html_safe, - codeClose: ''.html_safe - }, - required: false - }, - { - type: 'text', - name: 'api_url', - title: s_('DatadogIntegration|API URL'), - help: s_('DatadogIntegration|(Advanced) The full URL for your Datadog site.'), - required: false - }, - { - type: 'password', - name: 'api_key', - title: _('API key'), - non_empty_password_title: s_('ProjectService|Enter new API key'), - non_empty_password_help: s_('ProjectService|Leave blank to use your current API key'), - help: ERB::Util.html_escape( - s_('DatadogIntegration|%{linkOpen}API key%{linkClose} used for authentication with Datadog.') - ) % { - linkOpen: %Q{}.html_safe, - linkClose: ''.html_safe - }, - required: true - } - ] - if Feature.enabled?(:datadog_integration_logs_collection, parent) - f.append({ - type: 'checkbox', - name: 'archive_trace_events', - title: s_('Logs'), - checkbox_label: s_('Enable logs collection'), - help: s_('When enabled, job logs are collected by Datadog and displayed along with pipeline execution traces.'), - required: false - }) + super + else + super.reject { _1.name == 'archive_trace_events' } end - - f += [ - { - type: 'text', - name: 'datadog_service', - title: s_('DatadogIntegration|Service'), - placeholder: 'gitlab-ci', - help: s_('DatadogIntegration|Tag all data from this GitLab instance in Datadog. Useful when managing several self-managed deployments.') - }, - { - type: 'text', - name: 'datadog_env', - title: s_('DatadogIntegration|Environment'), - placeholder: 'ci', - help: ERB::Util.html_escape( - s_('DatadogIntegration|For self-managed deployments, set the %{codeOpen}env%{codeClose} tag for all the data sent to Datadog. %{linkOpen}How do I use tags?%{linkClose}') - ) % { - codeOpen: ''.html_safe, - codeClose: ''.html_safe, - linkOpen: ''.html_safe, - linkClose: ''.html_safe - } - }, - { - type: 'textarea', - name: 'datadog_tags', - title: s_('DatadogIntegration|Tags'), - placeholder: "tag:value\nanother_tag:value", - help: ERB::Util.html_escape( - s_('DatadogIntegration|Custom tags in Datadog. Enter one tag per line in the %{codeOpen}key:value%{codeClose} format. %{linkOpen}How do I use tags?%{linkClose}') - ) % { - codeOpen: ''.html_safe, - codeClose: ''.html_safe, - linkOpen: ''.html_safe, - linkClose: ''.html_safe - } - } - ] - - f end override :hook_url diff --git a/app/models/integrations/discord.rb b/app/models/integrations/discord.rb index 790e41e5a2a..ecabf23c90b 100644 --- a/app/models/integrations/discord.rb +++ b/app/models/integrations/discord.rb @@ -23,10 +23,6 @@ module Integrations s_('Send notifications about project events to a Discord channel. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end - def event_field(event) - # No-op. - end - def default_channel_placeholder # No-op. end @@ -43,7 +39,7 @@ module Integrations type: 'select', name: 'branches_to_be_notified', title: s_('Integrations|Branches for which notifications are to be sent'), - choices: branch_choices + choices: self.class.branch_choices } ] end diff --git a/app/models/integrations/drone_ci.rb b/app/models/integrations/drone_ci.rb index 35524503dea..b1f72b7144e 100644 --- a/app/models/integrations/drone_ci.rb +++ b/app/models/integrations/drone_ci.rb @@ -60,8 +60,7 @@ module Integrations response = Gitlab::HTTP.try_get( commit_status_path(sha, ref), verify: enable_ssl_verification, - extra_log_info: { project_id: project_id }, - use_read_total_timeout: true + extra_log_info: { project_id: project_id } ) status = diff --git a/app/models/integrations/emails_on_push.rb b/app/models/integrations/emails_on_push.rb index ab458bb2c27..ed12a3a8d63 100644 --- a/app/models/integrations/emails_on_push.rb +++ b/app/models/integrations/emails_on_push.rb @@ -6,12 +6,35 @@ module Integrations RECIPIENTS_LIMIT = 750 - boolean_accessor :send_from_committer_email - boolean_accessor :disable_diffs - prop_accessor :recipients, :branches_to_be_notified validates :recipients, presence: true, if: :validate_recipients? validate :number_of_recipients_within_limit, if: :validate_recipients? + field :send_from_committer_email, + type: 'checkbox', + title: -> { s_("EmailsOnPushService|Send from committer") }, + help: -> do + @help ||= begin + domains = Notify.allowed_email_domains.map { |domain| "user@#{domain}" }.join(", ") + + s_("EmailsOnPushService|Send notifications from the committer's email address if the domain matches the domain used by your GitLab instance (such as %{domains}).") % { domains: domains } + end + end + + field :disable_diffs, + type: 'checkbox', + title: -> { s_("EmailsOnPushService|Disable code diffs") }, + help: -> { s_("EmailsOnPushService|Don't include possibly sensitive code diffs in notification body.") } + + field :branches_to_be_notified, + type: 'select', + title: -> { s_('Integrations|Branches for which notifications are to be sent') }, + choices: branch_choices + + field :recipients, + type: 'textarea', + placeholder: -> { s_('EmailsOnPushService|tanuki@example.com gitlab@example.com') }, + help: -> { s_('EmailsOnPushService|Emails separated by whitespace.') } + def self.valid_recipients(recipients) recipients.split.grep(Devise.email_regexp).uniq(&:downcase) end @@ -67,28 +90,6 @@ module Integrations Gitlab::Utils.to_boolean(self.disable_diffs) end - def fields - domains = Notify.allowed_email_domains.map { |domain| "user@#{domain}" }.join(", ") - [ - { type: 'checkbox', name: 'send_from_committer_email', title: s_("EmailsOnPushService|Send from committer"), - help: s_("EmailsOnPushService|Send notifications from the committer's email address if the domain matches the domain used by your GitLab instance (such as %{domains}).") % { domains: domains } }, - { type: 'checkbox', name: 'disable_diffs', title: s_("EmailsOnPushService|Disable code diffs"), - help: s_("EmailsOnPushService|Don't include possibly sensitive code diffs in notification body.") }, - { - type: 'select', - name: 'branches_to_be_notified', - title: s_('Integrations|Branches for which notifications are to be sent'), - choices: branch_choices - }, - { - type: 'textarea', - name: 'recipients', - placeholder: s_('EmailsOnPushService|tanuki@example.com gitlab@example.com'), - help: s_('EmailsOnPushService|Emails separated by whitespace.') - } - ] - end - private def number_of_recipients_within_limit diff --git a/app/models/integrations/external_wiki.rb b/app/models/integrations/external_wiki.rb index 18c48411e30..bc2ea193a84 100644 --- a/app/models/integrations/external_wiki.rb +++ b/app/models/integrations/external_wiki.rb @@ -2,9 +2,14 @@ module Integrations class ExternalWiki < Integration - prop_accessor :external_wiki_url validates :external_wiki_url, presence: true, public_url: true, if: :activated? + field :external_wiki_url, + title: -> { s_('ExternalWikiService|External wiki URL') }, + placeholder: -> { s_('ExternalWikiService|https://example.com/xxx/wiki/...') }, + help: -> { s_('ExternalWikiService|Enter the URL to the external wiki.') }, + required: true + def title s_('ExternalWikiService|External wiki') end @@ -17,19 +22,6 @@ module Integrations 'external_wiki' end - def fields - [ - { - type: 'text', - name: 'external_wiki_url', - title: s_('ExternalWikiService|External wiki URL'), - placeholder: s_('ExternalWikiService|https://example.com/xxx/wiki/...'), - help: 'Enter the URL to the external wiki.', - required: true - } - ] - end - def help docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/wiki/index', anchor: 'link-an-external-wiki'), target: '_blank', rel: 'noopener noreferrer' @@ -37,7 +29,7 @@ module Integrations end def execute(_data) - response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true, use_read_total_timeout: true) + response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true) response.body if response.code == 200 rescue StandardError nil diff --git a/app/models/integrations/field.rb b/app/models/integrations/field.rb index cbda418755b..53c8f5f623e 100644 --- a/app/models/integrations/field.rb +++ b/app/models/integrations/field.rb @@ -4,14 +4,16 @@ module Integrations class Field SECRET_NAME = %r/token|key|password|passphrase|secret/.freeze + BOOLEAN_ATTRIBUTES = %i[required api_only exposes_secrets].freeze + ATTRIBUTES = %i[ - section type placeholder required choices value checkbox_label + section type placeholder choices value checkbox_label title help non_empty_password_help non_empty_password_title - api_only - exposes_secrets - ].freeze + ].concat(BOOLEAN_ATTRIBUTES).freeze + + TYPES = %w[text textarea password checkbox select].freeze attr_reader :name, :integration_class @@ -22,6 +24,13 @@ module Integrations attributes[:type] = SECRET_NAME.match?(@name) ? 'password' : type attributes[:api_only] = api_only @attributes = attributes.freeze + + invalid_attributes = attributes.keys - ATTRIBUTES + if invalid_attributes.present? + raise ArgumentError, "Invalid attributes #{invalid_attributes.inspect}" + elsif !TYPES.include?(self[:type]) + raise ArgumentError, "Invalid type #{self[:type].inspect}" + end end def [](key) @@ -34,11 +43,19 @@ module Integrations end def secret? - @attributes[:type] == 'password' + self[:type] == 'password' end ATTRIBUTES.each do |name| define_method(name) { self[name] } end + + BOOLEAN_ATTRIBUTES.each do |name| + define_method("#{name}?") { !!self[name] } + end + + TYPES.each do |type| + define_method("#{type}?") { self[:type] == type } + end end end diff --git a/app/models/integrations/flowdock.rb b/app/models/integrations/flowdock.rb index 703d8013bab..52efb29f2c1 100644 --- a/app/models/integrations/flowdock.rb +++ b/app/models/integrations/flowdock.rb @@ -2,9 +2,16 @@ module Integrations class Flowdock < Integration - prop_accessor :token validates :token, presence: true, if: :activated? + field :token, + type: 'password', + help: -> { s_('FlowdockService|Enter your Flowdock token.') }, + non_empty_password_title: -> { s_('ProjectService|Enter new token') }, + non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, + placeholder: '1b609b52537...', + required: true + def title 'Flowdock' end @@ -22,20 +29,6 @@ module Integrations 'flowdock' end - def fields - [ - { - type: 'password', - name: 'token', - help: s_('FlowdockService|Enter your Flowdock token.'), - non_empty_password_title: s_('ProjectService|Enter new token'), - non_empty_password_help: s_('ProjectService|Leave blank to use your current token.'), - placeholder: '1b609b52537...', - required: true - } - ] - end - def self.supported_events %w(push) end diff --git a/app/models/integrations/hangouts_chat.rb b/app/models/integrations/hangouts_chat.rb index 8c68c9ff95a..df112ad6ca8 100644 --- a/app/models/integrations/hangouts_chat.rb +++ b/app/models/integrations/hangouts_chat.rb @@ -19,9 +19,6 @@ module Integrations s_('Before enabling this integration, create a webhook for the room in Google Chat where you want to receive notifications from this project. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end - def event_field(event) - end - def default_channel_placeholder end @@ -42,7 +39,7 @@ module Integrations type: 'select', name: 'branches_to_be_notified', title: s_('Integrations|Branches for which notifications are to be sent'), - choices: branch_choices + choices: self.class.branch_choices } ] end diff --git a/app/models/integrations/harbor.rb b/app/models/integrations/harbor.rb index 44813795fc0..82981493822 100644 --- a/app/models/integrations/harbor.rb +++ b/app/models/integrations/harbor.rb @@ -4,7 +4,7 @@ module Integrations class Harbor < Integration prop_accessor :url, :project_name, :username, :password - validates :url, public_url: true, presence: true, if: :activated? + validates :url, public_url: true, presence: true, addressable_url: { allow_localhost: false, allow_local_network: false }, if: :activated? validates :project_name, presence: true, if: :activated? validates :username, presence: true, if: :activated? validates :password, format: { with: ::Ci::Maskable::REGEX }, if: :activated? diff --git a/app/models/integrations/irker.rb b/app/models/integrations/irker.rb index 780f4bef0c9..3f3e321f45e 100644 --- a/app/models/integrations/irker.rb +++ b/app/models/integrations/irker.rb @@ -4,13 +4,55 @@ require 'uri' module Integrations class Irker < Integration - prop_accessor :server_host, :server_port, :default_irc_uri - prop_accessor :recipients, :channels - boolean_accessor :colorize_messages validates :recipients, presence: true, if: :validate_recipients? - before_validation :get_channels + field :server_host, + placeholder: 'localhost', + title: -> { s_('IrkerService|Server host (optional)') }, + help: -> { s_('IrkerService|irker daemon hostname (defaults to localhost).') } + + field :server_port, + placeholder: 6659, + title: -> { s_('IrkerService|Server port (optional)') }, + help: -> { s_('IrkerService|irker daemon port (defaults to 6659).') } + + field :default_irc_uri, + title: -> { s_('IrkerService|Default IRC URI (optional)') }, + help: -> { s_('IrkerService|URI to add before each recipient.') }, + placeholder: 'irc://irc.network.net:6697/' + + field :recipients, + type: 'textarea', + title: -> { s_('IrkerService|Recipients') }, + placeholder: 'irc[s]://irc.network.net[:port]/#channel', + required: true, + help: -> do + recipients_docs_link = ActionController::Base.helpers.link_to( + s_('IrkerService|How to enter channels or users?'), + Rails.application.routes.url_helpers.help_page_url( + 'user/project/integrations/irker', + anchor: 'enter-irker-recipients' + ), + target: '_blank', rel: 'noopener noreferrer' + ) + + ERB::Util.html_escape( + s_('IrkerService|Channels and users separated by whitespaces. %{recipients_docs_link}') + ) % { + recipients_docs_link: recipients_docs_link.html_safe + } + end + + field :colorize_messages, + type: 'checkbox', + title: -> { _('Colorize messages') } + + # NOTE: This field is only used internally to store the parsed + # channels from the `recipients` field, it should not be exposed + # in the UI or API. + prop_accessor :channels + def title s_('IrkerService|irker (IRC gateway)') end @@ -30,17 +72,10 @@ module Integrations def execute(data) return unless supported_events.include?(data[:object_kind]) - if Feature.enabled?(:rename_integrations_workers) - Integrations::IrkerWorker.perform_async( - project_id, channels, - colorize_messages, data, settings - ) - else - ::IrkerWorker.perform_async( - project_id, channels, - colorize_messages, data, settings - ) - end + Integrations::IrkerWorker.perform_async( + project_id, channels, + colorize_messages, data, settings + ) end def settings @@ -50,34 +85,6 @@ module Integrations } end - def fields - recipients_docs_link = ActionController::Base.helpers.link_to( - s_('IrkerService|How to enter channels or users?'), - Rails.application.routes.url_helpers.help_page_url( - 'user/project/integrations/irker', - anchor: 'enter-irker-recipients' - ), - target: '_blank', rel: 'noopener noreferrer' - ) - - [ - { type: 'text', name: 'server_host', placeholder: 'localhost', title: s_('IrkerService|Server host (optional)'), - help: s_('IrkerService|irker daemon hostname (defaults to localhost).') }, - { type: 'text', name: 'server_port', placeholder: 6659, title: s_('IrkerService|Server port (optional)'), - help: s_('IrkerService|irker daemon port (defaults to 6659).') }, - { type: 'text', name: 'default_irc_uri', title: s_('IrkerService|Default IRC URI (optional)'), - help: s_('IrkerService|URI to add before each recipient.'), - placeholder: 'irc://irc.network.net:6697/' }, - { type: 'textarea', name: 'recipients', title: s_('IrkerService|Recipients'), - placeholder: 'irc[s]://irc.network.net[:port]/#channel', required: true, - help: format( - s_('IrkerService|Channels and users separated by whitespaces. %{recipients_docs_link}').html_safe, - recipients_docs_link: recipients_docs_link.html_safe - ) }, - { type: 'checkbox', name: 'colorize_messages', title: _('Colorize messages') } - ] - end - def help docs_link = ActionController::Base.helpers.link_to( _('Learn more.'), diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb index 125f52104d4..c9c9b9d59d6 100644 --- a/app/models/integrations/jira.rb +++ b/app/models/integrations/jira.rb @@ -71,11 +71,12 @@ module Integrations non_empty_password_help: -> { s_('JiraService|Leave blank to use your current password or API token.') }, help: -> { s_('JiraService|Use a password for server version and an API token for cloud version.') } + field :jira_issue_transition_id, api_only: true + # TODO: we can probably just delegate as part of # https://gitlab.com/gitlab-org/gitlab/issues/29404 # These fields are API only, so no field definition is required. data_field :jira_issue_transition_automatic - data_field :jira_issue_transition_id data_field :project_key data_field :issues_enabled data_field :vulnerabilities_enabled diff --git a/app/models/integrations/mattermost.rb b/app/models/integrations/mattermost.rb index d9ccbb7ea34..dae11b99bc5 100644 --- a/app/models/integrations/mattermost.rb +++ b/app/models/integrations/mattermost.rb @@ -3,6 +3,7 @@ module Integrations class Mattermost < BaseChatNotification include SlackMattermostNotifier + extend ::Gitlab::Utils::Override def title s_('Mattermost notifications') @@ -28,5 +29,10 @@ module Integrations def webhook_placeholder 'http://mattermost.example.com/hooks/' end + + override :configurable_channels? + def configurable_channels? + true + end end end diff --git a/app/models/integrations/microsoft_teams.rb b/app/models/integrations/microsoft_teams.rb index 625ee0bc522..69863f164cd 100644 --- a/app/models/integrations/microsoft_teams.rb +++ b/app/models/integrations/microsoft_teams.rb @@ -22,9 +22,6 @@ module Integrations 'https://outlook.office.com/webhook/…' end - def event_field(event) - end - def default_channel_placeholder end @@ -47,7 +44,7 @@ module Integrations section: SECTION_TYPE_CONFIGURATION, name: 'branches_to_be_notified', title: s_('Integrations|Branches for which notifications are to be sent'), - choices: branch_choices + choices: self.class.branch_choices } ] end diff --git a/app/models/integrations/mock_ci.rb b/app/models/integrations/mock_ci.rb index 0b3a9bc5405..2d8e26d409f 100644 --- a/app/models/integrations/mock_ci.rb +++ b/app/models/integrations/mock_ci.rb @@ -49,7 +49,7 @@ module Integrations # # => 'running' # def commit_status(sha, ref) - response = Gitlab::HTTP.get(commit_status_path(sha), verify: enable_ssl_verification, use_read_total_timeout: true) + response = Gitlab::HTTP.get(commit_status_path(sha), verify: enable_ssl_verification) read_commit_status(response) rescue Errno::ECONNREFUSED :error diff --git a/app/models/integrations/packagist.rb b/app/models/integrations/packagist.rb index 758c9e4761b..05ee919892d 100644 --- a/app/models/integrations/packagist.rb +++ b/app/models/integrations/packagist.rb @@ -5,7 +5,25 @@ module Integrations include HasWebHook extend Gitlab::Utils::Override - prop_accessor :username, :token, :server + field :username, + title: -> { _('Username') }, + help: -> { s_('Enter your Packagist username.') }, + placeholder: '', + required: true + + field :token, + type: 'password', + title: -> { _('Token') }, + help: -> { s_('Enter your Packagist token.') }, + non_empty_password_title: -> { s_('ProjectService|Enter new token') }, + non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, + placeholder: '', + required: true + + field :server, + title: -> { _('Server (optional)') }, + help: -> { s_('Enter your Packagist server. Defaults to https://packagist.org.') }, + placeholder: 'https://packagist.org' validates :username, presence: true, if: :activated? validates :token, presence: true, if: :activated? @@ -22,37 +40,6 @@ module Integrations 'packagist' end - def fields - [ - { - type: 'text', - name: 'username', - title: _('Username'), - help: s_('Enter your Packagist username.'), - placeholder: '', - required: true - }, - { - type: 'password', - name: 'token', - title: _('Token'), - help: s_('Enter your Packagist token.'), - non_empty_password_title: s_('ProjectService|Enter new token'), - non_empty_password_help: s_('ProjectService|Leave blank to use your current token.'), - placeholder: '', - required: true - }, - { - type: 'text', - name: 'server', - title: _('Server (optional)'), - help: s_('Enter your Packagist server. Defaults to https://packagist.org.'), - placeholder: 'https://packagist.org', - required: false - } - ] - end - def self.supported_events %w(push merge_request tag_push) end diff --git a/app/models/integrations/pipelines_email.rb b/app/models/integrations/pipelines_email.rb index f15482dc2e1..77cbba25f2c 100644 --- a/app/models/integrations/pipelines_email.rb +++ b/app/models/integrations/pipelines_email.rb @@ -6,11 +6,26 @@ module Integrations RECIPIENTS_LIMIT = 30 - prop_accessor :recipients, :branches_to_be_notified - boolean_accessor :notify_only_broken_pipelines, :notify_only_default_branch validates :recipients, presence: true, if: :validate_recipients? validate :number_of_recipients_within_limit, if: :validate_recipients? + field :recipients, + type: 'textarea', + help: -> { _('Comma-separated list of email addresses.') }, + required: true + + field :notify_only_broken_pipelines, + type: 'checkbox' + + field :notify_only_default_branch, + type: 'checkbox', + api_only: true + + field :branches_to_be_notified, + type: 'select', + title: -> { s_('Integrations|Branches for which notifications are to be sent') }, + choices: branch_choices + def initialize_properties super @@ -65,21 +80,6 @@ module Integrations project&.ci_pipelines&.any? end - def fields - [ - { type: 'textarea', - name: 'recipients', - help: _('Comma-separated list of email addresses.'), - required: true }, - { type: 'checkbox', - name: 'notify_only_broken_pipelines' }, - { type: 'select', - name: 'branches_to_be_notified', - title: s_('Integrations|Branches for which notifications are to be sent'), - choices: branch_choices } - ] - end - def test(data) result = execute(data, force: true) diff --git a/app/models/integrations/pivotaltracker.rb b/app/models/integrations/pivotaltracker.rb index 931ccf46655..d32fb974339 100644 --- a/app/models/integrations/pivotaltracker.rb +++ b/app/models/integrations/pivotaltracker.rb @@ -4,9 +4,22 @@ module Integrations class Pivotaltracker < Integration API_ENDPOINT = 'https://www.pivotaltracker.com/services/v5/source_commits' - prop_accessor :token, :restrict_to_branch validates :token, presence: true, if: :activated? + field :token, + type: 'password', + help: -> { s_('PivotalTrackerService|Pivotal Tracker API token. User must have access to the story. All comments are attributed to this user.') }, + non_empty_password_title: -> { s_('ProjectService|Enter new token') }, + non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, + required: true + + field :restrict_to_branch, + title: -> { s_('Integrations|Restrict to branch (optional)') }, + help: -> do + s_('PivotalTrackerService|Comma-separated list of branches to ' \ + 'automatically inspect. Leave blank to include all branches.') + end + def title 'Pivotal Tracker' end @@ -24,26 +37,6 @@ module Integrations 'pivotaltracker' end - def fields - [ - { - type: 'password', - name: 'token', - help: s_('PivotalTrackerService|Pivotal Tracker API token. User must have access to the story. All comments are attributed to this user.'), - non_empty_password_title: s_('ProjectService|Enter new token'), - non_empty_password_help: s_('ProjectService|Leave blank to use your current token.'), - required: true - }, - { - type: 'text', - name: 'restrict_to_branch', - title: 'Restrict to branch (optional)', - help: s_('PivotalTrackerService|Comma-separated list of branches to ' \ - 'automatically inspect. Leave blank to include all branches.') - } - ] - end - def self.supported_events %w(push) end diff --git a/app/models/integrations/prometheus.rb b/app/models/integrations/prometheus.rb index 36060565317..e672a985810 100644 --- a/app/models/integrations/prometheus.rb +++ b/app/models/integrations/prometheus.rb @@ -4,11 +4,30 @@ module Integrations class Prometheus < BaseMonitoring include PrometheusAdapter - # Access to prometheus is directly through the API - prop_accessor :api_url - prop_accessor :google_iap_service_account_json - prop_accessor :google_iap_audience_client_id - boolean_accessor :manual_configuration + field :manual_configuration, + type: 'checkbox', + title: -> { s_('PrometheusService|Active') }, + help: -> { s_('PrometheusService|Select this checkbox to override the auto configuration settings with your own settings.') }, + required: true + + field :api_url, + title: 'API URL', + placeholder: -> { s_('PrometheusService|https://prometheus.example.com/') }, + help: -> { s_('PrometheusService|The Prometheus API base URL.') }, + required: true + + field :google_iap_audience_client_id, + title: 'Google IAP Audience Client ID', + placeholder: -> { s_('PrometheusService|IAP_CLIENT_ID.apps.googleusercontent.com') }, + help: -> { s_('PrometheusService|The ID of the IAP-secured resource.') }, + required: false + + field :google_iap_service_account_json, + type: 'textarea', + title: 'Google IAP Service Account JSON', + placeholder: -> { s_('PrometheusService|{ "type": "service_account", "project_id": ... }') }, + help: -> { s_('PrometheusService|The contents of the credentials.json file of your service account.') }, + required: false # We need to allow the self-monitoring project to connect to the internal # Prometheus instance. @@ -45,43 +64,6 @@ module Integrations 'prometheus' end - def fields - [ - { - type: 'checkbox', - name: 'manual_configuration', - title: s_('PrometheusService|Active'), - help: s_('PrometheusService|Select this checkbox to override the auto configuration settings with your own settings.'), - required: true - }, - { - type: 'text', - name: 'api_url', - title: 'API URL', - placeholder: s_('PrometheusService|https://prometheus.example.com/'), - help: s_('PrometheusService|The Prometheus API base URL.'), - required: true - }, - { - type: 'text', - name: 'google_iap_audience_client_id', - title: 'Google IAP Audience Client ID', - placeholder: s_('PrometheusService|IAP_CLIENT_ID.apps.googleusercontent.com'), - help: s_('PrometheusService|The ID of the IAP-secured resource.'), - autocomplete: 'off', - required: false - }, - { - type: 'textarea', - name: 'google_iap_service_account_json', - title: 'Google IAP Service Account JSON', - placeholder: s_('PrometheusService|{ "type": "service_account", "project_id": ... }'), - help: s_('PrometheusService|The contents of the credentials.json file of your service account.'), - required: false - } - ] - end - # Check we can connect to the Prometheus API def test(*args) return { success: false, result: 'Prometheus configuration error' } unless prometheus_client diff --git a/app/models/integrations/pushover.rb b/app/models/integrations/pushover.rb index 7fd5efa8765..791e27c5db7 100644 --- a/app/models/integrations/pushover.rb +++ b/app/models/integrations/pushover.rb @@ -4,9 +4,73 @@ module Integrations class Pushover < Integration BASE_URI = 'https://api.pushover.net/1' - prop_accessor :api_key, :user_key, :device, :priority, :sound validates :api_key, :user_key, :priority, presence: true, if: :activated? + field :api_key, + type: 'password', + title: -> { _('API key') }, + help: -> { s_('PushoverService|Enter your application key.') }, + non_empty_password_title: -> { s_('ProjectService|Enter new API key') }, + non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current API key.') }, + placeholder: '', + required: true + + field :user_key, + type: 'password', + title: -> { _('User key') }, + help: -> { s_('PushoverService|Enter your user key.') }, + non_empty_password_title: -> { s_('PushoverService|Enter new user key') }, + non_empty_password_help: -> { s_('PushoverService|Leave blank to use your current user key.') }, + placeholder: '', + required: true + + field :device, + title: -> { _('Devices (optional)') }, + help: -> { s_('PushoverService|Leave blank for all active devices.') }, + placeholder: '' + + field :priority, + type: 'select', + required: true, + choices: -> do + [ + [s_('PushoverService|Lowest priority'), -2], + [s_('PushoverService|Low priority'), -1], + [s_('PushoverService|Normal priority'), 0], + [s_('PushoverService|High priority'), 1] + ] + end + + field :sound, + type: 'select', + choices: -> do + [ + ['Device default sound', nil], + ['Pushover (default)', 'pushover'], + %w(Bike bike), + %w(Bugle bugle), + ['Cash Register', 'cashregister'], + %w(Classical classical), + %w(Cosmic cosmic), + %w(Falling falling), + %w(Gamelan gamelan), + %w(Incoming incoming), + %w(Intermission intermission), + %w(Magic magic), + %w(Mechanical mechanical), + ['Piano Bar', 'pianobar'], + %w(Siren siren), + ['Space Alarm', 'spacealarm'], + ['Tug Boat', 'tugboat'], + ['Alien Alarm (long)', 'alien'], + ['Climb (long)', 'climb'], + ['Persistent (long)', 'persistent'], + ['Pushover Echo (long)', 'echo'], + ['Up Down (long)', 'updown'], + ['None (silent)', 'none'] + ] + end + def title 'Pushover' end @@ -19,81 +83,6 @@ module Integrations 'pushover' end - def fields - [ - { - type: 'password', - name: 'api_key', - title: _('API key'), - help: s_('PushoverService|Enter your application key.'), - non_empty_password_title: s_('ProjectService|Enter new API key'), - non_empty_password_help: s_('ProjectService|Leave blank to use your current API key.'), - placeholder: '', - required: true - }, - { - type: 'password', - name: 'user_key', - title: _('User key'), - help: s_('PushoverService|Enter your user key.'), - non_empty_password_title: s_('PushoverService|Enter new user key'), - non_empty_password_help: s_('PushoverService|Leave blank to use your current user key.'), - placeholder: '', - required: true - }, - { - type: 'text', - name: 'device', - title: _('Devices (optional)'), - help: s_('PushoverService|Leave blank for all active devices.'), - placeholder: '' - }, - { - type: 'select', - name: 'priority', - required: true, - choices: - [ - [s_('PushoverService|Lowest priority'), -2], - [s_('PushoverService|Low priority'), -1], - [s_('PushoverService|Normal priority'), 0], - [s_('PushoverService|High priority'), 1] - ], - default_choice: 0 - }, - { - type: 'select', - name: 'sound', - choices: - [ - ['Device default sound', nil], - ['Pushover (default)', 'pushover'], - %w(Bike bike), - %w(Bugle bugle), - ['Cash Register', 'cashregister'], - %w(Classical classical), - %w(Cosmic cosmic), - %w(Falling falling), - %w(Gamelan gamelan), - %w(Incoming incoming), - %w(Intermission intermission), - %w(Magic magic), - %w(Mechanical mechanical), - ['Piano Bar', 'pianobar'], - %w(Siren siren), - ['Space Alarm', 'spacealarm'], - ['Tug Boat', 'tugboat'], - ['Alien Alarm (long)', 'alien'], - ['Climb (long)', 'climb'], - ['Persistent (long)', 'persistent'], - ['Pushover Echo (long)', 'echo'], - ['Up Down (long)', 'updown'], - ['None (silent)', 'none'] - ] - } - ] - end - def self.supported_events %w(push) end diff --git a/app/models/integrations/shimo.rb b/app/models/integrations/shimo.rb index dd25a0bc558..8bc296e0320 100644 --- a/app/models/integrations/shimo.rb +++ b/app/models/integrations/shimo.rb @@ -2,9 +2,12 @@ module Integrations class Shimo < BaseThirdPartyWiki - prop_accessor :external_wiki_url validates :external_wiki_url, presence: true, public_url: true, if: :activated? + field :external_wiki_url, + title: -> { s_('Shimo|Shimo Workspace URL') }, + required: true + def render? return false unless Feature.enabled?(:shimo_integration, project) @@ -25,21 +28,10 @@ module Integrations # support for `test` method def execute(_data) - response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true, use_read_total_timeout: true) + response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true) response.body if response.code == 200 rescue StandardError nil end - - def fields - [ - { - type: 'text', - name: 'external_wiki_url', - title: s_('Shimo|Shimo Workspace URL'), - required: true - } - ] - end end end diff --git a/app/models/integrations/slack.rb b/app/models/integrations/slack.rb index 0381db3a67e..93263229109 100644 --- a/app/models/integrations/slack.rb +++ b/app/models/integrations/slack.rb @@ -55,5 +55,10 @@ module Integrations Gitlab::UsageDataCounters::HLLRedisCounter.track_event(key, values: user_id) end + + override :configurable_channels? + def configurable_channels? + true + end end end diff --git a/app/models/integrations/teamcity.rb b/app/models/integrations/teamcity.rb index a23aa5f783d..e0299c9ac5f 100644 --- a/app/models/integrations/teamcity.rb +++ b/app/models/integrations/teamcity.rb @@ -156,7 +156,7 @@ module Integrations end def get_path(path) - Gitlab::HTTP.try_get(build_url(path), verify: enable_ssl_verification, basic_auth: basic_auth, extra_log_info: { project_id: project_id }, use_read_total_timeout: true) + Gitlab::HTTP.try_get(build_url(path), verify: enable_ssl_verification, basic_auth: basic_auth, extra_log_info: { project_id: project_id }) end def post_to_build_queue(data, branch) @@ -167,8 +167,7 @@ module Integrations '', headers: { 'Content-type' => 'application/xml' }, verify: enable_ssl_verification, - basic_auth: basic_auth, - use_read_total_timeout: true + basic_auth: basic_auth ) end diff --git a/app/models/integrations/unify_circuit.rb b/app/models/integrations/unify_circuit.rb index f085423d229..f10a75fac5d 100644 --- a/app/models/integrations/unify_circuit.rb +++ b/app/models/integrations/unify_circuit.rb @@ -19,9 +19,6 @@ module Integrations s_('Integrations|Send notifications about project events to a Unify Circuit conversation. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end - def event_field(event) - end - def default_channel_placeholder end @@ -38,7 +35,7 @@ module Integrations type: 'select', name: 'branches_to_be_notified', title: s_('Integrations|Branches for which notifications are to be sent'), - choices: branch_choices + choices: self.class.branch_choices } ] end @@ -49,8 +46,7 @@ module Integrations response = Gitlab::HTTP.post(webhook, body: { subject: message.project_name, text: message.summary, - markdown: true, - use_read_total_timeout: true + markdown: true }.to_json) response if response.success? diff --git a/app/models/integrations/webex_teams.rb b/app/models/integrations/webex_teams.rb index 345dd98cbc1..75be457dcf5 100644 --- a/app/models/integrations/webex_teams.rb +++ b/app/models/integrations/webex_teams.rb @@ -19,9 +19,6 @@ module Integrations s_("WebexTeamsService|Send notifications about project events to a Webex Teams conversation. %{docs_link}") % { docs_link: docs_link.html_safe } end - def event_field(event) - end - def default_channel_placeholder end @@ -38,7 +35,7 @@ module Integrations type: 'select', name: 'branches_to_be_notified', title: s_('Integrations|Branches for which notifications are to be sent'), - choices: branch_choices + choices: self.class.branch_choices } ] end @@ -47,7 +44,7 @@ module Integrations def notify(message, opts) header = { 'Content-Type' => 'application/json' } - response = Gitlab::HTTP.post(webhook, headers: header, body: { markdown: message.summary }.to_json, use_read_total_timeout: true) + response = Gitlab::HTTP.post(webhook, headers: header, body: { markdown: message.summary }.to_json) response if response.success? end diff --git a/app/models/integrations/youtrack.rb b/app/models/integrations/youtrack.rb index ab6e1da27f8..fa719f925ed 100644 --- a/app/models/integrations/youtrack.rb +++ b/app/models/integrations/youtrack.rb @@ -33,10 +33,7 @@ module Integrations end def fields - [ - { type: 'text', name: 'project_url', title: _('Project URL'), help: s_('IssueTracker|The URL to the project in YouTrack.'), required: true }, - { type: 'text', name: 'issues_url', title: s_('ProjectService|Issue URL'), help: s_('IssueTracker|The URL to view an issue in the YouTrack project. Must contain %{colon_id}.') % { colon_id: ':id'.html_safe }, required: true } - ] + super.select { _1.name.in?(%w[project_url issues_url]) } end end end diff --git a/app/models/integrations/zentao.rb b/app/models/integrations/zentao.rb index c33df465fde..11db469f7ee 100644 --- a/app/models/integrations/zentao.rb +++ b/app/models/integrations/zentao.rb @@ -4,7 +4,28 @@ module Integrations class Zentao < Integration include Gitlab::Routing - data_field :url, :api_url, :api_token, :zentao_product_xid + self.field_storage = :data_fields + + field :url, + title: -> { s_('ZentaoIntegration|ZenTao Web URL') }, + placeholder: 'https://www.zentao.net', + help: -> { s_('ZentaoIntegration|Base URL of the ZenTao instance.') }, + required: true + + field :api_url, + title: -> { s_('ZentaoIntegration|ZenTao API URL (optional)') }, + help: -> { s_('ZentaoIntegration|If different from Web URL.') } + + field :api_token, + type: 'password', + title: -> { s_('ZentaoIntegration|ZenTao API token') }, + non_empty_password_title: -> { s_('ZentaoIntegration|Enter new ZenTao API token') }, + non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, + required: true + + field :zentao_product_xid, + title: -> { s_('ZentaoIntegration|ZenTao Product ID') }, + required: true validates :url, public_url: true, presence: true, if: :activated? validates :api_url, public_url: true, allow_blank: true @@ -47,39 +68,6 @@ module Integrations %w() end - def fields - [ - { - type: 'text', - name: 'url', - title: s_('ZentaoIntegration|ZenTao Web URL'), - placeholder: 'https://www.zentao.net', - help: s_('ZentaoIntegration|Base URL of the ZenTao instance.'), - required: true - }, - { - type: 'text', - name: 'api_url', - title: s_('ZentaoIntegration|ZenTao API URL (optional)'), - help: s_('ZentaoIntegration|If different from Web URL.') - }, - { - type: 'password', - name: 'api_token', - title: s_('ZentaoIntegration|ZenTao API token'), - non_empty_password_title: s_('ZentaoIntegration|Enter new ZenTao API token'), - non_empty_password_help: s_('ProjectService|Leave blank to use your current token.'), - required: true - }, - { - type: 'text', - name: 'zentao_product_xid', - title: s_('ZentaoIntegration|ZenTao Product ID'), - required: true - } - ] - end - private def client diff --git a/app/models/issue.rb b/app/models/issue.rb index 47aa2b24feb..cae42115bef 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -46,7 +46,7 @@ class Issue < ApplicationRecord TYPES_FOR_LIST = %w(issue incident).freeze belongs_to :project - has_one :namespace, through: :project + belongs_to :namespace, inverse_of: :issues belongs_to :duplicated_to, class_name: 'Issue' belongs_to :closed_by, class_name: 'User' @@ -98,6 +98,7 @@ class Issue < ApplicationRecord validates :project, presence: true validates :issue_type, presence: true + validates :namespace, presence: true, if: -> { project.present? } enum issue_type: WorkItems::Type.base_types @@ -123,8 +124,24 @@ class Issue < ApplicationRecord scope :order_due_date_desc, -> { reorder(arel_table[:due_date].desc.nulls_last) } scope :order_closest_future_date, -> { reorder(Arel.sql('CASE WHEN issues.due_date >= CURRENT_DATE THEN 0 ELSE 1 END ASC, ABS(CURRENT_DATE - issues.due_date) ASC')) } 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 :order_severity_asc, -> do + build_keyset_order_on_joined_column( + scope: includes(:issuable_severity), + attribute_name: 'issuable_severities_severity', + column: IssuableSeverity.arel_table[:severity], + direction: :asc, + nullable: :nulls_first + ) + end + scope :order_severity_desc, -> do + build_keyset_order_on_joined_column( + scope: includes(:issuable_severity), + attribute_name: 'issuable_severities_severity', + column: IssuableSeverity.arel_table[:severity], + direction: :desc, + nullable: :nulls_last + ) + end scope :order_escalation_status_asc, -> { includes(:incident_management_issuable_escalation_status).order(IncidentManagement::IssuableEscalationStatus.arel_table[:status].asc.nulls_last).references(:incident_management_issuable_escalation_status) } scope :order_escalation_status_desc, -> { includes(:incident_management_issuable_escalation_status).order(IncidentManagement::IssuableEscalationStatus.arel_table[:status].desc.nulls_last).references(:incident_management_issuable_escalation_status) } scope :order_closed_at_asc, -> { reorder(arel_table[:closed_at].asc.nulls_last) } @@ -184,6 +201,8 @@ class Issue < ApplicationRecord scope :with_null_relative_position, -> { where(relative_position: nil) } scope :with_non_null_relative_position, -> { where.not(relative_position: nil) } + before_validation :ensure_namespace_id + after_commit :expire_etag_cache, unless: :importing? after_save :ensure_metrics, unless: :importing? after_create_commit :record_create_action, unless: :importing? @@ -231,6 +250,31 @@ class Issue < ApplicationRecord alias_method :with_state, :with_state_id alias_method :with_states, :with_state_ids + def build_keyset_order_on_joined_column(scope:, attribute_name:, column:, direction:, nullable:) + reversed_direction = direction == :asc ? :desc : :asc + + # rubocop: disable GitlabSecurity/PublicSend + order = ::Gitlab::Pagination::Keyset::Order.build([ + ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: attribute_name, + column_expression: column, + order_expression: column.send(direction).send(nullable), + reversed_order_expression: column.send(reversed_direction).send(nullable), + order_direction: direction, + distinct: false, + add_to_projections: true, + nullable: nullable + ), + ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'id', + order_expression: arel_table['id'].desc + ) + ]) + # rubocop: enable GitlabSecurity/PublicSend + + order.apply_cursor_conditions(scope).order(order) + end + override :order_upvotes_desc def order_upvotes_desc reorder(upvotes_count: :desc) @@ -328,11 +372,11 @@ 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_by_relative_position - when 'severity_asc' then order_severity_asc.with_order_id_desc - when 'severity_desc' then order_severity_desc.with_order_id_desc - when 'escalation_status_asc' then order_escalation_status_asc.with_order_id_desc - when 'escalation_status_desc' then order_escalation_status_desc.with_order_id_desc - when 'closed_at_asc' then order_closed_at_asc + when 'severity_asc' then order_severity_asc + when 'severity_desc' then order_severity_desc + when 'escalation_status_asc' then order_escalation_status_asc + when 'escalation_status_desc' then order_escalation_status_desc + when 'closed_at', 'closed_at_asc' then order_closed_at_asc when 'closed_at_desc' then order_closed_at_desc else super @@ -405,14 +449,6 @@ class Issue < ApplicationRecord end end - # Returns boolean if a related branch exists for the current issue - # ignores merge requests branchs - def has_related_branch? - project.repository.branch_names.any? do |branch| - /\A#{iid}-(?!\d+-stable)/i =~ branch - end - end - # To allow polymorphism with MergeRequest. def source_project project @@ -656,6 +692,10 @@ class Issue < ApplicationRecord # Symptom of running out of space - schedule rebalancing Issues::RebalancingWorker.perform_async(nil, *project.self_or_root_group_ids) end + + def ensure_namespace_id + self.namespace = project.project_namespace if project + end end Issue.prepend_mod_with('Issue') diff --git a/app/models/key.rb b/app/models/key.rb index 5268ce2e040..9f6029cc5d4 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -28,7 +28,7 @@ class Key < ApplicationRecord validate :key_meets_restrictions validate :expiration, on: :create - validate :banned_key, if: :should_check_for_banned_key? + validate :banned_key, if: :key_changed? delegate :name, :email, to: :user, prefix: true @@ -121,6 +121,12 @@ class Key < ApplicationRecord @public_key ||= Gitlab::SSHPublicKey.new(key) end + def ensure_sha256_fingerprint! + return if self.fingerprint_sha256 + + save if generate_fingerprint + end + private def generate_fingerprint @@ -143,12 +149,6 @@ class Key < ApplicationRecord end end - def should_check_for_banned_key? - return false unless user - - key_changed? && Feature.enabled?(:ssh_banned_key, user) - end - def banned_key return unless public_key.banned? diff --git a/app/models/member.rb b/app/models/member.rb index bb5d2b10f8e..dcca63b5691 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -219,7 +219,23 @@ class Member < ApplicationRecord class << self def search(query) - joins(:user).merge(User.search(query, use_minimum_char_limit: false)) + scope = joins(:user).merge(User.search(query, use_minimum_char_limit: false)) + + return scope unless Gitlab::Pagination::Keyset::Order.keyset_aware?(scope) + + # If the User.search method returns keyset pagination aware AR scope then we + # need call apply_cursor_conditions which adds the ORDER BY columns from the scope + # to the SELECT clause. + # + # Why is this needed: + # When using keyset pagination, the next page is loaded using the ORDER BY + # values of the last record (cursor). This query selects `members.*` and + # orders by a custom SQL expression on `users` and `users.name`. The values + # will not be part of `members.*`. + # + # Result: `SELECT members.*, users.column1, users.column2 FROM members ...` + order = Gitlab::Pagination::Keyset::Order.extract_keyset_order_object(scope) + order.apply_cursor_conditions(scope).reorder(order) end def search_invite_email(query) diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 791cb6f0dff..c97f00364fd 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -21,30 +21,30 @@ class ProjectMember < Member end class << self - # Add users to projects with passed access option + # Add members to projects with passed access option # # access can be an integer representing a access code # or symbol like :maintainer representing role # # Ex. - # add_users_to_projects( + # add_members_to_projects( # project_ids, # user_ids, # ProjectMember::MAINTAINER # ) # - # add_users_to_projects( + # add_members_to_projects( # project_ids, # user_ids, # :maintainer # ) # - def add_users_to_projects(project_ids, users, access_level, current_user: nil, expires_at: nil) + def add_members_to_projects(project_ids, users, access_level, current_user: nil, expires_at: nil) self.transaction do project_ids.each do |project_id| project = Project.find(project_id) - Members::Projects::CreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass + Members::Projects::CreatorService.add_members( # rubocop:disable CodeReuse/ServiceClass project, users, access_level, @@ -111,7 +111,7 @@ class ProjectMember < Member # rubocop:disable CodeReuse/ServiceClass if blocking - AuthorizedProjectUpdate::ProjectRecalculatePerUserService.new(project, user).execute + AuthorizedProjectUpdate::ProjectRecalculatePerUserWorker.bulk_perform_and_wait([[project.id, user.id]]) else AuthorizedProjectUpdate::ProjectRecalculatePerUserWorker.perform_async(project.id, user.id) end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 1a3464d05a2..ec97ab0ea42 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -174,6 +174,10 @@ class MergeRequest < ApplicationRecord merge_request.merge_jid = nil end + before_transition any => :closed do |merge_request| + merge_request.merge_error = nil + end + after_transition any => :opened do |merge_request| merge_request.run_after_commit do UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id) @@ -1567,6 +1571,7 @@ class MergeRequest < ApplicationRecord variables.append(key: 'CI_MERGE_REQUEST_PROJECT_PATH', value: project.full_path) variables.append(key: 'CI_MERGE_REQUEST_PROJECT_URL', value: project.web_url) variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_NAME', value: target_branch.to_s) + variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_PROTECTED', value: ProtectedBranch.protected?(target_project, target_branch).to_s) variables.append(key: 'CI_MERGE_REQUEST_TITLE', value: title) variables.append(key: 'CI_MERGE_REQUEST_ASSIGNEES', value: assignee_username_list) if assignees.present? variables.append(key: 'CI_MERGE_REQUEST_MILESTONE', value: milestone.title) if milestone diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 87afb7a489a..e08b2cc2a7d 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -21,6 +21,10 @@ class MergeRequestDiff < ApplicationRecord # from the database if this sentinel is seen FILES_COUNT_SENTINEL = 2**15 - 1 + # External diff cache key used by diffs export + EXTERNAL_DIFFS_CACHE_TMPDIR = 'project-%{project_id}-external-mr-%{mr_id}-diff-%{id}-cache' + EXTERNAL_DIFF_CACHE_CHUNK_SIZE = 8.megabytes + belongs_to :merge_request manual_inverse_association :merge_request, :merge_request_diff @@ -545,6 +549,28 @@ class MergeRequestDiff < ApplicationRecord merge_request_diff_files.reset end + # Yields locally cached external diff if it's externally stored. + # Used during Project Export to speed up externally + # stored merge request diffs export + def cached_external_diff + return yield(nil) unless stored_externally? + + cache_external_diff unless File.exist?(external_diff_cache_filepath) + + File.open(external_diff_cache_filepath) do |file| + yield(file) + end + end + + def remove_cached_external_diff + Gitlab::Utils.check_path_traversal!(external_diff_cache_dir) + Gitlab::Utils.check_allowed_absolute_path!(external_diff_cache_dir, [Dir.tmpdir]) + + return unless Dir.exist?(external_diff_cache_dir) + + FileUtils.rm_rf(external_diff_cache_dir) + end + private def convert_external_diffs_to_database @@ -791,6 +817,31 @@ class MergeRequestDiff < ApplicationRecord def sort_diffs(diffs) Gitlab::Diff::FileCollectionSorter.new(diffs).sort end + + # Downloads external diff to a temp storage location. + def cache_external_diff + return unless stored_externally? + return if File.exist?(external_diff_cache_filepath) + + Dir.mkdir(external_diff_cache_dir) unless Dir.exist?(external_diff_cache_dir) + + opening_external_diff do |external_diff| + File.open(external_diff_cache_filepath, 'wb') do |file| + file.write(external_diff.read(EXTERNAL_DIFF_CACHE_CHUNK_SIZE)) until external_diff.eof? + end + end + end + + def external_diff_cache_filepath + File.join(external_diff_cache_dir, "diff-#{id}") + end + + def external_diff_cache_dir + File.join( + Dir.tmpdir, + EXTERNAL_DIFFS_CACHE_TMPDIR % { project_id: project.id, mr_id: merge_request_id, id: id } + ) + end end MergeRequestDiff.prepend_mod_with('MergeRequestDiff') diff --git a/app/models/merge_request_diff_file.rb b/app/models/merge_request_diff_file.rb index f7648937c1d..36902e43a77 100644 --- a/app/models/merge_request_diff_file.rb +++ b/app/models/merge_request_diff_file.rb @@ -15,7 +15,12 @@ class MergeRequestDiffFile < ApplicationRecord end def utf8_diff - fetched_diff = diff + fetched_diff = if Feature.enabled?(:externally_stored_diffs_caching_export) && + merge_request_diff&.stored_externally? + diff_export + else + diff + end return '' if fetched_diff.blank? @@ -45,4 +50,40 @@ class MergeRequestDiffFile < ApplicationRecord content end end + + private + + # This method is meant to be used during Project Export. + # It is identical to the behaviour in #diff with the only + # difference of caching externally stored diffs on local disk in + # temp storage location in order to improve diff export performance. + def diff_export + content = merge_request_diff.cached_external_diff do |file| + file.seek(external_diff_offset) + + force_encode_utf8(file.read(external_diff_size)) + end + + # See #diff + if binary? + content = begin + content.unpack1('m0') + rescue ArgumentError + content + end + end + + content + rescue StandardError => e + log_payload = { + message: 'Cached external diff export failed', + merge_request_diff_file_id: id, + merge_request_diff_id: merge_request_diff&.id + } + + Gitlab::ExceptionLogFormatter.format!(e, log_payload) + Gitlab::AppLogger.warn(log_payload) + + diff + end end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 5bb06cdbb4a..f23a859b119 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -74,6 +74,8 @@ class Namespace < ApplicationRecord has_many :sync_events, class_name: 'Namespaces::SyncEvent' has_one :cluster_enabled_grant, inverse_of: :namespace, class_name: 'Clusters::ClusterEnabledGrant' + has_many :work_items, inverse_of: :namespace + has_many :issues, inverse_of: :namespace validates :owner, presence: true, if: ->(n) { n.owner_required? } validates :name, @@ -341,6 +343,10 @@ class Namespace < ApplicationRecord end end + def emails_enabled? + !emails_disabled? + end + def lfs_enabled? # User namespace will always default to the global setting Gitlab.config.lfs.enabled @@ -450,9 +456,14 @@ class Namespace < ApplicationRecord end def pages_virtual_domain + cache = if Feature.enabled?(:cache_pages_domain_api, root_ancestor) + ::Gitlab::Pages::CacheControl.for_namespace(root_ancestor.id) + end + Pages::VirtualDomain.new( - all_projects_with_pages.includes(:route, :project_feature, pages_metadatum: :pages_deployment), - trim_prefix: full_path + projects: all_projects_with_pages.includes(:route, :project_feature, pages_metadatum: :pages_deployment), + trim_prefix: full_path, + cache: cache ) end diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb index 504daf2662e..595e34821af 100644 --- a/app/models/namespace_setting.rb +++ b/app/models/namespace_setting.rb @@ -24,14 +24,27 @@ class NamespaceSetting < ApplicationRecord chronic_duration_attr :subgroup_runner_token_expiration_interval_human_readable, :subgroup_runner_token_expiration_interval chronic_duration_attr :project_runner_token_expiration_interval_human_readable, :project_runner_token_expiration_interval - NAMESPACE_SETTINGS_PARAMS = [:default_branch_name, :delayed_project_removal, - :lock_delayed_project_removal, :resource_access_token_creation_allowed, - :prevent_sharing_groups_outside_hierarchy, :new_user_signups_cap, - :setup_for_company, :jobs_to_be_done, :runner_token_expiration_interval, :enabled_git_access_protocol, - :subgroup_runner_token_expiration_interval, :project_runner_token_expiration_interval].freeze + NAMESPACE_SETTINGS_PARAMS = %i[ + default_branch_name + delayed_project_removal + lock_delayed_project_removal + resource_access_token_creation_allowed + prevent_sharing_groups_outside_hierarchy + new_user_signups_cap + setup_for_company + jobs_to_be_done + runner_token_expiration_interval + enabled_git_access_protocol + subgroup_runner_token_expiration_interval + project_runner_token_expiration_interval + ].freeze self.primary_key = :namespace_id + def self.allowed_namespace_settings_params + NAMESPACE_SETTINGS_PARAMS + end + sanitizes! :default_branch_name def prevent_sharing_groups_outside_hierarchy diff --git a/app/models/note.rb b/app/models/note.rb index 41e45a8759f..986a85acac6 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -111,6 +111,7 @@ class Note < ApplicationRecord end validate :does_not_exceed_notes_limit?, on: :create, unless: [:system?, :importing?] + validate :validate_created_after # @deprecated attachments are handled by the Upload model. # @@ -665,6 +666,25 @@ class Note < ApplicationRecord ) end + def mentioned_users(current_user = nil) + users = super + + return users unless confidential? + + Ability.users_that_can_read_internal_notes(users, resource_parent) + end + + def mentioned_filtered_user_ids_for(references) + return super unless confidential? + + user_ids = references.mentioned_user_ids.presence + + return [] if user_ids.blank? + + users = User.where(id: user_ids) + Ability.users_that_can_read_internal_notes(users, resource_parent).pluck(:id) + end + private def system_note_viewable_by?(user) @@ -729,6 +749,13 @@ class Note < ApplicationRecord errors.add(:base, _('Maximum number of comments exceeded')) if noteable.notes.count >= Noteable::MAX_NOTES_LIMIT end + def validate_created_after + return unless created_at + return if created_at >= '1970-01-01' + + errors.add(:created_at, s_('Note|The created date provided is too far in the past.')) + end + def noteable_label_url_method for_merge_request? ? :project_merge_requests_url : :project_issues_url end diff --git a/app/models/notification_recipient.rb b/app/models/notification_recipient.rb index 79a84231083..b3eaed154e2 100644 --- a/app/models/notification_recipient.rb +++ b/app/models/notification_recipient.rb @@ -125,6 +125,10 @@ class NotificationRecipient @project ? @project.emails_disabled? : @group&.emails_disabled? end + def emails_enabled? + !emails_disabled? + end + def read_ability return if @skip_read_ability return @read_ability if instance_variable_defined?(:@read_ability) diff --git a/app/models/oauth_access_token.rb b/app/models/oauth_access_token.rb index 9789d8ed62b..20130f01d44 100644 --- a/app/models/oauth_access_token.rb +++ b/app/models/oauth_access_token.rb @@ -7,6 +7,8 @@ class OauthAccessToken < Doorkeeper::AccessToken alias_attribute :user, :resource_owner scope :distinct_resource_owner_counts, ->(applications) { where(application: applications).distinct.group(:application_id).count(:resource_owner_id) } + scope :latest_per_application, -> { select('distinct on(application_id) *').order(application_id: :desc, created_at: :desc) } + scope :preload_application, -> { preload(:application) } def scopes=(value) if value.is_a?(Array) diff --git a/app/models/operations/feature_flags_client.rb b/app/models/operations/feature_flags_client.rb index 1c65c3f096e..e8c237abbc5 100644 --- a/app/models/operations/feature_flags_client.rb +++ b/app/models/operations/feature_flags_client.rb @@ -4,6 +4,8 @@ module Operations class FeatureFlagsClient < ApplicationRecord include TokenAuthenticatable + DEFAULT_UNLEASH_API_VERSION = 1 + self.table_name = 'operations_feature_flags_clients' belongs_to :project @@ -13,6 +15,8 @@ module Operations add_authentication_token_field :token, encrypted: :required + attr_accessor :unleash_app_name + before_validation :ensure_token! def self.find_for_project_and_token(project, token) @@ -21,5 +25,25 @@ module Operations where(project_id: project).find_by_token(token) end + + def self.update_last_feature_flag_updated_at!(project) + where(project: project).update_all(last_feature_flag_updated_at: Time.current) + end + + def unleash_api_version + DEFAULT_UNLEASH_API_VERSION + end + + def unleash_api_features + return [] unless unleash_app_name.present? + + Operations::FeatureFlag.for_unleash_client(project, unleash_app_name) + end + + def unleash_api_cache_key + "api_version:#{unleash_api_version}:" \ + "app_name:#{unleash_app_name}:" \ + "updated_at:#{last_feature_flag_updated_at.to_i}" + end end end diff --git a/app/models/packages/cleanup/policy.rb b/app/models/packages/cleanup/policy.rb index d7df90a4ce0..35f58f3680d 100644 --- a/app/models/packages/cleanup/policy.rb +++ b/app/models/packages/cleanup/policy.rb @@ -23,10 +23,25 @@ module Packages where.not(keep_n_duplicated_package_files: 'all') end + def self.with_packages + exists_select = ::Packages::Package.installable + .where('packages_packages.project_id = packages_cleanup_policies.project_id') + .select(1) + where('EXISTS (?)', exists_select) + end + + def self.runnable + runnable_schedules.with_packages.order(next_run_at: :asc) + end + def set_next_run_at # fixed cadence of 12 hours self.next_run_at = Time.zone.now + 12.hours end + + def keep_n_duplicated_package_files_disabled? + keep_n_duplicated_package_files == 'all' + end end end end diff --git a/app/models/packages/debian/file_entry.rb b/app/models/packages/debian/file_entry.rb index eb66f4acfa9..b70b6c460d2 100644 --- a/app/models/packages/debian/file_entry.rb +++ b/app/models/packages/debian/file_entry.rb @@ -4,6 +4,7 @@ module Packages module Debian class FileEntry include ActiveModel::Model + include ::Packages::FIPS DIGESTS = %i[md5 sha1 sha256].freeze FILENAME_REGEX = %r{\A[a-zA-Z0-9][a-zA-Z0-9_.~+-]*\z}.freeze @@ -31,6 +32,8 @@ module Packages private def valid_package_file_digests + raise DisabledError, 'Debian registry is not FIPS compliant' if Gitlab::FIPS.enabled? + DIGESTS.each do |digest| package_file_digest = package_file["file_#{digest}"] sum = public_send("#{digest}sum") # rubocop:disable GitlabSecurity/PublicSend diff --git a/app/models/pages/virtual_domain.rb b/app/models/pages/virtual_domain.rb index 497f67993ae..119cc7fc166 100644 --- a/app/models/pages/virtual_domain.rb +++ b/app/models/pages/virtual_domain.rb @@ -2,8 +2,9 @@ module Pages class VirtualDomain - def initialize(projects, trim_prefix: nil, domain: nil) + def initialize(projects:, cache: nil, trim_prefix: nil, domain: nil) @projects = projects + @cache = cache @trim_prefix = trim_prefix @domain = domain end @@ -27,8 +28,12 @@ module Pages paths.sort_by(&:prefix).reverse end + def cache_key + @cache_key ||= cache&.cache_key + end + private - attr_reader :projects, :trim_prefix, :domain + attr_reader :projects, :trim_prefix, :domain, :cache end end diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb index 93119bbff1f..9e93bff4acf 100644 --- a/app/models/pages_domain.rb +++ b/app/models/pages_domain.rb @@ -209,7 +209,15 @@ class PagesDomain < ApplicationRecord def pages_virtual_domain return unless pages_deployed? - Pages::VirtualDomain.new([project], domain: self) + cache = if Feature.enabled?(:cache_pages_domain_api, project.root_namespace) + ::Gitlab::Pages::CacheControl.for_project(project.id) + end + + Pages::VirtualDomain.new( + projects: [project], + domain: self, + cache: cache + ) end def clear_auto_ssl_failure diff --git a/app/models/preloaders/users_max_access_level_in_projects_preloader.rb b/app/models/preloaders/users_max_access_level_in_projects_preloader.rb index b4ce61a869c..99a31a620c5 100644 --- a/app/models/preloaders/users_max_access_level_in_projects_preloader.rb +++ b/app/models/preloaders/users_max_access_level_in_projects_preloader.rb @@ -12,6 +12,8 @@ module Preloaders def execute return unless @projects.present? && @users.present? + preload_users_namespace_bans(@users) + access_levels.each do |(project_id, user_id), access_level| project = projects_by_id[project_id] @@ -42,5 +44,11 @@ module Preloaders def projects_by_id @projects_by_id ||= @projects.index_by(&:id) end + + def preload_users_namespace_bans(_users) + # overridden in EE + end end end + +# Preloaders::UsersMaxAccessLevelInProjectsPreloader.prepend_mod diff --git a/app/models/project.rb b/app/models/project.rb index dca47911d20..46e25564eab 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -247,7 +247,6 @@ class Project < ApplicationRecord has_many :export_jobs, class_name: 'ProjectExportJob' has_many :bulk_import_exports, class_name: 'BulkImports::Export', inverse_of: :project 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' @@ -261,6 +260,7 @@ class Project < ApplicationRecord has_many :merge_request_metrics, foreign_key: 'target_project', class_name: 'MergeRequest::Metrics', inverse_of: :target_project has_many :source_of_merge_requests, foreign_key: 'source_project_id', class_name: 'MergeRequest' has_many :issues + has_many :incident_management_issuable_escalation_statuses, through: :issues, inverse_of: :project, class_name: 'IncidentManagement::IssuableEscalationStatus' has_many :labels, class_name: 'ProjectLabel' has_many :integrations has_many :events @@ -434,7 +434,6 @@ 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 @@ -442,33 +441,29 @@ class Project < ApplicationRecord accepts_nested_attributes_for :prometheus_integration, update_only: true accepts_nested_attributes_for :alerting_setting, update_only: true - delegate :feature_available?, :builds_enabled?, :wiki_enabled?, - :merge_requests_enabled?, :forking_enabled?, :issues_enabled?, - :pages_enabled?, :analytics_enabled?, :snippets_enabled?, :public_pages?, :private_pages?, - :merge_requests_access_level, :forking_access_level, :issues_access_level, - :wiki_access_level, :snippets_access_level, :builds_access_level, - :repository_access_level, :package_registry_access_level, :pages_access_level, :metrics_dashboard_access_level, :analytics_access_level, - :operations_enabled?, :operations_access_level, :security_and_compliance_access_level, - :container_registry_access_level, :container_registry_enabled?, - to: :project_feature, allow_nil: true - alias_method :container_registry_enabled, :container_registry_enabled? - delegate :show_default_award_emojis, :show_default_award_emojis=, :show_default_award_emojis?, - :enforce_auth_checks_on_uploads, :enforce_auth_checks_on_uploads=, :enforce_auth_checks_on_uploads?, - :warn_about_potentially_unwanted_characters, :warn_about_potentially_unwanted_characters=, :warn_about_potentially_unwanted_characters?, - to: :project_setting, allow_nil: true - delegate :scheduled?, :started?, :in_progress?, :failed?, :finished?, - prefix: :import, to: :import_state, allow_nil: true + delegate :merge_requests_access_level, :forking_access_level, :issues_access_level, + :wiki_access_level, :snippets_access_level, :builds_access_level, + :repository_access_level, :package_registry_access_level, :pages_access_level, + :metrics_dashboard_access_level, :analytics_access_level, + :operations_access_level, :security_and_compliance_access_level, + :container_registry_access_level, + to: :project_feature, allow_nil: true + + delegate :show_default_award_emojis, :show_default_award_emojis=, + :enforce_auth_checks_on_uploads, :enforce_auth_checks_on_uploads=, + :warn_about_potentially_unwanted_characters, :warn_about_potentially_unwanted_characters=, + to: :project_setting, allow_nil: true + delegate :squash_always?, :squash_never?, :squash_enabled_by_default?, :squash_readonly?, to: :project_setting delegate :squash_option, :squash_option=, to: :project_setting delegate :mr_default_target_self, :mr_default_target_self=, to: :project_setting delegate :previous_default_branch, :previous_default_branch=, to: :project_setting - delegate :no_import?, to: :import_state, allow_nil: true delegate :name, to: :owner, allow_nil: true, prefix: true delegate :members, to: :team, prefix: true - delegate :add_user, :add_users, to: :team + delegate :add_member, :add_members, to: :team delegate :add_guest, :add_reporter, :add_developer, :add_maintainer, :add_owner, :add_role, to: :team delegate :group_runners_enabled, :group_runners_enabled=, to: :ci_cd_settings, allow_nil: true - delegate :root_ancestor, :certificate_based_clusters_enabled?, to: :namespace, allow_nil: true + delegate :root_ancestor, to: :namespace, allow_nil: true delegate :last_pipeline, to: :commit, allow_nil: true delegate :external_dashboard_url, to: :metrics_setting, allow_nil: true, prefix: true delegate :dashboard_timezone, to: :metrics_setting, allow_nil: true, prefix: true @@ -476,6 +471,7 @@ class Project < ApplicationRecord delegate :forward_deployment_enabled, :forward_deployment_enabled=, to: :ci_cd_settings, prefix: :ci, allow_nil: true delegate :job_token_scope_enabled, :job_token_scope_enabled=, to: :ci_cd_settings, prefix: :ci, allow_nil: true delegate :keep_latest_artifact, :keep_latest_artifact=, to: :ci_cd_settings, allow_nil: true + delegate :opt_in_jwt, :opt_in_jwt=, to: :ci_cd_settings, prefix: :ci, allow_nil: true delegate :restrict_user_defined_variables, :restrict_user_defined_variables=, to: :ci_cd_settings, allow_nil: true delegate :separated_caches, :separated_caches=, to: :ci_cd_settings, prefix: :ci, allow_nil: true delegate :runner_token_expiration_interval, :runner_token_expiration_interval=, :runner_token_expiration_interval_human_readable, :runner_token_expiration_interval_human_readable=, to: :ci_cd_settings, allow_nil: true @@ -483,7 +479,6 @@ class Project < ApplicationRecord delegate :allow_merge_on_skipped_pipeline, :allow_merge_on_skipped_pipeline?, :allow_merge_on_skipped_pipeline=, :has_confluence?, :has_shimo?, to: :project_setting - delegate :active?, to: :prometheus_integration, allow_nil: true, prefix: true delegate :merge_commit_template, :merge_commit_template=, to: :project_setting, allow_nil: true delegate :squash_commit_template, :squash_commit_template=, to: :project_setting, allow_nil: true @@ -667,7 +662,6 @@ class Project < ApplicationRecord scope :created_by, -> (user) { where(creator: user) } scope :imported_from, -> (type) { where(import_type: type) } scope :imported, -> { where.not(import_type: nil) } - scope :with_tracing_enabled, -> { joins(:tracing_setting) } scope :with_enabled_error_tracking, -> { joins(:error_tracking_setting).where(project_error_tracking_settings: { enabled: true }) } scope :with_service_desk_key, -> (key) do @@ -676,10 +670,12 @@ class Project < ApplicationRecord joins(:service_desk_setting).where('service_desk_settings.project_key' => key) end - scope :with_topic, ->(topic_name) do + scope :with_topic, ->(topic) { where(id: topic.project_topics.select(:project_id)) } + + scope :with_topic_by_name, ->(topic_name) do topic = Projects::Topic.find_by_name(topic_name) - topic ? where(id: topic.project_topics.select(:project_id)) : none + topic ? with_topic(topic) : none end enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 } @@ -917,6 +913,14 @@ class Project < ApplicationRecord association(:namespace).loaded? end + def certificate_based_clusters_enabled? + !!namespace&.certificate_based_clusters_enabled? + end + + def prometheus_integration_active? + !!prometheus_integration&.active? + end + def personal_namespace_holder?(user) return false unless personal? return false unless user @@ -933,6 +937,42 @@ class Project < ApplicationRecord super.presence || build_project_setting end + def show_default_award_emojis? + !!project_setting&.show_default_award_emojis? + end + + def enforce_auth_checks_on_uploads? + !!project_setting&.enforce_auth_checks_on_uploads? + end + + def warn_about_potentially_unwanted_characters? + !!project_setting&.warn_about_potentially_unwanted_characters? + end + + def no_import? + !!import_state&.no_import? + end + + def import_scheduled? + !!import_state&.scheduled? + end + + def import_started? + !!import_state&.started? + end + + def import_in_progress? + !!import_state&.in_progress? + end + + def import_failed? + !!import_state&.failed? + end + + def import_finished? + !!import_state&.finished? + end + def all_pipelines if builds_enabled? super @@ -998,6 +1038,9 @@ class Project < ApplicationRecord end end + def emails_enabled? + !emails_disabled? + end override :lfs_enabled? def lfs_enabled? return namespace.lfs_enabled? if self[:lfs_enabled].nil? @@ -1840,6 +1883,59 @@ class Project < ApplicationRecord end end + def feature_available?(feature, user = nil) + !!project_feature&.feature_available?(feature, user) + end + + def builds_enabled? + !!project_feature&.builds_enabled? + end + + def wiki_enabled? + !!project_feature&.wiki_enabled? + end + + def merge_requests_enabled? + !!project_feature&.merge_requests_enabled? + end + + def forking_enabled? + !!project_feature&.forking_enabled? + end + + def issues_enabled? + !!project_feature&.issues_enabled? + end + + def pages_enabled? + !!project_feature&.pages_enabled? + end + + def analytics_enabled? + !!project_feature&.analytics_enabled? + end + + def snippets_enabled? + !!project_feature&.snippets_enabled? + end + + def public_pages? + !!project_feature&.public_pages? + end + + def private_pages? + !!project_feature&.private_pages? + end + + def operations_enabled? + !!project_feature&.operations_enabled? + end + + def container_registry_enabled? + !!project_feature&.container_registry_enabled? + end + alias_method :container_registry_enabled, :container_registry_enabled? + def enable_ci project_feature.update_attribute(:builds_access_level, ProjectFeature::ENABLED) end @@ -2762,10 +2858,6 @@ class Project < ApplicationRecord instance.token end - def tracing_external_url - tracing_setting&.external_url - end - override :git_garbage_collect_worker_klass def git_garbage_collect_worker_klass Projects::GitGarbageCollectWorker @@ -2907,6 +2999,10 @@ class Project < ApplicationRecord build_artifacts_size_refresh&.started? end + def group_group_links + group&.shared_with_group_links&.of_ancestors_and_self || GroupGroupLink.none + end + def security_training_available? licensed_feature_available?(:security_training) end diff --git a/app/models/project_export_job.rb b/app/models/project_export_job.rb index c7fe3d7bc10..decc71ee193 100644 --- a/app/models/project_export_job.rb +++ b/app/models/project_export_job.rb @@ -2,6 +2,7 @@ class ProjectExportJob < ApplicationRecord belongs_to :project + has_many :relation_exports, class_name: 'Projects::ImportExport::RelationExport' validates :project, :jid, :status, presence: true diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index f478af32788..0a30e125c83 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -69,6 +69,11 @@ class ProjectFeature < ApplicationRecord default_value_for :metrics_dashboard_access_level, value: PRIVATE, allows_nil: false default_value_for :operations_access_level, value: ENABLED, allows_nil: false default_value_for :security_and_compliance_access_level, value: PRIVATE, allows_nil: false + default_value_for :monitor_access_level, value: ENABLED, allows_nil: false + default_value_for :infrastructure_access_level, value: ENABLED, allows_nil: false + default_value_for :feature_flags_access_level, value: ENABLED, allows_nil: false + default_value_for :environments_access_level, value: ENABLED, allows_nil: false + default_value_for :releases_access_level, value: ENABLED, allows_nil: false default_value_for(:pages_access_level, allows_nil: false) do |feature| if ::Gitlab::Pages.access_control_is_forced? diff --git a/app/models/project_import_state.rb b/app/models/project_import_state.rb index b1c1a5b6697..7711c6d604a 100644 --- a/app/models/project_import_state.rb +++ b/app/models/project_import_state.rb @@ -31,6 +31,10 @@ class ProjectImportState < ApplicationRecord transition started: :finished end + event :cancel do + transition [:none, :scheduled, :started] => :canceled + end + event :fail_op do transition [:scheduled, :started] => :failed end @@ -39,6 +43,7 @@ class ProjectImportState < ApplicationRecord state :started state :finished state :failed + state :canceled after_transition [:none, :finished, :failed] => :scheduled do |state, _| state.run_after_commit do @@ -51,7 +56,7 @@ class ProjectImportState < ApplicationRecord end end - after_transition any => :finished do |state, _| + after_transition any => [:canceled, :finished] do |state, _| if state.jid.present? Gitlab::SidekiqStatus.unset(state.jid) @@ -59,7 +64,7 @@ class ProjectImportState < ApplicationRecord end end - after_transition any => :failed do |state, _| + after_transition any => [:canceled, :failed] do |state, _| state.project.remove_import_data end diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb index e9fd7e4446c..59d2e3deb4f 100644 --- a/app/models/project_setting.rb +++ b/app/models/project_setting.rb @@ -5,6 +5,8 @@ class ProjectSetting < ApplicationRecord belongs_to :project, inverse_of: :project_setting + scope :for_projects, ->(projects) { where(project_id: projects) } + enum squash_option: { never: 0, always: 1, diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 97ab5aa2619..5641fbfb867 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -8,23 +8,23 @@ class ProjectTeam end def add_guest(user, current_user: nil) - add_user(user, :guest, current_user: current_user) + add_member(user, :guest, current_user: current_user) end def add_reporter(user, current_user: nil) - add_user(user, :reporter, current_user: current_user) + add_member(user, :reporter, current_user: current_user) end def add_developer(user, current_user: nil) - add_user(user, :developer, current_user: current_user) + add_member(user, :developer, current_user: current_user) end def add_maintainer(user, current_user: nil) - add_user(user, :maintainer, current_user: current_user) + add_member(user, :maintainer, current_user: current_user) end def add_owner(user, current_user: nil) - add_user(user, :owner, current_user: current_user) + add_member(user, :owner, current_user: current_user) end def add_role(user, role, current_user: nil) @@ -43,8 +43,8 @@ class ProjectTeam member end - def add_users(users, access_level, current_user: nil, expires_at: nil, tasks_to_be_done: [], tasks_project_id: nil) - Members::Projects::CreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass + def add_members(users, access_level, current_user: nil, expires_at: nil, tasks_to_be_done: [], tasks_project_id: nil) + Members::Projects::CreatorService.add_members( # rubocop:disable CodeReuse/ServiceClass project, users, access_level, @@ -55,8 +55,8 @@ class ProjectTeam ) end - def add_user(user, access_level, current_user: nil, expires_at: nil) - Members::Projects::CreatorService.add_user( # rubocop:disable CodeReuse/ServiceClass + def add_member(user, access_level, current_user: nil, expires_at: nil) + Members::Projects::CreatorService.add_member( # rubocop:disable CodeReuse/ServiceClass project, user, access_level, diff --git a/app/models/project_tracing_setting.rb b/app/models/project_tracing_setting.rb deleted file mode 100644 index 93fa80aed67..00000000000 --- a/app/models/project_tracing_setting.rb +++ /dev/null @@ -1,15 +0,0 @@ -# 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/projects/import_export/relation_export.rb b/app/models/projects/import_export/relation_export.rb new file mode 100644 index 00000000000..0a31e525ac2 --- /dev/null +++ b/app/models/projects/import_export/relation_export.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Projects + module ImportExport + class RelationExport < ApplicationRecord + self.table_name = 'project_relation_exports' + + belongs_to :project_export_job + + has_one :upload, + class_name: 'Projects::ImportExport::RelationExportUpload', + foreign_key: :project_relation_export_id, + inverse_of: :relation_export + + validates :export_error, length: { maximum: 300 } + validates :jid, length: { maximum: 255 } + validates :project_export_job, presence: true + validates :relation, presence: true, length: { maximum: 255 }, uniqueness: { scope: :project_export_job_id } + validates :status, numericality: { only_integer: true }, presence: true + end + end +end diff --git a/app/models/projects/import_export/relation_export_upload.rb b/app/models/projects/import_export/relation_export_upload.rb new file mode 100644 index 00000000000..965dc39d19f --- /dev/null +++ b/app/models/projects/import_export/relation_export_upload.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Projects + module ImportExport + class RelationExportUpload < ApplicationRecord + include WithUploads + include ObjectStorage::BackgroundMove + + self.table_name = 'project_relation_export_uploads' + + belongs_to :relation_export, + class_name: 'Projects::ImportExport::RelationExport', + foreign_key: :project_relation_export_id, + inverse_of: :upload + + mount_uploader :export_file, ImportExportUploader + end + end +end diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index 77038d52efe..7cf15439b47 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -4,6 +4,8 @@ class ProtectedBranch < ApplicationRecord include ProtectedRef include Gitlab::SQL::Pattern + CACHE_EXPIRE_IN = 1.hour + scope :requiring_code_owner_approval, -> { where(code_owner_approval_required: true) } @@ -29,7 +31,7 @@ class ProtectedBranch < ApplicationRecord return true if project.empty_repo? && project.default_branch_protected? return false if ref_name.blank? - Rails.cache.fetch(protected_ref_cache_key(project, ref_name)) do + Rails.cache.fetch(protected_ref_cache_key(project, ref_name), expires_in: CACHE_EXPIRE_IN) do self.matching(ref_name, protected_refs: protected_refs(project)).present? end end diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb index 7f41f0907d5..f8d500e106b 100644 --- a/app/models/remote_mirror.rb +++ b/app/models/remote_mirror.rb @@ -128,7 +128,7 @@ class RemoteMirror < ApplicationRecord def sync return unless sync? - if recently_scheduled? + if schedule_with_delay? RepositoryUpdateRemoteMirrorWorker.perform_in(backoff_delay, self.id, Time.current) else RepositoryUpdateRemoteMirrorWorker.perform_async(self.id, Time.current) @@ -261,7 +261,8 @@ class RemoteMirror < ApplicationRecord super end - def recently_scheduled? + def schedule_with_delay? + return false if Feature.enabled?(:remote_mirror_no_delay, project, type: :ops) return false unless self.last_update_started_at self.last_update_started_at >= Time.current - backoff_delay diff --git a/app/models/repository.rb b/app/models/repository.rb index 0135020e586..0da71d87457 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -1049,8 +1049,8 @@ class Repository blob_data_at(sha, '.lfsconfig') end - def changelog_config(ref = 'HEAD') - blob_data_at(ref, Gitlab::Changelog::Config::FILE_PATH) + def changelog_config(ref, path) + blob_data_at(ref, path) end def fetch_ref(source_repository, source_ref:, target_ref:) diff --git a/app/models/snippet.rb b/app/models/snippet.rb index cf4b83d44c2..c813c5cb5b8 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -18,6 +18,7 @@ class Snippet < ApplicationRecord include CanMoveRepositoryStorage include AfterCommitQueue extend ::Gitlab::Utils::Override + include CreatedAtFilterable MAX_FILE_COUNT = 10 diff --git a/app/models/ssh_host_key.rb b/app/models/ssh_host_key.rb index ac7ba9530dd..daa64f4e087 100644 --- a/app/models/ssh_host_key.rb +++ b/app/models/ssh_host_key.rb @@ -12,7 +12,15 @@ class SshHostKey end def as_json(*) - { bits: bits, fingerprint: fingerprint, type: type, index: index } + { bits: bits, type: type, index: index }.merge(fingerprint_data) + end + + private + + def fingerprint_data + data = { fingerprint_sha256: fingerprint_sha256 } + data[:fingerprint] = fingerprint unless Gitlab::FIPS.enabled? + data end end diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb index 4d17a4d332c..59f7d852ce6 100644 --- a/app/models/terraform/state.rb +++ b/app/models/terraform/state.rb @@ -3,6 +3,7 @@ module Terraform class State < ApplicationRecord include UsageStatistics + include AfterCommitQueue HEX_REGEXP = %r{\A\h+\z}.freeze UUID_LENGTH = 32 diff --git a/app/models/todo.rb b/app/models/todo.rb index 45ab770a0f6..cff7a93f72f 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -230,6 +230,10 @@ class Todo < ApplicationRecord target_type == AlertManagement::Alert.name end + def for_issue_or_work_item? + [Issue.name, WorkItem.name].any? { |klass_name| target_type == klass_name } + end + # override to return commits, which are not active record def target if for_commit? diff --git a/app/models/user.rb b/app/models/user.rb index 40096dfa411..12f434db631 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -9,6 +9,7 @@ class User < ApplicationRecord include Gitlab::SQL::Pattern include AfterCommitQueue include Avatarable + include Awareness include Referable include Sortable include CaseSensitivity @@ -80,7 +81,7 @@ class User < ApplicationRecord serialize :otp_backup_codes, JSON # rubocop:disable Cop/ActiveRecordSerialize devise :lockable, :recoverable, :rememberable, :trackable, - :validatable, :omniauthable, :confirmable, :registerable + :validatable, :omniauthable, :confirmable, :registerable, :pbkdf2_encryptable include AdminChangedPasswordNotifier @@ -88,6 +89,7 @@ class User < ApplicationRecord # and should be added after Devise modules are initialized. include AsyncDeviseEmail include ForcedEmailConfirmation + include RequireEmailVerification MINIMUM_INACTIVE_DAYS = 90 MINIMUM_DAYS_CREATED = 7 @@ -220,6 +222,7 @@ class User < ApplicationRecord has_many :custom_attributes, class_name: 'UserCustomAttribute' has_many :callouts, class_name: 'Users::Callout' has_many :group_callouts, class_name: 'Users::GroupCallout' + has_many :namespace_callouts, class_name: 'Users::NamespaceCallout' has_many :term_agreements belongs_to :accepted_term, class_name: 'ApplicationSetting::Term' @@ -476,8 +479,8 @@ class User < ApplicationRecord end scope :order_recent_sign_in, -> { reorder(arel_table[:current_sign_in_at].desc.nulls_last) } scope :order_oldest_sign_in, -> { reorder(arel_table[:current_sign_in_at].asc.nulls_last) } - scope :order_recent_last_activity, -> { reorder(arel_table[:last_activity_on].desc.nulls_last) } - scope :order_oldest_last_activity, -> { reorder(arel_table[:last_activity_on].asc.nulls_first) } + scope :order_recent_last_activity, -> { reorder(arel_table[:last_activity_on].desc.nulls_last, arel_table[:id].asc) } + scope :order_oldest_last_activity, -> { reorder(arel_table[:last_activity_on].asc.nulls_first, arel_table[:id].desc) } scope :by_id_and_login, ->(id, login) { where(id: id).where('username = LOWER(:login) OR email = LOWER(:login)', login: login) } scope :dormant, -> { with_state(:active).human_or_service_user.where('last_activity_on <= ?', MINIMUM_INACTIVE_DAYS.day.ago.to_date) } scope :with_no_activity, -> { with_state(:active).human_or_service_user.where(last_activity_on: nil).where('created_at <= ?', MINIMUM_DAYS_CREATED.day.ago.to_date) } @@ -687,7 +690,33 @@ class User < ApplicationRecord scope = options[:with_private_emails] ? with_primary_or_secondary_email(query) : with_public_email(query) scope = scope.or(search_by_name_or_username(query, use_minimum_char_limit: options[:use_minimum_char_limit])) - scope.reorder(sanitized_order_sql, :name) + if Feature.enabled?(:use_keyset_aware_user_search_query) + order = Gitlab::Pagination::Keyset::Order.build([ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'users_match_priority', + order_expression: sanitized_order_sql.asc, + add_to_projections: true, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'users_name', + order_expression: arel_table[:name].asc, + add_to_projections: true, + nullable: :not_nullable, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'users_id', + order_expression: arel_table[:id].asc, + add_to_projections: true, + nullable: :not_nullable, + distinct: true + ) + ]) + scope.reorder(order) + else + scope.reorder(sanitized_order_sql, :name) + end end # Limits the result set to users _not_ in the given query/list of IDs. @@ -894,21 +923,59 @@ class User < ApplicationRecord reset_password_sent_at.present? && reset_password_sent_at >= 1.minute.ago end - # See https://gitlab.com/gitlab-org/security/gitlab/-/issues/638 - DISALLOWED_PASSWORDS = %w[123qweQWE!@#000000000].freeze + def authenticatable_salt + return encrypted_password[0, 29] unless Feature.enabled?(:pbkdf2_password_encryption) + return super if password_strategy == :pbkdf2_sha512 + + encrypted_password[0, 29] + end # Overwrites valid_password? from Devise::Models::DatabaseAuthenticatable # In constant-time, check both that the password isn't on a denylist AND # that the password is the user's password def valid_password?(password) + return false unless password_allowed?(password) + return super if Feature.enabled?(:pbkdf2_password_encryption) + + Devise::Encryptor.compare(self.class, encrypted_password, password) + rescue Devise::Pbkdf2Encryptable::Encryptors::InvalidHash + validate_and_migrate_bcrypt_password(password) + rescue ::BCrypt::Errors::InvalidHash + false + end + + # This method should be removed once the :pbkdf2_password_encryption feature flag is removed. + def password=(new_password) + if Feature.enabled?(:pbkdf2_password_encryption) && Feature.enabled?(:pbkdf2_password_encryption_write, self) + super + else + # Copied from Devise DatabaseAuthenticatable. + @password = new_password + self.encrypted_password = Devise::Encryptor.digest(self.class, new_password) if new_password.present? + end + end + + def password_strategy + super + rescue Devise::Pbkdf2Encryptable::Encryptors::InvalidHash + begin + return :bcrypt if BCrypt::Password.new(encrypted_password) + rescue BCrypt::Errors::InvalidHash + :unknown + end + end + + # See https://gitlab.com/gitlab-org/security/gitlab/-/issues/638 + DISALLOWED_PASSWORDS = %w[123qweQWE!@#000000000].freeze + + def password_allowed?(password) password_allowed = true + DISALLOWED_PASSWORDS.each do |disallowed_password| password_allowed = false if Devise.secure_compare(password, disallowed_password) end - original_result = super - - password_allowed && original_result + password_allowed end def remember_me! @@ -1570,6 +1637,10 @@ class User < ApplicationRecord self.followees.exists?(user.id) end + def followed_by?(user) + self.followers.include?(user) + end + def follow(user) return false if self.id == user.id @@ -1625,7 +1696,7 @@ class User < ApplicationRecord end def oauth_authorized_tokens - Doorkeeper::AccessToken.where(resource_owner_id: id, revoked_at: nil) + OauthAccessToken.where(resource_owner_id: id, revoked_at: nil) end # Returns the projects a user contributed to in the last year. @@ -1899,7 +1970,7 @@ class User < ApplicationRecord end # override, from Devise - def lock_access! + def lock_access!(opts = {}) Gitlab::AppLogger.info("Account Locked: username=#{username}") super end @@ -2015,6 +2086,13 @@ class User < ApplicationRecord callout_dismissed?(callout, ignore_dismissal_earlier_than) end + def dismissed_callout_for_namespace?(feature_name:, namespace:, ignore_dismissal_earlier_than: nil) + source_feature_name = "#{feature_name}_#{namespace.id}" + callout = namespace_callouts_by_feature_name[source_feature_name] + + callout_dismissed?(callout, ignore_dismissal_earlier_than) + end + # Load the current highest access by looking directly at the user's memberships def current_highest_access_level members.non_request.maximum(:access_level) @@ -2041,6 +2119,11 @@ class User < ApplicationRecord .find_or_initialize_by(feature_name: ::Users::GroupCallout.feature_names[feature_name], group_id: group_id) end + def find_or_initialize_namespace_callout(feature_name, namespace_id) + namespace_callouts + .find_or_initialize_by(feature_name: ::Users::NamespaceCallout.feature_names[feature_name], namespace_id: namespace_id) + end + def can_trigger_notifications? confirmed? && !blocked? && !ghost? end @@ -2158,6 +2241,10 @@ class User < ApplicationRecord @group_callouts_by_feature_name ||= group_callouts.index_by(&:source_feature_name) end + def namespace_callouts_by_feature_name + @namespace_callouts_by_feature_name ||= namespace_callouts.index_by(&:source_feature_name) + end + def authorized_groups_without_shared_membership Group.from_union([ groups.select(*Namespace.cached_column_list), @@ -2318,6 +2405,15 @@ class User < ApplicationRecord Ci::NamespaceMirror.contains_traversal_ids(traversal_ids) end + + def validate_and_migrate_bcrypt_password(password) + return false unless Devise::Encryptor.compare(self.class, encrypted_password, password) + return true unless Feature.enabled?(:pbkdf2_password_encryption_write, self) + + update_attribute(:password, password) + rescue ::BCrypt::Errors::InvalidHash + false + end end User.prepend_mod_with('User') diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb index 0ecae4d148a..570e3ae9b3c 100644 --- a/app/models/users/callout.rb +++ b/app/models/users/callout.rb @@ -49,11 +49,14 @@ module Users storage_enforcement_banner_fourth_enforcement_threshold: 46, attention_requests_top_nav: 47, attention_requests_side_nav: 48, - minute_limit_banner: 49, + # 49 was removed with https://gitlab.com/gitlab-org/gitlab/-/merge_requests/91533 + # because the banner was no longer relevant. + # Records will be migrated with https://gitlab.com/gitlab-org/gitlab/-/issues/367293 preview_user_over_limit_free_plan_alert: 50, # EE-only user_reached_limit_free_plan_alert: 51, # EE-only submit_license_usage_data_banner: 52, # EE-only - personal_project_limitations_banner: 53 # EE-only + personal_project_limitations_banner: 53, # EE-only + mr_experience_survey: 54 } validates :feature_name, diff --git a/app/models/users/group_callout.rb b/app/models/users/group_callout.rb index 373bc30889f..0ea7b8199aa 100644 --- a/app/models/users/group_callout.rb +++ b/app/models/users/group_callout.rb @@ -16,7 +16,8 @@ module Users storage_enforcement_banner_third_enforcement_threshold: 5, storage_enforcement_banner_fourth_enforcement_threshold: 6, preview_user_over_limit_free_plan_alert: 7, # EE-only - user_reached_limit_free_plan_alert: 8 # EE-only + user_reached_limit_free_plan_alert: 8, # EE-only + free_group_limited_alert: 9 # EE-only } validates :group, presence: true diff --git a/app/models/users/in_product_marketing_email.rb b/app/models/users/in_product_marketing_email.rb index 82c2e336a09..f220cfd17c5 100644 --- a/app/models/users/in_product_marketing_email.rb +++ b/app/models/users/in_product_marketing_email.rb @@ -41,7 +41,7 @@ module Users # Tracks we don't send emails for (e.g. unsuccessful experiment). These # are kept since we already have DB records that use the enum value. - INACTIVE_TRACK_NAMES = %w(invite_team).freeze + INACTIVE_TRACK_NAMES = %w[invite_team experience].freeze ACTIVE_TRACKS = tracks.except(*INACTIVE_TRACK_NAMES) scope :for_user_with_track_and_series, -> (user, track, series) do diff --git a/app/models/users/namespace_callout.rb b/app/models/users/namespace_callout.rb new file mode 100644 index 00000000000..a20a196a4ef --- /dev/null +++ b/app/models/users/namespace_callout.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Users + class NamespaceCallout < ApplicationRecord + include Users::Calloutable + + self.table_name = 'user_namespace_callouts' + + belongs_to :namespace + + enum feature_name: { + invite_members_banner: 1, + approaching_seat_count_threshold: 2, # EE-only + storage_enforcement_banner_first_enforcement_threshold: 3, + storage_enforcement_banner_second_enforcement_threshold: 4, + storage_enforcement_banner_third_enforcement_threshold: 5, + storage_enforcement_banner_fourth_enforcement_threshold: 6, + preview_user_over_limit_free_plan_alert: 7, # EE-only + user_reached_limit_free_plan_alert: 8, # EE-only + web_hook_disabled: 9 + } + + validates :namespace, presence: true + validates :feature_name, + presence: true, + uniqueness: { scope: [:user_id, :namespace_id] }, + inclusion: { in: NamespaceCallout.feature_names.keys } + + def source_feature_name + "#{feature_name}_#{namespace_id}" + end + end +end diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 647b4e787c6..63c60f5a89e 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -316,6 +316,7 @@ class WikiPage end def update_front_matter(attrs) + return unless Gitlab::WikiPages::FrontMatterParser.enabled?(container) return unless attrs.has_key?(:front_matter) fm_yaml = serialize_front_matter(attrs[:front_matter]) @@ -326,7 +327,7 @@ class WikiPage def parsed_content strong_memoize(:parsed_content) do - Gitlab::WikiPages::FrontMatterParser.new(raw_content).parse + Gitlab::WikiPages::FrontMatterParser.new(raw_content, container).parse end end diff --git a/app/models/work_item.rb b/app/models/work_item.rb index bdd9aae90a4..d29df0c31fc 100644 --- a/app/models/work_item.rb +++ b/app/models/work_item.rb @@ -1,9 +1,12 @@ # frozen_string_literal: true class WorkItem < Issue + include Gitlab::Utils::StrongMemoize + self.table_name = 'issues' self.inheritance_column = :_type_disabled + belongs_to :namespace, inverse_of: :work_items has_one :parent_link, class_name: '::WorkItems::ParentLink', foreign_key: :work_item_id has_one :work_item_parent, through: :parent_link, class_name: 'WorkItem' @@ -22,8 +25,10 @@ class WorkItem < Issue end def widgets - work_item_type.widgets.map do |widget_class| - widget_class.new(self) + strong_memoize(:widgets) do + work_item_type.widgets.map do |widget_class| + widget_class.new(self) + end end end diff --git a/app/models/work_items/parent_link.rb b/app/models/work_items/parent_link.rb index 3c405dbce3b..f5ebbfa59b8 100644 --- a/app/models/work_items/parent_link.rb +++ b/app/models/work_items/parent_link.rb @@ -5,11 +5,13 @@ module WorkItems self.table_name = 'work_item_parent_links' MAX_CHILDREN = 100 + PARENT_TYPES = [:issue, :incident].freeze belongs_to :work_item belongs_to :work_item_parent, class_name: 'WorkItem' - validates :work_item, :work_item_parent, presence: true + validates :work_item_parent, presence: true + validates :work_item, presence: true, uniqueness: true validate :validate_child_type validate :validate_parent_type validate :validate_same_project @@ -21,15 +23,20 @@ module WorkItems return unless work_item unless work_item.task? - errors.add :work_item, _('Only Task can be assigned as a child in hierarchy.') + errors.add :work_item, _('only Task can be assigned as a child in hierarchy.') end end def validate_parent_type return unless work_item_parent - unless work_item_parent.issue? - errors.add :work_item_parent, _('Only Issue can be parent of Task.') + base_type = work_item_parent.work_item_type.base_type.to_sym + unless PARENT_TYPES.include?(base_type) + parent_names = WorkItems::Type::BASE_TYPES.slice(*WorkItems::ParentLink::PARENT_TYPES) + .values.map { |type| type[:name] } + + errors.add :work_item_parent, _('only %{parent_types} can be parent of Task.') % + { parent_types: parent_names.to_sentence } end end @@ -37,7 +44,7 @@ module WorkItems return if work_item.nil? || work_item_parent.nil? if work_item.resource_parent != work_item_parent.resource_parent - errors.add :work_item_parent, _('Parent must be in the same project as child.') + errors.add :work_item_parent, _('parent must be in the same project as child.') end end @@ -46,7 +53,7 @@ module WorkItems max = persisted? ? MAX_CHILDREN : MAX_CHILDREN - 1 if work_item_parent.child_links.count > max - errors.add :work_item_parent, _('Parent already has maximum number of children.') + errors.add :work_item_parent, _('parent already has maximum number of children.') end end end diff --git a/app/models/work_items/type.rb b/app/models/work_items/type.rb index bf251a3ade5..e38d0ae153a 100644 --- a/app/models/work_items/type.rb +++ b/app/models/work_items/type.rb @@ -21,11 +21,11 @@ module WorkItems }.freeze WIDGETS_FOR_TYPE = { - issue: [Widgets::Description, Widgets::Hierarchy], - incident: [Widgets::Description], + issue: [Widgets::Assignees, Widgets::Description, Widgets::Hierarchy, Widgets::Weight], + incident: [Widgets::Description, Widgets::Hierarchy], test_case: [Widgets::Description], requirement: [Widgets::Description], - task: [Widgets::Description, Widgets::Hierarchy] + task: [Widgets::Assignees, Widgets::Description, Widgets::Hierarchy, Widgets::Weight] }.freeze cache_markdown_field :description, pipeline: :single_line diff --git a/app/models/work_items/widgets/assignees.rb b/app/models/work_items/widgets/assignees.rb new file mode 100644 index 00000000000..ecbbee1bcfb --- /dev/null +++ b/app/models/work_items/widgets/assignees.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module WorkItems + module Widgets + class Assignees < Base + delegate :assignees, to: :work_item + delegate :allows_multiple_assignees?, to: :work_item + end + end +end diff --git a/app/models/work_items/widgets/description.rb b/app/models/work_items/widgets/description.rb index 35b6d295321..1e84d172bef 100644 --- a/app/models/work_items/widgets/description.rb +++ b/app/models/work_items/widgets/description.rb @@ -4,10 +4,6 @@ module WorkItems module Widgets class Description < Base delegate :description, to: :work_item - - def update(params:) - work_item.description = params[:description] if params&.key?(:description) - end end end end diff --git a/app/models/work_items/widgets/hierarchy.rb b/app/models/work_items/widgets/hierarchy.rb index dadd341de83..930aced8ace 100644 --- a/app/models/work_items/widgets/hierarchy.rb +++ b/app/models/work_items/widgets/hierarchy.rb @@ -4,13 +4,13 @@ module WorkItems module Widgets class Hierarchy < Base def parent - return unless Feature.enabled?(:work_items_hierarchy, work_item.project) + return unless work_item.project.work_items_feature_flag_enabled? work_item.work_item_parent end def children - return WorkItem.none unless Feature.enabled?(:work_items_hierarchy, work_item.project) + return WorkItem.none unless work_item.project.work_items_feature_flag_enabled? work_item.work_item_children end diff --git a/app/models/work_items/widgets/weight.rb b/app/models/work_items/widgets/weight.rb new file mode 100644 index 00000000000..f589378f307 --- /dev/null +++ b/app/models/work_items/widgets/weight.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module WorkItems + module Widgets + class Weight < Base + delegate :weight, to: :work_item + end + end +end diff --git a/app/models/x509_certificate.rb b/app/models/x509_certificate.rb index 2c1d0110b7c..7c2581b8bb2 100644 --- a/app/models/x509_certificate.rb +++ b/app/models/x509_certificate.rb @@ -16,7 +16,7 @@ class X509Certificate < ApplicationRecord has_many :x509_commit_signatures, class_name: 'CommitSignatures::X509CommitSignature', inverse_of: 'x509_certificate' # rfc 5280 - 4.2.1.2 Subject Key Identifier - validates :subject_key_identifier, presence: true, format: { with: /\A(\h{2}:){19}\h{2}\z/ } + validates :subject_key_identifier, presence: true, format: { with: Gitlab::Regex.x509_subject_key_identifier_regex } # rfc 5280 - 4.1.2.6 Subject validates :subject, presence: true # rfc 5280 - 4.1.2.6 Subject (subjectAltName contains the email address) diff --git a/app/models/x509_issuer.rb b/app/models/x509_issuer.rb index 4b75e38bbde..81491d8e507 100644 --- a/app/models/x509_issuer.rb +++ b/app/models/x509_issuer.rb @@ -4,7 +4,7 @@ class X509Issuer < ApplicationRecord has_many :x509_certificates, inverse_of: 'x509_issuer' # rfc 5280 - 4.2.1.1 Authority Key Identifier - validates :subject_key_identifier, presence: true, format: { with: /\A(\h{2}:){19}\h{2}\z/ } + validates :subject_key_identifier, presence: true, format: { with: Gitlab::Regex.x509_subject_key_identifier_regex } # rfc 5280 - 4.1.2.4 Issuer validates :subject, presence: true # rfc 5280 - 4.2.1.13 CRL Distribution Points -- cgit v1.2.3