From 7c36c68eb54b606d6d6c9bebd3c944cf9ee96ac2 Mon Sep 17 00:00:00 2001 From: Matthias Kaeppler <mkaeppler@gitlab.com> Date: Wed, 30 Oct 2024 15:27:01 +0100 Subject: [PATCH] Support namespaces in X-Gitlab-Duo-Seat-Count We currently only populate this header based on instance-wide add-on purchases and seat counts, which does not work for gitlab.com Changelog: fixed EE: true --- .../gitlab_subscriptions/add_on_purchase.rb | 6 +- ee/lib/gitlab/ai_gateway.rb | 2 +- ee/lib/gitlab/cloud_connector.rb | 13 ++- ee/spec/lib/gitlab/ai_gateway_spec.rb | 22 ++-- ee/spec/lib/gitlab/cloud_connector_spec.rb | 10 +- .../add_on_purchase_spec.rb | 106 ++++++++++++------ 6 files changed, 108 insertions(+), 51 deletions(-) diff --git a/ee/app/models/gitlab_subscriptions/add_on_purchase.rb b/ee/app/models/gitlab_subscriptions/add_on_purchase.rb index 43d8ca174471e..8cba472df1ecc 100644 --- a/ee/app/models/gitlab_subscriptions/add_on_purchase.rb +++ b/ee/app/models/gitlab_subscriptions/add_on_purchase.rb @@ -75,8 +75,10 @@ def self.uniq_namespace_ids pluck(:namespace_id).compact.uniq end - def self.maximum_duo_seat_count - active.for_duo_pro_or_duo_enterprise.pluck(:quantity).max || 0 + def self.maximum_duo_seat_count(namespace_ids: []) + scope = active.for_duo_pro_or_duo_enterprise + scope = scope.by_namespace(namespace_ids) if namespace_ids.any? + scope.pluck(:quantity).max || 0 end def already_assigned?(user) diff --git a/ee/lib/gitlab/ai_gateway.rb b/ee/lib/gitlab/ai_gateway.rb index a63ca445c317b..f299270502062 100644 --- a/ee/lib/gitlab/ai_gateway.rb +++ b/ee/lib/gitlab/ai_gateway.rb @@ -63,7 +63,7 @@ def self.headers(user:, service:, agent: nil, lsp_version: nil) # Forward the request time to the model gateway to calculate latency 'X-Gitlab-Rails-Send-Start' => Time.now.to_f.to_s, 'x-gitlab-enabled-feature-flags' => enabled_feature_flags.uniq.join(',') - }.merge(Gitlab::CloudConnector.ai_headers(user)) + }.merge(Gitlab::CloudConnector.ai_headers(user, namespace_ids: allowed_by_namespace_ids)) .tap do |result| result['User-Agent'] = agent if agent # Forward the User-Agent on to the model gateway diff --git a/ee/lib/gitlab/cloud_connector.rb b/ee/lib/gitlab/cloud_connector.rb index d4937f6e51a59..c288f1d83e61f 100644 --- a/ee/lib/gitlab/cloud_connector.rb +++ b/ee/lib/gitlab/cloud_connector.rb @@ -22,9 +22,18 @@ def headers(user) end end - def ai_headers(user) + ### + # Returns required HTTP header fields when making AI requests through Cloud Connector. + # + # user - User making the request, may be null. + # namespace_ids - Namespaces for which to return the maximum allowed Duo seat count. + # This should only be set when the request is made on gitlab.com. + def ai_headers(user, namespace_ids: []) + effective_seat_count = GitlabSubscriptions::AddOnPurchase.maximum_duo_seat_count( + namespace_ids: namespace_ids + ) headers(user).merge( - 'X-Gitlab-Duo-Seat-Count' => GitlabSubscriptions::AddOnPurchase.maximum_duo_seat_count.to_s + 'X-Gitlab-Duo-Seat-Count' => effective_seat_count.to_s ) end end diff --git a/ee/spec/lib/gitlab/ai_gateway_spec.rb b/ee/spec/lib/gitlab/ai_gateway_spec.rb index 91e2819675a52..e39948ee39515 100644 --- a/ee/spec/lib/gitlab/ai_gateway_spec.rb +++ b/ee/spec/lib/gitlab/ai_gateway_spec.rb @@ -86,6 +86,17 @@ let(:service) { instance_double(CloudConnector::BaseAvailableServiceData, name: service_name) } let(:agent) { nil } let(:lsp_version) { nil } + let(:cloud_connector_headers) do + { + 'X-Gitlab-Host-Name' => 'hostname', + 'X-Gitlab-Instance-Id' => 'ABCDEF', + 'X-Gitlab-Global-User-Id' => '123ABC', + 'X-Gitlab-Realm' => 'self-managed', + 'X-Gitlab-Version' => '17.1.0', + 'X-Gitlab-Duo-Seat-Count' => "50" + } + end + let(:expected_headers) do { 'X-Gitlab-Authentication-Type' => 'oidc', @@ -94,14 +105,8 @@ 'Content-Type' => 'application/json', 'X-Request-ID' => an_instance_of(String), 'X-Gitlab-Rails-Send-Start' => an_instance_of(String), - 'X-Gitlab-Global-User-Id' => an_instance_of(String), - 'X-Gitlab-Host-Name' => Gitlab.config.gitlab.host, - 'X-Gitlab-Instance-Id' => an_instance_of(String), - 'X-Gitlab-Realm' => Gitlab::CloudConnector::GITLAB_REALM_SELF_MANAGED, - 'X-Gitlab-Version' => Gitlab.version_info.to_s, - 'X-Gitlab-Duo-Seat-Count' => "0", 'x-gitlab-enabled-feature-flags' => an_instance_of(String) - } + }.merge(cloud_connector_headers) end subject(:headers) { described_class.headers(user: user, service: service, agent: agent, lsp_version: lsp_version) } @@ -109,6 +114,9 @@ before do allow(service).to receive(:access_token).with(user).and_return(token) allow(user).to receive(:allowed_to_use?).with(service_name).and_yield(enabled_by_namespace_ids) + allow(Gitlab::CloudConnector).to( + receive(:ai_headers).with(user, namespace_ids: enabled_by_namespace_ids).and_return(cloud_connector_headers) + ) end it { is_expected.to match(expected_headers) } diff --git a/ee/spec/lib/gitlab/cloud_connector_spec.rb b/ee/spec/lib/gitlab/cloud_connector_spec.rb index a3984800ed425..1aae2c79314ed 100644 --- a/ee/spec/lib/gitlab/cloud_connector_spec.rb +++ b/ee/spec/lib/gitlab/cloud_connector_spec.rb @@ -53,15 +53,19 @@ super().merge('X-Gitlab-Duo-Seat-Count' => '0') end + let(:namespace_ids) { [1, 42] } + it_behaves_like 'building HTTP headers' - subject(:headers) { described_class.ai_headers(user) } + subject(:headers) { described_class.ai_headers(user, namespace_ids: namespace_ids) } context 'when Duo seats have been purchased' do let(:user) { nil } - it 'set the header to the correct number of seats' do - expect(GitlabSubscriptions::AddOnPurchase).to receive(:maximum_duo_seat_count).and_return(5) + it 'sets the seat count header to the correct number of seats' do + expect(GitlabSubscriptions::AddOnPurchase).to( + receive(:maximum_duo_seat_count).with(namespace_ids: namespace_ids).and_return(5) + ) expect(headers).to include('X-Gitlab-Duo-Seat-Count' => '5') end diff --git a/ee/spec/models/gitlab_subscriptions/add_on_purchase_spec.rb b/ee/spec/models/gitlab_subscriptions/add_on_purchase_spec.rb index 0645a9f77a88a..e525572a074ce 100644 --- a/ee/spec/models/gitlab_subscriptions/add_on_purchase_spec.rb +++ b/ee/spec/models/gitlab_subscriptions/add_on_purchase_spec.rb @@ -598,60 +598,94 @@ end describe '.maximum_duo_seat_count' do - context 'when there is no duo add-on' do - let(:expected_maximum_duo_seat_count) { 0 } + shared_examples 'seat count calculation' do + subject(:seat_count) { described_class.maximum_duo_seat_count(namespace_ids: namespace_ids) } - it 'returns the default of 0' do - expect(described_class.maximum_duo_seat_count).to eq(expected_maximum_duo_seat_count) + context 'when there is no duo add-on' do + let(:expected_maximum_duo_seat_count) { 0 } + + it 'returns the default of 0' do + expect(seat_count).to eq(expected_maximum_duo_seat_count) + end end - end - context 'when there is a duo pro add-on' do - let(:expected_maximum_duo_seat_count) { 10 } + context 'when there is a duo pro add-on' do + let(:expected_maximum_duo_seat_count) { 10 } - it 'returns the number of seats purchased' do - create(:gitlab_subscription_add_on_purchase, :active, :gitlab_duo_pro, - quantity: expected_maximum_duo_seat_count) + it 'returns the number of seats purchased' do + create(:gitlab_subscription_add_on_purchase, :active, :gitlab_duo_pro, + options.merge(quantity: expected_maximum_duo_seat_count)) - expect(described_class.maximum_duo_seat_count).to eq(expected_maximum_duo_seat_count) + expect(seat_count).to eq(expected_maximum_duo_seat_count) + end end - end - context 'when there is a duo enterprise add-on' do - let(:expected_maximum_duo_seat_count) { 20 } + context 'when there is a duo enterprise add-on' do + let(:expected_maximum_duo_seat_count) { 20 } - it 'returns the number of seats purchased' do - create(:gitlab_subscription_add_on_purchase, :active, :duo_enterprise, - quantity: expected_maximum_duo_seat_count) + it 'returns the number of seats purchased' do + create(:gitlab_subscription_add_on_purchase, :active, :duo_enterprise, + options.merge(quantity: expected_maximum_duo_seat_count)) - expect(described_class.maximum_duo_seat_count).to eq(expected_maximum_duo_seat_count) + expect(seat_count).to eq(expected_maximum_duo_seat_count) + end + end + + context 'when there is both a duo pro add-on and a duo enterprise trial add-on' do + let(:expected_duo_enterprise_seat_count) { 15 } + let(:expected_duo_pro_seat_count) { 5 } + + it 'returns the maximum number of seats purchased for the add-on with the most seats' do + create(:gitlab_subscription_add_on_purchase, :active, :duo_enterprise, :trial, + options.merge(quantity: expected_duo_enterprise_seat_count)) + create(:gitlab_subscription_add_on_purchase, :active, :gitlab_duo_pro, + options.merge(quantity: expected_duo_pro_seat_count)) + + expect(seat_count).to eq(expected_duo_enterprise_seat_count) + end end - end - context 'when there is both a duo pro add-on and a duo enterprise trial add-on' do - let(:expected_duo_enterprise_seat_count) { 15 } - let(:expected_duo_pro_seat_count) { 5 } + context 'when there is both a duo pro trial add-on and a duo enterprise add-on' do + let(:expected_duo_enterprise_seat_count) { 10 } + let(:expected_duo_pro_seat_count) { 40 } - it 'returns the maximum number of seats purchased for the add-on with the most seats' do - create(:gitlab_subscription_add_on_purchase, :active, :duo_enterprise, :trial, - quantity: expected_duo_enterprise_seat_count) - create(:gitlab_subscription_add_on_purchase, :active, :gitlab_duo_pro, quantity: expected_duo_pro_seat_count) + it 'returns the maximum number of seats purchased for the add-on with the most seats' do + create(:gitlab_subscription_add_on_purchase, :active, :duo_enterprise, + options.merge(quantity: expected_duo_enterprise_seat_count)) + create(:gitlab_subscription_add_on_purchase, :active, :gitlab_duo_pro, :trial, + options.merge(quantity: expected_duo_pro_seat_count)) - expect(described_class.maximum_duo_seat_count).to eq(expected_duo_enterprise_seat_count) + expect(seat_count).to eq(expected_duo_pro_seat_count) + end end end - context 'when there is both a duo pro trial add-on and a duo enterprise add-on' do - let(:expected_duo_enterprise_seat_count) { 10 } - let(:expected_duo_pro_seat_count) { 40 } + context 'when no namespace IDs are given' do + let(:namespace_ids) { [] } + let(:options) { { namespace: nil } } + + include_examples 'seat count calculation' + end + + context 'when namespace IDs are given' do + let_it_be(:group) { create(:group) } + + let(:namespace_ids) { [group.id] } + let(:options) { { namespace: group } } + + include_examples 'seat count calculation' - it 'returns the maximum number of seats purchased for the add-on with the most seats' do - create(:gitlab_subscription_add_on_purchase, :active, :duo_enterprise, - quantity: expected_duo_enterprise_seat_count) - create(:gitlab_subscription_add_on_purchase, :active, :gitlab_duo_pro, :trial, - quantity: expected_duo_pro_seat_count) + context 'with more than one group namespace' do + let_it_be(:other_group) { create(:group) } - expect(described_class.maximum_duo_seat_count).to eq(expected_duo_pro_seat_count) + it 'returns the highest seat count of both' do + create(:gitlab_subscription_add_on_purchase, :active, :duo_enterprise, + quantity: 20, namespace: group) + create(:gitlab_subscription_add_on_purchase, :active, :gitlab_duo_pro, + quantity: 10, namespace: other_group) + + expect(described_class.maximum_duo_seat_count(namespace_ids: [group.id, other_group.id])).to eq(20) + end end end end -- GitLab