diff options
35 files changed, 1059 insertions, 7 deletions
diff --git a/.rubocop_todo/layout/argument_alignment.yml b/.rubocop_todo/layout/argument_alignment.yml index bb17d0371dc..33f6e917932 100644 --- a/.rubocop_todo/layout/argument_alignment.yml +++ b/.rubocop_todo/layout/argument_alignment.yml @@ -1474,7 +1474,6 @@ Layout/ArgumentAlignment: - 'ee/spec/services/ee/vulnerability_feedback_module/update_service_spec.rb' - 'ee/spec/services/elastic/process_bookkeeping_service_spec.rb' - 'ee/spec/services/epics/issue_promote_service_spec.rb' - - 'ee/spec/services/epics/tree_reorder_service_spec.rb' - 'ee/spec/services/geo/blob_upload_service_spec.rb' - 'ee/spec/services/geo/framework_repository_sync_service_spec.rb' - 'ee/spec/services/geo/hashed_storage_attachments_migration_service_spec.rb' diff --git a/.rubocop_todo/layout/line_length.yml b/.rubocop_todo/layout/line_length.yml index a2cbf2b5b2d..d3181bbeef2 100644 --- a/.rubocop_todo/layout/line_length.yml +++ b/.rubocop_todo/layout/line_length.yml @@ -2251,7 +2251,6 @@ Layout/LineLength: - 'ee/spec/services/epic_issues/create_service_spec.rb' - 'ee/spec/services/epics/issue_promote_service_spec.rb' - 'ee/spec/services/epics/related_epic_links/create_service_spec.rb' - - 'ee/spec/services/epics/tree_reorder_service_spec.rb' - 'ee/spec/services/epics/update_dates_service_spec.rb' - 'ee/spec/services/epics/update_service_spec.rb' - 'ee/spec/services/external_status_checks/update_service_spec.rb' diff --git a/.rubocop_todo/layout/space_inside_parens.yml b/.rubocop_todo/layout/space_inside_parens.yml index 53e00f78f29..ebe25607970 100644 --- a/.rubocop_todo/layout/space_inside_parens.yml +++ b/.rubocop_todo/layout/space_inside_parens.yml @@ -67,7 +67,6 @@ Layout/SpaceInsideParens: - 'ee/spec/services/ee/users/update_service_spec.rb' - 'ee/spec/services/epic_issues/update_service_spec.rb' - 'ee/spec/services/epics/related_epic_links/destroy_service_spec.rb' - - 'ee/spec/services/epics/tree_reorder_service_spec.rb' - 'ee/spec/services/geo/container_repository_sync_spec.rb' - 'ee/spec/services/geo/replication_toggle_request_service_spec.rb' - 'ee/spec/services/gitlab_subscriptions/create_service_spec.rb' diff --git a/app/graphql/resolvers/clusters/agents/authorizations/ci_access_resolver.rb b/app/graphql/resolvers/clusters/agents/authorizations/ci_access_resolver.rb new file mode 100644 index 00000000000..c36338439e6 --- /dev/null +++ b/app/graphql/resolvers/clusters/agents/authorizations/ci_access_resolver.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Resolvers + module Clusters + module Agents + module Authorizations + class CiAccessResolver < BaseResolver + type Types::Clusters::Agents::Authorizations::CiAccessType, null: true + + alias_method :project, :object + + def resolve(*) + ::Clusters::Agents::Authorizations::CiAccess::Finder.new(project).execute + end + end + end + end + end +end diff --git a/app/graphql/types/clusters/agents/authorizations/ci_access_type.rb b/app/graphql/types/clusters/agents/authorizations/ci_access_type.rb new file mode 100644 index 00000000000..a60f32b8b0b --- /dev/null +++ b/app/graphql/types/clusters/agents/authorizations/ci_access_type.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + module Clusters + module Agents + module Authorizations + class CiAccessType < BaseObject # rubocop:disable Graphql/AuthorizeTypes + graphql_name 'ClusterAgentAuthorizationCiAccess' + + field :agent, Types::Clusters::AgentType, + description: 'Authorized cluster agent.', + null: true + + field :config, GraphQL::Types::JSON, # rubocop:disable Graphql/JSONType + description: 'Configuration for the authorized project.', + null: true + end + end + end + end +end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 5ebc1cf7ddd..a0f9864351c 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -522,6 +522,12 @@ module Types description: 'Cluster agents associated with the project.', resolver: ::Resolvers::Clusters::AgentsResolver + field :ci_access_authorized_agents, ::Types::Clusters::Agents::Authorizations::CiAccessType.connection_type, + null: true, + description: 'Authorized cluster agents for the project through ci_access keyword.', + resolver: ::Resolvers::Clusters::Agents::Authorizations::CiAccessResolver, + authorize: :read_cluster_agent + field :merge_commit_template, GraphQL::Types::String, null: true, description: 'Template used to create merge commit message in merge requests.' diff --git a/app/models/clusters/agent.rb b/app/models/clusters/agent.rb index 374deabfe33..55fd41d6c0a 100644 --- a/app/models/clusters/agent.rb +++ b/app/models/clusters/agent.rb @@ -57,6 +57,63 @@ module Clusters def to_ability_name :cluster end + + def ci_access_authorized_for?(user) + return false unless user + return false unless ::Feature.enabled?(:expose_authorized_cluster_agents, project) + + ::Project.from_union( + all_ci_access_authorized_projects_for(user).limit(1), + all_ci_access_authorized_namespaces_for(user).limit(1) + ).exists? + end + + private + + def all_ci_access_authorized_projects_for(user) + ::Project.joins(:ci_access_project_authorizations) + .joins(:project_authorizations) + .where(agent_project_authorizations: { agent_id: id }) + .where(project_authorizations: { user_id: user.id, access_level: Gitlab::Access::DEVELOPER.. }) + end + + def all_ci_access_authorized_namespaces_for(user) + ::Project.with(root_namespace_cte.to_arel) + .with(all_ci_access_authorized_namespaces_cte.to_arel) + .joins('INNER JOIN all_authorized_namespaces ON all_authorized_namespaces.id = projects.namespace_id') + .joins(:project_authorizations) + .where(project_authorizations: { user_id: user.id, access_level: Gitlab::Access::DEVELOPER.. }) + end + + def root_namespace_cte + Gitlab::SQL::CTE.new(:root_namespace, root_namespace.to_sql) + end + + def all_ci_access_authorized_namespaces_cte + Gitlab::SQL::CTE.new(:all_authorized_namespaces, all_ci_access_authorized_namespaces.to_sql) + end + + def all_ci_access_authorized_namespaces + Namespace.select("traversal_ids[array_length(traversal_ids, 1)] AS id") + .joins("INNER JOIN root_namespace ON " \ + "namespaces.traversal_ids @> ARRAY[root_namespace.root_id]") + .joins("INNER JOIN agent_group_authorizations ON " \ + "namespaces.traversal_ids @> ARRAY[agent_group_authorizations.group_id::integer]") + .where(agent_group_authorizations: { agent_id: id }) + end + + def root_namespace + Namespace.select("traversal_ids[1] AS root_id") + .where("traversal_ids @> ARRAY(?)", project_namespace) + .limit(1) + end + + def project_namespace + ::Project.select('namespace_id') + .joins(:cluster_agents) + .where(cluster_agents: { id: id }) + .limit(1) + end end end diff --git a/app/models/environment_status.rb b/app/models/environment_status.rb index d06d0a99948..58c57a60f9d 100644 --- a/app/models/environment_status.rb +++ b/app/models/environment_status.rb @@ -100,11 +100,14 @@ class EnvironmentStatus def self.build_environments_status(mr, user, pipeline) return [] unless pipeline - pipeline.environments_in_self_and_project_descendants.includes(:project).available.map do |environment| + environments = pipeline.environments_in_self_and_project_descendants.includes(:project) + environments = environments.available if Feature.disabled?(:review_apps_redeploy_mr_widget, mr.project) + environments.map do |environment| next unless Ability.allowed?(user, :read_environment, environment) EnvironmentStatus.new(pipeline.project, environment, mr, pipeline.sha) end.compact end + private_class_method :build_environments_status end diff --git a/app/models/project.rb b/app/models/project.rb index c1f5a2315ef..a33f0fc11b8 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -364,6 +364,7 @@ class Project < ApplicationRecord has_many :kubernetes_namespaces, class_name: 'Clusters::KubernetesNamespace' has_many :management_clusters, class_name: 'Clusters::Cluster', foreign_key: :management_project_id, inverse_of: :management_project has_many :cluster_agents, class_name: 'Clusters::Agent' + has_many :ci_access_project_authorizations, class_name: 'Clusters::Agents::Authorizations::CiAccess::ProjectAuthorization' has_many :prometheus_metrics has_many :prometheus_alerts, inverse_of: :project diff --git a/app/policies/clusters/agent_policy.rb b/app/policies/clusters/agent_policy.rb index 25e78c84802..afacf782a76 100644 --- a/app/policies/clusters/agent_policy.rb +++ b/app/policies/clusters/agent_policy.rb @@ -5,5 +5,15 @@ module Clusters alias_method :cluster_agent, :subject delegate { cluster_agent.project } + + # This condition is more expensive than the same permission check in ProjectPolicy, + # so having a higher score. + condition(:ci_access_authorized_agent, score: 10) do + @subject.ci_access_authorized_for?(@user) + end + + rule { ci_access_authorized_agent }.policy do + enable :read_cluster_agent + end end end diff --git a/config/feature_flags/development/expose_authorized_cluster_agents.yml b/config/feature_flags/development/expose_authorized_cluster_agents.yml new file mode 100644 index 00000000000..9110c2be6c6 --- /dev/null +++ b/config/feature_flags/development/expose_authorized_cluster_agents.yml @@ -0,0 +1,8 @@ +--- +name: expose_authorized_cluster_agents +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/117128 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/407841 +milestone: '16.0' +type: development +group: group::environments +default_enabled: false diff --git a/config/feature_flags/development/review_apps_redeploy_mr_widget.yml b/config/feature_flags/development/review_apps_redeploy_mr_widget.yml new file mode 100644 index 00000000000..c2bbdef3c83 --- /dev/null +++ b/config/feature_flags/development/review_apps_redeploy_mr_widget.yml @@ -0,0 +1,8 @@ +--- +name: review_apps_redeploy_mr_widget +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/118260 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/407456 +milestone: '15.11' +type: development +group: group::pipeline execution +default_enabled: false diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index aa27bdbb64b..c8272c1a90d 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -7678,6 +7678,29 @@ The edge type for [`ClusterAgentActivityEvent`](#clusteragentactivityevent). | <a id="clusteragentactivityeventedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. | | <a id="clusteragentactivityeventedgenode"></a>`node` | [`ClusterAgentActivityEvent`](#clusteragentactivityevent) | The item at the end of the edge. | +#### `ClusterAgentAuthorizationCiAccessConnection` + +The connection type for [`ClusterAgentAuthorizationCiAccess`](#clusteragentauthorizationciaccess). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="clusteragentauthorizationciaccessconnectionedges"></a>`edges` | [`[ClusterAgentAuthorizationCiAccessEdge]`](#clusteragentauthorizationciaccessedge) | A list of edges. | +| <a id="clusteragentauthorizationciaccessconnectionnodes"></a>`nodes` | [`[ClusterAgentAuthorizationCiAccess]`](#clusteragentauthorizationciaccess) | A list of nodes. | +| <a id="clusteragentauthorizationciaccessconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. | + +#### `ClusterAgentAuthorizationCiAccessEdge` + +The edge type for [`ClusterAgentAuthorizationCiAccess`](#clusteragentauthorizationciaccess). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="clusteragentauthorizationciaccessedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. | +| <a id="clusteragentauthorizationciaccessedgenode"></a>`node` | [`ClusterAgentAuthorizationCiAccess`](#clusteragentauthorizationciaccess) | The item at the end of the edge. | + #### `ClusterAgentConnection` The connection type for [`ClusterAgent`](#clusteragent). @@ -12375,6 +12398,15 @@ four standard [pagination arguments](#connection-pagination-arguments): | <a id="clusteragentactivityeventrecordedat"></a>`recordedAt` | [`Time`](#time) | Timestamp the event was recorded. | | <a id="clusteragentactivityeventuser"></a>`user` | [`UserCore`](#usercore) | User associated with the event. | +### `ClusterAgentAuthorizationCiAccess` + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="clusteragentauthorizationciaccessagent"></a>`agent` | [`ClusterAgent`](#clusteragent) | Authorized cluster agent. | +| <a id="clusteragentauthorizationciaccessconfig"></a>`config` | [`JSON`](#json) | Configuration for the authorized project. | + ### `ClusterAgentToken` #### Fields @@ -18586,6 +18618,7 @@ Represents a product analytics dashboard visualization. | <a id="projectautoclosereferencedissues"></a>`autocloseReferencedIssues` | [`Boolean`](#boolean) | Indicates if issues referenced by merge requests and commits within the default branch are closed automatically. | | <a id="projectavatarurl"></a>`avatarUrl` | [`String`](#string) | URL to avatar image file of the project. | | <a id="projectbranchrules"></a>`branchRules` | [`BranchRuleConnection`](#branchruleconnection) | Branch rules configured for the project. (see [Connections](#connections)) | +| <a id="projectciaccessauthorizedagents"></a>`ciAccessAuthorizedAgents` | [`ClusterAgentAuthorizationCiAccessConnection`](#clusteragentauthorizationciaccessconnection) | Authorized cluster agents for the project through ci_access keyword. (see [Connections](#connections)) | | <a id="projectcicdsettings"></a>`ciCdSettings` | [`ProjectCiCdSetting`](#projectcicdsetting) | CI/CD settings for the project. | | <a id="projectciconfigpathordefault"></a>`ciConfigPathOrDefault` | [`String!`](#string) | Path of the CI configuration file. | | <a id="projectcijobtokenscope"></a>`ciJobTokenScope` | [`CiJobTokenScopeType`](#cijobtokenscopetype) | The CI Job Tokens scope of access. | diff --git a/doc/development/rake_tasks.md b/doc/development/rake_tasks.md index 82e96befd11..a5345527203 100644 --- a/doc/development/rake_tasks.md +++ b/doc/development/rake_tasks.md @@ -145,6 +145,58 @@ bin/rake 'gitlab:seed:vulnerabilities' bin/rake 'gitlab:seed:vulnerabilities[group-path/project-path]' ``` +#### Seed a project with environments + +You can seed a project with [environments](../ci/environments/index.md). + +By default, this creates 10 environments, each with the prefix `ENV_`. +Only `project_path` is required to run this command. + +```shell +bundle exec rake "gitlab:seed:project_environments[project_path, seed_count, prefix]" + +# Examples +bundle exec rake "gitlab:seed:project_environments[flightjs/Flight]" +bundle exec rake "gitlab:seed:project_environments[flightjs/Flight, 25, FLIGHT_ENV_]" +``` + +#### Seed CI variables + +You can seed a project, group, or instance with [CI variables](../ci/variables/index.md). + +By default, each command creates 10 CI variables. Variable names are prepended with its own +default prefix (`VAR_` for project-level variables, `GROUP_VAR_` for group-level variables, +and `INSTANCE_VAR_` for instance-level variables). + +Instance-level variables do not have environment scopes. Project-level and group-level variables +use the default `"*"` environment scope if no `environment_scope` is supplied. If `environment_scope` +is set to `"unique"`, each variable is created with its own unique environment. + +```shell +# Seed a project with project-level CI variables +# Only `project_path` is required to run this command. +bundle exec rake "gitlab:seed:ci_variables_project[project_path, seed_count, environment_scope, prefix]" + +# Seed a group with group-level CI variables +# Only `group_name` is required to run this command. +bundle exec rake "gitlab:seed:ci_variables_group[group_name, seed_count, environment_scope, prefix]" + +# Seed an instance with instance-level CI variables +bundle exec rake "gitlab:seed:ci_variables_instance[seed_count, prefix]" + +# Examples +bundle exec rake "gitlab:seed:ci_variables_project[flightjs/Flight]" +bundle exec rake "gitlab:seed:ci_variables_project[flightjs/Flight, 25, staging]" +bundle exec rake "gitlab:seed:ci_variables_project[flightjs/Flight, 25, unique, CI_VAR_]" + +bundle exec rake "gitlab:seed:ci_variables_group[group_name]" +bundle exec rake "gitlab:seed:ci_variables_group[group_name, 25, staging]" +bundle exec rake "gitlab:seed:ci_variables_group[group_name, 25, unique, CI_VAR_]" + +bundle exec rake "gitlab:seed:ci_variables_instance" +bundle exec rake "gitlab:seed:ci_variables_instance[25, CI_VAR_]" +``` + ### Automation If you're very sure that you want to **wipe the current database** and refill diff --git a/lib/gitlab/seeders/ci/variables_group_seeder.rb b/lib/gitlab/seeders/ci/variables_group_seeder.rb new file mode 100644 index 00000000000..e5c5ee4b75f --- /dev/null +++ b/lib/gitlab/seeders/ci/variables_group_seeder.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Gitlab + module Seeders + module Ci + class VariablesGroupSeeder + DEFAULT_SEED_COUNT = 10 + DEFAULT_PREFIX = 'GROUP_VAR_' + DEFAULT_ENV = '*' + + def initialize(params) + @group = Group.find_by_name(params[:name]) + @seed_count = params[:seed_count] || DEFAULT_SEED_COUNT + @environment_scope = params[:environment_scope] || DEFAULT_ENV + @prefix = params[:prefix] || DEFAULT_PREFIX + end + + def seed + if @group.nil? + warn 'ERROR: Group name is invalid.' + return + end + + max_id = group.variables.maximum(:id).to_i + seed_count.times do + max_id += 1 + create_ci_variable(max_id) + end + end + + private + + attr_reader :environment_scope, :group, :prefix, :seed_count + + def create_ci_variable(id) + env = environment_scope == 'unique' ? "env_#{id}" : environment_scope + key = "#{prefix}#{id}" + + if group.variables.by_environment_scope(env).find_by_key(key).present? + warn "WARNING: Group CI Variable with key '#{key}' already exists. Skipping to next CI variable..." + return + end + + group.variables.create( + environment_scope: env, + key: key, + value: SecureRandom.hex(32) + ) + end + end + end + end +end diff --git a/lib/gitlab/seeders/ci/variables_instance_seeder.rb b/lib/gitlab/seeders/ci/variables_instance_seeder.rb new file mode 100644 index 00000000000..d43defd192f --- /dev/null +++ b/lib/gitlab/seeders/ci/variables_instance_seeder.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Gitlab + module Seeders + module Ci + class VariablesInstanceSeeder + DEFAULT_SEED_COUNT = 10 + DEFAULT_PREFIX = 'INSTANCE_VAR_' + + def initialize(params = {}) + @seed_count = params[:seed_count] || DEFAULT_SEED_COUNT + @prefix = params[:prefix] || DEFAULT_PREFIX + end + + def seed + max_id = ::Ci::InstanceVariable.maximum(:id).to_i + seed_count.times do + max_id += 1 + create_ci_variable(max_id) + end + end + + private + + attr_reader :prefix, :seed_count + + def create_ci_variable(id) + key = "#{prefix}#{id}" + + if ::Ci::InstanceVariable.find_by_key(key) + warn "WARNING: Instance CI Variable with key '#{key}' already exists. Skipping to next CI variable..." + return + end + + ::Ci::InstanceVariable.new( + key: key, + value: SecureRandom.hex(32) + ).save + end + end + end + end +end diff --git a/lib/gitlab/seeders/ci/variables_project_seeder.rb b/lib/gitlab/seeders/ci/variables_project_seeder.rb new file mode 100644 index 00000000000..c6b3dac7a4d --- /dev/null +++ b/lib/gitlab/seeders/ci/variables_project_seeder.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Gitlab + module Seeders + module Ci + class VariablesProjectSeeder + DEFAULT_SEED_COUNT = 10 + DEFAULT_PREFIX = 'VAR_' + DEFAULT_ENV = '*' + + def initialize(params) + @project = Project.find_by_full_path(params[:project_path]) + @seed_count = params[:seed_count] || DEFAULT_SEED_COUNT + @environment_scope = params[:environment_scope] || DEFAULT_ENV + @prefix = params[:prefix] || DEFAULT_PREFIX + end + + def seed + if @project.nil? + warn 'ERROR: Project path is invalid.' + return + end + + max_id = project.variables.maximum(:id).to_i + seed_count.times do + max_id += 1 + create_ci_variable(max_id) + end + end + + private + + attr_reader :environment_scope, :prefix, :project, :seed_count + + def create_ci_variable(id) + env = environment_scope == 'unique' ? "env_#{id}" : environment_scope + key = "#{prefix}#{id}" + + if project.variables.by_environment_scope(env).find_by_key(key).present? + warn "WARNING: Project CI Variable with key '#{key}' already exists. Skipping to next CI variable..." + end + + project.variables.create( + environment_scope: env, + key: key, + value: SecureRandom.hex(32) + ) + end + end + end + end +end diff --git a/lib/gitlab/seeders/project_environment_seeder.rb b/lib/gitlab/seeders/project_environment_seeder.rb new file mode 100644 index 00000000000..3fc7d3d9b12 --- /dev/null +++ b/lib/gitlab/seeders/project_environment_seeder.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Gitlab + module Seeders + class ProjectEnvironmentSeeder + DEFAULT_SEED_COUNT = 10 + DEFAULT_PREFIX = 'ENV_' + + def initialize(params) + @project = Project.find_by_full_path(params[:project_path]) + @seed_count = params[:seed_count] || DEFAULT_SEED_COUNT + @prefix = params[:prefix] || DEFAULT_PREFIX + end + + def seed + if @project.nil? + warn 'ERROR: Project path is invalid.' + return + end + + max_id = project.environments.maximum(:id).to_i + seed_count.times do + max_id += 1 + create_project_environment_scope(max_id) + end + end + + private + + attr_reader :project, :seed_count, :prefix + + def create_project_environment_scope(id) + name = "#{prefix}#{id}" + + if project.environments.find_by_name(name).present? + warn "WARNING: Project Environment '#{name}' already exists. Skipping to next CI variable..." + return + end + + project.environments.create(name: name) + end + end + end +end diff --git a/lib/tasks/gitlab/seed/ci_variables_group.rake b/lib/tasks/gitlab/seed/ci_variables_group.rake new file mode 100644 index 00000000000..d58923eab0e --- /dev/null +++ b/lib/tasks/gitlab/seed/ci_variables_group.rake @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Seed group with CI variables +# +# @param name - name of the group to add CI variables to +# @param seed_count - total number of CI variables to create (default: 10) +# @param environment_scope - environment scope of the variable (default: '*') +# If "unique", it will create a unique environment_scope per variable. +# @param prefix - prefix of the variable key (default: 'GROUP_VAR_') +# +# @example +# bundle exec rake "gitlab:seed:ci_variables_group[kitchen-sink, 5, unique]" +# +namespace :gitlab do + namespace :seed do + desc 'Seed group with CI Variables' + task :ci_variables_group, + [:name, :seed_count, :environment_scope, :prefix] => :gitlab_environment do |_t, args| + Gitlab::Seeders::Ci::VariablesGroupSeeder.new( + name: args.name, + seed_count: args.seed_count&.to_i, + prefix: args&.prefix, + environment_scope: args&.environment_scope + ).seed + puts "Task finished!" + end + end +end diff --git a/lib/tasks/gitlab/seed/ci_variables_instance.rake b/lib/tasks/gitlab/seed/ci_variables_instance.rake new file mode 100644 index 00000000000..15a101a1cf1 --- /dev/null +++ b/lib/tasks/gitlab/seed/ci_variables_instance.rake @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +# Seed instance with CI variables +# +# @param seed_count - total number of CI variables to create (default: 10) +# @param prefix - prefix of the variable key (default: 'INSTANCE_VAR_') +# +# @example +# bundle exec rake "gitlab:seed:ci_variables_instance[5, INSTANCE_TEST_]" +# +namespace :gitlab do + namespace :seed do + desc 'Seed instance with CI Variables' + task :ci_variables_instance, + [:seed_count, :prefix] => :gitlab_environment do |_t, args| + Gitlab::Seeders::Ci::VariablesInstanceSeeder.new( + seed_count: args.seed_count&.to_i, + prefix: args&.prefix + ).seed + puts "Task finished!" + end + end +end diff --git a/lib/tasks/gitlab/seed/ci_variables_project.rake b/lib/tasks/gitlab/seed/ci_variables_project.rake new file mode 100644 index 00000000000..64f8b8d2a86 --- /dev/null +++ b/lib/tasks/gitlab/seed/ci_variables_project.rake @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Seed project with CI variables +# +# @param project_path - path of the project to add CI variables to +# @param seed_count - total number of CI variables to create (default: 10) +# @param environment_scope - environment scope of the variable (default: '*') +# If "unique", it will create a unique environment_scope per variable. +# @param prefix - prefix of the variable key (default: 'VAR_') +# +# @example +# bundle exec rake "gitlab:seed:ci_variables_project[root/paper, 5, production, prod_var_]" +# +namespace :gitlab do + namespace :seed do + desc 'Seed project with CI Variables' + task :ci_variables_project, + [:project_path, :seed_count, :environment_scope, :prefix] => :gitlab_environment do |_t, args| + Gitlab::Seeders::Ci::VariablesProjectSeeder.new( + project_path: args.project_path, + seed_count: args.seed_count&.to_i, + prefix: args&.prefix, + environment_scope: args&.environment_scope + ).seed + puts "Task finished!" + end + end +end diff --git a/lib/tasks/gitlab/seed/project_environments.rake b/lib/tasks/gitlab/seed/project_environments.rake new file mode 100644 index 00000000000..8557c130297 --- /dev/null +++ b/lib/tasks/gitlab/seed/project_environments.rake @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# Seed project with environments +# +# @param project_path - path of the project to add environments to +# @param seed_count - total number of environments to create (default: 10) +# @param prefix - prefix used for the environment name (default: 'ENV_') +# +# @example +# bundle exec rake "gitlab:seed:project_environments[root/paper, 5, staging_]" +# +namespace :gitlab do + namespace :seed do + desc 'Seed project with environments' + task :project_environments, [:project_path, :seed_count, :prefix] => :gitlab_environment do |_t, args| + Gitlab::Seeders::ProjectEnvironmentSeeder.new( + project_path: args.project_path, + seed_count: args.seed_count&.to_i, + prefix: args&.prefix + ).seed + puts "Task finished!" + end + end +end diff --git a/spec/graphql/resolvers/clusters/agents/authorizations/ci_access_resolver_spec.rb b/spec/graphql/resolvers/clusters/agents/authorizations/ci_access_resolver_spec.rb new file mode 100644 index 00000000000..b5280365794 --- /dev/null +++ b/spec/graphql/resolvers/clusters/agents/authorizations/ci_access_resolver_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::Clusters::Agents::Authorizations::CiAccessResolver, + feature_category: :deployment_management do + include GraphqlHelpers + + it { expect(described_class.type).to eq(Types::Clusters::Agents::Authorizations::CiAccessType) } + it { expect(described_class.null).to be_truthy } + + describe '#resolve' do + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user, maintainer_projects: [project]) } + + let(:ctx) { { current_user: user } } + + subject { resolve(described_class, obj: project, ctx: ctx) } + + it 'calls the finder' do + expect_next_instance_of(::Clusters::Agents::Authorizations::CiAccess::Finder, project) do |finder| + expect(finder).to receive(:execute) + end + + subject + end + end +end diff --git a/spec/graphql/types/clusters/agents/authorizations/ci_access_type_spec.rb b/spec/graphql/types/clusters/agents/authorizations/ci_access_type_spec.rb new file mode 100644 index 00000000000..17725ec11e0 --- /dev/null +++ b/spec/graphql/types/clusters/agents/authorizations/ci_access_type_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['ClusterAgentAuthorizationCiAccess'], + feature_category: :deployment_management do + let(:fields) { %i[agent config] } + + it { expect(described_class.graphql_name).to eq('ClusterAgentAuthorizationCiAccess') } + it { expect(described_class).to have_graphql_fields(fields) } +end diff --git a/spec/graphql/types/project_type_spec.rb b/spec/graphql/types/project_type_spec.rb index 2e42cd2daad..4cae970de64 100644 --- a/spec/graphql/types/project_type_spec.rb +++ b/spec/graphql/types/project_type_spec.rb @@ -34,7 +34,7 @@ RSpec.describe GitlabSchema.types['Project'] do issue_status_counts terraform_states alert_management_integrations container_repositories container_repositories_count pipeline_analytics squash_read_only sast_ci_configuration - cluster_agent cluster_agents agent_configurations + cluster_agent cluster_agents agent_configurations ci_access_authorized_agents ci_template timelogs merge_commit_template squash_commit_template work_item_types recent_issue_boards ci_config_path_or_default packages_cleanup_policy ci_variables timelog_categories fork_targets branch_rules ci_config_variables pipeline_schedules languages diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 66b57deb643..658e20608b1 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -508,6 +508,7 @@ project: - cluster - clusters - cluster_agents +- ci_access_project_authorizations - cluster_project - creator - cycle_analytics_stages diff --git a/spec/lib/gitlab/seeders/ci/variables_group_seeder_spec.rb b/spec/lib/gitlab/seeders/ci/variables_group_seeder_spec.rb new file mode 100644 index 00000000000..52898cb17a5 --- /dev/null +++ b/spec/lib/gitlab/seeders/ci/variables_group_seeder_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Gitlab::Seeders::Ci::VariablesGroupSeeder, feature_category: :secrets_management do + let_it_be(:group) { create(:group) } + + let(:seeder) { described_class.new(name: group.name) } + + let(:custom_seeder) do + described_class.new( + name: group.name, + seed_count: 2, + environment_scope: 'staging', + prefix: 'STAGING_' + ) + end + + let(:unique_env_seeder) do + described_class.new( + name: group.name, + seed_count: 2, + environment_scope: 'unique' + ) + end + + let(:invalid_group_name_seeder) do + described_class.new( + name: 'nonexistent_group', + seed_count: 1 + ) + end + + describe '#seed' do + it 'creates group-level CI variables with default values' do + expect { seeder.seed }.to change { + group.variables.count + }.by(Gitlab::Seeders::Ci::VariablesGroupSeeder::DEFAULT_SEED_COUNT) + + ci_variable = group.reload.variables.last + + expect(ci_variable.key.include?('GROUP_VAR_')).to eq true + expect(ci_variable.environment_scope).to eq '*' + end + + it 'creates group-level CI variables with custom arguments' do + expect { custom_seeder.seed }.to change { + group.variables.count + }.by(2) + + ci_variable = group.reload.variables.last + + expect(ci_variable.key.include?('STAGING_')).to eq true + expect(ci_variable.environment_scope).to eq 'staging' + end + + it 'creates group-level CI variables with unique environment scopes' do + unique_env_seeder.seed + + ci_variable_first_env = group.reload.variables.first.environment_scope + ci_variable_last_env = group.reload.variables.last.environment_scope + + expect(ci_variable_first_env).not_to eq ci_variable_last_env + end + + it 'skips seeding when group name is invalid' do + expect { invalid_group_name_seeder.seed }.to change { + group.variables.count + }.by(0) + end + + it 'skips CI variable creation if CI variable already exists' do + group.variables.create!( + environment_scope: '*', + key: "GROUP_VAR_#{group.variables.maximum(:id).to_i}", + value: SecureRandom.hex(32) + ) + + # first id is assigned randomly, so we're creating a new variable + # based on that id that is sure to be skipped during seed + group.variables.create!( + environment_scope: '*', + key: "GROUP_VAR_#{group.variables.maximum(:id).to_i + 2}", + value: SecureRandom.hex(32) + ) + + expect { seeder.seed }.to change { + group.variables.count + }.by(Gitlab::Seeders::Ci::VariablesGroupSeeder::DEFAULT_SEED_COUNT - 1) + end + end +end diff --git a/spec/lib/gitlab/seeders/ci/variables_instance_seeder_spec.rb b/spec/lib/gitlab/seeders/ci/variables_instance_seeder_spec.rb new file mode 100644 index 00000000000..5b6d2471edd --- /dev/null +++ b/spec/lib/gitlab/seeders/ci/variables_instance_seeder_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Gitlab::Seeders::Ci::VariablesInstanceSeeder, feature_category: :secrets_management do + let(:seeder) { described_class.new } + + let(:custom_seeder) do + described_class.new( + seed_count: 2, + prefix: 'STAGING_' + ) + end + + describe '#seed' do + it 'creates instance-level CI variables with default values' do + expect { seeder.seed }.to change { + Ci::InstanceVariable.all.count + }.by(Gitlab::Seeders::Ci::VariablesInstanceSeeder::DEFAULT_SEED_COUNT) + + ci_variable = Ci::InstanceVariable.last + + expect(ci_variable.key.include?('INSTANCE_VAR_')).to eq true + end + + it 'creates instance-level CI variables with custom arguments' do + expect { custom_seeder.seed }.to change { + Ci::InstanceVariable.all.count + }.by(2) + + ci_variable = Ci::InstanceVariable.last + + expect(ci_variable.key.include?('STAGING_')).to eq true + end + + it 'skips CI variable creation if CI variable already exists' do + ::Ci::InstanceVariable.new( + key: "INSTANCE_VAR_#{::Ci::InstanceVariable.maximum(:id).to_i}", + value: SecureRandom.hex(32) + ).save! + + # first id is assigned randomly, so we're creating a new variable + # based on that id that is sure to be skipped during seed + ::Ci::InstanceVariable.new( + key: "INSTANCE_VAR_#{::Ci::InstanceVariable.maximum(:id).to_i + 2}", + value: SecureRandom.hex(32) + ).save! + + expect { seeder.seed }.to change { + Ci::InstanceVariable.all.count + }.by(Gitlab::Seeders::Ci::VariablesInstanceSeeder::DEFAULT_SEED_COUNT - 1) + end + end +end diff --git a/spec/lib/gitlab/seeders/ci/variables_project_seeder_spec.rb b/spec/lib/gitlab/seeders/ci/variables_project_seeder_spec.rb new file mode 100644 index 00000000000..45b6a0a51fd --- /dev/null +++ b/spec/lib/gitlab/seeders/ci/variables_project_seeder_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Gitlab::Seeders::Ci::VariablesProjectSeeder, feature_category: :secrets_management do + let_it_be(:project) { create(:project) } + + let(:seeder) { described_class.new(project_path: project.full_path) } + + let(:custom_seeder) do + described_class.new( + project_path: project.full_path, + seed_count: 2, + environment_scope: 'staging', + prefix: 'STAGING_' + ) + end + + let(:unique_env_seeder) do + described_class.new( + project_path: project.full_path, + seed_count: 2, + environment_scope: 'unique' + ) + end + + let(:invalid_project_path_seeder) do + described_class.new( + project_path: 'invalid_path', + seed_count: 1 + ) + end + + describe '#seed' do + it 'creates project-level CI variables with default values' do + expect { seeder.seed }.to change { + project.variables.count + }.by(Gitlab::Seeders::Ci::VariablesProjectSeeder::DEFAULT_SEED_COUNT) + + ci_variable = project.reload.variables.last + + expect(ci_variable.key.include?('VAR_')).to eq true + expect(ci_variable.environment_scope).to eq '*' + end + + it 'creates project-level CI variables with custom arguments' do + expect { custom_seeder.seed }.to change { + project.variables.count + }.by(2) + + ci_variable = project.reload.variables.last + + expect(ci_variable.key.include?('STAGING_')).to eq true + expect(ci_variable.environment_scope).to eq 'staging' + end + + it 'creates project-level CI variables with unique environment scopes' do + unique_env_seeder.seed + + ci_variable_first_env = project.reload.variables.first.environment_scope + ci_variable_last_env = project.reload.variables.last.environment_scope + + expect(ci_variable_first_env).not_to eq ci_variable_last_env + end + + it 'skips seeding when project path is invalid' do + expect { invalid_project_path_seeder.seed }.to change { + project.variables.count + }.by(0) + end + + it 'skips CI variable creation if CI variable already exists' do + project.variables.create!( + environment_scope: '*', + key: "VAR_#{project.variables.maximum(:id).to_i}", + value: SecureRandom.hex(32) + ) + + # first id is assigned randomly, so we're creating a new variable + # based on that id that is sure to be skipped during seed + project.variables.create!( + environment_scope: '*', + key: "VAR_#{project.variables.maximum(:id).to_i + 2}", + value: SecureRandom.hex(32) + ) + + expect { seeder.seed }.to change { + project.variables.count + }.by(Gitlab::Seeders::Ci::VariablesProjectSeeder::DEFAULT_SEED_COUNT - 1) + end + end +end diff --git a/spec/lib/gitlab/seeders/project_environment_seeder_spec.rb b/spec/lib/gitlab/seeders/project_environment_seeder_spec.rb new file mode 100644 index 00000000000..8401d189373 --- /dev/null +++ b/spec/lib/gitlab/seeders/project_environment_seeder_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Gitlab::Seeders::ProjectEnvironmentSeeder, feature_category: :secrets_management do + let_it_be(:project) { create(:project) } + + let(:seeder) { described_class.new(project_path: project.full_path) } + let(:custom_seeder) do + described_class.new(project_path: project.full_path, seed_count: 2, prefix: 'staging_') + end + + let(:invalid_project_path_seeder) do + described_class.new(project_path: 'invalid_path', seed_count: 1) + end + + describe '#seed' do + it 'creates environments for the project' do + expect { seeder.seed }.to change { + project.environments.count + }.by(Gitlab::Seeders::ProjectEnvironmentSeeder::DEFAULT_SEED_COUNT) + end + + it 'creates environments with custom arguments' do + expect { custom_seeder.seed }.to change { + project.environments.count + }.by(2) + + env = project.environments.last + + expect(env.name.include?('staging_')).to eq true + end + + it 'skips seeding when project path is invalid' do + expect { invalid_project_path_seeder.seed }.to change { + project.environments.count + }.by(0) + end + + it 'skips environment creation if environment already exists' do + project.environments.create!(name: "ENV_#{project.environments.maximum(:id).to_i}") + + # first id is assigned randomly, so we're creating a new variable + # based on that id that is sure to be skipped during seed + project.environments.create!(name: "ENV_#{project.environments.maximum(:id).to_i + 2}") + + expect { seeder.seed }.to change { + project.environments.count + }.by(Gitlab::Seeders::ProjectEnvironmentSeeder::DEFAULT_SEED_COUNT - 1) + end + end +end diff --git a/spec/models/clusters/agent_spec.rb b/spec/models/clusters/agent_spec.rb index df8ad861aff..2c560b992c1 100644 --- a/spec/models/clusters/agent_spec.rb +++ b/spec/models/clusters/agent_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Clusters::Agent do +RSpec.describe Clusters::Agent, feature_category: :deployment_management do subject { create(:cluster_agent) } it { is_expected.to belong_to(:created_by_user).class_name('User').optional } @@ -163,4 +163,75 @@ RSpec.describe Clusters::Agent do it { is_expected.to be_like_time(event2.recorded_at) } end + + describe '#ci_access_authorized_for?' do + using RSpec::Parameterized::TableSyntax + + let_it_be(:organization) { create(:group) } + let_it_be(:agent_management_project) { create(:project, group: organization) } + let_it_be(:agent) { create(:cluster_agent, project: agent_management_project) } + let_it_be(:deployment_project) { create(:project, group: organization) } + + let(:user) { create(:user) } + + subject { agent.ci_access_authorized_for?(user) } + + it { is_expected.to eq(false) } + + context 'with project-level authorization' do + let!(:authorization) { create(:agent_ci_access_project_authorization, agent: agent, project: deployment_project) } + + where(:user_role, :allowed) do + :guest | false + :reporter | false + :developer | true + :maintainer | true + :owner | true + end + + with_them do + before do + deployment_project.add_member(user, user_role) + end + + it { is_expected.to eq(allowed) } + end + + context 'when expose_authorized_cluster_agents feature flag is disabled' do + before do + stub_feature_flags(expose_authorized_cluster_agents: false) + end + + it { is_expected.to eq(false) } + end + end + + context 'with group-level authorization' do + let!(:authorization) { create(:agent_ci_access_group_authorization, agent: agent, group: organization) } + + where(:user_role, :allowed) do + :guest | false + :reporter | false + :developer | true + :maintainer | true + :owner | true + end + + with_them do + before do + organization.add_member(user, user_role) + end + + it { is_expected.to eq(allowed) } + end + + context 'when expose_authorized_cluster_agents feature flag is disabled' do + before do + stub_feature_flags(expose_authorized_cluster_agents: false) + end + + it { is_expected.to eq(false) } + end + end + end end diff --git a/spec/models/environment_status_spec.rb b/spec/models/environment_status_spec.rb index 2f1edf9ab94..cb14d98da72 100644 --- a/spec/models/environment_status_spec.rb +++ b/spec/models/environment_status_spec.rb @@ -265,6 +265,7 @@ RSpec.describe EnvironmentStatus do context 'when environment is stopped' do before do + stub_feature_flags(review_apps_redeploy_mr_widget: false) environment.stop! end @@ -272,6 +273,17 @@ RSpec.describe EnvironmentStatus do expect(subject.count).to eq(0) end end + + context 'when environment is stopped and review_apps_redeploy_mr_widget is turned on' do + before do + stub_feature_flags(review_apps_redeploy_mr_widget: true) + environment.stop! + end + + it 'returns environment regardless of status' do + expect(subject.count).to eq(1) + end + end end end end diff --git a/spec/policies/clusters/agent_policy_spec.rb b/spec/policies/clusters/agent_policy_spec.rb index 8f778d318ed..200cb8ae99b 100644 --- a/spec/policies/clusters/agent_policy_spec.rb +++ b/spec/policies/clusters/agent_policy_spec.rb @@ -24,5 +24,13 @@ RSpec.describe Clusters::AgentPolicy do it { expect(policy).to be_allowed :admin_cluster } end + + context 'when agent is ci_access authorized for project members' do + before do + allow(cluster_agent).to receive(:ci_access_authorized_for?).with(user).and_return(true) + end + + it { expect(policy).to be_allowed :read_cluster_agent } + end end end diff --git a/spec/requests/api/graphql/project/ci_access_authorized_agents_spec.rb b/spec/requests/api/graphql/project/ci_access_authorized_agents_spec.rb new file mode 100644 index 00000000000..dd76f6425fe --- /dev/null +++ b/spec/requests/api/graphql/project/ci_access_authorized_agents_spec.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Project.ci_access_authorized_agents', feature_category: :deployment_management do + include GraphqlHelpers + + let_it_be(:organization) { create(:group) } + let_it_be(:agent_management_project) { create(:project, :private, group: organization) } + let_it_be(:agent) { create(:cluster_agent, project: agent_management_project) } + + let_it_be(:deployment_project) { create(:project, :private, group: organization) } + let_it_be(:deployment_developer) { create(:user).tap { |u| deployment_project.add_developer(u) } } + let_it_be(:deployment_reporter) { create(:user).tap { |u| deployment_project.add_reporter(u) } } + + let(:user) { deployment_developer } + + let(:query) do + %( + query { + project(fullPath: "#{deployment_project.full_path}") { + ciAccessAuthorizedAgents { + nodes { + agent { + id + name + project { + name + } + } + config + } + } + } + } + ) + end + + subject { GitlabSchema.execute(query, context: { current_user: user }).as_json } + + context 'with project authorization' do + let!(:ci_access) { create(:agent_ci_access_project_authorization, agent: agent, project: deployment_project) } + + it 'returns the authorized agent' do + authorized_agents = subject.dig('data', 'project', 'ciAccessAuthorizedAgents', 'nodes') + + expect(authorized_agents.count).to eq(1) + + authorized_agent = authorized_agents.first + + expect(authorized_agent['agent']['id']).to eq(agent.to_global_id.to_s) + expect(authorized_agent['agent']['name']).to eq(agent.name) + expect(authorized_agent['config']).to eq({ "default_namespace" => "production" }) + expect(authorized_agent['agent']['project']).to be_nil # User is not authorized to read other resources. + end + + context 'when user is developer in the agent management project' do + before do + agent_management_project.add_developer(deployment_developer) + end + + it 'returns the project information as well' do + authorized_agent = subject.dig('data', 'project', 'ciAccessAuthorizedAgents', 'nodes').first + + expect(authorized_agent['agent']['project']['name']).to eq(agent_management_project.name) + end + end + + context 'when user is reporter' do + let(:user) { deployment_reporter } + + it 'returns nothing' do + expect(subject['data']['project']['ciAccessAuthorizedAgents']).to be_nil + end + end + end + + context 'with group authorization' do + let!(:ci_access) { create(:agent_ci_access_group_authorization, agent: agent, group: organization) } + + it 'returns the authorized agent' do + authorized_agents = subject.dig('data', 'project', 'ciAccessAuthorizedAgents', 'nodes') + + expect(authorized_agents.count).to eq(1) + + authorized_agent = authorized_agents.first + + expect(authorized_agent['agent']['id']).to eq(agent.to_global_id.to_s) + expect(authorized_agent['agent']['name']).to eq(agent.name) + expect(authorized_agent['config']).to eq({ "default_namespace" => "production" }) + expect(authorized_agent['agent']['project']).to be_nil # User is not authorized to read other resources. + end + + context 'when user is developer in the agent management project' do + before do + agent_management_project.add_developer(deployment_developer) + end + + it 'returns the project information as well' do + authorized_agent = subject.dig('data', 'project', 'ciAccessAuthorizedAgents', 'nodes').first + + expect(authorized_agent['agent']['project']['name']).to eq(agent_management_project.name) + end + end + + context 'when user is reporter' do + let(:user) { deployment_reporter } + + it 'returns nothing' do + expect(subject['data']['project']['ciAccessAuthorizedAgents']).to be_nil + end + end + end + + context 'when deployment project is not authorized to ci_access to the agent' do + it 'returns empty' do + authorized_agents = subject.dig('data', 'project', 'ciAccessAuthorizedAgents', 'nodes') + + expect(authorized_agents).to be_empty + end + end +end diff --git a/spec/support/rspec_order_todo.yml b/spec/support/rspec_order_todo.yml index c81c5c41844..3b35b43d25c 100644 --- a/spec/support/rspec_order_todo.yml +++ b/spec/support/rspec_order_todo.yml @@ -2713,7 +2713,6 @@ - './ee/spec/services/epics/related_epic_links/list_service_spec.rb' - './ee/spec/services/epics/reopen_service_spec.rb' - './ee/spec/services/epics/transfer_service_spec.rb' -- './ee/spec/services/epics/tree_reorder_service_spec.rb' - './ee/spec/services/epics/update_dates_service_spec.rb' - './ee/spec/services/epics/update_service_spec.rb' - './ee/spec/services/external_status_checks/create_service_spec.rb' |