Skip to content

GraphQL: Add `group.runnerCloudProvisioning` field

What does this MR do and why?

This MR adds the runnerCloudProvisioning field to GroupType, similar to !145831 (merged) which introduced it at the ProjectType level. This doesn't require a documentation review since it is autogenerated from an existing type.

It also temporarily removes the regions/zones/machineTypes fields, since they currently only work at the project level due to JWT class limitations, and are not actually in use.

Patch to re-add support
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index d2d6e23ba90a..3ebac12f6b6f 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -9951,6 +9951,75 @@ The edge type for [`CiProjectVariable`](#ciprojectvariable).
 | <a id="ciprojectvariableedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. |
 | <a id="ciprojectvariableedgenode"></a>`node` | [`CiProjectVariable`](#ciprojectvariable) | The item at the end of the edge. |
 
+#### `CiRunnerCloudProvisioningMachineTypeConnection`
+
+The connection type for [`CiRunnerCloudProvisioningMachineType`](#cirunnercloudprovisioningmachinetype).
+
+##### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="cirunnercloudprovisioningmachinetypeconnectionedges"></a>`edges` | [`[CiRunnerCloudProvisioningMachineTypeEdge]`](#cirunnercloudprovisioningmachinetypeedge) | A list of edges. |
+| <a id="cirunnercloudprovisioningmachinetypeconnectionnodes"></a>`nodes` | [`[CiRunnerCloudProvisioningMachineType]`](#cirunnercloudprovisioningmachinetype) | A list of nodes. |
+| <a id="cirunnercloudprovisioningmachinetypeconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. |
+
+#### `CiRunnerCloudProvisioningMachineTypeEdge`
+
+The edge type for [`CiRunnerCloudProvisioningMachineType`](#cirunnercloudprovisioningmachinetype).
+
+##### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="cirunnercloudprovisioningmachinetypeedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. |
+| <a id="cirunnercloudprovisioningmachinetypeedgenode"></a>`node` | [`CiRunnerCloudProvisioningMachineType`](#cirunnercloudprovisioningmachinetype) | The item at the end of the edge. |
+
+#### `CiRunnerCloudProvisioningRegionConnection`
+
+The connection type for [`CiRunnerCloudProvisioningRegion`](#cirunnercloudprovisioningregion).
+
+##### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="cirunnercloudprovisioningregionconnectionedges"></a>`edges` | [`[CiRunnerCloudProvisioningRegionEdge]`](#cirunnercloudprovisioningregionedge) | A list of edges. |
+| <a id="cirunnercloudprovisioningregionconnectionnodes"></a>`nodes` | [`[CiRunnerCloudProvisioningRegion]`](#cirunnercloudprovisioningregion) | A list of nodes. |
+| <a id="cirunnercloudprovisioningregionconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. |
+
+#### `CiRunnerCloudProvisioningRegionEdge`
+
+The edge type for [`CiRunnerCloudProvisioningRegion`](#cirunnercloudprovisioningregion).
+
+##### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="cirunnercloudprovisioningregionedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. |
+| <a id="cirunnercloudprovisioningregionedgenode"></a>`node` | [`CiRunnerCloudProvisioningRegion`](#cirunnercloudprovisioningregion) | The item at the end of the edge. |
+
+#### `CiRunnerCloudProvisioningZoneConnection`
+
+The connection type for [`CiRunnerCloudProvisioningZone`](#cirunnercloudprovisioningzone).
+
+##### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="cirunnercloudprovisioningzoneconnectionedges"></a>`edges` | [`[CiRunnerCloudProvisioningZoneEdge]`](#cirunnercloudprovisioningzoneedge) | A list of edges. |
+| <a id="cirunnercloudprovisioningzoneconnectionnodes"></a>`nodes` | [`[CiRunnerCloudProvisioningZone]`](#cirunnercloudprovisioningzone) | A list of nodes. |
+| <a id="cirunnercloudprovisioningzoneconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. |
+
+#### `CiRunnerCloudProvisioningZoneEdge`
+
+The edge type for [`CiRunnerCloudProvisioningZone`](#cirunnercloudprovisioningzone).
+
+##### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="cirunnercloudprovisioningzoneedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. |
+| <a id="cirunnercloudprovisioningzoneedgenode"></a>`node` | [`CiRunnerCloudProvisioningZone`](#cirunnercloudprovisioningzone) | The item at the end of the edge. |
+
 #### `CiRunnerConnection`
 
 The connection type for [`CiRunner`](#cirunner).
@@ -16523,6 +16592,29 @@ Returns [`CiRunnerStatus!`](#cirunnerstatus).
 | ---- | ---- | ----------- |
 | <a id="cirunnerstatuslegacymode"></a>`legacyMode` **{warning-solid}** | [`String`](#string) | **Deprecated** in GitLab 15.0. Will be removed in 17.0. |
 
+### `CiRunnerCloudProvisioningMachineType`
+
+Machine type used for runner cloud provisioning.
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="cirunnercloudprovisioningmachinetypedescription"></a>`description` | [`String`](#string) | Description of the machine type. |
+| <a id="cirunnercloudprovisioningmachinetypename"></a>`name` | [`GoogleCloudMachineType`](#googlecloudmachinetype) | Name of the machine type. |
+| <a id="cirunnercloudprovisioningmachinetypezone"></a>`zone` | [`GoogleCloudZone`](#googlecloudzone) | Zone of the machine type. |
+
+### `CiRunnerCloudProvisioningRegion`
+
+Region used for runner cloud provisioning.
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="cirunnercloudprovisioningregiondescription"></a>`description` | [`String`](#string) | Description of the region. |
+| <a id="cirunnercloudprovisioningregionname"></a>`name` | [`GoogleCloudRegion`](#googlecloudregion) | Name of the region. |
+
 ### `CiRunnerCloudProvisioningStep`
 
 Step used to provision the runner to Google Cloud.
@@ -16535,6 +16627,17 @@ Step used to provision the runner to Google Cloud.
 | <a id="cirunnercloudprovisioningsteplanguageidentifier"></a>`languageIdentifier` | [`String`](#string) | Identifier of the language used for the instructions field. This identifier can be any of the identifiers specified in the [list of supported languages and lexers](https://github.com/rouge-ruby/rouge/wiki/List-of-supported-languages-and-lexers). |
 | <a id="cirunnercloudprovisioningsteptitle"></a>`title` | [`String`](#string) | Title of the step. |
 
+### `CiRunnerCloudProvisioningZone`
+
+Zone used for runner cloud provisioning.
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="cirunnercloudprovisioningzonedescription"></a>`description` | [`String`](#string) | Description of the zone. |
+| <a id="cirunnercloudprovisioningzonename"></a>`name` | [`GoogleCloudZone`](#googlecloudzone) | Name of the zone. |
+
 ### `CiRunnerGoogleCloudProvisioning`
 
 Information used for runner Google Cloud provisioning.
@@ -16544,9 +16647,26 @@ Information used for runner Google Cloud provisioning.
 | Name | Type | Description |
 | ---- | ---- | ----------- |
 | <a id="cirunnergooglecloudprovisioningprojectsetupshellscript"></a>`projectSetupShellScript` | [`String`](#string) | Instructions for setting up a Google Cloud project. |
+| <a id="cirunnergooglecloudprovisioningregions"></a>`regions` | [`CiRunnerCloudProvisioningRegionConnection`](#cirunnercloudprovisioningregionconnection) | Regions available for provisioning a runner. Only available for projects. (see [Connections](#connections)) |
 
 #### Fields with arguments
 
+##### `CiRunnerGoogleCloudProvisioning.machineTypes`
+
+Machine types available for provisioning a runner. Only available for projects.
+
+Returns [`CiRunnerCloudProvisioningMachineTypeConnection`](#cirunnercloudprovisioningmachinetypeconnection).
+
+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 |
+| ---- | ---- | ----------- |
+| <a id="cirunnergooglecloudprovisioningmachinetypeszone"></a>`zone` | [`GoogleCloudZone!`](#googlecloudzone) | Zone to retrieve machine types for. |
+
 ##### `CiRunnerGoogleCloudProvisioning.provisioningSteps`
 
 Steps used to provision a runner in the cloud.
@@ -16562,6 +16682,22 @@ Returns [`[CiRunnerCloudProvisioningStep!]`](#cirunnercloudprovisioningstep).
 | <a id="cirunnergooglecloudprovisioningprovisioningstepsrunnertoken"></a>`runnerToken` | [`String`](#string) | Authentication token of the runner. |
 | <a id="cirunnergooglecloudprovisioningprovisioningstepszone"></a>`zone` | [`GoogleCloudZone!`](#googlecloudzone) | Name of the zone to provision the runner in. |
 
+##### `CiRunnerGoogleCloudProvisioning.zones`
+
+Zones available for provisioning a runner. Only available for projects.
+
+Returns [`CiRunnerCloudProvisioningZoneConnection`](#cirunnercloudprovisioningzoneconnection).
+
+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 |
+| ---- | ---- | ----------- |
+| <a id="cirunnergooglecloudprovisioningzonesregion"></a>`region` | [`GoogleCloudRegion`](#googlecloudregion) | Region to retrieve zones for. Returns all zones if not specified. |
+
 ### `CiRunnerManager`
 
 #### Fields
diff --git a/ee/app/graphql/types/ci/runner_google_cloud_provisioning_type.rb b/ee/app/graphql/types/ci/runner_google_cloud_provisioning_type.rb
index 7f0ffc898365..49ae83e67ff4 100644
--- a/ee/app/graphql/types/ci/runner_google_cloud_provisioning_type.rb
+++ b/ee/app/graphql/types/ci/runner_google_cloud_provisioning_type.rb
@@ -12,6 +12,34 @@ class RunnerGoogleCloudProvisioningType < BaseObject
 
       authorize :read_runner_cloud_provisioning_info
 
+      field :regions, Types::Ci::RunnerCloudProvisioningRegionType.connection_type,
+        description: 'Regions available for provisioning a runner. Only available for projects.',
+        null: true,
+        connection_extension: Gitlab::Graphql::Extensions::ForwardOnlyExternallyPaginatedArrayExtension,
+        max_page_size: GoogleCloudPlatform::Compute::ListRegionsService::MAX_RESULTS_LIMIT,
+        default_page_size: GoogleCloudPlatform::Compute::ListRegionsService::MAX_RESULTS_LIMIT
+
+      field :zones, Types::Ci::RunnerCloudProvisioningZoneType.connection_type,
+        description: 'Zones available for provisioning a runner. Only available for projects.',
+        null: true,
+        connection_extension: Gitlab::Graphql::Extensions::ForwardOnlyExternallyPaginatedArrayExtension,
+        max_page_size: GoogleCloudPlatform::Compute::ListZonesService::MAX_RESULTS_LIMIT,
+        default_page_size: GoogleCloudPlatform::Compute::ListZonesService::MAX_RESULTS_LIMIT do
+          argument :region, Types::GoogleCloud::RegionType, required: false,
+            description: 'Region to retrieve zones for. Returns all zones if not specified.'
+        end
+
+      field :machine_types,
+        Types::Ci::RunnerCloudProvisioningMachineTypeType.connection_type,
+        description: 'Machine types available for provisioning a runner. Only available for projects.',
+        null: true,
+        connection_extension: Gitlab::Graphql::Extensions::ForwardOnlyExternallyPaginatedArrayExtension,
+        max_page_size: GoogleCloudPlatform::Compute::ListMachineTypesService::MAX_RESULTS_LIMIT,
+        default_page_size: GoogleCloudPlatform::Compute::ListMachineTypesService::MAX_RESULTS_LIMIT do
+          argument :zone, Types::GoogleCloud::ZoneType, required: true,
+            description: 'Zone to retrieve machine types for.'
+        end
+
       field :project_setup_shell_script, GraphQL::Types::String, null: true,
         description: 'Instructions for setting up a Google Cloud project.'
 
@@ -23,6 +51,44 @@ def self.authorized?(object, context)
         super(object[:container], context)
       end
 
+      def regions(after: nil, first: nil)
+        check_wlif_availability!
+
+        response = GoogleCloudPlatform::Compute::ListRegionsService
+          .new(container: container, current_user: current_user,
+            params: default_params(after, first).merge(google_cloud_project_id: google_cloud_project_id))
+          .execute
+
+        externally_paginated_array(response, after)
+      end
+
+      def zones(region: nil, after: nil, first: nil)
+        check_wlif_availability!
+
+        params = default_params(after, first)
+        params[:filter] = "name=#{region}-*" if region
+        params[:google_cloud_project_id] = google_cloud_project_id if google_cloud_project_id
+
+        response = GoogleCloudPlatform::Compute::ListZonesService
+          .new(container: container, current_user: current_user, params: params)
+          .execute
+
+        externally_paginated_array(response, after)
+      end
+
+      def machine_types(zone:, after: nil, first: nil)
+        check_wlif_availability!
+
+        response = GoogleCloudPlatform::Compute::ListMachineTypesService
+          .new(
+            container: container, current_user: current_user, zone: zone,
+            params: default_params(after, first).merge(google_cloud_project_id: google_cloud_project_id)
+          )
+          .execute
+
+        externally_paginated_array(response, after)
+      end
+
       def project_setup_shell_script
         template = ERB.new(File.read(SHELL_SCRIPT_TEMPLATE_PATH))
 
@@ -42,6 +108,26 @@ def container
       def google_cloud_project_id
         object[:cloud_project_id]
       end
+
+      def default_params(after, first)
+        { max_results: first, page_token: after }.compact
+      end
+
+      def externally_paginated_array(response, after)
+        raise_resource_not_available_error!(response.message) if response.error?
+
+        Gitlab::Graphql::ExternallyPaginatedArray.new(
+          after,
+          response.payload[:next_page_token],
+          *response.payload[:items]
+        )
+      end
+
+      def check_wlif_availability!
+        return if container.is_a?(Project)
+
+        raise_resource_not_available_error!('This field is currently only available for projects')
+      end
     end
   end
 end
diff --git a/ee/spec/requests/api/graphql/project/runner_google_cloud_provisioning_spec.rb b/ee/spec/requests/api/graphql/project/runner_google_cloud_provisioning_spec.rb
index 9318ed14121e..1ec1eba044d2 100644
--- a/ee/spec/requests/api/graphql/project/runner_google_cloud_provisioning_spec.rb
+++ b/ee/spec/requests/api/graphql/project/runner_google_cloud_provisioning_spec.rb
@@ -7,11 +7,6 @@
   using RSpec::Parameterized::TableSyntax
 
   let_it_be_with_refind(:group) { create(:group) }
-  let_it_be(:group_owner) { create(:user).tap { |user| group.add_owner(user) } }
-  let_it_be_with_refind(:group_wlif_integration) do
-    create(:google_cloud_platform_workload_identity_federation_integration, project: nil, group: group)
-  end
-
   let_it_be_with_refind(:project) { create(:project, group: group) }
   let_it_be(:project_maintainer) { create(:user).tap { |user| group.add_maintainer(user) } }
   let_it_be_with_refind(:project_wlif_integration) do
@@ -45,156 +40,375 @@
     stub_saas_features(google_cloud_support: true)
   end
 
-  where(:parent_field, :container, :current_user) do
-    :group   | ref(:group)   | ref(:group_owner)
-    :project | ref(:project) | ref(:project_maintainer)
-  end
+  describe 'collections' do
+    # the collection methods currently only work for projects, due to the fact that GoogleCloudPlatform::Jwt
+    # only accepts projects
+    let(:container) { project }
+    let(:parent_field) { :project }
+    let(:current_user) { project_maintainer }
+    let(:client_klass) { GoogleCloudPlatform::Compute::Client }
+    let(:expected_compute_client_args) do
+      {
+        wlif_integration: container.google_cloud_platform_workload_identity_federation_integration,
+        user: current_user,
+        params: { google_cloud_project_id: google_cloud_project_id }
+      }
+    end
 
-  with_them do
-    context 'when cloud_project_id is invalid' do
-      let(:google_cloud_project_id) { 'project_id_override' }
+    let(:current_page_token) { nil }
+    let(:expected_next_page_token) { nil }
+    let(:base_item_query_args) { {} }
+    let(:item_query_args) { {} }
+    let(:node_name) { :regions }
+    let(:item_type) { 'CiRunnerCloudProvisioningRegion' }
+    let(:inner_fragment) do
+      query_nodes(
+        node_name,
+        args: base_item_query_args.merge(item_query_args),
+        of: item_type,
+        include_pagination_info: true)
+    end
 
-      it 'returns an error' do
-        request
+    shared_examples 'a query handling client errors' do
+      shared_examples 'returns error when client raises' do |error_klass, message|
+        it "returns error when client raises #{error_klass}" do
+          expect_next_instance_of(GoogleCloudPlatform::Compute::Client, expected_compute_client_args) do |client|
+            expect(client).to receive(client_method).and_raise(error_klass, message)
+          end
 
-        expect_graphql_errors_to_include('"project_id_override" is not a valid project name')
+          post_graphql(query, current_user: current_user)
+          expect_graphql_errors_to_include(message)
+        end
       end
+
+      it_behaves_like 'returns error when client raises', GoogleCloudPlatform::ApiError, 'api error'
+      it_behaves_like 'returns error when client raises', GoogleCloudPlatform::AuthenticationError,
+        'Unable to authenticate against Google Cloud'
     end
 
-    describe 'projectSetupShellScript' do
-      let(:inner_fragment) { 'projectSetupShellScript' }
-      let(:options_response) do
+    shared_examples 'a query calling compute client' do
+      let(:page_size) { GoogleCloudPlatform::Compute::BaseService::MAX_RESULTS_LIMIT }
+      let(:actual_returned_nodes) { returned_nodes }
+      let(:expected_client_args) { {} }
+      let(:expected_pagination_client_args) do
+        { max_results: page_size, page_token: current_page_token, order_by: nil }
+      end
+
+      before do
+        allow_next_instance_of(client_klass, expected_compute_client_args) do |client|
+          allow(client).to receive(client_method)
+            .with(a_hash_including(**expected_pagination_client_args.merge(expected_client_args))) do
+            compute_type = client_method.to_s.camelize.singularize
+            google_cloud_object_list(compute_type, actual_returned_nodes, next_page_token: expected_next_page_token)
+          end
+        end
+
         request
+      end
 
-        graphql_data_at(
-          GraphqlHelpers.fieldnamerize(parent_field), 'runnerCloudProvisioning', 'projectSetupShellScript')
+      shared_examples 'a client returning paginated response' do
+        it 'returns paginated response with items from client' do
+          graphql_field_name = GraphqlHelpers.fieldnamerize(client_method)
+
+          expect(options_response[graphql_field_name]).to match({
+            'nodes' => expected_nodes.map { |node_props| a_graphql_entity_for(nil, **node_props) },
+            'pageInfo' => a_hash_including(
+              'hasPreviousPage' => !!current_page_token,
+              'hasNextPage' => !!expected_next_page_token,
+              'endCursor' => expected_next_page_token
+            )
+          })
+        end
       end
 
-      it 'returns a script' do
-        request
-        expect_graphql_errors_to_be_empty
+      it_behaves_like 'a working graphql query'
+      it_behaves_like 'a client returning paginated response'
+
+      context 'with arguments' do
+        let(:current_page_token) { 'prev_page_token' }
+        let(:page_size) { 10 }
+        let(:base_item_query_args) do
+          { after: current_page_token, first: page_size }
+        end
+
+        it_behaves_like 'a client returning paginated response'
+
+        context 'with pagination arguments requesting next page' do
+          let(:current_page_token) { 'next_page_token' }
+          let(:expected_next_page_token) { 'next_page_token2' }
+          let(:page_size) { 1 }
+          let(:expected_nodes) { returned_nodes[1..] }
+          let(:actual_returned_nodes) { returned_nodes[1..] }
+          let(:base_item_query_args) { { after: current_page_token, first: page_size } }
+
+          it_behaves_like 'a client returning paginated response'
+        end
+      end
+    end
+
+    describe 'regions' do
+      let(:item_type) { 'CiRunnerCloudProvisioningRegion' }
+      let(:client_method) { :regions }
+      let(:node_name) { :regions }
+      let(:regions) do
+        [
+          { name: 'us-east1', description: 'us-east1' },
+          { name: 'us-west1', description: 'us-west1' }
+        ]
+      end
+
+      let(:returned_nodes) { regions }
+      let(:expected_nodes) { returned_nodes }
+      let(:expected_client_args) { { filter: nil } }
+
+      it_behaves_like 'a query handling client errors'
+      it_behaves_like 'a query calling compute client'
+    end
+
+    describe 'zones' do
+      let(:item_type) { 'CiRunnerCloudProvisioningZone' }
+      let(:client_method) { :zones }
+      let(:node_name) { :zones }
+      let(:zones) do
+        [
+          { name: 'us-east1-a', description: 'us-east1-a' },
+          { name: 'us-west1-a', description: 'us-west1-a' }
+        ]
+      end
+
+      let(:returned_nodes) { zones }
+      let(:expected_nodes) { returned_nodes }
+      let(:expected_client_args) { { filter: nil } }
 
-        expect(options_response).to be_a(String)
-        expect(options_response).to include google_cloud_project_id
+      it_behaves_like 'a query handling client errors'
+      it_behaves_like 'a query calling compute client'
+
+      context 'with specified region' do
+        let(:region) { 'us-east1' }
+        let(:item_query_args) { { region: region } }
+        let(:returned_nodes) { zones.select { |z| z[:name].starts_with?(region) } }
+        let(:expected_next_page_token) { 'next_page_token' }
+
+        it_behaves_like 'a query calling compute client' do
+          let(:expected_client_args) { { filter: "name=#{region}-*" } }
+        end
       end
     end
 
-    describe 'provisioningSteps' do
-      let_it_be(:runner) { create(:ci_runner, :project, projects: [project]) }
+    describe 'machineTypes' do
+      let(:item_type) { 'CiRunnerCloudProvisioningMachineType' }
+      let(:client_method) { :machine_types }
+      let(:node_name) { :machine_types }
+      let(:machine_types) do
+        [
+          { zone: zone, name: 'e2-highcpu-8', description: 'Efficient Instance, 8 vCPUs, 8 GB RAM' },
+          { zone: zone, name: 'e2-highcpu-16', description: 'Efficient Instance, 16 vCPUs, 16 GB RAM' }
+        ]
+      end
 
-      let(:region) { 'us-central1' }
-      let(:zone) { 'us-central1-a' }
-      let(:machine_type) { 'n2d-standard-2' }
-      let(:runner_token) { runner.token }
-      let(:args) do
-        {
-          region: region,
-          zone: zone,
-          ephemeral_machine_type: machine_type,
-          runner_token: runner_token
-        }
+      let(:zone) { 'us-east1-a' }
+      let(:item_query_args) { { zone: zone } }
+      let(:returned_nodes) { machine_types }
+      let(:expected_nodes) { returned_nodes }
+      let(:expected_client_args) { { filter: "name=#{zone}-*" } }
+
+      it_behaves_like 'a query handling client errors'
+      it_behaves_like 'a query calling compute client'
+    end
+
+    context 'when integration is not present' do
+      before do
+        container.google_cloud_platform_workload_identity_federation_integration.destroy!
       end
 
-      let(:inner_fragment) do
-        query_graphql_field(:provisioning_steps, args,
-          all_graphql_fields_for('CiRunnerCloudProvisioningStep'), '[CiRunnerCloudProvisioningStep!]')
+      it 'returns error' do
+        post_graphql(query, current_user: current_user)
+        expect_graphql_errors_to_include(/integration not set/)
       end
+    end
 
-      let(:options_response) do
-        request
-        graphql_data_at(GraphqlHelpers.fieldnamerize(parent_field), 'runnerCloudProvisioning', 'provisioningSteps')
+    context 'when integration is inactive' do
+      before do
+        container.google_cloud_platform_workload_identity_federation_integration.update_column(:active, false)
       end
 
-      it 'returns provisioning steps', :aggregate_failures do
-        request
-        expect_graphql_errors_to_be_empty
+      it 'returns error' do
+        post_graphql(query, current_user: current_user)
+        expect_graphql_errors_to_include(/integration not active/)
+      end
+    end
 
-        expect(options_response).to match([
-          {
-            'instructions' => /google_project += "#{google_cloud_project_id}"/,
-            'languageIdentifier' => 'terraform',
-            'title' => 'Save the Terraform script to a file'
-          },
+    private
+
+    def google_cloud_object_list(compute_type, returned_nodes, next_page_token:)
+      item_type = "Google::Cloud::Compute::V1::#{compute_type}"
+
+      # rubocop:disable RSpec/VerifiedDoubles -- these generated objects don't actually expose the methods
+      double("#{item_type}List",
+        items: returned_nodes.map { |props| double(item_type, **props) },
+        next_page_token: next_page_token
+      )
+      # rubocop:enable RSpec/VerifiedDoubles
+    end
+  end
+
+  describe 'common group/project fields' do
+    let_it_be(:group_owner) { create(:user).tap { |user| group.add_owner(user) } }
+    let_it_be_with_refind(:group_wlif_integration) do
+      create(:google_cloud_platform_workload_identity_federation_integration, project: nil, group: group)
+    end
+
+    where(:parent_field, :container, :current_user) do
+      :group   | ref(:group)   | ref(:group_owner)
+      :project | ref(:project) | ref(:project_maintainer)
+    end
+
+    with_them do
+      context 'when cloud_project_id is invalid' do
+        let(:google_cloud_project_id) { 'project_id_override' }
+
+        it 'returns an error' do
+          request
+
+          expect_graphql_errors_to_include('"project_id_override" is not a valid project name')
+        end
+      end
+
+      describe 'projectSetupShellScript' do
+        let(:inner_fragment) { 'projectSetupShellScript' }
+        let(:options_response) do
+          request
+
+          graphql_data_at(
+            GraphqlHelpers.fieldnamerize(parent_field), 'runnerCloudProvisioning', 'projectSetupShellScript')
+        end
+
+        it 'returns a script' do
+          request
+          expect_graphql_errors_to_be_empty
+
+          expect(options_response).to be_a(String)
+          expect(options_response).to include google_cloud_project_id
+        end
+      end
+
+      describe 'provisioningSteps' do
+        let_it_be(:runner) { create(:ci_runner, :project, projects: [project]) }
+
+        let(:region) { 'us-central1' }
+        let(:zone) { 'us-central1-a' }
+        let(:machine_type) { 'n2d-standard-2' }
+        let(:runner_token) { runner.token }
+        let(:args) do
           {
-            'instructions' => /gitlab_runner="#{runner_token}"/,
-            'languageIdentifier' => 'shell',
-            'title' => 'Apply the Terraform script'
+            region: region,
+            zone: zone,
+            ephemeral_machine_type: machine_type,
+            runner_token: runner_token
           }
-        ])
-      end
+        end
 
-      context 'with nil runner token' do
-        let(:runner_token) { nil }
+        let(:inner_fragment) do
+          query_graphql_field(:provisioning_steps, args,
+            all_graphql_fields_for('CiRunnerCloudProvisioningStep'), '[CiRunnerCloudProvisioningStep!]')
+        end
+
+        let(:options_response) do
+          request
+          graphql_data_at(GraphqlHelpers.fieldnamerize(parent_field), 'runnerCloudProvisioning', 'provisioningSteps')
+        end
 
-        it 'is successful and generates a unique deployment id' do
+        it 'returns provisioning steps', :aggregate_failures do
           request
           expect_graphql_errors_to_be_empty
 
           expect(options_response).to match([
-            a_hash_including('instructions' => /name = "grit-[A-Za-z0-9_\-]{8}"/),
-            an_instance_of(Hash)
+            {
+              'instructions' => /google_project += "#{google_cloud_project_id}"/,
+              'languageIdentifier' => 'terraform',
+              'title' => 'Save the Terraform script to a file'
+            },
+            {
+              'instructions' => /gitlab_runner="#{runner_token}"/,
+              'languageIdentifier' => 'shell',
+              'title' => 'Apply the Terraform script'
+            }
           ])
         end
 
-        context 'when user does not have permissions to create runner' do
-          before do
-            allow(Ability).to receive(:allowed?).and_call_original
-            allow(Ability).to receive(:allowed?).with(current_user, :create_runner, anything).and_return(false)
-          end
+        context 'with nil runner token' do
+          let(:runner_token) { nil }
 
-          it 'returns an error' do
+          it 'is successful and generates a unique deployment id' do
             request
+            expect_graphql_errors_to_be_empty
 
-            expect_graphql_errors_to_include(s_('Runners|The user is not allowed to create a runner'))
+            expect(options_response).to match([
+              a_hash_including('instructions' => /name = "grit-[A-Za-z0-9_\-]{8}"/),
+              an_instance_of(Hash)
+            ])
           end
-        end
-      end
 
-      context 'with invalid runner token' do
-        let(:runner_token) { 'invalid-token' }
+          context 'when user does not have permissions to create runner' do
+            before do
+              allow(Ability).to receive(:allowed?).and_call_original
+              allow(Ability).to receive(:allowed?).with(current_user, :create_runner, anything).and_return(false)
+            end
 
-        it 'returns an error' do
-          request
+            it 'returns an error' do
+              request
 
-          expect_graphql_errors_to_include(s_('Runners|The runner authentication token is invalid'))
+              expect_graphql_errors_to_include(s_('Runners|The user is not allowed to create a runner'))
+            end
+          end
         end
-      end
 
-      context 'when user cannot provision runners' do
-        before do
-          allow(Ability).to receive(:allowed?).and_call_original
-          allow(Ability).to receive(:allowed?).with(current_user, :provision_cloud_runner, container)
-            .and_return(false)
+        context 'with invalid runner token' do
+          let(:runner_token) { 'invalid-token' }
+
+          it 'returns an error' do
+            request
+
+            expect_graphql_errors_to_include(s_('Runners|The runner authentication token is invalid'))
+          end
         end
 
-        it 'returns an error' do
-          request
+        context 'when user cannot provision runners' do
+          before do
+            allow(Ability).to receive(:allowed?).and_call_original
+            allow(Ability).to receive(:allowed?).with(current_user, :provision_cloud_runner, container)
+              .and_return(false)
+          end
+
+          it 'returns an error' do
+            request
 
-          expect_graphql_errors_to_include("You don't have permissions to provision cloud runners")
+            expect_graphql_errors_to_include("You don't have permissions to provision cloud runners")
+          end
         end
       end
-    end
 
-    context 'when user is not a maintainer or higher' do
-      let(:current_user) { create(:user).tap { |user| container.add_developer(user) } }
+      context 'when user is not a maintainer or higher' do
+        let(:current_user) { create(:user).tap { |user| container.add_developer(user) } }
 
-      it { is_expected.to be nil }
-    end
-
-    context 'when SaaS feature is not enabled' do
-      before do
-        stub_saas_features(google_cloud_support: false)
+        it { is_expected.to be nil }
       end
 
-      it { is_expected.to be nil }
-    end
+      context 'when SaaS feature is not enabled' do
+        before do
+          stub_saas_features(google_cloud_support: false)
+        end
 
-    context 'when google_cloud_runner_provisioning FF is disabled' do
-      before do
-        stub_feature_flags(google_cloud_runner_provisioning: false)
+        it { is_expected.to be nil }
       end
 
-      it { is_expected.to be nil }
+      context 'when google_cloud_runner_provisioning FF is disabled' do
+        before do
+          stub_feature_flags(google_cloud_runner_provisioning: false)
+        end
+
+        it { is_expected.to be nil }
+      end
     end
   end
 end

EE: true

Closes #438316 (closed)

Review status

Reviews Reviewer Status
backend (initial) @10io
backend (maintainer) @allison.browne
devopsverify @allison.browne
authorization @jarka

MR acceptance checklist

Please evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Screenshots or screen recordings

Screenshots are required for UI changes, and strongly recommended for all other merge requests.

image

How to set up and validate locally

Numbered steps to set up and validate the change are strongly suggested.

  1. Run the following query in http://gdk.test:3000/-/graphql-explorer:

    {
      group(fullPath: "gitlab-org") {
        id
        runnerCloudProvisioning(
          provider: GOOGLE_CLOUD
          cloudProjectId: "dev-gcp-s3c-integrati-9abafed1"
        ) {
          ... on CiRunnerGoogleCloudProvisioning {
            provisioningSteps(
              region: "us-central1"
              zone: "us-central1-a"
              ephemeralMachineType: "n2d-standard-2"
            ) {
              title
              languageIdentifier
              instructions
            }
          }
        }
      }
      
      project(fullPath: "gitlab-org/playground") {
        id
        runnerCloudProvisioning(
          provider: GOOGLE_CLOUD
          cloudProjectId: "dev-gcp-s3c-integrati-9abafed1"
        ) {
          ... on CiRunnerGoogleCloudProvisioning {
            zones(first: 2) {
              nodes {
                description
              }
            }
            provisioningSteps(
              region: "us-central1"
              zone: "us-central1-a"
              ephemeralMachineType: "n2d-standard-2"
            ) {
              title
              languageIdentifier
              instructions
            }
          }
        }
      }
    }
Edited by Pedro Pombeiro

Merge request reports