diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 092a40cb1862987c4b8cb33562fa5a8c45ff55a7..c9198be8ca66f7b8615cca6e9e35f3c19925ed7b 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -21782,6 +21782,22 @@ four standard [pagination arguments](#pagination-arguments): | ---- | ---- | ----------- | | `sort` | [`GroupReleaseSort`](#groupreleasesort) | Sort group releases by given criteria. | +##### `Group.remoteDevelopmentClusterAgents` + +Cluster agents in the namespace with remote development capabilities. + +Returns [`ClusterAgentConnection`](#clusteragentconnection). + +This field returns a [connection](#connections). It accepts the +four standard [pagination arguments](#pagination-arguments): +`before: String`, `after: String`, `first: Int`, and `last: Int`. + +###### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `filter` | [`NamespaceClusterAgentFilter!`](#namespaceclusteragentfilter) | Filter the types of cluster agents to return. | + ##### `Group.runnerCloudProvisioning` Information used for provisioning the runner on a cloud provider. Returns `null` if `:google_cloud_support_feature_flag` feature flag is disabled, or the GitLab instance is not a SaaS instance. @@ -25102,6 +25118,22 @@ four standard [pagination arguments](#pagination-arguments): | `withIssuesEnabled` | [`Boolean`](#boolean) | Return only projects with issues enabled. | | `withMergeRequestsEnabled` | [`Boolean`](#boolean) | Return only projects with merge requests enabled. | +##### `Namespace.remoteDevelopmentClusterAgents` + +Cluster agents in the namespace with remote development capabilities. + +Returns [`ClusterAgentConnection`](#clusteragentconnection). + +This field returns a [connection](#connections). It accepts the +four standard [pagination arguments](#pagination-arguments): +`before: String`, `after: String`, `first: Int`, and `last: Int`. + +###### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `filter` | [`NamespaceClusterAgentFilter!`](#namespaceclusteragentfilter) | Filter the types of cluster agents to return. | + ##### `Namespace.scanExecutionPolicies` Scan Execution Policies of the namespace. @@ -33390,6 +33422,14 @@ Different toggles for changing mutator behavior. | `REMOVE` | Performs a removal operation. | | `REPLACE` | Performs a replace operation. | +### `NamespaceClusterAgentFilter` + +Possible filter types for remote development cluster agents in a namespace. + +| Value | Description | +| ----- | ----------- | +| `AVAILABLE` | Cluster agents in the namespace that can be used for hosting workspaces. | + ### `NamespaceProjectSort` Values for sorting projects. diff --git a/ee/app/finders/remote_development/cluster_agents_finder.rb b/ee/app/finders/remote_development/cluster_agents_finder.rb new file mode 100644 index 0000000000000000000000000000000000000000..8b74eb76b037937205127be4a059da4d6833c543 --- /dev/null +++ b/ee/app/finders/remote_development/cluster_agents_finder.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module RemoteDevelopment + class ClusterAgentsFinder + def self.execute(namespace:, filter:) + case filter + when :available + relevant_mappings = RemoteDevelopmentNamespaceClusterAgentMapping.for_namespaces(namespace.traversal_ids) + relevant_mappings = NamespaceClusterAgentMappings::Validations.filter_valid_namespace_cluster_agent_mappings( + namespace_cluster_agent_mappings: relevant_mappings + ) + + Clusters::Agent.id_in(relevant_mappings.map(&:cluster_agent_id)).with_remote_development_enabled + else + raise "Unsupported value for filter: #{filter}" + end + end + end +end diff --git a/ee/app/graphql/ee/types/namespace_type.rb b/ee/app/graphql/ee/types/namespace_type.rb index eba03dd43571b7e22fbddb373200b55c3dc67749..497c885a8c330ec7459f5dfca8901f30e21bce82 100644 --- a/ee/app/graphql/ee/types/namespace_type.rb +++ b/ee/app/graphql/ee/types/namespace_type.rb @@ -139,6 +139,13 @@ module NamespaceType alpha: { milestone: '16.9' }, authorize: :modify_product_analytics_settings + field :remote_development_cluster_agents, + ::Types::Clusters::AgentType.connection_type, + extras: [:lookahead], + null: true, + description: 'Cluster agents in the namespace with remote development capabilities', + resolver: ::Resolvers::RemoteDevelopment::AgentsForNamespaceResolver + def product_analytics_stored_events_limit object.root_ancestor.product_analytics_stored_events_limit end diff --git a/ee/app/graphql/resolvers/remote_development/agents_for_namespace_resolver.rb b/ee/app/graphql/resolvers/remote_development/agents_for_namespace_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..0d77e6131256e1d7a59d9025b16bc96e6c1249e2 --- /dev/null +++ b/ee/app/graphql/resolvers/remote_development/agents_for_namespace_resolver.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Resolvers + module RemoteDevelopment + class AgentsForNamespaceResolver < ::Resolvers::BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type Types::Clusters::AgentType.connection_type, null: true + + argument :filter, Types::RemoteDevelopment::NamespaceClusterAgentFilterEnum, + required: true, + description: 'Filter the types of cluster agents to return.' + + def resolve(**args) + unless License.feature_available?(:remote_development) + raise_resource_not_available_error! "'remote_development' licensed feature is not available" + end + + unless Feature.enabled?(:remote_development_namespace_agent_authorization, @object.root_ancestor) + raise_resource_not_available_error!( + "'remote_development_namespace_agent_authorization' feature flag is disabled" + ) + end + + raise_resource_not_available_error! unless @object.group_namespace? + + ::RemoteDevelopment::ClusterAgentsFinder.execute( + namespace: @object, + filter: args[:filter].downcase.to_sym + ) + end + end + end +end diff --git a/ee/app/graphql/types/remote_development/namespace_cluster_agent_filter_enum.rb b/ee/app/graphql/types/remote_development/namespace_cluster_agent_filter_enum.rb new file mode 100644 index 0000000000000000000000000000000000000000..8298a18a2daa73b38990e824272c74a39bca001f --- /dev/null +++ b/ee/app/graphql/types/remote_development/namespace_cluster_agent_filter_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module RemoteDevelopment + class NamespaceClusterAgentFilterEnum < BaseEnum + graphql_name 'NamespaceClusterAgentFilter' + description 'Possible filter types for remote development cluster agents in a namespace' + + value 'AVAILABLE', + description: "Cluster agents in the namespace that can be used for hosting workspaces.", value: 'AVAILABLE' + end + end +end diff --git a/ee/app/models/remote_development/remote_development_namespace_cluster_agent_mapping.rb b/ee/app/models/remote_development/remote_development_namespace_cluster_agent_mapping.rb index 1fee4f910cc2357e5dec7b3df3c198b25cb29d87..caba0a492010b0b6bc9d12eed0c183c2cdc93c9a 100644 --- a/ee/app/models/remote_development/remote_development_namespace_cluster_agent_mapping.rb +++ b/ee/app/models/remote_development/remote_development_namespace_cluster_agent_mapping.rb @@ -15,5 +15,7 @@ class RemoteDevelopmentNamespaceClusterAgentMapping < ApplicationRecord validates :namespace, presence: true validates :agent, presence: true validates :user, presence: true + + scope :for_namespaces, ->(namespace_ids) { where(namespace_id: namespace_ids) } end end diff --git a/ee/lib/remote_development/namespace_cluster_agent_mappings/validations.rb b/ee/lib/remote_development/namespace_cluster_agent_mappings/validations.rb new file mode 100644 index 0000000000000000000000000000000000000000..11709fcc99b5f5e7d278b969b3e29055f52b5887 --- /dev/null +++ b/ee/lib/remote_development/namespace_cluster_agent_mappings/validations.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module RemoteDevelopment + module NamespaceClusterAgentMappings + class Validations + # The function checks and filters clusters agents that reside within a namespace. All other + # agents are excluded from the response. A cluster agent is said to reside within a namespace + # if the namespace id is present in the traversal ids of the project bound to the cluster agent + # + # @param [Array] namespace_cluster_agent_mappings + # @return [Array] + def self.filter_valid_namespace_cluster_agent_mappings(namespace_cluster_agent_mappings:) + agent_ids = namespace_cluster_agent_mappings.map(&:cluster_agent_id) + traversal_ids_for_agents = traversal_ids_for_cluster_agents(cluster_agent_ids: agent_ids) + namespace_cluster_agent_mappings.filter do |mapping| + traversal_ids_for_agents.fetch(mapping.cluster_agent_id, []).include?(mapping.namespace_id) + end + end + + # @param [Array] cluster_agent_ids + # @return [Hash] + def self.traversal_ids_for_cluster_agents(cluster_agent_ids:) + agents_by_id = Clusters::Agent.id_in(cluster_agent_ids).index_by(&:id) + + projects_by_id = Project.id_in(agents_by_id.values.map(&:project_id)).index_by(&:id) + + project_namespaces_by_id = + Namespaces::ProjectNamespace + .id_in(projects_by_id.values.map(&:project_namespace_id)) + .index_by(&:id) + + cluster_agent_ids.each_with_object({}) do |cluster_agent_id, hash| + next unless agents_by_id.has_key?(cluster_agent_id) + + agent = agents_by_id[cluster_agent_id] + + # projects_by_id must contain agent.project_id as "agents" table has a ON CASCADE DELETE constraint with + # respect to the "projects" table. As such, if an agent can be retrieved from the database, + # so should its project + project = projects_by_id[agent.project_id] + + # project_namespaces_by_id must contain project.project_namespace_id as "projects" table has a + # ON CASCADE DELETE constraint with respect to the projects table. As such, if a project can be retrieved + # from the database, so should its project_namespace + project_namespace = project_namespaces_by_id[project.project_namespace_id] + + hash[cluster_agent_id] = project_namespace.traversal_ids + end + end + + private_class_method :traversal_ids_for_cluster_agents + end + end +end diff --git a/ee/spec/finders/remote_development/cluster_agents_finder_spec.rb b/ee/spec/finders/remote_development/cluster_agents_finder_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..476357b3a0195af23e89b667b2f4245831b96a86 --- /dev/null +++ b/ee/spec/finders/remote_development/cluster_agents_finder_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe RemoteDevelopment::ClusterAgentsFinder, feature_category: :remote_development do + let_it_be(:user) { create(:user) } + let_it_be(:root_agent) { create(:ee_cluster_agent, :with_remote_development_agent_config) } + let_it_be(:nested_agent) { create(:ee_cluster_agent, :with_remote_development_agent_config) } + let_it_be_with_reload(:root_namespace) do + create(:group, + projects: [root_agent.project], + children: [ + create(:group, + projects: [nested_agent.project] + ) + ] + ) + end + + let(:nested_namespace) { root_namespace.children.first } + let(:namespace) { root_namespace } + let(:filter) { :available } + + before_all do + create( + :remote_development_namespace_cluster_agent_mapping, + user: user, + agent: nested_agent, + namespace: root_namespace + ) + end + + subject(:response) do + described_class.execute( + namespace: namespace, + filter: filter + ).to_a + end + + context 'with filter_type set to available' do + context 'when all cluster agents are bound to the namespace' do + it 'returns all cluster agents passed in the parameters' do + expect(response).to eq([nested_agent]) + end + end + + context 'when cluster agents are bound to ancestors of the namespace' do + let(:namespace) { nested_namespace } + + it 'returns cluster agents including those bound to the ancestors' do + expect(response).to eq([nested_agent]) + end + end + + context 'when the same cluster agent is bound to a namespace as well as its ancestors' do + # Set this up in a way such that same agent is mapped to two namespaces: + # the namespace in the request as well as its ancestor + before do + create( + :remote_development_namespace_cluster_agent_mapping, + user: user, + namespace: nested_namespace, + agent: nested_agent + ) + end + + let(:namespace) { nested_namespace } + + it 'returns distinct cluster agents in the response' do + expect(response).to eq([nested_agent]) + end + end + + context 'when a bound cluster agent does not have remote development enabled' do + before do + nested_agent.remote_development_agent_config.update!(enabled: false) + end + + it 'ignores agents with remote development disabled in the response' do + expect(response).to eq([]) + end + end + end + + context 'with an invalid value for filter_type' do + let(:filter) { "some_invalid_value" } + + it 'raises a RuntimeError' do + expect { response }.to raise_error(RuntimeError, "Unsupported value for filter: #{filter}") + end + end +end diff --git a/ee/spec/lib/remote_development/namespace_cluster_agent_mappings/validations_spec.rb b/ee/spec/lib/remote_development/namespace_cluster_agent_mappings/validations_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..eeb30209338b223c3a52e34ef9524f2619f07003 --- /dev/null +++ b/ee/spec/lib/remote_development/namespace_cluster_agent_mappings/validations_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe RemoteDevelopment::NamespaceClusterAgentMappings::Validations, feature_category: :remote_development do + describe 'filter_valid_namespace_cluster_agent_mappings' do + let_it_be(:user) { create(:user) } + let_it_be(:root_agent) { create(:cluster_agent) } + let_it_be(:nested_agent) { create(:cluster_agent) } + let_it_be(:root_namespace) do + create(:group, + projects: [root_agent.project], + children: [ + create(:group, + projects: [nested_agent.project] + ) + ] + ) + end + + let(:namespace) { root_namespace } + let(:namespace_cluster_agent_mappings) do + [ + build_stubbed( + :remote_development_namespace_cluster_agent_mapping, + user: user, + namespace: namespace, + agent: root_agent + ), + build_stubbed( + :remote_development_namespace_cluster_agent_mapping, + user: user, + namespace: namespace, + agent: nested_agent + ) + ] + end + + subject(:response) do + described_class.filter_valid_namespace_cluster_agent_mappings( + namespace_cluster_agent_mappings: namespace_cluster_agent_mappings + ) + end + + context 'when cluster agents exist within the namespace' do + it 'returns all cluster agents passed in the parameters' do + expect(response).to eq(namespace_cluster_agent_mappings) + end + end + + context 'when a cluster agent does not exist within the mapped namespace' do + # With this, all namespace-agent mappings will be bound to the nested namespace. + # As such, one of the mappings will be between the nested namespace and the root agent which is considered + # invalid and should be excluded from the results + let(:namespace) { root_namespace.children.first } + + it 'returns cluster agents excluding those that do not reside in the namespace' do + mappings_with_nested_agent = namespace_cluster_agent_mappings.filter do |mapping| + mapping.agent == nested_agent + end + + expect(response).to eq(mappings_with_nested_agent) + end + end + + context 'when a non-existent cluster agent is passed in the parameters' do + let(:nested_agent) { build_stubbed(:cluster_agent) } + + it 'returns cluster agents excluding those are non-existent' do + mappings_without_nested_agent = namespace_cluster_agent_mappings.filter do |mapping| + mapping.agent != nested_agent + end + + expect(response).to eq(mappings_without_nested_agent) + end + end + + context 'when an empty list of agents is passed in the parameters' do + let(:namespace_cluster_agent_mappings) { [] } + + it 'returns an empty array' do + expect(response).to eq([]) + end + end + end +end diff --git a/ee/spec/models/remote_development/remote_development_namespace_cluster_agent_mapping_spec.rb b/ee/spec/models/remote_development/remote_development_namespace_cluster_agent_mapping_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c78b1bfd2cc2fa315c64de780137a8786b2ea111 --- /dev/null +++ b/ee/spec/models/remote_development/remote_development_namespace_cluster_agent_mapping_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe RemoteDevelopment::RemoteDevelopmentNamespaceClusterAgentMapping, feature_category: :remote_development do + let_it_be(:user) { create(:user) } + let_it_be_with_reload(:namespace) { create(:group) } + let_it_be_with_reload(:agent) { create(:cluster_agent) } + + subject(:namespace_cluster_agent_mapping) do + create( + :remote_development_namespace_cluster_agent_mapping, + user: user, + agent: agent, + namespace: namespace + ) + end + + describe 'associations' do + context "for belongs_to" do + it do + is_expected + .to belong_to(:user) + .class_name('User') + .with_foreign_key(:creator_id) + .inverse_of(:created_remote_development_namespace_cluster_agent_mappings) + end + + it do + is_expected + .to belong_to(:namespace) + .inverse_of(:remote_development_namespace_cluster_agent_mappings) + end + + it do + is_expected + .to belong_to(:agent) + .class_name('Clusters::Agent') + .with_foreign_key(:cluster_agent_id) + .inverse_of(:remote_development_namespace_cluster_agent_mappings) + end + end + + context 'when from factory' do + it 'has correct associations from factory' do + expect(namespace_cluster_agent_mapping.user).to eq(user) + expect(namespace_cluster_agent_mapping.agent).to eq(agent) + expect(namespace_cluster_agent_mapping.namespace).to eq(namespace) + end + end + end +end diff --git a/ee/spec/requests/api/graphql/remote_development/namespace/remote_development_cluster_agents/with_available_filter_arg_spec.rb b/ee/spec/requests/api/graphql/remote_development/namespace/remote_development_cluster_agents/with_available_filter_arg_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..328f0ebe03bf274c1f00cf422fd5d8e631e7b712 --- /dev/null +++ b/ee/spec/requests/api/graphql/remote_development/namespace/remote_development_cluster_agents/with_available_filter_arg_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Query.namespace.remote_development_cluster_agents(filter: AVAILABLE)', + feature_category: :remote_development do + include GraphqlHelpers + include StubFeatureFlags + + let_it_be(:user) { create(:user) } + let(:stub_finder_response) { [agent] } + let_it_be(:current_user) { user } + # Setup cluster and user such that the user has the bare minimum permissions + # to be able to receive the agent when calling the API i.e. the user has Developer access + # to the agent project ONLY (and not a group-level access) + let_it_be(:agent) do + create(:ee_cluster_agent, :in_group, :with_remote_development_agent_config).tap do |agent| + agent.project.add_developer(user) + end + end + + let_it_be(:namespace) { agent.project.namespace } + let_it_be(:namespace_agent_mapping) do + create( + :remote_development_namespace_cluster_agent_mapping, + user: user, + agent: agent, + namespace: namespace + ) + end + + let(:fields) do + <<~QUERY + nodes { + #{all_graphql_fields_for('cluster_agents'.classify, max_depth: 1)} + } + QUERY + end + + let(:agent_names_in_response) { subject.pluck('name') } + let(:query) do + graphql_query_for( + :namespace, + { full_path: namespace.full_path }, + query_graphql_field( + :remote_development_cluster_agents, + { filter: :AVAILABLE }, + fields + ) + ) + end + + subject { graphql_data.dig('namespace', 'remoteDevelopmentClusterAgents', 'nodes') } + + before do + stub_licensed_features(remote_development: true) + end + + context 'when the params are valid' do + it 'returns a cluster agent' do + post_graphql(query, current_user: current_user) + + expect(agent_names_in_response).to eq([agent.name]) + end + end + + context 'when remote_development feature is unlicensed' do + before do + stub_licensed_features(remote_development: false) + end + + it 'returns an error' do + post_graphql(query, current_user: current_user) + + expect_graphql_errors_to_include "'remote_development' licensed feature is not available" + end + end + + context 'when the feature flag is disabled' do + before do + stub_feature_flags(remote_development_namespace_agent_authorization: false) + end + + it 'returns an error' do + post_graphql(query, current_user: current_user) + + expect_graphql_errors_to_include "'remote_development_namespace_agent_authorization' feature flag is disabled" + end + end + + context 'when the provided namespace is not a group namespace' do + let(:namespace) { agent.project.project_namespace } + + it 'returns an error' do + post_graphql(query, current_user: current_user) + + expect_graphql_errors_to_include "does not exist or you don't have permission to perform this action" + end + end + + context 'when user does not have access to the project' do + # simulate test conditions by creating the maximum privileged user that does/should + # not have the permission to access the agent + let(:current_user) do + create(:user).tap do |user| + agent.project.add_reporter(user) + end + end + + it 'skips agents for which the user does not have access' do + post_graphql(query, current_user: current_user) + + expect(agent_names_in_response).to eq([]) + end + end +end