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