diff --git a/ee/app/policies/ee/project_policy.rb b/ee/app/policies/ee/project_policy.rb index 32f12f7db02b8d32fecc19f7dd190cfc4c048587..003c75349cd38c66619220362292e6a4eb50bc43 100644 --- a/ee/app/policies/ee/project_policy.rb +++ b/ee/app/policies/ee/project_policy.rb @@ -149,6 +149,11 @@ module ProjectPolicy @subject.feature_available?(:combined_project_analytics_dashboards, @user) end + condition(:google_cloud_support_available, scope: :global) do + # TODO: This will be renamed to google_cloud_platform_support (https://gitlab.com/gitlab-org/gitlab/-/issues/438989) + ::Gitlab::Saas.feature_available?(:google_artifact_registry) + end + condition(:status_page_available) do @subject.feature_available?(:status_page, @user) end @@ -772,6 +777,8 @@ module ProjectPolicy rule { status_page_available & can?(:owner_access) }.enable :mark_issue_for_publication rule { status_page_available & can?(:developer_access) }.enable :publish_status_page + rule { google_cloud_support_available & can?(:maintainer_access) }.enable :read_runner_cloud_provisioning_options + rule { hidden }.policy do prevent :download_code prevent :build_download_code diff --git a/ee/app/services/google_cloud_platform/compute/base_service.rb b/ee/app/services/google_cloud_platform/compute/base_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..60c9647d6f6b9d2d5f3597ef66355acebfbd888c --- /dev/null +++ b/ee/app/services/google_cloud_platform/compute/base_service.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +module GoogleCloudPlatform + module Compute + class BaseService < ::BaseProjectService + include BaseServiceUtility + + VALID_ORDER_BY_COLUMNS = %w[creationTimestamp name].freeze + VALID_ORDER_BY_DIRECTIONS = %w[asc desc].freeze + + MAX_RESULTS_LIMIT = 500 + + ERROR_RESPONSES = { + saas_only: ServiceResponse.error(message: "This is a SaaS-only feature that can't run here"), + feature_flag_disabled: ServiceResponse.error(message: 'Feature flag not enabled'), + access_denied: ServiceResponse.error(message: 'Access denied'), + no_integration: ServiceResponse.error(message: 'Project Artifact Registry integration not set'), + integration_not_active: ServiceResponse.error(message: 'Project Artifact Registry integration not active'), + google_cloud_authentication_error: + ServiceResponse.error(message: 'Unable to authenticate against Google Cloud'), + invalid_order_by: ServiceResponse.error(message: 'Invalid order_by value'), + max_results_out_of_bounds: ServiceResponse.error(message: 'Max results argument is out-of-bounds') + }.freeze + + GCP_API_ERROR_MESSAGE = 'Unsuccessful Google Cloud API request' + + def execute + params[:max_results] ||= MAX_RESULTS_LIMIT + + validation_response = validate_before_execute + return validation_response if validation_response&.error? + + handling_client_errors { call_client } + end + + private + + def validate_before_execute + return ERROR_RESPONSES[:saas_only] unless Gitlab::Saas.feature_available?(:google_artifact_registry) + return ERROR_RESPONSES[:feature_flag_disabled] unless Feature.enabled?(:gcp_runner, project, type: :wip) + return ERROR_RESPONSES[:access_denied] unless allowed? + + return ERROR_RESPONSES[:no_integration] unless project_integration + return ERROR_RESPONSES[:integration_not_active] unless project_integration.active + + return ERROR_RESPONSES[:max_results_out_of_bounds] unless (1..MAX_RESULTS_LIMIT).cover?(max_results) + return ERROR_RESPONSES[:invalid_order_by] unless valid_order_by?(order_by) + + ServiceResponse.success + end + + def allowed? + can?(current_user, :read_runner_cloud_provisioning_options, project) + end + + def valid_order_by?(value) + return true if value.blank? + + column, direction = value.split(' ') + + return false unless column.in?(VALID_ORDER_BY_COLUMNS) + return false unless direction.in?(VALID_ORDER_BY_DIRECTIONS) + + true + end + + def client + ::GoogleCloudPlatform::Compute::Client.new( + project: project, + user: current_user, + gcp_project_id: gcp_project_id, + gcp_wlif: gcp_wlif + ) + end + + def gcp_project_id + project_integration.artifact_registry_project_id + end + + def gcp_wlif + project_integration.wlif + end + + def project_integration + project.google_cloud_platform_artifact_registry_integration + end + strong_memoize_attr :project_integration + + def max_results + params[:max_results] + end + + def filter + params[:filter] + end + + def order_by + params[:order_by] + end + + def page_token + params[:page_token] + end + + def handling_client_errors + yield + rescue ::GoogleCloudPlatform::AuthenticationError => e + log_error_with_project_id(message: e.message) + ERROR_RESPONSES[:google_cloud_authentication_error] + rescue ::GoogleCloudPlatform::ApiError => e + log_error_with_project_id(message: e.message) + ServiceResponse.error(message: "#{GCP_API_ERROR_MESSAGE}: #{e.message}") + end + + def log_error_with_project_id(message:) + log_error(class_name: self.class.name, project_id: project.id, message: message) + end + end + end +end diff --git a/ee/app/services/google_cloud_platform/compute/list_machine_types_service.rb b/ee/app/services/google_cloud_platform/compute/list_machine_types_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..36ac06f50b86abb5963539de06e210c36ace272e --- /dev/null +++ b/ee/app/services/google_cloud_platform/compute/list_machine_types_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module GoogleCloudPlatform + module Compute + class ListMachineTypesService < ::GoogleCloudPlatform::Compute::BaseService + MISSING_ZONE_ERROR_RESPONSE = ServiceResponse.error(message: 'Zone value must be provided').freeze + + def initialize(project:, current_user:, zone:, params: {}) + super(project: project, current_user: current_user, params: params.merge(zone: zone)) + end + + private + + def zone + params[:zone] + end + + def call_client + return MISSING_ZONE_ERROR_RESPONSE if zone.blank? + + machine_types = client.machine_types( + zone: zone, + filter: filter, + max_results: max_results, + page_token: page_token, + order_by: order_by + ) + + ServiceResponse.success(payload: { + items: machine_types.items.map { |t| { name: t.name, description: t.description, zone: t.zone } }, + next_page_token: machine_types.next_page_token + }) + end + end + end +end diff --git a/ee/app/services/google_cloud_platform/compute/list_regions_service.rb b/ee/app/services/google_cloud_platform/compute/list_regions_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..30b29acd5b4494b1b9fdf03d29f2bdbd65ad8179 --- /dev/null +++ b/ee/app/services/google_cloud_platform/compute/list_regions_service.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module GoogleCloudPlatform + module Compute + class ListRegionsService < ::GoogleCloudPlatform::Compute::BaseService + private + + def call_client + regions = client.regions(filter: filter, max_results: max_results, page_token: page_token, order_by: order_by) + ServiceResponse.success(payload: { + items: regions.items.map { |r| { name: r.name, description: r.description } }, + next_page_token: regions.next_page_token + }) + end + end + end +end diff --git a/ee/app/services/google_cloud_platform/compute/list_zones_service.rb b/ee/app/services/google_cloud_platform/compute/list_zones_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..a3934c3aa73e685e0c3e629f357ae494b80fd89b --- /dev/null +++ b/ee/app/services/google_cloud_platform/compute/list_zones_service.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module GoogleCloudPlatform + module Compute + class ListZonesService < ::GoogleCloudPlatform::Compute::BaseService + private + + def call_client + zones = client.zones(filter: filter, max_results: max_results, page_token: page_token, order_by: order_by) + ServiceResponse.success(payload: { + items: zones.items.map { |z| { name: z.name, description: z.description } }, + next_page_token: zones.next_page_token + }) + end + end + end +end diff --git a/ee/lib/google_cloud_platform/compute/client.rb b/ee/lib/google_cloud_platform/compute/client.rb index 55e6935b7057cc25ca03bde58c733d568ec42c5f..be9734840fcf40e978631108bb0caad2d33bc58c 100644 --- a/ee/lib/google_cloud_platform/compute/client.rb +++ b/ee/lib/google_cloud_platform/compute/client.rb @@ -32,8 +32,8 @@ class Client < ::GoogleCloudPlatform::BaseClient # # Possible exceptions: # - # +GoogleCloudPlatform::Compute::BaseClient::AuthenticationError+ if an error occurs during the authentication. - # +GoogleCloudPlatform::Compute::BaseClient::ApiError+ if an error occurs when interacting with the + # +GoogleCloudPlatform::AuthenticationError+ if an error occurs during the authentication. + # +GoogleCloudPlatform::ApiError+ if an error occurs when interacting with the # Google Cloud API. def regions(filter: nil, max_results: 500, order_by: nil, page_token: nil) request = ::Google::Cloud::Compute::V1::ListRegionsRequest.new( @@ -71,8 +71,8 @@ def regions(filter: nil, max_results: 500, order_by: nil, page_token: nil) # # Possible exceptions: # - # +GoogleCloudPlatform::Compute::BaseClient::AuthenticationError+ if an error occurs during the authentication. - # +GoogleCloudPlatform::Compute::BaseClient::ApiError+ if an error occurs when interacting with the + # +GoogleCloudPlatform::AuthenticationError+ if an error occurs during the authentication. + # +GoogleCloudPlatform::ApiError+ if an error occurs when interacting with the # Google Cloud API. def zones(filter: nil, max_results: 500, order_by: nil, page_token: nil) request = ::Google::Cloud::Compute::V1::ListZonesRequest.new( @@ -111,8 +111,8 @@ def zones(filter: nil, max_results: 500, order_by: nil, page_token: nil) # # Possible exceptions: # - # +GoogleCloudPlatform::Compute::BaseClient::AuthenticationError+ if an error occurs during the authentication. - # +GoogleCloudPlatform::Compute::BaseClient::ApiError+ if an error occurs when interacting with the + # +GoogleCloudPlatform::AuthenticationError+ if an error occurs during the authentication. + # +GoogleCloudPlatform::ApiError+ if an error occurs when interacting with the # Google Cloud API. def machine_types(zone:, filter: nil, max_results: 500, order_by: nil, page_token: nil) request = ::Google::Cloud::Compute::V1::ListMachineTypesRequest.new( diff --git a/ee/spec/policies/project_policy_spec.rb b/ee/spec/policies/project_policy_spec.rb index 05507381b362fe02874cdf2880316798b4e9d5be..a7735db0574c8026932ff4117742d38520304ad5 100644 --- a/ee/spec/policies/project_policy_spec.rb +++ b/ee/spec/policies/project_policy_spec.rb @@ -3531,4 +3531,28 @@ def create_member_role(member, abilities = member_role_abilities) end end end + + describe 'read_runner_cloud_provisioning_options policy' do + let(:current_user) { maintainer } + + it { is_expected.to be_disallowed(:read_runner_cloud_provisioning_options) } + + context 'when SaaS-only feature is available' do + before do + stub_saas_features(google_artifact_registry: true) + end + + context 'the user is a maintainer' do + let(:current_user) { maintainer } + + it { is_expected.to be_allowed(:read_runner_cloud_provisioning_options) } + end + + context 'the user is a guest' do + let(:current_user) { guest } + + it { is_expected.to be_disallowed(:read_runner_cloud_provisioning_options) } + end + end + end end diff --git a/ee/spec/services/google_cloud_platform/compute/list_machine_types_service_spec.rb b/ee/spec/services/google_cloud_platform/compute/list_machine_types_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..4e3902246add347b0a6f06661a9ebdf9daa1970c --- /dev/null +++ b/ee/spec/services/google_cloud_platform/compute/list_machine_types_service_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GoogleCloudPlatform::Compute::ListMachineTypesService, feature_category: :fleet_visibility do + using RSpec::Parameterized::TableSyntax + include_context 'for a compute service' + + describe '#execute' do + let(:zone) { 'us-central-1a' } + let(:filter) { 'name=test' } + let(:max_results) { 50 } + let(:page_token) { 'token' } + let(:order_by) { 'name asc' } + let(:service) { described_class.new(project: project, current_user: user, zone: zone, params: params) } + let(:params) do + { filter: filter, max_results: max_results, page_token: page_token, order_by: order_by } + end + + subject(:response) { service.execute } + + it_behaves_like 'a compute service handling validation errors', client_method: :machine_types + + context 'with saas only feature enabled' do + before do + stub_saas_features(google_artifact_registry: true) + + allow(client_double).to receive(:machine_types) + .with(zone: zone, filter: filter, max_results: max_results, page_token: page_token, order_by: order_by) + .and_return(dummy_list_response) + end + + it 'returns the machine_types' do + expect(response).to be_success + expect(response.payload[:items]).to be_a Enumerable + expect(response.payload[:items]).to contain_exactly({ + name: 'test', zone: 'us-central1-a', description: 'Large machine type' + }) + expect(response.payload[:next_page_token]).to eq('next_page_token') + end + + context 'with a missing zone value' do + let(:zone) { nil } + + it 'returns error' do + expect(response).to be_error + expect(response.message).to eq('Zone value must be provided') + end + end + + context 'with an invalid order_by' do + where(:field, :direction) do + 'test' | 'asc' + 'name' | 'greater_than' + '' | 'desc' + 'name' | '' + end + + with_them do + let(:order_by) { "#{field} #{direction}" } + + it_behaves_like 'returning an error service response', message: 'Invalid order_by value' + end + end + + context 'with an invalid max_results' do + where(:max_results) { [0, described_class::MAX_RESULTS_LIMIT + 1] } + + with_them do + it_behaves_like 'returning an error service response', message: 'Max results argument is out-of-bounds' + end + end + end + + private + + def dummy_list_response + ::Google::Cloud::Compute::V1::MachineTypeList.new( + items: [ + ::Google::Cloud::Compute::V1::MachineType.new( + name: 'test', zone: 'us-central1-a', description: 'Large machine type' + ) + ], + next_page_token: 'next_page_token' + ) + end + end +end diff --git a/ee/spec/services/google_cloud_platform/compute/list_regions_service_spec.rb b/ee/spec/services/google_cloud_platform/compute/list_regions_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c6d6b306416ce92cfa4c25093875931bff93257b --- /dev/null +++ b/ee/spec/services/google_cloud_platform/compute/list_regions_service_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GoogleCloudPlatform::Compute::ListRegionsService, feature_category: :fleet_visibility do + using RSpec::Parameterized::TableSyntax + include_context 'for a compute service' + + describe '#execute' do + let(:filter) { 'name=test' } + let(:max_results) { 50 } + let(:page_token) { 'token' } + let(:order_by) { 'name asc' } + let(:params) do + { filter: filter, max_results: max_results, page_token: page_token, order_by: order_by } + end + + subject(:response) { service.execute } + + it_behaves_like 'a compute service handling validation errors', client_method: :regions + + context 'with saas only feature enabled' do + before do + stub_saas_features(google_artifact_registry: true) + + allow(client_double).to receive(:regions) + .with(filter: filter, max_results: max_results, page_token: page_token, order_by: order_by) + .and_return(dummy_list_response) + end + + it 'returns the regions' do + expect(response).to be_success + expect(response.payload[:items]).to be_a Enumerable + expect(response.payload[:items]).to contain_exactly({ name: 'test', description: 'us-central1' }) + expect(response.payload[:next_page_token]).to eq('next_page_token') + end + + context 'with an invalid order_by' do + where(:field, :direction) do + 'test' | 'asc' + 'name' | 'greater_than' + '' | 'desc' + 'name' | '' + end + + with_them do + let(:order_by) { "#{field} #{direction}" } + + it_behaves_like 'returning an error service response', message: 'Invalid order_by value' + end + + context 'with an invalid max_results' do + where(:max_results) { [0, described_class::MAX_RESULTS_LIMIT + 1] } + + with_them do + it_behaves_like 'returning an error service response', message: 'Max results argument is out-of-bounds' + end + end + end + end + + private + + def dummy_list_response + ::Google::Cloud::Compute::V1::RegionList.new( + items: [::Google::Cloud::Compute::V1::Region.new(name: 'test', description: 'us-central1')], + next_page_token: 'next_page_token' + ) + end + end +end diff --git a/ee/spec/services/google_cloud_platform/compute/list_zones_service_spec.rb b/ee/spec/services/google_cloud_platform/compute/list_zones_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..0a33ffbf812a4376e3c93492fc8a4b5bc78cd30c --- /dev/null +++ b/ee/spec/services/google_cloud_platform/compute/list_zones_service_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GoogleCloudPlatform::Compute::ListZonesService, feature_category: :fleet_visibility do + using RSpec::Parameterized::TableSyntax + include_context 'for a compute service' + + describe '#execute' do + let(:filter) { 'name=test' } + let(:max_results) { 50 } + let(:page_token) { 'token' } + let(:order_by) { 'name asc' } + let(:params) do + { filter: filter, max_results: max_results, page_token: page_token, order_by: order_by } + end + + subject(:response) { service.execute } + + it_behaves_like 'a compute service handling validation errors', client_method: :zones + + context 'with saas only feature enabled' do + before do + stub_saas_features(google_artifact_registry: true) + + allow(client_double).to receive(:zones) + .with(filter: filter, max_results: max_results, page_token: page_token, order_by: order_by) + .and_return(dummy_list_response) + end + + it 'returns the zones' do + expect(response).to be_success + expect(response.payload[:items]).to be_a Enumerable + expect(response.payload[:items]).to contain_exactly({ name: 'test', description: 'us-central1-a' }) + expect(response.payload[:next_page_token]).to eq('next_page_token') + end + + context 'with an invalid order_by' do + where(:field, :direction) do + 'test' | 'asc' + 'name' | 'greater_than' + '' | 'desc' + 'name' | '' + end + + with_them do + let(:order_by) { "#{field} #{direction}" } + + it_behaves_like 'returning an error service response', message: 'Invalid order_by value' + end + end + + context 'with an invalid max_results' do + where(:max_results) { [0, described_class::MAX_RESULTS_LIMIT + 1] } + + with_them do + it_behaves_like 'returning an error service response', message: 'Max results argument is out-of-bounds' + end + end + end + + private + + def dummy_list_response + ::Google::Cloud::Compute::V1::ZoneList.new( + items: [::Google::Cloud::Compute::V1::Zone.new(name: 'test', description: 'us-central1-a')], + next_page_token: 'next_page_token' + ) + end + end +end diff --git a/ee/spec/support/shared_contexts/google_cloud_platform/compute/services_shared_contexts.rb b/ee/spec/support/shared_contexts/google_cloud_platform/compute/services_shared_contexts.rb new file mode 100644 index 0000000000000000000000000000000000000000..7eb5aece3a916f0024dc8a1ea2cbe10f7447ddb4 --- /dev/null +++ b/ee/spec/support/shared_contexts/google_cloud_platform/compute/services_shared_contexts.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +RSpec.shared_context 'for a compute service' do + let_it_be_with_reload(:project) { create(:project, :private) } + let_it_be_with_refind(:project_integration) do + create( + :google_cloud_platform_artifact_registry_integration, + project: project, + artifact_registry_project_id: 'gcp_project_id', + workload_identity_pool_project_number: '555', + workload_identity_pool_id: 'my_pool', + workload_identity_pool_provider_id: 'my_provider' + ) + end + + let(:user) { project.owner } + let(:service) { described_class.new(project: project, current_user: user, params: params) } + let(:client_double) { instance_double('::GoogleCloudPlatform::Compute::Client') } + + before do + allow(::GoogleCloudPlatform::Compute::Client).to receive(:new) + .with( + project: project, + user: user, + gcp_project_id: project_integration.artifact_registry_project_id, + gcp_wlif: project_integration.wlif + ).and_return(client_double) + end +end diff --git a/ee/spec/support/shared_examples/google_cloud_platform/compute/services_shared_examples.rb b/ee/spec/support/shared_examples/google_cloud_platform/compute/services_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..0e9a9f56d4f68960a16f687222f2ef4c9c7112a7 --- /dev/null +++ b/ee/spec/support/shared_examples/google_cloud_platform/compute/services_shared_examples.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'a compute service handling validation errors' do |client_method:| + it_behaves_like 'returning an error service response', message: "This is a SaaS-only feature that can't run here" + + context 'with saas only feature enabled' do + before do + stub_saas_features(google_artifact_registry: true) + end + + shared_examples 'logging an error' do |message:| + it 'logs an error' do + expect(service).to receive(:log_error) + .with(class_name: described_class.name, project_id: project.id, message: message) + + subject + end + end + + context 'with not enough permissions' do + let_it_be(:user) { create(:user).tap { |user| project.add_developer(user) } } + + it_behaves_like 'returning an error service response', message: 'Access denied' + end + + context 'with gcp_runner FF disabled' do + before do + stub_feature_flags(gcp_runner: false) + end + + it_behaves_like 'returning an error service response', message: 'Feature flag not enabled' + end + + context 'with no integration' do + before do + project_integration.destroy! + end + + it_behaves_like 'returning an error service response', message: 'Project Artifact Registry integration not set' + end + + context 'with disabled integration' do + before do + project_integration.update!(active: false) + end + + it_behaves_like 'returning an error service response', message: 'Project Artifact Registry integration not active' + end + + context 'when client raises AuthenticationError' do + before do + allow(client_double).to receive(client_method).and_raise(::GoogleCloudPlatform::AuthenticationError, 'boom') + end + + it_behaves_like 'returning an error service response', message: 'Unable to authenticate against Google Cloud' + it_behaves_like 'logging an error', message: 'boom' + end + + context 'when client raises ApiError' do + before do + allow(client_double).to receive(client_method).and_raise(::GoogleCloudPlatform::ApiError, 'invalid arg') + end + + it_behaves_like 'returning an error service response', + message: "#{described_class::GCP_API_ERROR_MESSAGE}: invalid arg" + it_behaves_like 'logging an error', message: 'invalid arg' + end + end +end