diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 2ffdcd4e7da32948942bcb14881e5b748d30dbf8..7c253de17af960046a7304fd576780cf318e1359 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -9853,6 +9853,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). @@ -16309,6 +16378,84 @@ Returns [`CiRunnerStatus!`](#cirunnerstatus). | ---- | ---- | ----------- | | <a id="cirunnerstatuslegacymode"></a>`legacyMode` **{warning-solid}** | [`String`](#string) | **Deprecated** in 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` | [`String`](#string) | Name of the machine type. | +| <a id="cirunnercloudprovisioningmachinetypezone"></a>`zone` | [`String`](#string) | Zone of the machine type. | + +### `CiRunnerCloudProvisioningOptions` + +Options for runner cloud provisioning. + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="cirunnercloudprovisioningoptionsregions"></a>`regions` | [`CiRunnerCloudProvisioningRegionConnection`](#cirunnercloudprovisioningregionconnection) | Regions available for provisioning a runner. (see [Connections](#connections)) | + +#### Fields with arguments + +##### `CiRunnerCloudProvisioningOptions.machineTypes` + +Machine types available for provisioning a runner. + +Returns [`CiRunnerCloudProvisioningMachineTypeConnection`](#cirunnercloudprovisioningmachinetypeconnection). + +This field returns a [connection](#connections). It accepts the +four standard [pagination arguments](#connection-pagination-arguments): +`before: String`, `after: String`, `first: Int`, and `last: Int`. + +###### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="cirunnercloudprovisioningoptionsmachinetypeszone"></a>`zone` | [`String!`](#string) | Zone for which to retrieve machine types. | + +##### `CiRunnerCloudProvisioningOptions.zones` + +Zones available for provisioning a runner. + +Returns [`CiRunnerCloudProvisioningZoneConnection`](#cirunnercloudprovisioningzoneconnection). + +This field returns a [connection](#connections). It accepts the +four standard [pagination arguments](#connection-pagination-arguments): +`before: String`, `after: String`, `first: Int`, and `last: Int`. + +###### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="cirunnercloudprovisioningoptionszonesregion"></a>`region` | [`String`](#string) | Region for which to retrieve zones. Returns all zones if not specified. | + +### `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` | [`String`](#string) | Name of the region. | + +### `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` | [`String`](#string) | Name of the zone. | + ### `CiRunnerManager` #### Fields @@ -25921,6 +26068,22 @@ four standard [pagination arguments](#connection-pagination-arguments): | <a id="projectrequirementsworkitemiid"></a>`workItemIid` | [`ID`](#id) | IID of the requirement work item, for example, "1". | | <a id="projectrequirementsworkitemiids"></a>`workItemIids` | [`[ID!]`](#id) | List of IIDs of requirement work items, for example, `[1, 2]`. | +##### `Project.runnerCloudProvisioningOptions` + +Options for runner cloud provisioning by a specified cloud provider. Returns `null` if `:gcp_runner` feature flag is disabled, or the GitLab instance is not a SaaS instance. + +NOTE: +**Introduced** in 16.9. +**Status**: Experiment. + +Returns [`CiRunnerCloudProvisioningOptions`](#cirunnercloudprovisioningoptions). + +###### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="projectrunnercloudprovisioningoptionsprovider"></a>`provider` | [`CiRunnerCloudProvider!`](#cirunnercloudprovider) | Identifier of the cloud provider. | + ##### `Project.runners` Find runners visible to the current user. @@ -30222,6 +30385,14 @@ Direction of access. | <a id="cirunneraccesslevelnot_protected"></a>`NOT_PROTECTED` | A runner that is not protected. | | <a id="cirunneraccesslevelref_protected"></a>`REF_PROTECTED` | A runner that is ref protected. | +### `CiRunnerCloudProvider` + +Runner cloud provider. + +| Value | Description | +| ----- | ----------- | +| <a id="cirunnercloudprovidergoogle_cloud"></a>`GOOGLE_CLOUD` | Google Cloud. | + ### `CiRunnerJobExecutionStatus` | Value | Description | diff --git a/ee/app/graphql/ee/types/project_type.rb b/ee/app/graphql/ee/types/project_type.rb index 3ae375fe2fb4db10448a8ac05a79d533ccce0f43..3e561be321fcbdf8e9096083767c8e3f63eba7a7 100644 --- a/ee/app/graphql/ee/types/project_type.rb +++ b/ee/app/graphql/ee/types/project_type.rb @@ -338,6 +338,17 @@ module ProjectType method: :downstream_project_subscriptions, description: 'Pipeline subscriptions for projects subscribed to the project.' + field :runner_cloud_provisioning_options, + ::Types::Ci::RunnerCloudProvisioningOptionsType, + null: true, + alpha: { milestone: '16.9' }, + description: 'Options for runner cloud provisioning by a specified cloud provider. ' \ + 'Returns `null` if `:gcp_runner` feature flag is disabled, or the GitLab instance ' \ + 'is not a SaaS instance.' do + argument :provider, ::Types::Ci::RunnerCloudProviderEnum, required: true, + description: 'Identifier of the cloud provider.' + end + field :ai_agents, ::Types::Ai::Agents::AgentType.connection_type, null: true, alpha: { milestone: '16.9' }, @@ -387,6 +398,16 @@ def compliance_frameworks end end end + + # TODO To be removed along with :gcp_runner feature flag. + # Use `method: :itself` on the related field (see https://graphql-ruby.org/fields/introduction.html#field-resolution). + # TODO Before unmarking the field as alpha, figure out solution for polymorphism based on provider argument, + # so that child objects call the correct cloud services + def runner_cloud_provisioning_options(provider:) # rubocop:disable Lint/UnusedMethodArgument -- Only one provider type is possible, and is already enforced by GraphQL + return if ::Feature.disabled?(:gcp_runner, project, type: :wip) + + project + end end end end diff --git a/ee/app/graphql/resolvers/ci/runner_cloud_provisioning_base_resolver.rb b/ee/app/graphql/resolvers/ci/runner_cloud_provisioning_base_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..660234a337bb9a5e86aa4e5bb02c7d67bbafd058 --- /dev/null +++ b/ee/app/graphql/resolvers/ci/runner_cloud_provisioning_base_resolver.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Resolvers + module Ci + # rubocop: disable Graphql/ResolverType -- the type is decided on the derived resolver class + class RunnerCloudProvisioningBaseResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + authorize :read_runner_cloud_provisioning_options + + private + + alias_method :project, :object + + 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 + end + # rubocop: enable Graphql/ResolverType + end +end diff --git a/ee/app/graphql/resolvers/ci/runner_cloud_provisioning_machine_types_resolver.rb b/ee/app/graphql/resolvers/ci/runner_cloud_provisioning_machine_types_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..ac64b780534fef2dab5314783bacf379a784277b --- /dev/null +++ b/ee/app/graphql/resolvers/ci/runner_cloud_provisioning_machine_types_resolver.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Resolvers + module Ci + class RunnerCloudProvisioningMachineTypesResolver < Resolvers::Ci::RunnerCloudProvisioningBaseResolver + type Types::Ci::RunnerCloudProvisioningMachineTypeType.connection_type, null: true + + description 'Machine types available for provisioning a runner.' + + argument :zone, GraphQL::Types::String, + required: true, + description: 'Zone for which to retrieve machine types.' + + max_page_size GoogleCloudPlatform::Compute::ListMachineTypesService::MAX_RESULTS_LIMIT + default_page_size GoogleCloudPlatform::Compute::ListMachineTypesService::MAX_RESULTS_LIMIT + + def resolve(zone:, after: nil, first: nil) + response = GoogleCloudPlatform::Compute::ListMachineTypesService + .new(project: project, current_user: current_user, zone: zone, params: default_params(after, first)) + .execute + + externally_paginated_array(response, after) + end + end + end +end diff --git a/ee/app/graphql/resolvers/ci/runner_cloud_provisioning_regions_resolver.rb b/ee/app/graphql/resolvers/ci/runner_cloud_provisioning_regions_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..bc641b3dcdd58cf7d094c9f4cf1d49df2991bdb8 --- /dev/null +++ b/ee/app/graphql/resolvers/ci/runner_cloud_provisioning_regions_resolver.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Resolvers + module Ci + class RunnerCloudProvisioningRegionsResolver < Resolvers::Ci::RunnerCloudProvisioningBaseResolver + type Types::Ci::RunnerCloudProvisioningRegionType.connection_type, null: true + + description 'Regions available for provisioning a runner.' + + max_page_size GoogleCloudPlatform::Compute::ListRegionsService::MAX_RESULTS_LIMIT + default_page_size GoogleCloudPlatform::Compute::ListRegionsService::MAX_RESULTS_LIMIT + + def resolve(after: nil, first: nil) + response = GoogleCloudPlatform::Compute::ListRegionsService + .new(project: project, current_user: current_user, params: default_params(after, first)) + .execute + + externally_paginated_array(response, after) + end + end + end +end diff --git a/ee/app/graphql/resolvers/ci/runner_cloud_provisioning_zones_resolver.rb b/ee/app/graphql/resolvers/ci/runner_cloud_provisioning_zones_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..72cd216eb9fe82692eaa4bece3d08a9b130cc22c --- /dev/null +++ b/ee/app/graphql/resolvers/ci/runner_cloud_provisioning_zones_resolver.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Resolvers + module Ci + class RunnerCloudProvisioningZonesResolver < Resolvers::Ci::RunnerCloudProvisioningBaseResolver + type Types::Ci::RunnerCloudProvisioningZoneType.connection_type, null: true + + description 'Zones available for provisioning a runner.' + + argument :region, GraphQL::Types::String, + required: false, + description: 'Region for which to retrieve zones. Returns all zones if not specified.' + + max_page_size GoogleCloudPlatform::Compute::ListZonesService::MAX_RESULTS_LIMIT + default_page_size GoogleCloudPlatform::Compute::ListZonesService::MAX_RESULTS_LIMIT + + def resolve(region: nil, after: nil, first: nil) + params = default_params(after, first) + params[:filter] = "name=#{region}-*" if region + + response = GoogleCloudPlatform::Compute::ListZonesService + .new(project: project, current_user: current_user, params: params) + .execute + + externally_paginated_array(response, after) + end + end + end +end diff --git a/ee/app/graphql/types/ci/runner_cloud_provider_enum.rb b/ee/app/graphql/types/ci/runner_cloud_provider_enum.rb new file mode 100644 index 0000000000000000000000000000000000000000..fa4c8d841ab2f5382b5fdd859600a33183482d12 --- /dev/null +++ b/ee/app/graphql/types/ci/runner_cloud_provider_enum.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + module Ci + class RunnerCloudProviderEnum < BaseEnum + graphql_name 'CiRunnerCloudProvider' + description 'Runner cloud provider.' + + value 'GOOGLE_CLOUD', value: :google_cloud, description: 'Google Cloud.' + end + end +end diff --git a/ee/app/graphql/types/ci/runner_cloud_provisioning_machine_type_type.rb b/ee/app/graphql/types/ci/runner_cloud_provisioning_machine_type_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..990e574db8db58f65398ca723affd6757f11ed39 --- /dev/null +++ b/ee/app/graphql/types/ci/runner_cloud_provisioning_machine_type_type.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop:disable Graphql/AuthorizeTypes -- This object is only available through one field, which is authorized. This type is mapped onto a simple hash, and therefore will not have a policy class for it. + class RunnerCloudProvisioningMachineTypeType < BaseObject + graphql_name 'CiRunnerCloudProvisioningMachineType' + description 'Machine type used for runner cloud provisioning.' + + field :zone, GraphQL::Types::String, + null: true, description: 'Zone of the machine type.' + + field :name, GraphQL::Types::String, + null: true, description: 'Name of the machine type.' + + field :description, GraphQL::Types::String, + null: true, description: 'Description of the machine type.' + end + # rubocop:enable Graphql/AuthorizeTypes + end +end diff --git a/ee/app/graphql/types/ci/runner_cloud_provisioning_options_type.rb b/ee/app/graphql/types/ci/runner_cloud_provisioning_options_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..dc7f52e3d7b94afa0d575fd330e54fae1ef77d2b --- /dev/null +++ b/ee/app/graphql/types/ci/runner_cloud_provisioning_options_type.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Types + module Ci + class RunnerCloudProvisioningOptionsType < BaseObject + graphql_name 'CiRunnerCloudProvisioningOptions' + description 'Options for runner cloud provisioning.' + + include Gitlab::Graphql::Authorize::AuthorizeResource + + authorize :read_runner_cloud_provisioning_options + + field :regions, Types::Ci::RunnerCloudProvisioningRegionType.connection_type, + null: true, + resolver: ::Resolvers::Ci::RunnerCloudProvisioningRegionsResolver, + connection_extension: Gitlab::Graphql::Extensions::ForwardOnlyExternallyPaginatedArrayExtension + + field :zones, Types::Ci::RunnerCloudProvisioningZoneType.connection_type, + null: true, + resolver: ::Resolvers::Ci::RunnerCloudProvisioningZonesResolver, + connection_extension: Gitlab::Graphql::Extensions::ForwardOnlyExternallyPaginatedArrayExtension + + field :machine_types, + Types::Ci::RunnerCloudProvisioningMachineTypeType.connection_type, + null: true, + resolver: ::Resolvers::Ci::RunnerCloudProvisioningMachineTypesResolver, + connection_extension: Gitlab::Graphql::Extensions::ForwardOnlyExternallyPaginatedArrayExtension + end + end +end diff --git a/ee/app/graphql/types/ci/runner_cloud_provisioning_region_type.rb b/ee/app/graphql/types/ci/runner_cloud_provisioning_region_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..468eb08f9075dd3ce22004de1e583eed8269fab7 --- /dev/null +++ b/ee/app/graphql/types/ci/runner_cloud_provisioning_region_type.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop:disable Graphql/AuthorizeTypes -- This object is only available through one field, which is authorized. This type is mapped onto a simple hash, and therefore will not have a policy class for it. + class RunnerCloudProvisioningRegionType < BaseObject + graphql_name 'CiRunnerCloudProvisioningRegion' + description 'Region used for runner cloud provisioning.' + + field :name, GraphQL::Types::String, + null: true, description: 'Name of the region.' + + field :description, GraphQL::Types::String, + null: true, description: 'Description of the region.' + end + # rubocop:enable Graphql/AuthorizeTypes + end +end diff --git a/ee/app/graphql/types/ci/runner_cloud_provisioning_zone_type.rb b/ee/app/graphql/types/ci/runner_cloud_provisioning_zone_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..cde0927cb9b20be5c3bff9801597423d513d21d3 --- /dev/null +++ b/ee/app/graphql/types/ci/runner_cloud_provisioning_zone_type.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop:disable Graphql/AuthorizeTypes -- This object is only available through one field, which is authorized. This type is mapped onto a simple hash, and therefore will not have a policy class for it. + class RunnerCloudProvisioningZoneType < BaseObject + graphql_name 'CiRunnerCloudProvisioningZone' + description 'Zone used for runner cloud provisioning.' + + field :name, GraphQL::Types::String, + null: true, description: 'Name of the zone.' + + field :description, GraphQL::Types::String, + null: true, description: 'Description of the zone.' + end + # rubocop:enable Graphql/AuthorizeTypes + end +end diff --git a/ee/spec/graphql/types/ci/runner_cloud_provisioning_machine_type_type_spec.rb b/ee/spec/graphql/types/ci/runner_cloud_provisioning_machine_type_type_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..3de774cf83f46bb38fb882975566431c31e8341a --- /dev/null +++ b/ee/spec/graphql/types/ci/runner_cloud_provisioning_machine_type_type_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['CiRunnerCloudProvisioningMachineType'], feature_category: :fleet_visibility do + specify do + expect(described_class.description).to eq('Machine type used for runner cloud provisioning.') + end + + it 'includes all expected fields' do + expected_fields = %w[zone name description] + + expect(described_class).to include_graphql_fields(*expected_fields) + end +end diff --git a/ee/spec/graphql/types/ci/runner_cloud_provisioning_region_type_spec.rb b/ee/spec/graphql/types/ci/runner_cloud_provisioning_region_type_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..85d1bccffeb2f2729a1cf020178e55cdfc3ad02b --- /dev/null +++ b/ee/spec/graphql/types/ci/runner_cloud_provisioning_region_type_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['CiRunnerCloudProvisioningRegion'], feature_category: :fleet_visibility do + specify do + expect(described_class.description).to eq('Region used for runner cloud provisioning.') + end + + it 'includes all expected fields' do + expected_fields = %w[name description] + + expect(described_class).to include_graphql_fields(*expected_fields) + end +end diff --git a/ee/spec/graphql/types/ci/runner_cloud_provisioning_zone_type_spec.rb b/ee/spec/graphql/types/ci/runner_cloud_provisioning_zone_type_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c915f58aaa3452dd2a8eb4f8ae7575e48cf6cee3 --- /dev/null +++ b/ee/spec/graphql/types/ci/runner_cloud_provisioning_zone_type_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['CiRunnerCloudProvisioningZone'], feature_category: :fleet_visibility do + specify do + expect(described_class.description).to eq('Zone used for runner cloud provisioning.') + end + + it 'includes all expected fields' do + expected_fields = %w[name description] + + expect(described_class).to include_graphql_fields(*expected_fields) + end +end diff --git a/ee/spec/graphql/types/project_type_spec.rb b/ee/spec/graphql/types/project_type_spec.rb index 8b62b1dc3a8aca4ce5bc8e5a6ce2054433d5e58e..222933d5e19787e5e25fd87625cdb24c6feb93ab 100644 --- a/ee/spec/graphql/types/project_type_spec.rb +++ b/ee/spec/graphql/types/project_type_spec.rb @@ -29,7 +29,7 @@ security_policy_project security_training_urls vulnerability_images only_allow_merge_if_all_status_checks_passed security_policy_project_linked_projects security_policy_project_linked_namespaces dependencies merge_requests_disable_committers_approval has_jira_vulnerability_issue_creation_enabled - ci_subscriptions_projects ci_subscribed_projects ai_agents duo_features_enabled + ci_subscriptions_projects ci_subscribed_projects ai_agents duo_features_enabled runner_cloud_provisioning_options ] expect(described_class).to include_graphql_fields(*expected_fields) @@ -491,6 +491,12 @@ it { is_expected.to have_graphql_resolver(Resolvers::Ai::Agents::FindAgentResolver) } end + describe 'runnerCloudProvisioningOptions', feature_category: :fleet_visibility do + subject { described_class.fields['runnerCloudProvisioningOptions'] } + + it { is_expected.to have_graphql_type(::Types::Ci::RunnerCloudProvisioningOptionsType) } + end + private def query_for_project(project) diff --git a/ee/spec/requests/api/graphql/project/runner_cloud_provisioning_options_spec.rb b/ee/spec/requests/api/graphql/project/runner_cloud_provisioning_options_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..1973920a007139f80f317556def2dc349671e80d --- /dev/null +++ b/ee/spec/requests/api/graphql/project/runner_cloud_provisioning_options_spec.rb @@ -0,0 +1,254 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'runnerCloudProvisioningOptions', feature_category: :fleet_visibility do + include GraphqlHelpers + + let_it_be_with_refind(:project) { create(:project) } + let_it_be_with_refind(:integration) { create(:google_cloud_platform_artifact_registry_integration, project: project) } + let_it_be(:maintainer) { create(:user).tap { |user| project.add_maintainer(user) } } + + let(:client_klass) { GoogleCloudPlatform::Compute::Client } + let(:current_user) { maintainer } + let(:expected_compute_client_args) do + { + project: project, + user: current_user, + gcp_project_id: integration.artifact_registry_project_id, + gcp_wlif: integration.wlif + } + end + + let(:current_page_token) { nil } + let(:expected_next_page_token) { nil } + let(:node_name) { :regions } + let(:item_type) { 'CiRunnerCloudProvisioningRegion' } + let(:base_item_query_args) { {} } + let(:item_query_args) { {} } + let(:query) do + graphql_query_for( + :project, { fullPath: project.full_path }, + query_graphql_field( + :runner_cloud_provisioning_options, { provider: :GOOGLE_CLOUD }, + query_nodes(node_name, args: base_item_query_args.merge(item_query_args), of: item_type, + include_pagination_info: true) + ) + ) + end + + let(:options_response) do + request + graphql_data_at('project', 'runnerCloudProvisioningOptions') + end + + subject(:request) do + post_graphql(query, current_user: current_user) + end + + before do + stub_saas_features(google_artifact_registry: true) + end + + 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 + + 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 + + shared_examples 'a query calling compute client' do + let(:page_size) { GoogleCloudPlatform::Compute::BaseService::MAX_RESULTS_LIMIT } + let(:expected_client_args) { {} } + let(:expected_pagination_client_args) { { max_results: page_size, page_token: current_page_token, order_by: nil } } + let(:actual_returned_nodes) { returned_nodes } + + 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 + + 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_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 } } + + 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 '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(: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 user does not have required permissions' do + let(:current_user) { create(:user).tap { |user| project.add_developer(user) } } + + it { is_expected.to be nil } + end + + context 'when SaaS feature is not enabled' do + before do + stub_saas_features(google_artifact_registry: false) + end + + it { is_expected.to be nil } + end + + context 'when gcp_runner FF is disabled' do + before do + stub_feature_flags(gcp_runner: false) + end + + it { is_expected.to be nil } + end + + context 'when integration is not present' do + before do + integration.destroy! + end + + it 'returns error' do + post_graphql(query, current_user: current_user) + expect_graphql_errors_to_include(/integration not set/) + end + end + + context 'when integration is inactive' do + before do + integration.update_column(:active, false) + end + + it 'returns error' do + post_graphql(query, current_user: current_user) + expect_graphql_errors_to_include(/integration not active/) + end + end + + 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