diff --git a/ee/app/models/gitlab_subscriptions/seat_assignment.rb b/ee/app/models/gitlab_subscriptions/seat_assignment.rb index f2779aa7ff5586d5d4dbc21ad71c43604172d47e..fc8ca45e4435c01cfb1d197404dcc364dfab2b7b 100644 --- a/ee/app/models/gitlab_subscriptions/seat_assignment.rb +++ b/ee/app/models/gitlab_subscriptions/seat_assignment.rb @@ -27,6 +27,10 @@ def self.find_by_namespace_and_user(namespace, user) by_namespace(namespace).by_user(user).first end + def self.by_namespace_and_users(namespace, users) + by_namespace(namespace).by_user(users) + end + private def gitlab_com_subscription? diff --git a/ee/app/services/gitlab_subscriptions/members/seat_assignments/sync_service.rb b/ee/app/services/gitlab_subscriptions/members/seat_assignments/sync_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..c7ba68641a5c3ffcd4b0292ac170e80abb3fd0a3 --- /dev/null +++ b/ee/app/services/gitlab_subscriptions/members/seat_assignments/sync_service.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +module GitlabSubscriptions + module Members + module SeatAssignments + class SyncService + include Gitlab::Utils::StrongMemoize + + def initialize(user_ids, namespace) + @user_ids = user_ids.compact.uniq + @root_namespace = namespace&.root_ancestor + @seats_created = [] + @seats_updated = [] + @seats_destroyed = [] + @errors = [] + end + + def execute + return unless ::Gitlab::Saas.feature_available?(:gitlab_com_subscriptions) + return user_namespace_error if root_namespace&.user_namespace? + return ServiceResponse.success(message: 'Nothing to process for empty user list') unless user_ids.any? + + sync_seat_assignments + + log_seat_assignment_sync + ServiceResponse.success + end + + private + + attr_reader :user_ids, :root_namespace + attr_accessor :seats_created, :seats_updated, :seats_destroyed, :errors + + def sync_seat_assignments + create_seat_assignments + update_seat_assignments + destroy_seat_assignments + end + + def create_seat_assignments + seat_changes[:seats_to_create].each do |user_id| + seat_assignment_attrs = seat_assignment(user_id, seat_types[user_id]) + seat = GitlabSubscriptions::SeatAssignment.new(seat_assignment_attrs) + + if seat.save + @seats_created << user_id + else + @errors << { user_id: user_id, error: seat.errors.full_messages } + end + end + end + + def update_seat_assignments + seat_changes[:seats_to_update].each do |user_id| + seat_assignment_attrs = seat_assignment(user_id, seat_types[user_id]) + seat = existing_seats_by_user_id[user_id] + + if seat.update(seat_assignment_attrs) + @seats_updated << user_id + else + @errors << { user_id: user_id, error: seat.errors.full_messages } + end + end + end + + def destroy_seat_assignments + seat_changes[:seats_to_remove].each do |user_id| + seat = existing_seats_by_user_id[user_id] + + if seat.destroy + @seats_destroyed << user_id + else + @errors << { user_id: user_id, error: seat.errors.full_messages } + end + end + end + + def seat_types + seat_assignable_user_ids = seat_changes[:seats_to_create] + seat_changes[:seats_to_update] + + GitlabSubscriptions::SeatTypeCalculator.bulk_execute( + seat_assignable_user_ids, root_namespace + ) + end + strong_memoize_attr :seat_types + + def seat_assignment(user_id, seat_type) + { + namespace_id: root_namespace.id, + user_id: user_id, + seat_type: seat_type, + organization_id: root_namespace.organization_id + } + end + + def seat_changes + seat_assignable_user_ids = seat_assignable_members + user_ids_with_seats = existing_seats_by_user_id.keys + + { + seats_to_create: seat_assignable_user_ids - user_ids_with_seats, + seats_to_update: user_ids_with_seats & seat_assignable_user_ids, + seats_to_remove: user_ids_with_seats - seat_assignable_user_ids + } + end + + def existing_seats_by_user_id + GitlabSubscriptions::SeatAssignment.by_namespace_and_users(root_namespace, user_ids) + .index_by(&:user_id) + end + strong_memoize_attr :existing_seats_by_user_id + + def seat_assignable_members + Member.seat_assignable(users: user_ids, namespace: root_namespace) + .pluck_user_ids + .uniq + end + strong_memoize_attr :seat_assignable_members + + def user_namespace_error + ServiceResponse.error( + message: 'Seat assignments unavailable for user namespaces on GitLab.com' + ) + end + + def log_seat_assignment_sync + Gitlab::AppJsonLogger.info( + class: self.class.name, + namespace_id: root_namespace.id, + message: 'Synced seat assignment records', + created_seats_user_ids: @seats_created, + updated_seats_user_ids: @seats_updated, + destroyed_seats_user_ids: @seats_destroyed, + errors: @errors + ) + end + end + end + end +end diff --git a/ee/spec/models/gitlab_subscriptions/seat_assignment_spec.rb b/ee/spec/models/gitlab_subscriptions/seat_assignment_spec.rb index 3eeb2d9d57a73087ffbb067ad30a659e32f1154a..554535dcb74391fae46e9bb7693673e3ac9c94f0 100644 --- a/ee/spec/models/gitlab_subscriptions/seat_assignment_spec.rb +++ b/ee/spec/models/gitlab_subscriptions/seat_assignment_spec.rb @@ -52,6 +52,26 @@ end end + describe '.by_namespace_and_users' do + it 'returns records by namespace and users' do + user_2 = create(:user) + user_3 = create(:user) + users = [user, user_2, user_3] + + assignment_1 = create(:gitlab_subscription_seat_assignment, user: user, namespace: namespace) + assignment_2 = create(:gitlab_subscription_seat_assignment, user: user_2, namespace: namespace) + + expect(described_class.by_namespace_and_users(namespace, users)).to contain_exactly( + assignment_1, + assignment_2 + ) + end + + it 'returns empty array when no records exist' do + expect(described_class.by_namespace_and_users(namespace, [user])).to be_empty + end + end + describe '.dormant_in_namespace', :freeze_time do let_it_be(:dormant_seat_assignment_1) do create(:gitlab_subscription_seat_assignment, namespace: namespace, last_activity_on: 91.days.ago) diff --git a/ee/spec/services/gitlab_subscriptions/members/seat_assignments/sync_service_spec.rb b/ee/spec/services/gitlab_subscriptions/members/seat_assignments/sync_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..e6c5f5e2e2eecac83bb74178ea4f2b26ecb39037 --- /dev/null +++ b/ee/spec/services/gitlab_subscriptions/members/seat_assignments/sync_service_spec.rb @@ -0,0 +1,412 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSubscriptions::Members::SeatAssignments::SyncService, feature_category: :seat_cost_management do + describe '#execute' do + let_it_be(:user) { create(:user) } + let_it_be(:root_namespace) { create(:group) } + + context 'when on saas', :saas do + let_it_be(:group) { create(:group, parent: root_namespace) } + let_it_be(:sub_group) { create(:group, parent: group) } + let_it_be(:project) { create(:project, group: group) } + let_it_be(:sub_project) { create(:project, group: sub_group) } + + context 'with valid params' do + shared_examples 'handles seat assignments' do + context 'when the user has a membership' do + before do + namespace.add_developer(user) + end + + context 'when the user has an existing seat assignment' do + let!(:existing_seat_assignment) do + create(:gitlab_subscription_seat_assignment, + user: user, + seat_type: nil, + namespace: root_namespace, + organization_id: root_namespace.organization_id + ) + end + + it 'does not create a new seat assignment' do + expect { described_class.new([user.id], namespace).execute } + .not_to change { GitlabSubscriptions::SeatAssignment.count } + end + + it 'updates the existing seat assignment' do + described_class.new([user.id], namespace).execute + + expect(existing_seat_assignment.reload).to have_attributes( + user: user, + seat_type: 'base', + namespace: root_namespace, + organization_id: root_namespace.organization_id + ) + end + + it 'logs the result' do + expect(Gitlab::AppJsonLogger).to receive(:info).with( + hash_including( + namespace_id: root_namespace.id, + message: 'Synced seat assignment records', + errors: [], + created_seats_user_ids: [], + updated_seats_user_ids: [user.id], + destroyed_seats_user_ids: [] + ) + ) + + described_class.new([user.id], namespace).execute + end + end + + context 'when the user does not have a seat assignment' do + it 'creates a seat assignment' do + expect { described_class.new([user.id], namespace).execute } + .to change { GitlabSubscriptions::SeatAssignment.count }.by(1) + end + + it 'logs the result' do + expect(Gitlab::AppJsonLogger).to receive(:info).with( + hash_including( + namespace_id: root_namespace.id, + message: 'Synced seat assignment records', + errors: [], + created_seats_user_ids: [user.id], + updated_seats_user_ids: [], + destroyed_seats_user_ids: [] + ) + ) + + described_class.new([user.id], namespace).execute + end + end + end + + context 'when the user has no memberships' do + context 'with an existing seat' do + before do + create(:gitlab_subscription_seat_assignment, + user: user, + namespace: root_namespace, + organization_id: root_namespace.organization_id + ) + end + + it 'destroys the seat' do + expect { described_class.new([user.id], namespace).execute } + .to change { GitlabSubscriptions::SeatAssignment.count }.by(-1) + end + + it 'logs the result' do + expect(Gitlab::AppJsonLogger).to receive(:info).with( + hash_including( + namespace_id: root_namespace.id, + message: 'Synced seat assignment records', + errors: [], + created_seats_user_ids: [], + updated_seats_user_ids: [], + destroyed_seats_user_ids: [user.id] + ) + ) + + described_class.new([user.id], namespace).execute + end + end + + it 'does not create a seat' do + expect { described_class.new([user.id], namespace).execute } + .not_to change { GitlabSubscriptions::SeatAssignment.count } + end + + it 'logs the result' do + expect(Gitlab::AppJsonLogger).to receive(:info).with( + hash_including( + namespace_id: root_namespace.id, + message: 'Synced seat assignment records', + errors: [], + created_seats_user_ids: [], + updated_seats_user_ids: [], + destroyed_seats_user_ids: [] + ) + ) + + described_class.new([user.id], namespace).execute + end + end + + it 'returns success response' do + result = described_class.new([user.id], namespace).execute + + expect(result).to be_success + end + end + + context 'with a group' do + let(:namespace) { group } + + it_behaves_like 'handles seat assignments' + end + + context 'with a sub group' do + let(:namespace) { sub_group } + + it_behaves_like 'handles seat assignments' + end + + context 'with a project' do + let(:namespace) { project } + + it_behaves_like 'handles seat assignments' + end + + context 'with a sub project' do + let(:namespace) { sub_project } + + it_behaves_like 'handles seat assignments' + end + + context 'with multiple users' do + it 'handles seat assigments' do + group.add_developer(user) + create(:gitlab_subscription_seat_assignment, user: user, seat_type: nil, namespace: root_namespace) + + user_2 = create(:user) + create(:gitlab_subscription_seat_assignment, user: user_2, namespace: root_namespace) + + user_3 = create(:user) + sub_project.add_maintainer(user_3) + + user_4 = create(:user) + + expect(Gitlab::AppJsonLogger).to receive(:info).with( + a_hash_including( + namespace_id: root_namespace.id, + message: 'Synced seat assignment records', + errors: [], + created_seats_user_ids: [user_3.id], + updated_seats_user_ids: [user.id], + destroyed_seats_user_ids: [user_2.id] + ) + ) + + described_class.new([user.id, user_2.id, user_3.id, user_4.id], group).execute + + expect(GitlabSubscriptions::SeatAssignment.all.pluck(:user_id)).to contain_exactly( + user.id, + user_3.id + ) + end + + context 'when a seat fails to be synced' do + it 'completes the rest of the actions and logs failures' do + group.add_developer(user) + create(:gitlab_subscription_seat_assignment, + user: user, + seat_type: 'base', + namespace: root_namespace, + organization_id: root_namespace.organization_id + ) + + user_2 = create(:user) + seat_assignment_2 = create(:gitlab_subscription_seat_assignment, user: user_2, namespace: root_namespace) + + user_3 = create(:user) + group.add_developer(user_3) + + allow(GitlabSubscriptions::SeatAssignment).to receive(:by_namespace_and_users) + .and_return([seat_assignment_2]) + + expect(Gitlab::AppJsonLogger).to receive(:info).with( + a_hash_including( + namespace_id: root_namespace.id, + message: 'Synced seat assignment records', + errors: [{ + user_id: user.id, + error: ['Namespace has already been taken'] + }], + created_seats_user_ids: [user_3.id], + updated_seats_user_ids: [], + destroyed_seats_user_ids: [user_2.id] + ) + ) + + described_class.new([user.id, user_2.id, user_3.id], group).execute + end + end + end + + context 'when it fails to create a seat assignment' do + it 'logs the error' do + group.add_developer(user) + + allow_next_instance_of(GitlabSubscriptions::SeatAssignment) do |seat| + allow(seat).to receive(:save).and_return(false) + allow(seat).to receive_message_chain(:errors, :full_messages).and_return(['Some error message']) + end + + expect(Gitlab::AppJsonLogger).to receive(:info).with( + a_hash_including( + namespace_id: root_namespace.id, + message: 'Synced seat assignment records', + errors: [{ + user_id: user.id, + error: ['Some error message'] + }], + created_seats_user_ids: [], + updated_seats_user_ids: [], + destroyed_seats_user_ids: [] + ) + ) + + described_class.new([user.id], group).execute + end + end + + context 'when it fails to update a seat assignment' do + let!(:existing_seat_assignment) do + create(:gitlab_subscription_seat_assignment, + user: user, + seat_type: 'base', + namespace: root_namespace, + organization_id: root_namespace.organization_id + ) + end + + it 'logs the error' do + group.add_developer(user) + + allow(GitlabSubscriptions::SeatAssignment).to receive(:by_namespace_and_users) + .and_return([existing_seat_assignment]) + + allow(existing_seat_assignment).to receive(:update).and_return(false) + allow(existing_seat_assignment).to receive_message_chain(:errors, :full_messages) + .and_return(['Some error message']) + + expect(Gitlab::AppJsonLogger).to receive(:info).with( + a_hash_including( + namespace_id: root_namespace.id, + message: 'Synced seat assignment records', + errors: [{ + user_id: user.id, + error: ['Some error message'] + }], + created_seats_user_ids: [], + updated_seats_user_ids: [], + destroyed_seats_user_ids: [] + ) + ) + + described_class.new([user.id], group).execute + end + end + + context 'when it fails to destroy a seat assignment' do + let!(:existing_seat_assignment) do + create(:gitlab_subscription_seat_assignment, + user: user, + seat_type: 'base', + namespace: root_namespace, + organization_id: root_namespace.organization_id + ) + end + + it 'logs the error' do + allow(GitlabSubscriptions::SeatAssignment).to receive(:by_namespace_and_users) + .and_return([existing_seat_assignment]) + + allow(existing_seat_assignment).to receive(:destroy).and_return(false) + allow(existing_seat_assignment).to receive_message_chain(:errors, :full_messages) + .and_return(['Some error message']) + + expect(Gitlab::AppJsonLogger).to receive(:info).with( + a_hash_including( + namespace_id: root_namespace.id, + message: 'Synced seat assignment records', + errors: [{ + user_id: user.id, + error: ['Some error message'] + }], + created_seats_user_ids: [], + updated_seats_user_ids: [], + destroyed_seats_user_ids: [] + ) + ) + + described_class.new([user.id], group).execute + end + end + end + + context 'when the root namespace is not a group namespace' do + let(:namespace) { create(:user_namespace) } + + it 'returns an error' do + result = described_class.new([user.id], namespace).execute + + expect(result).to be_error + expect(result.message).to eq( + 'Seat assignments unavailable for user namespaces on GitLab.com' + ) + end + end + + context 'with empty user_ids provided' do + it 'returns success response' do + result = described_class.new([], group).execute + + expect(result).to be_success + expect(result.message).to eq('Nothing to process for empty user list') + end + end + + context 'when user_ids contains mixed elements' do + before_all do + group.add_developer(user) + end + + it 'handles seat assignment for valid user ids only' do + expect(Gitlab::AppJsonLogger).to receive(:info).with( + a_hash_including( + namespace_id: root_namespace.id, + message: 'Synced seat assignment records', + errors: [], + created_seats_user_ids: [user.id], + updated_seats_user_ids: [], + destroyed_seats_user_ids: [] + ) + ) + + described_class.new([user.id, nil], group).execute + end + end + + context 'when duplicate user ids are provided' do + it 'handles seat assignment for the deduplicated user once' do + group.add_developer(user) + + expect(Gitlab::AppJsonLogger).to receive(:info).with( + a_hash_including( + namespace_id: root_namespace.id, + message: 'Synced seat assignment records', + errors: [], + created_seats_user_ids: [user.id], + updated_seats_user_ids: [], + destroyed_seats_user_ids: [] + ) + ) + + described_class.new([user.id, user.id], group).execute + end + end + end + + context 'when on self-managed' do + it 'returns nil' do + expect(described_class.new([user.id], root_namespace).execute).to be_nil + end + end + end +end