diff --git a/ee/lib/api/geo_sites.rb b/ee/lib/api/geo_sites.rb new file mode 100644 index 0000000000000000000000000000000000000000..45d7135cae23d92f36e163a69ee138a738bdbe32 --- /dev/null +++ b/ee/lib/api/geo_sites.rb @@ -0,0 +1,339 @@ +# frozen_string_literal: true + +module API + class GeoSites < ::API::Base + include PaginationParams + include APIGuard + + feature_category :geo_replication + urgency :low + + before do + authenticate_admin_or_geo_site! + end + + helpers do + def authenticate_admin_or_geo_site! + if gitlab_geo_node_token? + bad_request! unless update_geo_sites_endpoint? + check_gitlab_geo_request_ip! + allow_paused_nodes! + authenticate_by_gitlab_geo_node_token! + else + authenticated_as_admin! + end + end + + def update_geo_sites_endpoint? + request.put? && request.path.match?(%r{/geo_sites/\d+}) + end + end + + resource :geo_sites do + # Example request: + # POST /geo_sites + desc 'Create a new Geo site' do + summary 'Creates a new Geo site' + success code: 200, model: EE::API::Entities::GeoSite + failure [ + { code: 400, message: 'Validation error' }, + { code: 401, message: '401 Unauthorized' }, + { code: 403, message: '403 Forbidden' } + ] + tags %w[geo_sites] + end + params do + optional :primary, type: Boolean, desc: 'Specifying whether this site will be primary. Defaults to false.' + optional :enabled, type: Boolean, desc: 'Specifying whether this site will be enabled. Defaults to true.' + requires :name, type: String, + desc: 'The unique identifier for the Geo site. Must match `geo_node_name` if it is set in `gitlab.rb`, ' \ + 'otherwise it must match `external_url`' + requires :url, type: String, desc: 'The user-facing URL for the Geo site' + optional :internal_url, type: String, + desc: 'The URL defined on the primary site that secondary site should use to contact it. ' \ + 'Returns `url` if not set.' + optional :files_max_capacity, type: Integer, + desc: 'Control the maximum concurrency of LFS/attachment backfill for this secondary site. Defaults to 10.' + optional :repos_max_capacity, type: Integer, + desc: 'Control the maximum concurrency of repository backfill for this secondary site. Defaults to 25.' + optional :verification_max_capacity, type: Integer, + desc: 'Control the maximum concurrency of repository verification for this site. Defaults to 100.' + optional :container_repositories_max_capacity, type: Integer, + desc: 'Control the maximum concurrency of container repository sync for this site. Defaults to 10.' + optional :sync_object_storage, type: Boolean, + desc: 'Flag indicating if the secondary Geo site will replicate blobs in Object Storage. Defaults to false.' + optional :selective_sync_type, type: String, + desc: 'Limit syncing to only specific groups, or shards. Valid values: `"namespaces"`, `"shards"`, or `null`' + optional :selective_sync_shards, type: Array[String], + coerce_with: ::API::Validations::Types::CommaSeparatedToArray.coerce, + desc: 'The repository storages whose projects should be synced, if `selective_sync_type` == `shards`' + optional :selective_sync_namespace_ids, as: :namespace_ids, type: Array[Integer], + coerce_with: Validations::Types::CommaSeparatedToIntegerArray.coerce, + desc: 'The IDs of groups that should be synced, if `selective_sync_type` == `namespaces`' + optional :minimum_reverification_interval, type: Integer, + desc: 'The interval (in days) in which the repository verification is valid. Once expired, it ' \ + 'will be reverified. This has no effect when set on a secondary site.' + end + post do + create_params = declared_params(include_missing: false) + + new_geo_site = ::Geo::NodeCreateService.new(create_params).execute + + if new_geo_site.persisted? + present new_geo_site, with: EE::API::Entities::GeoSite + else + render_validation_error!(new_geo_site) + end + end + + # Example request: + # GET /geo_sites + desc 'Retrieves the available Geo sites' do + summary 'Retrieve configuration about all Geo sites' + success code: 200, model: EE::API::Entities::GeoSite + failure [ + { code: 400, message: '400 Bad request' }, + { code: 401, message: '401 Unauthorized' }, + { code: 403, message: '403 Forbidden' } + ] + is_array true + tags %w[geo_sites] + end + params do + use :pagination + end + + get do + sites = GeoNode.all + + present paginate(sites), with: EE::API::Entities::GeoSite + end + + # Example request: + # GET /geo_sites/status + desc 'Get status for all Geo sites' do + summary 'Get all Geo site statuses' + success code: 200, model: EE::API::Entities::GeoSiteStatus + failure [ + { code: 400, message: '400 Bad request' }, + { code: 401, message: '401 Unauthorized' }, + { code: 403, message: '403 Forbidden' } + ] + is_array true + tags %w[geo_sites] + end + params do + use :pagination + end + get '/status' do + status = GeoNodeStatus.all + + present paginate(status), with: EE::API::Entities::GeoSiteStatus + end + + # Example request: + # GET /geo_sites/current/failures + desc 'Get project sync or verification failures that occurred on the current site' do + summary 'Get project registry failures for the current Geo site' + success code: 200, model: ::GeoProjectRegistryEntity + failure [ + { code: 400, message: '400 Bad request' }, + { code: 401, message: '401 Unauthorized' }, + { code: 403, message: '403 Forbidden' }, + { code: 404, message: '404 Failure type unknown Not Found' } + ] + is_array true + tags %w[geo_sites] + end + params do + optional :type, type: String, values: %w[wiki repository], desc: 'Type of failure (repository/wiki)' + optional :failure_type, type: String, values: %w[sync checksum_mismatch verification], default: 'sync', + desc: 'Show verification failures' + use :pagination + end + get '/current/failures' do + not_found!('Geo site not found') unless Gitlab::Geo.current_node + forbidden!('Failures can only be requested from a secondary site') unless Gitlab::Geo.current_node.secondary? + + type = params[:type].to_s.to_sym + + project_registries = + case params[:failure_type] + when 'sync' + ::Geo::ProjectRegistry.sync_failed(type) + when 'verification' + ::Geo::ProjectRegistry.verification_failed(type) + when 'checksum_mismatch' + ::Geo::ProjectRegistry.mismatch(type) + end + + present paginate(project_registries), with: ::GeoProjectRegistryEntity + end + + route_param :id, type: Integer, desc: 'The ID of the site' do + helpers do + include ::Gitlab::Utils::StrongMemoize + + def geo_site + GeoNode.find(params[:id]) + end + strong_memoize_attr :geo_site + + def geo_site_status + status = GeoNodeStatus.fast_current_node_status if GeoNode.current?(geo_site) + status || geo_site.status + end + strong_memoize_attr :geo_site_status + end + + # Example request: + # GET /geo_sites/:id + desc 'Get a single GeoSite' do + summary 'Retrieve configuration about a specific Geo site' + success code: 200, model: EE::API::Entities::GeoSite + failure [ + { code: 400, message: '400 Bad request' }, + { code: 401, message: '401 Unauthorized' }, + { code: 403, message: '403 Forbidden' }, + { code: 404, message: '404 GeoSite Not Found' } + ] + tags %w[geo_sites] + end + get do + not_found!('GeoSite') unless geo_site + + present geo_site, with: EE::API::Entities::GeoSite + end + + # Example request: + # GET /geo_sites/:id/status + desc 'Get metrics for a single Geo site' do + summary 'Get Geo metrics for a single site' + success code: 200, model: EE::API::Entities::GeoSiteStatus + failure [ + { code: 400, message: '400 Bad request' }, + { code: 401, message: '401 Unauthorized' }, + { code: 403, message: '403 Forbidden' }, + { code: 404, message: '404 GeoSite Not Found' } + ] + tags %w[geo_sites] + end + params do + optional :refresh, type: Boolean, + desc: 'Attempt to fetch the latest status from the Geo site directly, ignoring the cache' + end + get 'status' do + not_found!('GeoSite') unless geo_site + + not_found!('Status for Geo site not found') unless geo_site_status + + present geo_site_status, with: EE::API::Entities::GeoSiteStatus + end + + # Example request: + # POST /geo_sites/:id/repair + desc 'Repair authentication of the Geo site' do + summary 'Repair authentication of the Geo site' + success code: 200, model: EE::API::Entities::GeoSiteStatus + failure [ + { code: 400, message: '400 Bad request' }, + { code: 401, message: '401 Unauthorized' }, + { code: 403, message: '403 Forbidden' }, + { code: 404, message: '404 GeoSite Not Found' } + ] + tags %w[geo_sites] + end + post 'repair' do + not_found!('GeoSite') unless geo_site + + if !geo_site.missing_oauth_application? || geo_site.repair + status 200 + present geo_site_status, with: EE::API::Entities::GeoSiteStatus + else + render_validation_error!(geo_site) + end + end + + # Example request: + # PUT /geo_sites/:id + desc 'Updates an existing Geo site' do + summary 'Edit a Geo site' + success code: 200, model: EE::API::Entities::GeoSite + failure [ + { code: 400, message: '400 Bad request' }, + { code: 401, message: '401 Unauthorized' }, + { code: 403, message: '403 Forbidden' }, + { code: 404, message: '404 GeoSite Not Found' } + ] + tags %w[geo_sites] + end + params do + optional :enabled, type: Boolean, desc: 'Flag indicating if the Geo site is enabled' + optional :name, type: String, + desc: 'The unique identifier for the Geo site. Must match `geo_node_name` if it is set in gitlab.rb, ' \ + 'otherwise it must match `external_url`' + optional :url, type: String, desc: 'The user-facing URL of the Geo site' + optional :internal_url, type: String, + desc: 'The URL defined on the primary site that secondary sites should use to contact it. ' \ + 'Returns `url` if not set.' + optional :files_max_capacity, type: Integer, + desc: 'Control the maximum concurrency of LFS/attachment backfill for this secondary site' + optional :repos_max_capacity, type: Integer, + desc: 'Control the maximum concurrency of repository backfill for this secondary site' + optional :verification_max_capacity, type: Integer, + desc: 'Control the maximum concurrency of repository verification for this site' + optional :container_repositories_max_capacity, type: Integer, + desc: 'Control the maximum concurrency of container repository sync for this site' + optional :sync_object_storage, type: Boolean, + desc: 'Flag indicating if the secondary Geo site will replicate blobs in Object Storage' + optional :selective_sync_type, type: String, + desc: 'Limit syncing to only specific groups, or shards. Valid values: `"namespaces"`, `"shards"`, ' \ + 'or `null`' + optional :selective_sync_shards, type: Array[String], + coerce_with: ::API::Validations::Types::CommaSeparatedToArray.coerce, + desc: 'The repository storages whose projects should be synced, if `selective_sync_type` == `shards`' + optional :selective_sync_namespace_ids, as: :namespace_ids, type: Array[Integer], + coerce_with: Validations::Types::CommaSeparatedToIntegerArray.coerce, + desc: 'The IDs of groups that should be synced, if `selective_sync_type` == `namespaces`' + optional :minimum_reverification_interval, type: Integer, + desc: 'The interval (in days) in which the repository verification is valid. Once expired, it ' \ + 'will be reverified. This has no effect when set on a secondary site.' + end + put do + not_found!('GeoSite') unless geo_site + + update_params = declared_params(include_missing: false) + + updated_geo_site = ::Geo::NodeUpdateService.new(geo_site, update_params).execute + + if updated_geo_site + present geo_site, with: EE::API::Entities::GeoSite + else + render_validation_error!(geo_site) + end + end + + # Example request: + # DELETE /geo_sites/:id + desc 'Remove the Geo site' do + summary 'Delete a Geo site' + success code: 204, message: '204 No Content' + failure [ + { code: 400, message: '400 Bad request' }, + { code: 401, message: '401 Unauthorized' }, + { code: 403, message: '403 Forbidden' }, + { code: 404, message: '404 GeoSite Not Found' } + ] + tags %w[geo_sites] + end + delete do + not_found!('GeoSite') unless geo_site + + geo_site.destroy! + + no_content! + end + end + end + end +end diff --git a/ee/lib/ee/api/api.rb b/ee/lib/ee/api/api.rb index 4c7b8ecdb932f06450ffd5d05d1cc34ad584d505..70fb986b0ad2df001ec72107e36be842c042c70b 100644 --- a/ee/lib/ee/api/api.rb +++ b/ee/lib/ee/api/api.rb @@ -26,6 +26,7 @@ module API mount ::API::Experiments mount ::API::GeoReplication mount ::API::GeoNodes + mount ::API::GeoSites mount ::API::Ldap mount ::API::LdapGroupLinks mount ::API::License diff --git a/ee/lib/ee/api/entities/geo_site.rb b/ee/lib/ee/api/entities/geo_site.rb index bc760a075e27c39f9f409a7a5411d1da18c14404..39acaca46e830b2e5035288ca3785f8508bf6e6a 100644 --- a/ee/lib/ee/api/entities/geo_site.rb +++ b/ee/lib/ee/api/entities/geo_site.rb @@ -20,40 +20,40 @@ class GeoSite < Grape::Entity expose :selective_sync_shards expose :namespace_ids, as: :selective_sync_namespace_ids expose :minimum_reverification_interval - expose :sync_object_storage, if: ->(geo_node, _) { geo_node.secondary? } + expose :sync_object_storage, if: ->(geo_site, _) { geo_site.secondary? } # Retained for backwards compatibility. Remove in API v5 expose :clone_protocol do |_record, _options| 'http' end - expose :web_edit_url do |geo_node| - ::Gitlab::Routing.url_helpers.edit_admin_geo_node_url(geo_node) + expose :web_edit_url do |geo_site| + ::Gitlab::Routing.url_helpers.edit_admin_geo_node_url(geo_site) end # @deprecated in favor of web_geo_replication_details_url - expose :web_geo_projects_url, if: ->(geo_node) { geo_node.secondary? }, - proc: ->(geo_node) { geo_node.geo_projects_url } + expose :web_geo_projects_url, if: ->(geo_site) { geo_site.secondary? }, + proc: ->(geo_site) { geo_site.geo_projects_url } - expose :web_geo_replication_details_url, if: ->(geo_node) { geo_node.secondary? }, - proc: ->(geo_node) { geo_node.geo_replication_details_url } + expose :web_geo_replication_details_url, if: ->(geo_site) { geo_site.secondary? }, + proc: ->(geo_site) { geo_site.geo_replication_details_url } expose :_links do - expose :self do |geo_node| - expose_url api_v4_geo_nodes_path(id: geo_node.id) + expose :self do |geo_site| + expose_url api_v4_geo_sites_path(id: geo_site.id) end - expose :status do |geo_node| - expose_url api_v4_geo_nodes_status_path(id: geo_node.id) + expose :status do |geo_site| + expose_url api_v4_geo_sites_status_path(id: geo_site.id) end - expose :repair do |geo_node| - expose_url api_v4_geo_nodes_repair_path(id: geo_node.id) + expose :repair do |geo_site| + expose_url api_v4_geo_sites_repair_path(id: geo_site.id) end end - expose :current do |geo_node| - ::GeoNode.current?(geo_node) + expose :current do |geo_site| + ::GeoNode.current?(geo_site) end end end diff --git a/ee/lib/ee/api/entities/geo_site_status.rb b/ee/lib/ee/api/entities/geo_site_status.rb index 5314c266bf48fa90bafa3b860d994157987aa99c..f79962ff209288c82cfffbaeb122a2bd0b0c4e37 100644 --- a/ee/lib/ee/api/entities/geo_site_status.rb +++ b/ee/lib/ee/api/entities/geo_site_status.rb @@ -14,8 +14,8 @@ class GeoSiteStatus < Grape::Entity end ::GeoNodeStatus.percentage_methods.each do |method_name| - expose method_name do |node| - number_to_percentage(node[method_name], precision: 2) + expose method_name do |site| + number_to_percentage(site[method_name], precision: 2) end end @@ -27,8 +27,8 @@ class GeoSiteStatus < Grape::Entity expose :replication_slots_used_count expose :healthy?, as: :healthy - expose :health do |node| - node.healthy? ? 'Healthy' : node.health + expose :health do |site| + site.healthy? ? 'Healthy' : site.health end expose :health_status expose :missing_oauth_application @@ -77,12 +77,12 @@ class GeoSiteStatus < Grape::Entity expose :storage_shards_match?, as: :storage_shards_match expose :_links do - expose :self do |geo_node_status| - expose_url api_v4_geo_nodes_status_path(id: geo_node_status.geo_node_id) + expose :self do |geo_site_status| + expose_url api_v4_geo_sites_status_path(id: geo_site_status.geo_node_id) end - expose :node do |geo_node_status| - expose_url api_v4_geo_nodes_path(id: geo_node_status.geo_node_id) + expose :site do |geo_site_status| + expose_url api_v4_geo_sites_path(id: geo_site_status.geo_node_id) end end diff --git a/ee/spec/lib/ee/api/entities/geo_site_spec.rb b/ee/spec/lib/ee/api/entities/geo_site_spec.rb index 7374a65928959c39a79f0e8af8ee2afd34e265fd..70c742e6c291c763a846c78c8d3ac34fdde034cc 100644 --- a/ee/spec/lib/ee/api/entities/geo_site_spec.rb +++ b/ee/spec/lib/ee/api/entities/geo_site_spec.rb @@ -24,15 +24,15 @@ end describe '#self' do - it { expect(subject[:_links][:self]).to eq expose_url(api_v4_geo_nodes_path(id: geo_node.id)) } + it { expect(subject[:_links][:self]).to eq expose_url(api_v4_geo_sites_path(id: geo_node.id)) } end describe '#status' do - it { expect(subject[:_links][:status]).to eq expose_url(api_v4_geo_nodes_status_path(id: geo_node.id)) } + it { expect(subject[:_links][:status]).to eq expose_url(api_v4_geo_sites_status_path(id: geo_node.id)) } end describe '#repair' do - it { expect(subject[:_links][:repair]).to eq expose_url(api_v4_geo_nodes_repair_path(id: geo_node.id)) } + it { expect(subject[:_links][:repair]).to eq expose_url(api_v4_geo_sites_repair_path(id: geo_node.id)) } end describe '#current' do diff --git a/ee/spec/requests/api/geo_sites_spec.rb b/ee/spec/requests/api/geo_sites_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..58bcf87db7afc77722cc764c055a453e0ccc3c0e --- /dev/null +++ b/ee/spec/requests/api/geo_sites_spec.rb @@ -0,0 +1,585 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::GeoSites, :aggregate_failures, :request_store, :geo, :prometheus, api: true, feature_category: :geo_replication do + include ApiHelpers + include ::EE::GeoHelpers + + include_context 'custom session' + + let!(:admin) { create(:admin) } + let!(:user) { create(:user) } + let!(:primary) { create(:geo_node, :primary) } + let!(:secondary) { create(:geo_node) } + let!(:secondary_status) { create(:geo_node_status, :healthy, geo_node: secondary) } + let(:unexisting_site_id) { non_existing_record_id } + let(:group_to_sync) { create(:group) } + + # rubocop:disable RSpec/AnyInstanceOf + + describe 'POST /geo_sites' do + it 'denies access if not admin' do + post api('/geo_sites', user), params: {} + expect(response).to have_gitlab_http_status(:forbidden) + end + + it 'returns rendering error if params are missing' do + post api('/geo_sites', admin, admin_mode: true), params: {} + expect(response).to have_gitlab_http_status(:bad_request) + end + + it 'delegates the creation of the Geo site to Geo::NodeCreateService' do + geo_site_params = { + name: 'Test Site 1', + url: 'http://example.com', + selective_sync_type: "shards", + selective_sync_shards: %w[shard1 shard2], + selective_sync_namespace_ids: group_to_sync.id, + minimum_reverification_interval: 10 + } + expect_next_instance_of(Geo::NodeCreateService) do |instance| + expect(instance).to receive(:execute).once.and_call_original + end + post api('/geo_sites', admin, admin_mode: true), params: geo_site_params + expect(response).to have_gitlab_http_status(:created) + end + + it 'returns error if failed to create a geo site' do + geo_site_params = { + name: 'Test Site 1', + url: 'http://example.com', + primary: true, + enabled: false + } + + expect_next_instance_of(Geo::NodeCreateService) do |instance| + expect(instance).to receive(:execute).once.and_call_original + end + post api('/geo_sites', admin, admin_mode: true), params: geo_site_params + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response).to include({ 'message' => { 'enabled' => ['Geo primary node cannot be disabled'], + 'primary' => ['node already exists'] } }) + end + end + + describe 'GET /geo_sites' do + it 'retrieves the Geo sites if admin is logged in' do + get api("/geo_sites", admin, admin_mode: true) + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/geo_sites', dir: 'ee') + end + + it 'denies access if not admin' do + get api('/geo_sites', user) + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + describe 'GET /geo_sites/:id' do + it 'retrieves the Geo sites if admin is logged in' do + get api("/geo_sites/#{primary.id}", admin, admin_mode: true) + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/geo_site', dir: 'ee') + expect(json_response['web_edit_url']).to end_with("/admin/geo/sites/#{primary.id}/edit") + + links = json_response['_links'] + expect(links['self']).to end_with("/api/v4/geo_sites/#{primary.id}") + expect(links['status']).to end_with("/api/v4/geo_sites/#{primary.id}/status") + expect(links['repair']).to end_with("/api/v4/geo_sites/#{primary.id}/repair") + end + + it_behaves_like '404 response' do + let(:request) { get api("/geo_sites/#{unexisting_site_id}", admin, admin_mode: true) } + end + + it 'denies access if not admin' do + get api('/geo_sites', user) + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + describe 'GET /geo_sites/status' do + it 'retrieves all Geo sites statuses if admin is logged in' do + create(:geo_node_status, :healthy, geo_node: primary) + + get api("/geo_sites/status", admin, admin_mode: true) + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/geo_site_statuses', dir: 'ee') + expect(json_response.size).to eq(2) + end + + it 'returns only one record if only one record exists' do + get api("/geo_sites/status", admin, admin_mode: true) + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/geo_site_statuses', dir: 'ee') + expect(json_response.size).to eq(1) + end + + it 'denies access if not admin' do + get api('/geo_sites', user) + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + describe 'GET /geo_sites/:id/status' do + it 'retrieves the Geo sites status if admin is logged in' do + stub_current_geo_node(primary) + secondary_status.update!(version: 'secondary-version', revision: 'secondary-revision') + + expect(GeoNodeStatus).not_to receive(:current_node_status) + + get api("/geo_sites/#{secondary.id}/status", admin, admin_mode: true) + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/geo_site_status', dir: 'ee') + + expect(json_response['version']).to eq('secondary-version') + expect(json_response['revision']).to eq('secondary-revision') + + links = json_response['_links'] + + expect(links['self']).to end_with("/api/v4/geo_sites/#{secondary.id}/status") + expect(links['site']).to end_with("/api/v4/geo_sites/#{secondary.id}") + end + + it 'fetches the current site status from redis' do + stub_current_geo_node(secondary) + + expect(GeoNodeStatus).to receive(:fast_current_node_status).and_return(secondary_status) + expect(GeoNode).to receive(:find).and_return(secondary) + + get api("/geo_sites/#{secondary.id}/status", admin, admin_mode: true) + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/geo_site_status', dir: 'ee') + end + + it 'shows the database-held response if current site status exists in the database, but not redis' do + stub_current_geo_node(secondary) + + expect(GeoNodeStatus).to receive(:fast_current_node_status).and_return(nil) + expect(GeoNode).to receive(:find).and_return(secondary) + + get api("/geo_sites/#{secondary.id}/status", admin, admin_mode: true) + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/geo_site_status', dir: 'ee') + end + + it 'the secondary shows 404 response if current site status does not exist in database or redis yet' do + stub_current_geo_node(secondary) + secondary_status.destroy! + + expect(GeoNodeStatus).to receive(:fast_current_node_status).and_return(nil) + expect(GeoNode).to receive(:find).and_return(secondary) + + get api("/geo_sites/#{secondary.id}/status", admin, admin_mode: true) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'the primary shows 404 response if secondary site status does not exist in database yet' do + stub_current_geo_node(primary) + secondary_status.destroy! + + expect(GeoNode).to receive(:find).and_return(secondary) + + get api("/geo_sites/#{secondary.id}/status", admin, admin_mode: true) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it_behaves_like '404 response' do + let(:request) { get api("/geo_sites/#{unexisting_site_id}/status", admin, admin_mode: true) } + end + + it 'denies access if not admin' do + get api("/geo_sites/#{secondary.id}/status", user) + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + describe 'POST /geo_sites/:id/repair' do + it_behaves_like '404 response' do + let(:request) { post api("/geo_sites/#{unexisting_site_id}/status", admin, admin_mode: true) } + end + + it 'denies access if not admin' do + post api("/geo_sites/#{secondary.id}/repair", user) + + expect(response).to have_gitlab_http_status(:forbidden) + end + + it 'returns 200 for the primary site' do + stub_current_geo_node(primary) + create(:geo_node_status, :healthy, geo_node: primary) + + post api("/geo_sites/#{primary.id}/repair", admin, admin_mode: true) + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/geo_site_status', dir: 'ee') + end + + it 'returns 200 when site does not need repairing' do + allow_any_instance_of(GeoNode).to receive(:missing_oauth_application?).and_return(false) + + post api("/geo_sites/#{secondary.id}/repair", admin, admin_mode: true) + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/geo_site_status', dir: 'ee') + end + + it 'repairs a secondary with oauth application missing' do + allow_any_instance_of(GeoNode).to receive(:missing_oauth_application?).and_return(true) + + post api("/geo_sites/#{secondary.id}/repair", admin, admin_mode: true) + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/geo_site_status', dir: 'ee') + end + + context 'when geo site is invalid' do + before do + secondary.update_attribute(:name, '') + end + + it 'returns validation error' do + allow_any_instance_of(GeoNode).to receive(:missing_oauth_application?).and_return(true) + + post api("/geo_sites/#{secondary.id}/repair", admin, admin_mode: true) + + expect(response).to have_gitlab_http_status(:bad_request) + end + end + end + + describe 'PUT /geo_sites/:id' do + it_behaves_like '404 response' do + let(:request) { put api("/geo_sites/#{unexisting_site_id}", admin, admin_mode: true), params: {} } + end + + it 'denies access if not admin' do + put api("/geo_sites/#{secondary.id}", user), params: {} + + expect(response).to have_gitlab_http_status(:forbidden) + end + + it 'updates the parameters' do + params = { + enabled: false, + url: 'https://updated.example.com/', + internal_url: 'https://internal-com.com/', + files_max_capacity: 33, + repos_max_capacity: 44, + verification_max_capacity: 55, + selective_sync_type: "shards", + selective_sync_shards: %w[shard1 shard2], + selective_sync_namespace_ids: [group_to_sync.id], + minimum_reverification_interval: 10 + }.stringify_keys + + put api("/geo_sites/#{secondary.id}", admin, admin_mode: true), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/geo_site', dir: 'ee') + expect(json_response).to include(params) + end + + it 'can update primary' do + params = { + url: 'https://updated.example.com/' + }.stringify_keys + + put api("/geo_sites/#{primary.id}", admin, admin_mode: true), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/geo_site', dir: 'ee') + expect(json_response).to include(params) + end + + it 'cannot disable a primary' do + params = { + enabled: false + }.stringify_keys + + put api("/geo_sites/#{primary.id}", admin, admin_mode: true), params: params + + expect(response).to have_gitlab_http_status(:bad_request) + end + + context 'with auth with geo site token' do + let(:geo_base_request) { Gitlab::Geo::BaseRequest.new(scope: ::Gitlab::Geo::API_SCOPE) } + + before do + stub_current_geo_node(primary) + allow(geo_base_request).to receive(:requesting_node) { secondary } + end + + it 'enables the secondary site' do + secondary.update!(enabled: false) + + put api("/geo_sites/#{secondary.id}"), params: { enabled: true }, headers: geo_base_request.headers + + expect(response).to have_gitlab_http_status(:ok) + expect(secondary.reload).to be_enabled + end + + it 'disables the secondary site' do + secondary.update!(enabled: true) + + put api("/geo_sites/#{secondary.id}"), params: { enabled: false }, headers: geo_base_request.headers + + expect(response).to have_gitlab_http_status(:ok) + expect(secondary.reload).not_to be_enabled + end + + it 'returns bad request if you try to update the primary' do + put api("/geo_sites/#{primary.id}"), params: { enabled: false }, headers: geo_base_request.headers + + expect(response).to have_gitlab_http_status(:bad_request) + expect(primary.reload).to be_enabled + end + + it 'responds with 401 when IP is not allowed' do + stub_application_setting(geo_node_allowed_ips: '192.34.34.34') + + put api("/geo_sites/#{secondary.id}"), params: {}, headers: geo_base_request.headers + + expect(response).to have_gitlab_http_status(:unauthorized) + end + + it 'responds 401 if auth header is bad' do + allow_any_instance_of(Gitlab::Geo::JwtRequestDecoder) + .to receive(:decode).and_raise(Gitlab::Geo::InvalidDecryptionKeyError) + + put api("/geo_sites/#{secondary.id}"), params: {}, headers: geo_base_request.headers + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + end + + describe 'DELETE /geo_sites/:id' do + it_behaves_like '404 response' do + let(:request) { delete api("/geo_sites/#{unexisting_site_id}", admin, admin_mode: true) } + end + + it 'denies access if not admin' do + delete api("/geo_sites/#{secondary.id}", user) + + expect(response).to have_gitlab_http_status(:forbidden) + end + + it 'deletes the site' do + delete api("/geo_sites/#{secondary.id}", admin, admin_mode: true) + + expect(response).to have_gitlab_http_status(:no_content) + end + + it 'returns 500 if Geo Site could not be deleted' do + allow_any_instance_of(GeoNode).to receive(:destroy!).and_raise(StandardError, 'Something wrong') + + delete api("/geo_sites/#{secondary.id}", admin, admin_mode: true) + + expect(response).to have_gitlab_http_status(:internal_server_error) + end + end + + describe 'GET /geo_sites/current/failures' do + context 'when primary site' do + before do + stub_current_geo_node(primary) + end + + it 'forbids requests' do + get api("/geo_sites/current/failures", admin, admin_mode: true) + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'when secondary site' do + before do + stub_current_geo_node(secondary) + end + + it 'fetches the current site failures' do + create(:geo_project_registry, :sync_failed) + create(:geo_project_registry, :sync_failed) + + get api("/geo_sites/current/failures", admin, admin_mode: true) + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/geo_project_registry', dir: 'ee') + end + + it 'does not show any registry when there is no failure' do + create(:geo_project_registry, :synced) + + get api("/geo_sites/current/failures", admin, admin_mode: true) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.count).to be_zero + end + + context 'when wiki type' do + it 'only shows wiki failures' do + create(:geo_project_registry, :wiki_sync_failed) + create(:geo_project_registry, :repository_sync_failed) + + get api("/geo_sites/current/failures", admin, admin_mode: true), params: { type: :wiki } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.count).to eq(1) + expect(json_response.first['wiki_retry_count']).to be > 0 + end + end + + context 'when repository type' do + it 'only shows repository failures' do + create(:geo_project_registry, :wiki_sync_failed) + create(:geo_project_registry, :repository_sync_failed) + + get api("/geo_sites/current/failures", admin, admin_mode: true), params: { type: :repository } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.count).to eq(1) + expect(json_response.first['repository_retry_count']).to be > 0 + end + end + + context 'when nonexistent type' do + it 'returns a bad request' do + create(:geo_project_registry, :repository_sync_failed) + + get api("/geo_sites/current/failures", admin, admin_mode: true), params: { type: :nonexistent } + + expect(response).to have_gitlab_http_status(:bad_request) + end + end + + it 'denies access if not admin' do + get api("/geo_sites/current/failures", user) + + expect(response).to have_gitlab_http_status(:forbidden) + end + + context 'when verification failures' do + before do + stub_current_geo_node(secondary) + end + + it 'fetches the current site checksum failures' do + create(:geo_project_registry, :repository_verification_failed) + create(:geo_project_registry, :wiki_verification_failed) + + get api("/geo_sites/current/failures", admin, admin_mode: true), params: { failure_type: 'verification' } + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/geo_project_registry', dir: 'ee') + end + + it 'does not show any registry when there is no failure' do + create(:geo_project_registry, :repository_verified) + + get api("/geo_sites/current/failures", admin, admin_mode: true), params: { failure_type: 'verification' } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.count).to be_zero + end + + context 'when wiki type' do + it 'only shows wiki verification failures' do + create(:geo_project_registry, :repository_verification_failed) + create(:geo_project_registry, :wiki_verification_failed) + + get api("/geo_sites/current/failures", admin, admin_mode: true), params: { failure_type: 'verification', + type: :wiki } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.count).to eq(1) + expect(json_response.first['last_wiki_verification_failure']).to be_present + end + end + + context 'when repository type' do + it 'only shows repository failures' do + create(:geo_project_registry, :repository_verification_failed) + create(:geo_project_registry, :wiki_verification_failed) + + get api("/geo_sites/current/failures", admin, admin_mode: true), params: { failure_type: 'verification', + type: :repository } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.count).to eq(1) + expect(json_response.first['last_repository_verification_failure']).to be_present + end + end + end + + context 'when checksum mismatch failures' do + before do + stub_current_geo_node(secondary) + end + + it 'fetches the checksum mismatch failures from current site' do + create(:geo_project_registry, :repository_checksum_mismatch) + create(:geo_project_registry, :wiki_checksum_mismatch) + + get api("/geo_sites/current/failures", admin, admin_mode: true), params: { failure_type: 'checksum_mismatch' } + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/geo_project_registry', dir: 'ee') + end + + it 'does not show any registry when there is no failure' do + create(:geo_project_registry, :repository_verified) + + get api("/geo_sites/current/failures", admin, admin_mode: true), params: { failure_type: 'checksum_mismatch' } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.count).to be_zero + end + + context 'when wiki type' do + it 'only shows wiki checksum mismatch failures' do + create(:geo_project_registry, :repository_checksum_mismatch) + create(:geo_project_registry, :wiki_checksum_mismatch) + + get api("/geo_sites/current/failures", admin, admin_mode: true), + params: { failure_type: 'checksum_mismatch', type: :wiki } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.count).to eq(1) + expect(json_response.first['wiki_checksum_mismatch']).to be_truthy + end + end + + context 'when repository type' do + it 'only shows repository checksum mismatch failures' do + create(:geo_project_registry, :repository_checksum_mismatch) + create(:geo_project_registry, :wiki_checksum_mismatch) + + get api("/geo_sites/current/failures", admin, admin_mode: true), + params: { failure_type: 'checksum_mismatch', type: :repository } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.count).to eq(1) + expect(json_response.first['repository_checksum_mismatch']).to be_truthy + end + end + end + end + end + + # rubocop:enable RSpec/AnyInstanceOf +end