diff options
Diffstat (limited to 'spec/services/cloud_seed')
11 files changed, 1188 insertions, 0 deletions
diff --git a/spec/services/cloud_seed/google_cloud/create_cloudsql_instance_service_spec.rb b/spec/services/cloud_seed/google_cloud/create_cloudsql_instance_service_spec.rb new file mode 100644 index 00000000000..f6f1206e753 --- /dev/null +++ b/spec/services/cloud_seed/google_cloud/create_cloudsql_instance_service_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe CloudSeed::GoogleCloud::CreateCloudsqlInstanceService, feature_category: :deployment_management do + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:gcp_project_id) { 'gcp_project_120' } + let(:environment_name) { 'test_env_42' } + let(:database_version) { 'POSTGRES_8000' } + let(:tier) { 'REIT_TIER' } + let(:service) do + described_class.new(project, user, { + gcp_project_id: gcp_project_id, + environment_name: environment_name, + database_version: database_version, + tier: tier + }) + end + + describe '#execute' do + before do + allow_next_instance_of(::Ci::VariablesFinder) do |variable_finder| + allow(variable_finder).to receive(:execute).and_return([]) + end + end + + it 'triggers creation of a cloudsql instance' do + expect_next_instance_of(GoogleApi::CloudPlatform::Client) do |client| + expected_instance_name = "gitlab-#{project.id}-postgres-8000-test-env-42" + expect(client).to receive(:create_cloudsql_instance).with( + gcp_project_id, + expected_instance_name, + String, + database_version, + 'us-east1', + tier + ) + end + + result = service.execute + expect(result[:status]).to be(:success) + end + + it 'triggers worker to manage cloudsql instance creation operation results' do + expect_next_instance_of(GoogleApi::CloudPlatform::Client) do |client| + expect(client).to receive(:create_cloudsql_instance) + end + + expect(GoogleCloud::CreateCloudsqlInstanceWorker).to receive(:perform_in) + + result = service.execute + expect(result[:status]).to be(:success) + end + + context 'when google APIs fail' do + it 'returns error' do + expect_next_instance_of(GoogleApi::CloudPlatform::Client) do |client| + expect(client).to receive(:create_cloudsql_instance).and_raise(Google::Apis::Error.new('mock-error')) + end + + result = service.execute + expect(result[:status]).to eq(:error) + end + end + + context 'when project has GCP_REGION defined' do + let(:gcp_region) { instance_double(::Ci::Variable, key: 'GCP_REGION', value: 'user-defined-region') } + + before do + allow_next_instance_of(::Ci::VariablesFinder) do |variable_finder| + allow(variable_finder).to receive(:execute).and_return([gcp_region]) + end + end + + it 'uses defined region' do + expect_next_instance_of(GoogleApi::CloudPlatform::Client) do |client| + expect(client).to receive(:create_cloudsql_instance).with( + gcp_project_id, + String, + String, + database_version, + 'user-defined-region', + tier + ) + end + + service.execute + end + end + end +end diff --git a/spec/services/cloud_seed/google_cloud/create_service_accounts_service_spec.rb b/spec/services/cloud_seed/google_cloud/create_service_accounts_service_spec.rb new file mode 100644 index 00000000000..da30037963b --- /dev/null +++ b/spec/services/cloud_seed/google_cloud/create_service_accounts_service_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe CloudSeed::GoogleCloud::CreateServiceAccountsService, feature_category: :deployment_management do + describe '#execute' do + before do + mock_google_oauth2_creds = Struct.new(:app_id, :app_secret) + .new('mock-app-id', 'mock-app-secret') + allow(Gitlab::Auth::OAuth::Provider).to receive(:config_for) + .with('google_oauth2') + .and_return(mock_google_oauth2_creds) + + allow_next_instance_of(GoogleApi::CloudPlatform::Client) do |client| + mock_service_account = Struct.new(:project_id, :unique_id, :email) + .new('mock-project-id', 'mock-unique-id', 'mock-email') + allow(client).to receive(:create_service_account) + .and_return(mock_service_account) + + allow(client).to receive(:create_service_account_key) + .and_return('mock-key') + + allow(client) + .to receive(:grant_service_account_roles) + end + end + + it 'creates unprotected vars', :aggregate_failures do + allow(ProtectedBranch).to receive(:protected?).and_return(false) + + project = create(:project) + + service = described_class.new( + project, + nil, + google_oauth2_token: 'mock-token', + gcp_project_id: 'mock-gcp-project-id', + environment_name: '*' + ) + + response = service.execute + + expect(response.status).to eq(:success) + expect(response.message).to eq('Service account generated successfully') + expect(project.variables.count).to eq(3) + expect(project.variables.first.protected).to eq(false) + expect(project.variables.second.protected).to eq(false) + expect(project.variables.third.protected).to eq(false) + end + + it 'creates protected vars', :aggregate_failures do + allow(ProtectedBranch).to receive(:protected?).and_return(true) + + project = create(:project) + + service = described_class.new( + project, + nil, + google_oauth2_token: 'mock-token', + gcp_project_id: 'mock-gcp-project-id', + environment_name: '*' + ) + + response = service.execute + + expect(response.status).to eq(:success) + expect(response.message).to eq('Service account generated successfully') + expect(project.variables.count).to eq(3) + expect(project.variables.first.protected).to eq(true) + expect(project.variables.second.protected).to eq(true) + expect(project.variables.third.protected).to eq(true) + end + end +end diff --git a/spec/services/cloud_seed/google_cloud/enable_cloud_run_service_spec.rb b/spec/services/cloud_seed/google_cloud/enable_cloud_run_service_spec.rb new file mode 100644 index 00000000000..09f1b3460cc --- /dev/null +++ b/spec/services/cloud_seed/google_cloud/enable_cloud_run_service_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe CloudSeed::GoogleCloud::EnableCloudRunService, feature_category: :deployment_management do + describe 'when a project does not have any gcp projects' do + let_it_be(:project) { create(:project) } + + it 'returns error' do + result = described_class.new(project).execute + + expect(result[:status]).to eq(:error) + expect(result[:message]).to eq('No GCP projects found. Configure a service account or GCP_PROJECT_ID ci variable.') + end + end + + describe 'when a project has 3 gcp projects' do + let_it_be(:project) { create(:project) } + + before do + project.variables.build(environment_scope: 'production', key: 'GCP_PROJECT_ID', value: 'prj-prod') + project.variables.build(environment_scope: 'staging', key: 'GCP_PROJECT_ID', value: 'prj-staging') + project.save! + end + + it 'enables cloud run, artifacts registry and cloud build', :aggregate_failures do + expect_next_instance_of(GoogleApi::CloudPlatform::Client) do |instance| + expect(instance).to receive(:enable_cloud_run).with('prj-prod') + expect(instance).to receive(:enable_artifacts_registry).with('prj-prod') + expect(instance).to receive(:enable_cloud_build).with('prj-prod') + expect(instance).to receive(:enable_cloud_run).with('prj-staging') + expect(instance).to receive(:enable_artifacts_registry).with('prj-staging') + expect(instance).to receive(:enable_cloud_build).with('prj-staging') + end + + result = described_class.new(project).execute + + expect(result[:status]).to eq(:success) + end + end +end diff --git a/spec/services/cloud_seed/google_cloud/enable_cloudsql_service_spec.rb b/spec/services/cloud_seed/google_cloud/enable_cloudsql_service_spec.rb new file mode 100644 index 00000000000..137393e4544 --- /dev/null +++ b/spec/services/cloud_seed/google_cloud/enable_cloudsql_service_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe CloudSeed::GoogleCloud::EnableCloudsqlService, feature_category: :deployment_management do + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user) } + let_it_be(:params) do + { + google_oauth2_token: 'mock-token', + gcp_project_id: 'mock-gcp-project-id', + environment_name: 'main' + } + end + + subject(:result) { described_class.new(project, user, params).execute } + + context 'when a project does not have any GCP_PROJECT_IDs configured' do + it 'creates GCP_PROJECT_ID project var' do + expect_next_instance_of(GoogleApi::CloudPlatform::Client) do |instance| + expect(instance).to receive(:enable_cloud_sql_admin).with('mock-gcp-project-id') + expect(instance).to receive(:enable_compute).with('mock-gcp-project-id') + expect(instance).to receive(:enable_service_networking).with('mock-gcp-project-id') + end + + expect(result[:status]).to eq(:success) + expect(project.variables.count).to eq(1) + expect(project.variables.first.key).to eq('GCP_PROJECT_ID') + expect(project.variables.first.value).to eq('mock-gcp-project-id') + end + end + + context 'when a project has GCP_PROJECT_IDs configured' do + before do + project.variables.build(environment_scope: 'production', key: 'GCP_PROJECT_ID', value: 'prj-prod') + project.variables.build(environment_scope: 'staging', key: 'GCP_PROJECT_ID', value: 'prj-staging') + project.save! + end + + after do + project.variables.destroy_all # rubocop:disable Cop/DestroyAll + project.save! + end + + it 'enables cloudsql, compute and service networking Google APIs', :aggregate_failures do + expect_next_instance_of(GoogleApi::CloudPlatform::Client) do |instance| + expect(instance).to receive(:enable_cloud_sql_admin).with('mock-gcp-project-id') + expect(instance).to receive(:enable_compute).with('mock-gcp-project-id') + expect(instance).to receive(:enable_service_networking).with('mock-gcp-project-id') + expect(instance).to receive(:enable_cloud_sql_admin).with('prj-prod') + expect(instance).to receive(:enable_compute).with('prj-prod') + expect(instance).to receive(:enable_service_networking).with('prj-prod') + expect(instance).to receive(:enable_cloud_sql_admin).with('prj-staging') + expect(instance).to receive(:enable_compute).with('prj-staging') + expect(instance).to receive(:enable_service_networking).with('prj-staging') + end + + expect(result[:status]).to eq(:success) + end + + context 'when Google APIs raise an error' do + it 'returns error result' do + allow_next_instance_of(GoogleApi::CloudPlatform::Client) do |instance| + allow(instance).to receive(:enable_cloud_sql_admin).with('mock-gcp-project-id') + allow(instance).to receive(:enable_compute).with('mock-gcp-project-id') + allow(instance).to receive(:enable_service_networking).with('mock-gcp-project-id') + allow(instance).to receive(:enable_cloud_sql_admin).with('prj-prod') + allow(instance).to receive(:enable_compute).with('prj-prod') + allow(instance).to receive(:enable_service_networking).with('prj-prod') + allow(instance).to receive(:enable_cloud_sql_admin).with('prj-staging') + allow(instance).to receive(:enable_compute).with('prj-staging') + allow(instance).to receive(:enable_service_networking).with('prj-staging') + .and_raise(Google::Apis::Error.new('error')) + end + + expect(result[:status]).to eq(:error) + expect(result[:message]).to eq('error') + end + end + end +end diff --git a/spec/services/cloud_seed/google_cloud/enable_vision_ai_service_spec.rb b/spec/services/cloud_seed/google_cloud/enable_vision_ai_service_spec.rb new file mode 100644 index 00000000000..c37b5681a4b --- /dev/null +++ b/spec/services/cloud_seed/google_cloud/enable_vision_ai_service_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe CloudSeed::GoogleCloud::EnableVisionAiService, feature_category: :deployment_management do + describe 'when a project does not have any gcp projects' do + let_it_be(:project) { create(:project) } + + it 'returns error' do + result = described_class.new(project).execute + message = 'No GCP projects found. Configure a service account or GCP_PROJECT_ID ci variable.' + + expect(result[:status]).to eq(:error) + expect(result[:message]).to eq(message) + end + end + + describe 'when a project has 3 gcp projects' do + let_it_be(:project) { create(:project) } + + before do + project.variables.build(environment_scope: 'production', key: 'GCP_PROJECT_ID', value: 'prj-prod') + project.variables.build(environment_scope: 'staging', key: 'GCP_PROJECT_ID', value: 'prj-staging') + project.save! + end + + it 'enables cloud run, artifacts registry and cloud build', :aggregate_failures do + expect_next_instance_of(GoogleApi::CloudPlatform::Client) do |instance| + expect(instance).to receive(:enable_vision_api).with('prj-prod') + expect(instance).to receive(:enable_vision_api).with('prj-staging') + end + + result = described_class.new(project).execute + + expect(result[:status]).to eq(:success) + end + end +end diff --git a/spec/services/cloud_seed/google_cloud/fetch_google_ip_list_service_spec.rb b/spec/services/cloud_seed/google_cloud/fetch_google_ip_list_service_spec.rb new file mode 100644 index 00000000000..c4a0be78213 --- /dev/null +++ b/spec/services/cloud_seed/google_cloud/fetch_google_ip_list_service_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe CloudSeed::GoogleCloud::FetchGoogleIpListService, :use_clean_rails_memory_store_caching, + :clean_gitlab_redis_rate_limiting, feature_category: :build_artifacts do + include StubRequests + + let(:google_cloud_ips) { File.read(Rails.root.join('spec/fixtures/cdn/google_cloud.json')) } + let(:headers) { { 'Content-Type' => 'application/json' } } + + subject { described_class.new.execute } + + before do + WebMock.stub_request(:get, described_class::GOOGLE_IP_RANGES_URL) + .to_return(status: 200, body: google_cloud_ips, headers: headers) + end + + describe '#execute' do + it 'returns a list of IPAddr subnets and caches the result' do + expect(::ObjectStorage::CDN::GoogleIpCache).to receive(:update!).and_call_original + expect(subject[:subnets]).to be_an(Array) + expect(subject[:subnets]).to all(be_an(IPAddr)) + end + + shared_examples 'IP range retrieval failure' do + it 'does not cache the result and logs an error' do + expect(Gitlab::ErrorTracking).to receive(:log_exception).and_call_original + expect(::ObjectStorage::CDN::GoogleIpCache).not_to receive(:update!) + expect(subject[:subnets]).to be_nil + end + end + + context 'with rate limit in effect' do + before do + 10.times { described_class.new.execute } + end + + it 'returns rate limit error' do + expect(subject[:status]).to eq(:error) + expect(subject[:message]).to eq("#{described_class} was rate limited") + end + end + + context 'when the URL returns a 404' do + before do + WebMock.stub_request(:get, described_class::GOOGLE_IP_RANGES_URL).to_return(status: 404) + end + + it_behaves_like 'IP range retrieval failure' + end + + context 'when the URL returns too large of a payload' do + before do + stub_const("#{described_class}::RESPONSE_BODY_LIMIT", 300) + end + + it_behaves_like 'IP range retrieval failure' + end + + context 'when the URL returns HTML' do + let(:headers) { { 'Content-Type' => 'text/html' } } + + it_behaves_like 'IP range retrieval failure' + end + + context 'when the URL returns empty results' do + let(:google_cloud_ips) { '{}' } + + it_behaves_like 'IP range retrieval failure' + end + end +end diff --git a/spec/services/cloud_seed/google_cloud/gcp_region_add_or_replace_service_spec.rb b/spec/services/cloud_seed/google_cloud/gcp_region_add_or_replace_service_spec.rb new file mode 100644 index 00000000000..2af03291484 --- /dev/null +++ b/spec/services/cloud_seed/google_cloud/gcp_region_add_or_replace_service_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe CloudSeed::GoogleCloud::GcpRegionAddOrReplaceService, feature_category: :deployment_management do + it 'adds and replaces GCP region vars' do + project = create(:project, :public) + service = described_class.new(project) + + service.execute('env_1', 'loc_1') + service.execute('env_2', 'loc_2') + service.execute('env_1', 'loc_3') + + list = project.variables.reload.filter { |variable| variable.key == Projects::GoogleCloud::GcpRegionsController::GCP_REGION_CI_VAR_KEY } + list = list.sort_by(&:environment_scope) + + aggregate_failures 'testing list of gcp regions' do + expect(list.length).to eq(2) + + # asserting that the first region is replaced + expect(list.first.environment_scope).to eq('env_1') + expect(list.first.value).to eq('loc_3') + + expect(list.second.environment_scope).to eq('env_2') + expect(list.second.value).to eq('loc_2') + end + end +end diff --git a/spec/services/cloud_seed/google_cloud/generate_pipeline_service_spec.rb b/spec/services/cloud_seed/google_cloud/generate_pipeline_service_spec.rb new file mode 100644 index 00000000000..14c1e6bae7f --- /dev/null +++ b/spec/services/cloud_seed/google_cloud/generate_pipeline_service_spec.rb @@ -0,0 +1,353 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe CloudSeed::GoogleCloud::GeneratePipelineService, feature_category: :deployment_management do + describe 'for cloud-run' do + describe 'when there is no existing pipeline' do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:maintainer) { create(:user) } + let_it_be(:service_params) { { action: described_class::ACTION_DEPLOY_TO_CLOUD_RUN } } + let_it_be(:service) { described_class.new(project, maintainer, service_params) } + + before do + project.add_maintainer(maintainer) + end + + it 'creates a new branch with commit for cloud-run deployment' do + response = service.execute + + branch_name = response[:branch_name] + commit = response[:commit] + local_branches = project.repository.local_branches + created_branch = local_branches.find { |branch| branch.name == branch_name } + + expect(response[:status]).to eq(:success) + expect(branch_name).to start_with('deploy-to-cloud-run-') + expect(created_branch).to be_present + expect(created_branch.target).to eq(commit[:result]) + end + + it 'generated pipeline includes cloud-run deployment' do + response = service.execute + + ref = response[:commit][:result] + gitlab_ci_yml = project.ci_config_for(ref) + + expect(response[:status]).to eq(:success) + expect(gitlab_ci_yml).to include('https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/library/-/raw/main/gcp/cloud-run.gitlab-ci.yml') + end + + context 'simulate errors' do + it 'fails to create branch' do + allow_next_instance_of(Branches::CreateService) do |create_service| + allow(create_service).to receive(:execute) + .and_return({ status: :error }) + end + + response = service.execute + expect(response[:status]).to eq(:error) + end + + it 'fails to commit changes' do + allow_next_instance_of(Files::CreateService) do |create_service| + allow(create_service).to receive(:execute) + .and_return({ status: :error }) + end + + response = service.execute + expect(response[:status]).to eq(:error) + end + end + end + + describe 'when there is an existing pipeline without `deploy` stage' do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:maintainer) { create(:user) } + let_it_be(:service_params) do + { action: CloudSeed::GoogleCloud::GeneratePipelineService::ACTION_DEPLOY_TO_CLOUD_RUN } + end + + let_it_be(:service) { described_class.new(project, maintainer, service_params) } + + before_all do + project.add_maintainer(maintainer) + + file_name = '.gitlab-ci.yml' + file_content = <<EOF +stages: + - build + - test + +build-java: + stage: build + script: mvn clean install + +test-java: + stage: test + script: mvn clean test +EOF + project.repository.create_file( + maintainer, + file_name, + file_content, + message: 'Pipeline with three stages and two jobs', + branch_name: project.default_branch + ) + end + + it 'introduces a `deploy` stage and includes the deploy-to-cloud-run job' do + response = service.execute + + branch_name = response[:branch_name] + gitlab_ci_yml = project.ci_config_for(branch_name) + pipeline = Gitlab::Config::Loader::Yaml.new(gitlab_ci_yml).load! + + expect(response[:status]).to eq(:success) + expect(pipeline[:stages]).to eq(%w[build test deploy]) + expect(pipeline[:include]).to be_present + expect(gitlab_ci_yml).to include('https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/library/-/raw/main/gcp/cloud-run.gitlab-ci.yml') + end + + it 'stringifies keys from the existing pipelines' do + response = service.execute + + branch_name = response[:branch_name] + gitlab_ci_yml = project.ci_config_for(branch_name) + + expect(YAML.safe_load(gitlab_ci_yml).keys).to eq(%w[stages build-java test-java include]) + end + end + + describe 'when there is an existing pipeline with `deploy` stage' do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:maintainer) { create(:user) } + let_it_be(:service_params) do + { action: CloudSeed::GoogleCloud::GeneratePipelineService::ACTION_DEPLOY_TO_CLOUD_RUN } + end + + let_it_be(:service) { described_class.new(project, maintainer, service_params) } + + before do + project.add_maintainer(maintainer) + + file_name = '.gitlab-ci.yml' + file_content = <<EOF +stages: + - build + - test + - deploy + +build-java: + stage: build + script: mvn clean install + +test-java: + stage: test + script: mvn clean test +EOF + project.repository.create_file( + maintainer, + file_name, + file_content, + message: 'Pipeline with three stages and two jobs', + branch_name: project.default_branch + ) + end + + it 'includes the deploy-to-cloud-run job' do + response = service.execute + + branch_name = response[:branch_name] + gitlab_ci_yml = project.ci_config_for(branch_name) + pipeline = Gitlab::Config::Loader::Yaml.new(gitlab_ci_yml).load! + + expect(response[:status]).to eq(:success) + expect(pipeline[:stages]).to eq(%w[build test deploy]) + expect(pipeline[:include]).to be_present + expect(gitlab_ci_yml).to include('https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/library/-/raw/main/gcp/cloud-run.gitlab-ci.yml') + end + end + + describe 'when there is an existing pipeline with `includes`' do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:maintainer) { create(:user) } + let_it_be(:service_params) do + { action: CloudSeed::GoogleCloud::GeneratePipelineService::ACTION_DEPLOY_TO_CLOUD_RUN } + end + + let_it_be(:service) { described_class.new(project, maintainer, service_params) } + + before do + project.add_maintainer(maintainer) + + file_name = '.gitlab-ci.yml' + file_content = <<EOF +stages: + - build + - test + - deploy + +include: + local: 'some-pipeline.yml' +EOF + project.repository.create_file( + maintainer, + file_name, + file_content, + message: 'Pipeline with three stages and two jobs', + branch_name: project.default_branch + ) + end + + it 'includes the deploy-to-cloud-run job' do + response = service.execute + + branch_name = response[:branch_name] + gitlab_ci_yml = project.ci_config_for(branch_name) + pipeline = Gitlab::Config::Loader::Yaml.new(gitlab_ci_yml).load! + + expect(response[:status]).to eq(:success) + expect(pipeline[:stages]).to eq(%w[build test deploy]) + expect(pipeline[:include]).to be_present + expect(gitlab_ci_yml).to include('https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/library/-/raw/main/gcp/cloud-run.gitlab-ci.yml') + end + end + end + + describe 'for cloud-storage' do + describe 'when there is no existing pipeline' do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:maintainer) { create(:user) } + let_it_be(:service_params) do + { action: CloudSeed::GoogleCloud::GeneratePipelineService::ACTION_DEPLOY_TO_CLOUD_STORAGE } + end + + let_it_be(:service) { described_class.new(project, maintainer, service_params) } + + before do + project.add_maintainer(maintainer) + end + + it 'creates a new branch with commit for cloud-storage deployment' do + response = service.execute + + branch_name = response[:branch_name] + commit = response[:commit] + local_branches = project.repository.local_branches + search_for_created_branch = local_branches.find { |branch| branch.name == branch_name } + + expect(response[:status]).to eq(:success) + expect(branch_name).to start_with('deploy-to-cloud-storage-') + expect(search_for_created_branch).to be_present + expect(search_for_created_branch.target).to eq(commit[:result]) + end + + it 'generated pipeline includes cloud-storage deployment' do + response = service.execute + + ref = response[:commit][:result] + gitlab_ci_yml = project.ci_config_for(ref) + + expect(response[:status]).to eq(:success) + expect(gitlab_ci_yml).to include('https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/library/-/raw/main/gcp/cloud-storage.gitlab-ci.yml') + end + end + end + + describe 'for vision ai' do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:maintainer) { create(:user) } + let_it_be(:service_params) { { action: described_class::ACTION_VISION_AI_PIPELINE } } + let_it_be(:service) { described_class.new(project, maintainer, service_params) } + + describe 'when there is no existing pipeline' do + before do + project.add_maintainer(maintainer) + end + + it 'creates a new branch with commit for cloud-run deployment' do + response = service.execute + + branch_name = response[:branch_name] + commit = response[:commit] + local_branches = project.repository.local_branches + created_branch = local_branches.find { |branch| branch.name == branch_name } + + expect(response[:status]).to eq(:success) + expect(branch_name).to start_with('vision-ai-pipeline-') + expect(created_branch).to be_present + expect(created_branch.target).to eq(commit[:result]) + end + + it 'generated pipeline includes vision ai deployment' do + response = service.execute + + ref = response[:commit][:result] + gitlab_ci_yml = project.ci_config_for(ref) + + expect(response[:status]).to eq(:success) + expect(gitlab_ci_yml).to include('https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/library/-/raw/main/gcp/vision-ai.gitlab-ci.yml') + end + + context 'simulate errors' do + it 'fails to create branch' do + allow_next_instance_of(Branches::CreateService) do |create_service| + allow(create_service).to receive(:execute) + .and_return({ status: :error }) + end + + response = service.execute + expect(response[:status]).to eq(:error) + end + + it 'fails to commit changes' do + allow_next_instance_of(Files::CreateService) do |create_service| + allow(create_service).to receive(:execute) + .and_return({ status: :error }) + end + + response = service.execute + expect(response[:status]).to eq(:error) + end + end + end + + describe 'when there is an existing pipeline with `includes`' do + before do + project.add_maintainer(maintainer) + + file_name = '.gitlab-ci.yml' + file_content = <<EOF +stages: + - validate + - detect + - render + +include: + local: 'some-pipeline.yml' +EOF + project.repository.create_file( + maintainer, + file_name, + file_content, + message: 'Pipeline with three stages and two jobs', + branch_name: project.default_branch + ) + end + + it 'includes the vision ai pipeline' do + response = service.execute + + branch_name = response[:branch_name] + gitlab_ci_yml = project.ci_config_for(branch_name) + pipeline = Gitlab::Config::Loader::Yaml.new(gitlab_ci_yml).load! + + expect(response[:status]).to eq(:success) + expect(pipeline[:stages]).to eq(%w[validate detect render]) + expect(pipeline[:include]).to be_present + expect(gitlab_ci_yml).to include('https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/library/-/raw/main/gcp/vision-ai.gitlab-ci.yml') + end + end + end +end diff --git a/spec/services/cloud_seed/google_cloud/get_cloudsql_instances_service_spec.rb b/spec/services/cloud_seed/google_cloud/get_cloudsql_instances_service_spec.rb new file mode 100644 index 00000000000..fb17d578af7 --- /dev/null +++ b/spec/services/cloud_seed/google_cloud/get_cloudsql_instances_service_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe CloudSeed::GoogleCloud::GetCloudsqlInstancesService, feature_category: :deployment_management do + let(:service) { described_class.new(project) } + let(:project) { create(:project) } + + context 'when project has no registered cloud sql instances' do + it 'result is empty' do + expect(service.execute.length).to eq(0) + end + end + + context 'when project has registered cloud sql instance' do + before do + keys = %w[ + GCP_PROJECT_ID + GCP_CLOUDSQL_INSTANCE_NAME + GCP_CLOUDSQL_CONNECTION_NAME + GCP_CLOUDSQL_PRIMARY_IP_ADDRESS + GCP_CLOUDSQL_VERSION + GCP_CLOUDSQL_DATABASE_NAME + GCP_CLOUDSQL_DATABASE_USER + GCP_CLOUDSQL_DATABASE_PASS + ] + + envs = %w[ + * + STG + PRD + ] + + keys.each do |key| + envs.each do |env| + project.variables.build(protected: false, environment_scope: env, key: key, value: "value-#{key}-#{env}") + end + end + end + + it 'result is grouped by environment', :aggregate_failures do + expect(service.execute).to contain_exactly( + { + ref: '*', + gcp_project: 'value-GCP_PROJECT_ID-*', + instance_name: 'value-GCP_CLOUDSQL_INSTANCE_NAME-*', + version: 'value-GCP_CLOUDSQL_VERSION-*' + }, + { + ref: 'STG', + gcp_project: 'value-GCP_PROJECT_ID-STG', + instance_name: 'value-GCP_CLOUDSQL_INSTANCE_NAME-STG', + version: 'value-GCP_CLOUDSQL_VERSION-STG' + }, + { + ref: 'PRD', + gcp_project: 'value-GCP_PROJECT_ID-PRD', + instance_name: 'value-GCP_CLOUDSQL_INSTANCE_NAME-PRD', + version: 'value-GCP_CLOUDSQL_VERSION-PRD' + } + ) + end + end +end diff --git a/spec/services/cloud_seed/google_cloud/service_accounts_service_spec.rb b/spec/services/cloud_seed/google_cloud/service_accounts_service_spec.rb new file mode 100644 index 00000000000..62d58b3198a --- /dev/null +++ b/spec/services/cloud_seed/google_cloud/service_accounts_service_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe CloudSeed::GoogleCloud::ServiceAccountsService, feature_category: :deployment_management do + let(:service) { described_class.new(project) } + + describe 'find_for_project' do + let_it_be(:project) { create(:project) } + + context 'when a project does not have GCP service account vars' do + before do + project.variables.build(key: 'blah', value: 'foo', environment_scope: 'world') + project.save! + end + + it 'returns an empty list' do + expect(service.find_for_project.length).to eq(0) + end + end + + context 'when a project has GCP service account ci vars' do + before do + project.variables.build(protected: true, environment_scope: '*', key: 'GCP_PROJECT_ID', value: 'prj1') + project.variables.build(protected: true, environment_scope: '*', key: 'GCP_SERVICE_ACCOUNT_KEY', value: 'mock') + project.variables.build(protected: true, environment_scope: 'staging', key: 'GCP_PROJECT_ID', value: 'prj2') + project.variables.build(protected: true, environment_scope: 'staging', key: 'GCP_SERVICE_ACCOUNT', value: 'mock') + project.variables.build(protected: true, environment_scope: 'production', key: 'GCP_PROJECT_ID', value: 'prj3') + project.variables.build(protected: true, environment_scope: 'production', key: 'GCP_SERVICE_ACCOUNT', value: 'mock') + project.variables.build(protected: true, environment_scope: 'production', key: 'GCP_SERVICE_ACCOUNT_KEY', value: 'mock') + project.save! + end + + it 'returns a list of service accounts' do + list = service.find_for_project + + aggregate_failures 'testing list of service accounts' do + expect(list.length).to eq(3) + + expect(list.first[:ref]).to eq('*') + expect(list.first[:gcp_project]).to eq('prj1') + expect(list.first[:service_account_exists]).to eq(false) + expect(list.first[:service_account_key_exists]).to eq(true) + + expect(list.second[:ref]).to eq('staging') + expect(list.second[:gcp_project]).to eq('prj2') + expect(list.second[:service_account_exists]).to eq(true) + expect(list.second[:service_account_key_exists]).to eq(false) + + expect(list.third[:ref]).to eq('production') + expect(list.third[:gcp_project]).to eq('prj3') + expect(list.third[:service_account_exists]).to eq(true) + expect(list.third[:service_account_key_exists]).to eq(true) + end + end + end + end + + describe 'add_for_project' do + let_it_be(:project) { create(:project) } + + it 'saves GCP creds as project CI vars' do + service.add_for_project('env_1', 'gcp_prj_id_1', 'srv_acc_1', 'srv_acc_key_1', true) + service.add_for_project('env_2', 'gcp_prj_id_2', 'srv_acc_2', 'srv_acc_key_2', false) + + list = service.find_for_project + + aggregate_failures 'testing list of service accounts' do + expect(list.length).to eq(2) + + expect(list.first[:ref]).to eq('env_1') + expect(list.first[:gcp_project]).to eq('gcp_prj_id_1') + expect(list.first[:service_account_exists]).to eq(true) + expect(list.first[:service_account_key_exists]).to eq(true) + + expect(list.second[:ref]).to eq('env_2') + expect(list.second[:gcp_project]).to eq('gcp_prj_id_2') + expect(list.second[:service_account_exists]).to eq(true) + expect(list.second[:service_account_key_exists]).to eq(true) + end + end + + it 'replaces previously stored CI vars with new CI vars' do + service.add_for_project('env_1', 'new_project', 'srv_acc_1', 'srv_acc_key_1', false) + + list = service.find_for_project + + aggregate_failures 'testing list of service accounts' do + expect(list.length).to eq(2) + + # asserting that the first service account is replaced + expect(list.first[:ref]).to eq('env_1') + expect(list.first[:gcp_project]).to eq('new_project') + expect(list.first[:service_account_exists]).to eq(true) + expect(list.first[:service_account_key_exists]).to eq(true) + + expect(list.second[:ref]).to eq('env_2') + expect(list.second[:gcp_project]).to eq('gcp_prj_id_2') + expect(list.second[:service_account_exists]).to eq(true) + expect(list.second[:service_account_key_exists]).to eq(true) + end + end + + it 'underlying project CI vars must be protected as per value' do + service.add_for_project('env_1', 'gcp_prj_id_1', 'srv_acc_1', 'srv_acc_key_1', true) + service.add_for_project('env_2', 'gcp_prj_id_2', 'srv_acc_2', 'srv_acc_key_2', false) + + expect(project.variables[0].protected).to eq(true) + expect(project.variables[1].protected).to eq(true) + expect(project.variables[2].protected).to eq(true) + expect(project.variables[3].protected).to eq(false) + expect(project.variables[4].protected).to eq(false) + expect(project.variables[5].protected).to eq(false) + end + end +end diff --git a/spec/services/cloud_seed/google_cloud/setup_cloudsql_instance_service_spec.rb b/spec/services/cloud_seed/google_cloud/setup_cloudsql_instance_service_spec.rb new file mode 100644 index 00000000000..ce02672e3fa --- /dev/null +++ b/spec/services/cloud_seed/google_cloud/setup_cloudsql_instance_service_spec.rb @@ -0,0 +1,228 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe CloudSeed::GoogleCloud::SetupCloudsqlInstanceService, feature_category: :deployment_management do + let(:random_user) { create(:user) } + let(:project) { create(:project) } + let(:list_databases_empty) { Google::Apis::SqladminV1beta4::ListDatabasesResponse.new(items: []) } + let(:list_users_empty) { Google::Apis::SqladminV1beta4::ListUsersResponse.new(items: []) } + let(:list_databases) do + Google::Apis::SqladminV1beta4::ListDatabasesResponse.new( + items: [ + Google::Apis::SqladminV1beta4::Database.new(name: 'postgres'), + Google::Apis::SqladminV1beta4::Database.new(name: 'main_db') + ]) + end + + let(:list_users) do + Google::Apis::SqladminV1beta4::ListUsersResponse.new( + items: [ + Google::Apis::SqladminV1beta4::User.new(name: 'postgres'), + Google::Apis::SqladminV1beta4::User.new(name: 'main_user') + ]) + end + + context 'when unauthorized user triggers worker' do + subject do + params = { + gcp_project_id: :gcp_project_id, + instance_name: :instance_name, + database_version: :database_version, + environment_name: :environment_name, + is_protected: :is_protected + } + described_class.new(project, random_user, params).execute + end + + it 'raises unauthorized error' do + message = subject[:message] + status = subject[:status] + + expect(status).to eq(:error) + expect(message).to eq('Unauthorized user') + end + end + + context 'when authorized user triggers worker' do + subject do + user = project.creator + params = { + gcp_project_id: :gcp_project_id, + instance_name: :instance_name, + database_version: :database_version, + environment_name: :environment_name, + is_protected: :is_protected + } + described_class.new(project, user, params).execute + end + + context 'when instance is not RUNNABLE' do + let(:get_instance_response_pending) do + Google::Apis::SqladminV1beta4::DatabaseInstance.new(state: 'PENDING') + end + + it 'raises error' do + allow_next_instance_of(GoogleApi::CloudPlatform::Client) do |google_api_client| + expect(google_api_client).to receive(:get_cloudsql_instance).and_return(get_instance_response_pending) + end + + message = subject[:message] + status = subject[:status] + + expect(status).to eq(:error) + expect(message).to eq('CloudSQL instance not RUNNABLE: {"state":"PENDING"}') + end + end + + context 'when instance is RUNNABLE' do + let(:get_instance_response_runnable) do + Google::Apis::SqladminV1beta4::DatabaseInstance.new( + connection_name: 'mock-connection-name', + ip_addresses: [Struct.new(:ip_address).new('1.2.3.4')], + state: 'RUNNABLE' + ) + end + + let(:operation_fail) { Google::Apis::SqladminV1beta4::Operation.new(status: 'FAILED') } + + let(:operation_done) { Google::Apis::SqladminV1beta4::Operation.new(status: 'DONE') } + + context 'when database creation fails' do + it 'raises error' do + allow_next_instance_of(GoogleApi::CloudPlatform::Client) do |google_api_client| + expect(google_api_client).to receive(:get_cloudsql_instance).and_return(get_instance_response_runnable) + expect(google_api_client).to receive(:create_cloudsql_database).and_return(operation_fail) + expect(google_api_client).to receive(:list_cloudsql_databases).and_return(list_databases_empty) + expect(google_api_client).to receive(:list_cloudsql_users).and_return(list_users_empty) + end + + message = subject[:message] + status = subject[:status] + + expect(status).to eq(:error) + expect(message).to eq('Database creation failed: {"status":"FAILED"}') + end + end + + context 'when user creation fails' do + it 'raises error' do + allow_next_instance_of(GoogleApi::CloudPlatform::Client) do |google_api_client| + expect(google_api_client).to receive(:get_cloudsql_instance).and_return(get_instance_response_runnable) + expect(google_api_client).to receive(:create_cloudsql_database).and_return(operation_done) + expect(google_api_client).to receive(:create_cloudsql_user).and_return(operation_fail) + expect(google_api_client).to receive(:list_cloudsql_databases).and_return(list_databases_empty) + expect(google_api_client).to receive(:list_cloudsql_users).and_return(list_users_empty) + end + + message = subject[:message] + status = subject[:status] + + expect(status).to eq(:error) + expect(message).to eq('User creation failed: {"status":"FAILED"}') + end + end + + context 'when database and user already exist' do + it 'does not try to create a database or user' do + allow_next_instance_of(GoogleApi::CloudPlatform::Client) do |google_api_client| + expect(google_api_client).to receive(:get_cloudsql_instance).and_return(get_instance_response_runnable) + expect(google_api_client).not_to receive(:create_cloudsql_database) + expect(google_api_client).not_to receive(:create_cloudsql_user) + expect(google_api_client).to receive(:list_cloudsql_databases).and_return(list_databases) + expect(google_api_client).to receive(:list_cloudsql_users).and_return(list_users) + end + + status = subject[:status] + expect(status).to eq(:success) + end + end + + context 'when database already exists' do + it 'does not try to create a database' do + allow_next_instance_of(GoogleApi::CloudPlatform::Client) do |google_api_client| + expect(google_api_client).to receive(:get_cloudsql_instance).and_return(get_instance_response_runnable) + expect(google_api_client).not_to receive(:create_cloudsql_database) + expect(google_api_client).to receive(:create_cloudsql_user).and_return(operation_done) + expect(google_api_client).to receive(:list_cloudsql_databases).and_return(list_databases) + expect(google_api_client).to receive(:list_cloudsql_users).and_return(list_users_empty) + end + + status = subject[:status] + expect(status).to eq(:success) + end + end + + context 'when user already exists' do + it 'does not try to create a user' do + allow_next_instance_of(GoogleApi::CloudPlatform::Client) do |google_api_client| + expect(google_api_client).to receive(:get_cloudsql_instance).and_return(get_instance_response_runnable) + expect(google_api_client).to receive(:create_cloudsql_database).and_return(operation_done) + expect(google_api_client).not_to receive(:create_cloudsql_user) + expect(google_api_client).to receive(:list_cloudsql_databases).and_return(list_databases_empty) + expect(google_api_client).to receive(:list_cloudsql_users).and_return(list_users) + end + + status = subject[:status] + expect(status).to eq(:success) + end + end + + context 'when database and user creation succeeds' do + it 'stores project CI vars' do + allow_next_instance_of(GoogleApi::CloudPlatform::Client) do |google_api_client| + expect(google_api_client).to receive(:get_cloudsql_instance).and_return(get_instance_response_runnable) + expect(google_api_client).to receive(:create_cloudsql_database).and_return(operation_done) + expect(google_api_client).to receive(:create_cloudsql_user).and_return(operation_done) + expect(google_api_client).to receive(:list_cloudsql_databases).and_return(list_databases_empty) + expect(google_api_client).to receive(:list_cloudsql_users).and_return(list_users_empty) + end + + subject + + aggregate_failures 'test generated vars' do + variables = project.reload.variables + + expect(variables.count).to eq(8) + expect(variables.find_by(key: 'GCP_PROJECT_ID').value).to eq("gcp_project_id") + expect(variables.find_by(key: 'GCP_CLOUDSQL_INSTANCE_NAME').value).to eq("instance_name") + expect(variables.find_by(key: 'GCP_CLOUDSQL_CONNECTION_NAME').value).to eq("mock-connection-name") + expect(variables.find_by(key: 'GCP_CLOUDSQL_PRIMARY_IP_ADDRESS').value).to eq("1.2.3.4") + expect(variables.find_by(key: 'GCP_CLOUDSQL_VERSION').value).to eq("database_version") + expect(variables.find_by(key: 'GCP_CLOUDSQL_DATABASE_NAME').value).to eq("main_db") + expect(variables.find_by(key: 'GCP_CLOUDSQL_DATABASE_USER').value).to eq("main_user") + expect(variables.find_by(key: 'GCP_CLOUDSQL_DATABASE_PASS').value).to be_present + end + end + + context 'when the ci variable already exists' do + before do + create( + :ci_variable, + project: project, + key: 'GCP_PROJECT_ID', + value: 'previous_gcp_project_id', + environment_scope: :environment_name + ) + end + + it 'overwrites existing GCP_PROJECT_ID var' do + allow_next_instance_of(GoogleApi::CloudPlatform::Client) do |google_api_client| + expect(google_api_client).to receive(:get_cloudsql_instance).and_return(get_instance_response_runnable) + expect(google_api_client).to receive(:create_cloudsql_database).and_return(operation_done) + expect(google_api_client).to receive(:create_cloudsql_user).and_return(operation_done) + expect(google_api_client).to receive(:list_cloudsql_databases).and_return(list_databases_empty) + expect(google_api_client).to receive(:list_cloudsql_users).and_return(list_users_empty) + end + + subject + + variables = project.reload.variables + value = variables.find_by(key: 'GCP_PROJECT_ID', environment_scope: :environment_name).value + expect(value).to eq("gcp_project_id") + end + end + end + end + end +end |