From 9bc0e5ddf7094947790d12ca7be336b40b91b331 Mon Sep 17 00:00:00 2001 From: Dmytro Biryukov <dbiryukov@gitlab.com> Date: Wed, 14 Jun 2023 23:07:39 +0000 Subject: [PATCH] Add GraphQL resolver, query to fetch group environment scopes --- .../groups/environment_scopes_finder.rb | 50 ++++++++++++++ .../group_environment_scopes_resolver.rb | 23 +++++++ ...group_environment_scope_connection_type.rb | 10 +++ .../types/ci/group_environment_scope_type.rb | 18 +++++ app/graphql/types/group_type.rb | 6 ++ app/models/ci/group_variable.rb | 13 ++++ doc/api/graphql/reference/index.md | 50 ++++++++++++++ .../groups/environment_scopes_finder_spec.rb | 48 +++++++++++++ .../group_environment_scopes_resolver_spec.rb | 45 ++++++++++++ .../ci/group_environment_scope_type_spec.rb | 11 +++ spec/models/ci/group_variable_spec.rb | 30 ++++++++ .../ci/group_environment_scopes_spec.rb | 68 +++++++++++++++++++ spec/support/finder_collection_allowlist.yml | 1 + 13 files changed, 373 insertions(+) create mode 100644 app/finders/groups/environment_scopes_finder.rb create mode 100644 app/graphql/resolvers/group_environment_scopes_resolver.rb create mode 100644 app/graphql/types/ci/group_environment_scope_connection_type.rb create mode 100644 app/graphql/types/ci/group_environment_scope_type.rb create mode 100644 spec/finders/groups/environment_scopes_finder_spec.rb create mode 100644 spec/graphql/resolvers/group_environment_scopes_resolver_spec.rb create mode 100644 spec/graphql/types/ci/group_environment_scope_type_spec.rb create mode 100644 spec/requests/api/graphql/ci/group_environment_scopes_spec.rb diff --git a/app/finders/groups/environment_scopes_finder.rb b/app/finders/groups/environment_scopes_finder.rb new file mode 100644 index 0000000000000..886be7881ee76 --- /dev/null +++ b/app/finders/groups/environment_scopes_finder.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +# Groups::EnvironmentsScopesFinder +# +# Arguments: +# group +# params: +# search: string +# +module Groups + class EnvironmentScopesFinder + DEFAULT_ENVIRONMENT_SCOPES_LIMIT = 100 + + def initialize(group:, params: {}) + @group = group + @params = params + end + + EnvironmentScope = Struct.new(:name) + + def execute + variables = group.variables + variables = by_name(variables) + variables = by_search(variables) + variables = variables.limit(DEFAULT_ENVIRONMENT_SCOPES_LIMIT) + environment_scope_names = variables.environment_scope_names + environment_scope_names.map { |environment_scope| EnvironmentScope.new(environment_scope) } + end + + private + + attr_reader :group, :params + + def by_name(group_variables) + if params[:name].present? + group_variables.by_environment_scope(params[:name]) + else + group_variables + end + end + + def by_search(group_variables) + if params[:search].present? + group_variables.for_environment_scope_like(params[:search]) + else + group_variables + end + end + end +end diff --git a/app/graphql/resolvers/group_environment_scopes_resolver.rb b/app/graphql/resolvers/group_environment_scopes_resolver.rb new file mode 100644 index 0000000000000..61ccb2eefbba6 --- /dev/null +++ b/app/graphql/resolvers/group_environment_scopes_resolver.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Resolvers + class GroupEnvironmentScopesResolver < BaseResolver + type Types::Ci::GroupEnvironmentScopeType.connection_type, null: true + + alias_method :group, :object + + argument :name, GraphQL::Types::String, + required: false, + description: 'Name of the environment scope.' + + argument :search, GraphQL::Types::String, + required: false, + description: 'Search query for environment scope name.' + + def resolve(**args) + return unless group.present? + + ::Groups::EnvironmentScopesFinder.new(group: group, params: args).execute + end + end +end diff --git a/app/graphql/types/ci/group_environment_scope_connection_type.rb b/app/graphql/types/ci/group_environment_scope_connection_type.rb new file mode 100644 index 0000000000000..ddbc00d3870cc --- /dev/null +++ b/app/graphql/types/ci/group_environment_scope_connection_type.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop: disable Graphql/AuthorizeTypes + class GroupEnvironmentScopeConnectionType < GraphQL::Types::Relay::BaseConnection + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/ci/group_environment_scope_type.rb b/app/graphql/types/ci/group_environment_scope_type.rb new file mode 100644 index 0000000000000..3a3a5a3f59feb --- /dev/null +++ b/app/graphql/types/ci/group_environment_scope_type.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop: disable Graphql/AuthorizeTypes + class GroupEnvironmentScopeType < BaseObject + graphql_name 'CiGroupEnvironmentScope' + description 'Ci/CD environment scope for a group.' + + connection_type_class(Types::Ci::GroupEnvironmentScopeConnectionType) + + field :name, GraphQL::Types::String, + null: true, + description: 'Scope name defininig the enviromnments that can use the variable.' + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb index 24326c131ce09..5fd6ee948d3d7 100644 --- a/app/graphql/types/group_type.rb +++ b/app/graphql/types/group_type.rb @@ -83,6 +83,12 @@ class GroupType < NamespaceType description: 'Merge requests for projects in this group.', resolver: Resolvers::GroupMergeRequestsResolver + field :environment_scopes, + Types::Ci::GroupEnvironmentScopeType.connection_type, + description: 'Environment scopes of the group.', + null: true, + resolver: Resolvers::GroupEnvironmentScopesResolver + field :milestones, description: 'Milestones of the group.', extras: [:lookahead], diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb index f04f0d27e512e..5522a01758f0b 100644 --- a/app/models/ci/group_variable.rb +++ b/app/models/ci/group_variable.rb @@ -23,6 +23,19 @@ class GroupVariable < Ci::ApplicationRecord scope :by_environment_scope, -> (environment_scope) { where(environment_scope: environment_scope) } scope :for_groups, ->(group_ids) { where(group_id: group_ids) } + scope :for_environment_scope_like, -> (query) do + top_level = 'LOWER(ci_group_variables.environment_scope) LIKE LOWER(?) || \'%\'' + search_like = "%#{sanitize_sql_like(query)}%" + + where(top_level, search_like) + end + + scope :environment_scope_names, -> do + group(:environment_scope) + .order(:environment_scope) + .pluck(:environment_scope) + end + self.limit_name = 'group_ci_variables' self.limit_scope = :group diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index c16748ec35ffd..cc0e45b76eabb 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -7784,6 +7784,29 @@ The edge type for [`CiGroup`](#cigroup). | <a id="cigroupedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. | | <a id="cigroupedgenode"></a>`node` | [`CiGroup`](#cigroup) | The item at the end of the edge. | +#### `CiGroupEnvironmentScopeConnection` + +The connection type for [`CiGroupEnvironmentScope`](#cigroupenvironmentscope). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="cigroupenvironmentscopeconnectionedges"></a>`edges` | [`[CiGroupEnvironmentScopeEdge]`](#cigroupenvironmentscopeedge) | A list of edges. | +| <a id="cigroupenvironmentscopeconnectionnodes"></a>`nodes` | [`[CiGroupEnvironmentScope]`](#cigroupenvironmentscope) | A list of nodes. | +| <a id="cigroupenvironmentscopeconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. | + +#### `CiGroupEnvironmentScopeEdge` + +The edge type for [`CiGroupEnvironmentScope`](#cigroupenvironmentscope). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="cigroupenvironmentscopeedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. | +| <a id="cigroupenvironmentscopeedgenode"></a>`node` | [`CiGroupEnvironmentScope`](#cigroupenvironmentscope) | The item at the end of the edge. | + #### `CiGroupVariableConnection` The connection type for [`CiGroupVariable`](#cigroupvariable). @@ -12719,6 +12742,16 @@ Represents a deployment freeze window of a project. | <a id="cigroupname"></a>`name` | [`String`](#string) | Name of the job group. | | <a id="cigroupsize"></a>`size` | [`Int`](#int) | Size of the group. | +### `CiGroupEnvironmentScope` + +Ci/CD environment scope for a group. + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="cigroupenvironmentscopename"></a>`name` | [`String`](#string) | Scope name defininig the enviromnments that can use the variable. | + ### `CiGroupVariable` CI/CD variables for a group. @@ -15866,6 +15899,23 @@ four standard [pagination arguments](#connection-pagination-arguments): | <a id="groupdescendantgroupsowned"></a>`owned` | [`Boolean`](#boolean) | Limit result to groups owned by authenticated user. | | <a id="groupdescendantgroupssearch"></a>`search` | [`String`](#string) | Search query for group name or group full path. | +##### `Group.environmentScopes` + +Environment scopes of the group. + +Returns [`CiGroupEnvironmentScopeConnection`](#cigroupenvironmentscopeconnection). + +This field returns a [connection](#connections). It accepts the +four standard [pagination arguments](#connection-pagination-arguments): +`before: String`, `after: String`, `first: Int`, `last: Int`. + +###### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="groupenvironmentscopesname"></a>`name` | [`String`](#string) | Name of the environment scope. | +| <a id="groupenvironmentscopessearch"></a>`search` | [`String`](#string) | Search query for environment scope name. | + ##### `Group.epic` Find a single epic. diff --git a/spec/finders/groups/environment_scopes_finder_spec.rb b/spec/finders/groups/environment_scopes_finder_spec.rb new file mode 100644 index 0000000000000..dfa32725e4afc --- /dev/null +++ b/spec/finders/groups/environment_scopes_finder_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Groups::EnvironmentScopesFinder, feature_category: :secrets_management do + describe '#execute' do + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group, :public) } + + let!(:environment1) { create(:ci_group_variable, group: group, key: 'var1', environment_scope: 'environment1') } + let!(:environment2) { create(:ci_group_variable, group: group, key: 'var2', environment_scope: 'environment2') } + let!(:environment3) { create(:ci_group_variable, group: group, key: 'var2', environment_scope: 'environment3') } + let(:finder) { described_class.new(group: group, params: params) } + + subject { finder.execute } + + context 'with default no arguments' do + let(:params) { {} } + + it do + expected_result = group.variables.environment_scope_names + + expect(subject.map(&:name)) + .to match_array(expected_result) + end + end + + context 'with search' do + let(:params) { { search: 'ment1' } } + + it do + expected_result = ['environment1'] + + expect(subject.map(&:name)) + .to match_array(expected_result) + end + end + + context 'with specific name' do + let(:params) { { name: 'environment3' } } + + it do + expect(subject.map(&:name)) + .to match_array([environment3.environment_scope]) + end + end + end +end diff --git a/spec/graphql/resolvers/group_environment_scopes_resolver_spec.rb b/spec/graphql/resolvers/group_environment_scopes_resolver_spec.rb new file mode 100644 index 0000000000000..71561137356f8 --- /dev/null +++ b/spec/graphql/resolvers/group_environment_scopes_resolver_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::GroupEnvironmentScopesResolver, feature_category: :secrets_management do + include GraphqlHelpers + + let_it_be(:current_user) { create(:user) } + let(:group) { create(:group) } + + context "with a group" do + let(:expected_environment_scopes) do + %w[environment1 environment2 environment3 environment4 environment5 environment6] + end + + before do + group.add_developer(current_user) + expected_environment_scopes.each_with_index do |env, index| + create(:ci_group_variable, group: group, key: "var#{index + 1}", environment_scope: env) + end + end + + describe '#resolve' do + it 'finds all environment scopes' do + expect(resolve_environment_scopes.map(&:name)).to match_array( + expected_environment_scopes + ) + end + end + end + + context 'without a group' do + describe '#resolve' do + it 'rails to find any environment scopes' do + expect(resolve_environment_scopes.map(&:name)).to match_array( + [] + ) + end + end + end + + def resolve_environment_scopes(args = {}, context = { current_user: current_user }) + resolve(described_class, obj: group, args: args, ctx: context) + end +end diff --git a/spec/graphql/types/ci/group_environment_scope_type_spec.rb b/spec/graphql/types/ci/group_environment_scope_type_spec.rb new file mode 100644 index 0000000000000..3e3f52ca4bb16 --- /dev/null +++ b/spec/graphql/types/ci/group_environment_scope_type_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['CiGroupEnvironmentScope'], feature_category: :secrets_management do + specify do + expect(described_class).to have_graphql_fields( + :name + ).at_least + end +end diff --git a/spec/models/ci/group_variable_spec.rb b/spec/models/ci/group_variable_spec.rb index a2751b9fb2040..5a8a2b391e179 100644 --- a/spec/models/ci/group_variable_spec.rb +++ b/spec/models/ci/group_variable_spec.rb @@ -54,6 +54,36 @@ it { expect(described_class.for_groups([group.id])).to eq([group_variable]) } end + describe '.for_environment_scope_like' do + let_it_be(:group) { create(:group) } + let_it_be(:variable1_on_staging1) { create(:ci_group_variable, group: group, environment_scope: 'staging1') } + let_it_be(:variable2_on_staging2) { create(:ci_group_variable, group: group, environment_scope: 'staging2') } + let_it_be(:variable3_on_production) { create(:ci_group_variable, group: group, environment_scope: 'production') } + + it { + expect(described_class.for_environment_scope_like('staging')) + .to match_array([variable1_on_staging1, variable2_on_staging2]) + } + + it { + expect(described_class.for_environment_scope_like('production')) + .to match_array([variable3_on_production]) + } + end + + describe '.environment_scope_names' do + let_it_be(:group) { create(:group) } + let_it_be(:variable1_on_staging1) { create(:ci_group_variable, group: group, environment_scope: 'staging1') } + let_it_be(:variable2_on_staging2) { create(:ci_group_variable, group: group, environment_scope: 'staging2') } + let_it_be(:variable3_on_staging2) { create(:ci_group_variable, group: group, environment_scope: 'staging2') } + let_it_be(:variable4_on_production) { create(:ci_group_variable, group: group, environment_scope: 'production') } + + it 'groups and orders' do + expect(described_class.environment_scope_names) + .to match_array(%w[production staging1 staging2]) + end + end + it_behaves_like 'cleanup by a loose foreign key' do let!(:model) { create(:ci_group_variable) } diff --git a/spec/requests/api/graphql/ci/group_environment_scopes_spec.rb b/spec/requests/api/graphql/ci/group_environment_scopes_spec.rb new file mode 100644 index 0000000000000..13a3a128979ff --- /dev/null +++ b/spec/requests/api/graphql/ci/group_environment_scopes_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Query.group(fullPath).environmentScopes', feature_category: :secrets_management do + include GraphqlHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let(:expected_environment_scopes) do + %w[ + group1_environment1 + group1_environment2 + group2_environment3 + group2_environment4 + group2_environment5 + group2_environment6 + ] + end + + let(:query) do + %( + query { + group(fullPath: "#{group.full_path}") { + environmentScopes#{environment_scopes_params} { + nodes { + name + } + } + } + } + ) + end + + before do + group.add_developer(user) + expected_environment_scopes.each_with_index do |env, index| + create(:ci_group_variable, group: group, key: "var#{index + 1}", environment_scope: env) + end + end + + context 'when query has no parameters' do + let(:environment_scopes_params) { "" } + + it 'returns all avaiable environment scopes' do + post_graphql(query, current_user: user) + + expect(graphql_data.dig('group', 'environmentScopes', 'nodes')).to eq( + expected_environment_scopes.map { |env_scope| { 'name' => env_scope } } + ) + end + end + + context 'when query has search parameters' do + let(:environment_scopes_params) { "(search: \"group1\")" } + + it 'returns only environment scopes with group1 prefix' do + post_graphql(query, current_user: user) + + expect(graphql_data.dig('group', 'environmentScopes', 'nodes')).to eq( + [ + { 'name' => 'group1_environment1' }, + { 'name' => 'group1_environment2' } + ] + ) + end + end +end diff --git a/spec/support/finder_collection_allowlist.yml b/spec/support/finder_collection_allowlist.yml index 8fcb4ee7b9c2a..7ac7e88867a84 100644 --- a/spec/support/finder_collection_allowlist.yml +++ b/spec/support/finder_collection_allowlist.yml @@ -5,6 +5,7 @@ # FooFinder # Reason: It uses a memory backend - Namespaces::BilledUsersFinder # Reason: There is no need to have anything else besides the ids is current structure - Namespaces::FreeUserCap::UsersFinder # Reason: There is no need to have anything else besides the count +- Groups::EnvironmentScopesFinder # Reason: There is no need to have anything else besides the simple strucutre with the scope name # Temporary excludes (aka TODOs) # For example: -- GitLab