diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index d950a275c85e677cd27b2958c2259d5b6d4d3e13..88c320f34a975412fb7fa321e4166dd8981ef025 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -489,6 +489,10 @@ - 1 - - gitlab_subscriptions_seat_assignments_group_links_create_or_update_seats - 1 +- - gitlab_subscriptions_seat_assignments_member_transfers_create_group_seats + - 1 +- - gitlab_subscriptions_seat_assignments_member_transfers_create_project_seats + - 1 - - gitlab_subscriptions_self_managed_duo_core_todo_notification - 1 - - gitlab_subscriptions_trials_apply_trial diff --git a/ee/app/workers/all_queues.yml b/ee/app/workers/all_queues.yml index 694b83dcdd42ded3c43f0dbfb23add9eb7cd8aa4..87ecf9e69b03e5bb1c3acbc0fb36b5e9f6876452 100644 --- a/ee/app/workers/all_queues.yml +++ b/ee/app/workers/all_queues.yml @@ -2443,6 +2443,26 @@ :idempotent: true :tags: [] :queue_namespace: +- :name: gitlab_subscriptions_seat_assignments_member_transfers_create_group_seats + :worker_name: GitlabSubscriptions::SeatAssignments::MemberTransfers::CreateGroupSeatsWorker + :feature_category: :seat_cost_management + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] + :queue_namespace: +- :name: gitlab_subscriptions_seat_assignments_member_transfers_create_project_seats + :worker_name: GitlabSubscriptions::SeatAssignments::MemberTransfers::CreateProjectSeatsWorker + :feature_category: :seat_cost_management + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] + :queue_namespace: - :name: gitlab_subscriptions_self_managed_duo_core_todo_notification :worker_name: GitlabSubscriptions::SelfManaged::DuoCoreTodoNotificationWorker :feature_category: :acquisition diff --git a/ee/app/workers/gitlab_subscriptions/seat_assignments/member_transfers/base_create_seats_worker.rb b/ee/app/workers/gitlab_subscriptions/seat_assignments/member_transfers/base_create_seats_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..4ccca787a695053478a0a6c2ed16a2ede1045665 --- /dev/null +++ b/ee/app/workers/gitlab_subscriptions/seat_assignments/member_transfers/base_create_seats_worker.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +# rubocop:disable Scalability/IdempotentWorker -- Idempotent declaration in child +module GitlabSubscriptions + module SeatAssignments + module MemberTransfers + class BaseCreateSeatsWorker + BATCH_SIZE = 100 + + def perform(source_id) + source = find_source_by_id(source_id) + return unless source + + create_missing_seat_assignments(source) + end + + private + + def create_missing_seat_assignments(source) + namespace_id = source.root_ancestor.id + organization_id = source.root_ancestor.organization_id + + collect_user_ids(source).each_batch(of: BATCH_SIZE) do |users| + seat_assignments = users.pluck_user_ids.map do |user_id| + { + namespace_id: namespace_id, + user_id: user_id, + organization_id: organization_id + } + end + + ::GitlabSubscriptions::SeatAssignment.insert_all( + seat_assignments, + unique_by: [:namespace_id, :user_id] + ) + end + end + + def collect_user_ids(_source) + raise NotImplementedError + end + + def find_source_by_id(_source_id) + raise NotImplementedError + end + end + end + end +end +# rubocop:enable Scalability/IdempotentWorker diff --git a/ee/app/workers/gitlab_subscriptions/seat_assignments/member_transfers/create_group_seats_worker.rb b/ee/app/workers/gitlab_subscriptions/seat_assignments/member_transfers/create_group_seats_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..dee6c1ac8d6b494221d0042c959939016469e3eb --- /dev/null +++ b/ee/app/workers/gitlab_subscriptions/seat_assignments/member_transfers/create_group_seats_worker.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module GitlabSubscriptions + module SeatAssignments + module MemberTransfers + class CreateGroupSeatsWorker < BaseCreateSeatsWorker + include ApplicationWorker + + feature_category :seat_cost_management + data_consistency :delayed + urgency :low + + defer_on_database_health_signal :gitlab_main, + [:subscription_seat_assignments, :members], 10.minutes + + idempotent! + + private + + def find_source_by_id(group_id) + Group.find_by_id(group_id) + end + + def collect_user_ids(group) + Member.for_self_and_descendants(group) + end + end + end + end +end diff --git a/ee/app/workers/gitlab_subscriptions/seat_assignments/member_transfers/create_project_seats_worker.rb b/ee/app/workers/gitlab_subscriptions/seat_assignments/member_transfers/create_project_seats_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..81ca2da14fabfdc520ffd4f658832d17c27e2013 --- /dev/null +++ b/ee/app/workers/gitlab_subscriptions/seat_assignments/member_transfers/create_project_seats_worker.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module GitlabSubscriptions + module SeatAssignments + module MemberTransfers + class CreateProjectSeatsWorker < BaseCreateSeatsWorker + include ApplicationWorker + + feature_category :seat_cost_management + data_consistency :delayed + urgency :low + + defer_on_database_health_signal :gitlab_main, + [:subscription_seat_assignments, :members], 10.minutes + + idempotent! + + private + + def find_source_by_id(project_id) + Project.find_by_id(project_id) + end + + def collect_user_ids(project) + project.project_members + end + end + end + end +end diff --git a/ee/spec/workers/gitlab_subscriptions/seat_assignments/member_transfers/base_create_seats_worker_spec.rb b/ee/spec/workers/gitlab_subscriptions/seat_assignments/member_transfers/base_create_seats_worker_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..e3b53f266372c956e676c16297675efb118a6e08 --- /dev/null +++ b/ee/spec/workers/gitlab_subscriptions/seat_assignments/member_transfers/base_create_seats_worker_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSubscriptions::SeatAssignments::MemberTransfers::BaseCreateSeatsWorker, :saas, feature_category: :seat_cost_management do + let(:worker) { described_class.new } + + describe '#collect_user_ids' do + it 'raises NotImplementedError' do + expect { worker.send(:collect_user_ids, double) }.to raise_error(NotImplementedError) + end + end + + describe '#find_source_by_id' do + it 'raises NotImplementedError' do + expect { worker.send(:find_source_by_id, double) }.to raise_error(NotImplementedError) + end + end +end diff --git a/ee/spec/workers/gitlab_subscriptions/seat_assignments/member_transfers/create_group_seats_worker_spec.rb b/ee/spec/workers/gitlab_subscriptions/seat_assignments/member_transfers/create_group_seats_worker_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..cb49a57ef2e2a8b5b7d7bc3a5dfe1268ea22476d --- /dev/null +++ b/ee/spec/workers/gitlab_subscriptions/seat_assignments/member_transfers/create_group_seats_worker_spec.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSubscriptions::SeatAssignments::MemberTransfers::CreateGroupSeatsWorker, :saas, feature_category: :seat_cost_management do + let(:worker) { described_class.new } + let_it_be(:user) { create(:user) } + let_it_be(:user_2) { create(:user) } + let_it_be(:user_3) { create(:user) } + + it_behaves_like 'worker with data consistency', described_class, data_consistency: :delayed + + it_behaves_like 'an idempotent worker' do + let_it_be(:group) { create(:group) } + + before do + create(:group_member, group: group, user: user) + end + + let(:job_args) { [group.id] } + end + + describe '#perform' do + context 'when the group is nil' do + it 'does nothing' do + expect(worker).not_to receive(:create_missing_seat_assignments) + + worker.perform(non_existing_record_id) + end + end + + context 'with a root group' do + let_it_be_with_refind(:group) { create(:group) } + + before do + create(:gitlab_subscription_seat_assignment, user: user, namespace: group) + end + + context 'when group namespace has missing seats' do + before do + create(:group_member, group: group, user: user) + create(:group_member, group: group, user: user_2) + create(:group_member, group: group, user: user_3) + end + + it 'creates seat assignments for the missing users' do + worker.perform(group.id) + + expect( + GitlabSubscriptions::SeatAssignment.by_namespace(group).pluck(:user_id) + ).to include(user_2.id, user_3.id) + end + + it 'creates seat assignments for all members' do + worker.perform(group.id) + + expect( + GitlabSubscriptions::SeatAssignment.by_namespace(group).pluck(:user_id) + ).to match_array([user.id, user_2.id, user_3.id]) + end + end + + context 'when a group namespace does not have missing seat assignments' do + before do + create(:group_member, group: group, user: user) + create(:group_member, group: group, user: user_2) + create(:group_member, group: group, user: user_3) + create(:gitlab_subscription_seat_assignment, :active, user: user_2, namespace: group) + create(:gitlab_subscription_seat_assignment, :active, user: user_3, namespace: group) + end + + it 'does not create seat assignments' do + expect do + worker.perform(group.id) + end.not_to change { GitlabSubscriptions::SeatAssignment.by_namespace(group).pluck(:user_id) } + end + end + + context 'when a root group has a project with members' do + let_it_be(:project) { create(:project, namespace: group) } + + before do + create(:group_member, group: group, user: user) + create(:project_member, project: project, user: user_2) + create(:project_member, project: project, user: user_3) + end + + it 'creates seat assignments for the project members' do + worker.perform(group.id) + + expect( + GitlabSubscriptions::SeatAssignment.by_namespace(group).pluck(:user_id) + ).to include(user_2.id, user_3.id) + end + end + + context 'when a group has a parent root group' do + let_it_be_with_refind(:transferred_group) { create(:group, parent: group) } + let_it_be(:user_4) { create(:user) } + + before do + create(:group_member, group: group, user: user_4) + create(:group_member, group: transferred_group, user: user_2) + create(:group_member, group: transferred_group, user: user_3) + end + + it 'creates seat assignments in the root group for the members of the group being passed' do + worker.perform(transferred_group.id) + + expect( + GitlabSubscriptions::SeatAssignment.by_namespace(group).pluck(:user_id) + ).to match_array([user.id, user_2.id, user_3.id]) + end + + it 'does not create seats for members of the root group if a child group is passed' do + worker.perform(transferred_group.id) + + expect( + GitlabSubscriptions::SeatAssignment.by_namespace(group).pluck(:user_id) + ).not_to include(user_4.id) + end + + it 'does not assign seats to the child group' do + worker.perform(transferred_group.id) + + expect( + GitlabSubscriptions::SeatAssignment.by_namespace(transferred_group) + ).to be_empty + end + end + + context 'with nested hierarchy' do + let_it_be(:child_group_a) { create(:group, parent: group) } + let_it_be(:child_group_b) { create(:group, parent: group) } + let_it_be(:child_group_c) { create(:group, parent: child_group_a) } + let_it_be(:user_4) { create(:user) } + + before do + create(:group_member, group: group, user: user) + create(:group_member, group: child_group_a, user: user_2) + create(:group_member, group: child_group_b, user: user_3) + create(:group_member, group: child_group_c, user: user_4) + end + + context 'when a leaf group is passed' do + it 'creates seat assignments in the root group only for the members of the leaf group' do + worker.perform(child_group_c.id) + + expect( + GitlabSubscriptions::SeatAssignment.by_namespace(group).pluck(:user_id) + ).to match_array([user.id, user_4.id]) + end + end + + context 'when a subhierarchy is passed' do + it 'creates seat assignments in the root group only for members of the subhierarchy' do + worker.perform(child_group_a.id) + + expect( + GitlabSubscriptions::SeatAssignment.by_namespace(group).pluck(:user_id) + ).to match_array([user.id, user_2.id, user_4.id]) + end + end + + context 'when a sibling group is passed' do + it 'creates seat assignments in the root group only for the members of sibling' do + worker.perform(child_group_b.id) + + expect( + GitlabSubscriptions::SeatAssignment.by_namespace(group).pluck(:user_id) + ).to match_array([user.id, user_3.id]) + end + end + + context 'when the root group is passed' do + it 'creates seat assignments in the root group for all members of the hierarchy' do + worker.perform(group.id) + + expect( + GitlabSubscriptions::SeatAssignment.by_namespace(group).pluck(:user_id) + ).to match_array([user.id, user_2.id, user_3.id, user_4.id]) + end + end + end + end + end +end diff --git a/ee/spec/workers/gitlab_subscriptions/seat_assignments/member_transfers/create_project_seats_worker_spec.rb b/ee/spec/workers/gitlab_subscriptions/seat_assignments/member_transfers/create_project_seats_worker_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..8255beac17cfa9bc8f1a95b5676c70bbb7eeea5f --- /dev/null +++ b/ee/spec/workers/gitlab_subscriptions/seat_assignments/member_transfers/create_project_seats_worker_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSubscriptions::SeatAssignments::MemberTransfers::CreateProjectSeatsWorker, :saas, feature_category: :seat_cost_management do + let(:worker) { described_class.new } + let_it_be(:user) { create(:user, :with_namespace) } + let_it_be(:user_2) { create(:user) } + let_it_be(:user_3) { create(:user) } + + it_behaves_like 'worker with data consistency', described_class, data_consistency: :delayed + + it_behaves_like 'an idempotent worker' do + let_it_be(:project) { create(:project) } + + before do + create(:project_member, project: project, user: user) + end + + let(:job_args) { [project.id] } + end + + describe '#perform' do + it 'does nothing when there is no project' do + expect(worker).not_to receive(:create_missing_seat_assignments) + + worker.perform(non_existing_record_id) + end + + context 'when the project root namespace is a user namespace' do + let_it_be(:project) { create(:project, namespace: user.namespace) } + + before do + create(:project_member, project: project, user: user_2) + create(:project_member, project: project, user: user_3) + create(:gitlab_subscription_seat_assignment, user: user, namespace: project.namespace) + end + + it 'creates seat assignments in the user namespace only for members of the project' do + worker.perform(project.id) + + expect( + GitlabSubscriptions::SeatAssignment.by_namespace(project.namespace).pluck(:user_id) + ).to match_array([user.id, user_2.id, user_3.id]) + end + + context 'when a user namespace does not have missing seat assignments' do + before do + create(:gitlab_subscription_seat_assignment, user: user_2, namespace: project.namespace) + create(:gitlab_subscription_seat_assignment, user: user_3, namespace: project.namespace) + end + + it 'does not create duplicates if seat assignments are already reconciled' do + expect do + worker.perform(project.id) + end.not_to change { GitlabSubscriptions::SeatAssignment.by_namespace(project.namespace).count } + end + end + end + + context 'when the project root namespace is a group' do + let_it_be_with_refind(:group) { create(:group) } + let_it_be(:project) { create(:project, namespace: group) } + + before do + create(:group_member, group: group, user: user) + create(:project_member, project: project, user: user_2) + create(:project_member, project: project, user: user_3) + end + + it 'creates seat assignments in the root group only for the members of the project' do + worker.perform(project.id) + + expect( + GitlabSubscriptions::SeatAssignment.by_namespace(group).pluck(:user_id) + ).to match_array([user_2.id, user_3.id]) + end + + context 'when the root group namespace already has seats for the project members' do + before do + create(:gitlab_subscription_seat_assignment, user: user_2, namespace: group) + create(:gitlab_subscription_seat_assignment, user: user_3, namespace: group) + end + + it 'does not create duplicate seats' do + expect do + worker.perform(project.id) + end.not_to change { GitlabSubscriptions::SeatAssignment.by_namespace(group).count } + end + end + end + + context 'when the project belongs to a subgroup' do + let_it_be_with_refind(:group) { create(:group) } + let_it_be(:child_group) { create(:group, parent: group) } + let_it_be(:project) { create(:project, namespace: child_group) } + + before do + create(:group_member, group: group, user: user) + create(:group_member, group: child_group, user: user_2) + create(:project_member, project: project, user: user_3) + end + + it 'creates seat assignments in the root group only for the members of the project' do + worker.perform(project.id) + + expect( + GitlabSubscriptions::SeatAssignment.by_namespace(group).pluck(:user_id) + ).to match_array([user_3.id]) + end + end + end +end