diff --git a/doc/raketasks/user_management.md b/doc/raketasks/user_management.md index 332d93ef207d87179f45a36ab5cb3e6f4adc3ae6..810bc1582b58a0c26db94ddf4f2d3d711765e8c7 100644 --- a/doc/raketasks/user_management.md +++ b/doc/raketasks/user_management.md @@ -178,6 +178,41 @@ sudo /etc/init.d/gitlab start ``` +## Bulk assign users to GitLab Duo Pro + +To perform bulk user assignment for GitLab Duo Pro, you can use the following Rake task: + +```shell +bundle exec rake duo_pro:bulk_user_assignment DUO_PRO_BULK_USER_FILE_PATH=path/to/your/file.csv +``` + +If you prefer to use square brackets in the file path, you can escape them or use double quotes: + +```shell +bundle exec rake duo_pro:bulk_user_assignment\['path/to/your/file.csv'\] +# or +bundle exec rake "duo_pro:bulk_user_assignment['path/to/your/file.csv']" +``` + +The CSV file should have the following format: + +```csv +username +user1 +user2 +user3 +user4 +etc.. +``` + +Ensure that the file contains a header named `username`, and each subsequent row represents a username for user assignment. + +The task might raise the following error messages: + +- `User is not found`: The specified user was not found. +- `ERROR_NO_SEATS_AVAILABLE`: No more seats are available for user assignment. +- `ERROR_INVALID_USER_MEMBERSHIP`: The user is not eligible for assignment due to being inactive, a bot, or a ghost. + ## Related topics - [Reset a user's password](../security/reset_user_password.md#use-a-rake-task) diff --git a/ee/lib/duo_pro/bulk_user_assignment.rb b/ee/lib/duo_pro/bulk_user_assignment.rb new file mode 100644 index 0000000000000000000000000000000000000000..a34b8ef9c64e24362501629adc16ac98f4c11986 --- /dev/null +++ b/ee/lib/duo_pro/bulk_user_assignment.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +# Duo Pro Bulk User Assignment +# 1. Set the `add_on_purchase` variable to point to your AddOnPurchase record +# add_on_purchase = GitlabSubscriptions::AddOnPurchase.find_by(add_on: GitlabSubscriptions::AddOn.code_suggestions.last) +# 2. Set the `usernames` variable to point to an array of usernames: +# usernames = ["user1", "user2", "user3", "user4", "user5"] +# If reading from a CSV file +# usernames = CSV.read(FILE_PATH, headers: true).pluck('username') +# 3. Execute the bulk assignment: +# DuoPro::BulkUserAssignment.new(usernames, add_on_purchase).execute + +# Error Messages: +# - `User is not found` +# - `ERROR_NO_SEATS_AVAILABLE`: No more seats are available. +# - `ERROR_INVALID_USER_MEMBERSHIP`: User is not eligible for assignment due to being inactive, a bot, or a ghost. + +module DuoPro + class BulkUserAssignment + attr_reader :usernames, :add_on_purchase, :successful_assignments, :failed_assignments + + def initialize(usernames, add_on_purchase) + @usernames = usernames + @add_on_purchase = add_on_purchase + @successful_assignments = [] + @failed_assignments = [] + end + + def execute + return 'AddOn not purchased' unless add_on_purchase + + process_users(usernames) + + { successful_assignments: successful_assignments, failed_assignments: failed_assignments } + end + + private + + def process_users(usernames) + usernames.each do |username| + user_to_be_assigned = User.find_by_username(username) + + unless user_to_be_assigned + log_failed_assignment("User is not found: #{username}") + next + end + + result = assign(user_to_be_assigned) + + if result.errors.include?("NO_SEATS_AVAILABLE") + log_no_seats_available(result, username) + break + end + + log_result(result, username) + end + end + + def assign(user) + ::GitlabSubscriptions::UserAddOnAssignments::SelfManaged::CreateService.new( + add_on_purchase: add_on_purchase, + user: user + ).execute + end + + def log_no_seats_available(result, username) + log_failed_assignment("Failed to assign seat to user: #{username}, Errors: #{result.errors}") + log_failed_assignment("##No seats are left; users starting from @#{username} onwards were not assigned.##") + end + + def log_successful_assignment(username) + successful_assignments << "User assigned: #{username}" + end + + def log_failed_assignment(message) + failed_assignments << message + end + + def log_result(result, username) + if result.errors.empty? + log_successful_assignment(username) + else + log_failed_assignment("Failed to assign seat to user: #{username}, Errors: #{result.errors}") + end + end + end +end diff --git a/ee/lib/tasks/duo_pro/bulk_user_assignment.rake b/ee/lib/tasks/duo_pro/bulk_user_assignment.rake new file mode 100644 index 0000000000000000000000000000000000000000..ddabee710a43e66c1a417fb8807d96fe2b442aaf --- /dev/null +++ b/ee/lib/tasks/duo_pro/bulk_user_assignment.rake @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +namespace :duo_pro do + desc 'Bulk user assignment for Code Suggestions' + task :bulk_user_assignment, [:duo_pro_bulk_user_file_path] => :environment do |_t, args| + file_path = args[:duo_pro_bulk_user_file_path] || ENV['DUO_PRO_BULK_USER_FILE_PATH'] + + unless file_path + raise <<~ERROR_MESSAGE.strip + ================================================================================ + ## ERROR ## + File path is not provided.#{' '} + Please set the DUO_PRO_BULK_USER_FILE_PATH environment variable#{' '} + or provide it as an argument. + ================================================================================ + ERROR_MESSAGE + end + + user_names = read_usernames_from_file(file_path) + add_on_purchase = find_add_on_purchase + + unless add_on_purchase + raise <<~ERROR_MESSAGE.strip + ================================================================================ + ## ERROR ## + Unable to find Duo Pro AddOn purchase.#{' '} + Please ensure the necessary AddOn is already purchased and exists. + ================================================================================ + ERROR_MESSAGE + end + + result = DuoPro::BulkUserAssignment.new(user_names, add_on_purchase).execute + display_results(result) + end + + private + + def read_usernames_from_file(file_path) + CSV.read(file_path, headers: true).pluck('username') + end + + def find_add_on_purchase + GitlabSubscriptions::AddOnPurchase.for_code_suggestions.active.first + end + + def display_results(result) + puts "\nSuccessful Assignments:" + puts result[:successful_assignments].join("\n") + + puts "\nFailed Assignments:" + puts result[:failed_assignments].join("\n") + end +end diff --git a/ee/spec/lib/duo_pro/bulk_user_assignment_spec.rb b/ee/spec/lib/duo_pro/bulk_user_assignment_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..08467ad082e6acd62bf4a251d835be67121a142d --- /dev/null +++ b/ee/spec/lib/duo_pro/bulk_user_assignment_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe DuoPro::BulkUserAssignment, feature_category: :purchase do + describe '#initialize' do + subject(:bulk_assignment) { described_class.new([], nil) } + + it 'initializes with the correct attributes' do + expect(bulk_assignment.usernames).to eq([]) + expect(bulk_assignment.add_on_purchase).to be_nil + expect(bulk_assignment.successful_assignments).to eq([]) + expect(bulk_assignment.failed_assignments).to eq([]) + end + end + + describe '#execute' do + let(:add_on) { create(:gitlab_subscription_add_on) } + let(:usernames) { User.pluck(:username) + ['code_suggestions_not_found_username'] } + + before do + create(:user, username: 'code_suggestions_active_user1') + create(:user, username: 'code_suggestions_active_user2') + create(:user, username: 'code_suggestions_active_user3') + create(:user, username: 'code_suggestions_extra_user1') + create(:user, username: 'code_suggestions_extra_user2') + create(:user, :blocked, username: 'code_suggestions_blocked_user') + create(:user, :banned, username: 'code_suggestions_banned_user') + create(:user, :bot, username: 'code_suggestions_bot_user') + create(:user, :ghost, username: 'code_suggestions_ghost_user') + end + + context 'when the AddOn is not purchased' do + it 'returns a message indicating AddOn not purchased' do + results = described_class.new([], nil).execute + expect(results).to eq('AddOn not purchased') + end + end + + context 'when the AddOn is purchased' do + let(:add_on_purchase) do + create(:gitlab_subscription_add_on_purchase, :self_managed, quantity: 10, add_on: add_on) + end + + subject(:bulk_assignment) { described_class.new(usernames, add_on_purchase) } + + context 'with enough seats' do + it 'returns success and failed assignments' do + results = bulk_assignment.execute + + expect(results[:successful_assignments]).to eq([ + "User assigned: code_suggestions_active_user1", + "User assigned: code_suggestions_active_user2", + "User assigned: code_suggestions_active_user3", + "User assigned: code_suggestions_extra_user1", + "User assigned: code_suggestions_extra_user2" + ]) + + expect(results[:failed_assignments]).to eq([ + "Failed to assign seat to user: code_suggestions_blocked_user, Errors: [\"INVALID_USER_MEMBERSHIP\"]", + "Failed to assign seat to user: code_suggestions_banned_user, Errors: [\"INVALID_USER_MEMBERSHIP\"]", + "Failed to assign seat to user: code_suggestions_bot_user, Errors: [\"INVALID_USER_MEMBERSHIP\"]", + "Failed to assign seat to user: code_suggestions_ghost_user, Errors: [\"INVALID_USER_MEMBERSHIP\"]", + "User is not found: code_suggestions_not_found_username" + ]) + end + end + + context 'with not enough seats' do + let(:add_on_purchase) do + create(:gitlab_subscription_add_on_purchase, :self_managed, quantity: 3, add_on: add_on) + end + + it 'returns success and failed assignments and stops execution' do + results = bulk_assignment.execute + + expect(results[:successful_assignments]).to eq( + ["User assigned: code_suggestions_active_user1", + "User assigned: code_suggestions_active_user2", + "User assigned: code_suggestions_active_user3"]) + + expect(results[:failed_assignments]).to eq( + ["Failed to assign seat to user: code_suggestions_extra_user1, Errors: [\"NO_SEATS_AVAILABLE\"]", + "##No seats are left; users starting from @code_suggestions_extra_user1 onwards were not assigned.##"]) + end + end + end + end +end diff --git a/ee/spec/tasks/duo_pro/bulk_user_assignment_spec.rb b/ee/spec/tasks/duo_pro/bulk_user_assignment_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..dd87c6e0b52e8c6d70f573b9131efd34a4cf14c0 --- /dev/null +++ b/ee/spec/tasks/duo_pro/bulk_user_assignment_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'duo_pro:bulk_user_assignment', feature_category: :purchase do + let(:csv_file_path) { 'spec/fixtures/duo_pro/bulk_user_assignment.csv' } + + before do + Rake.application.rake_require('tasks/duo_pro/bulk_user_assignment') + end + + describe 'duo_pro:bulk_user_assignment task' do + context 'when file_path/DUO_PRO_BULK_USER_FILE_PATH is not provided' do + it 'raises an error' do + expect do + run_rake_task('duo_pro:bulk_user_assignment') + end.to raise_error(RuntimeError, /File path is not provided/) + end + end + + context 'when Duo Pro AddOn purchase is not found' do + it 'raises an error for missing Duo Pro AddOn purchase' do + expected_error_message = "Unable to find Duo Pro AddOn purchase." + + expect { run_rake_task('duo_pro:bulk_user_assignment', csv_file_path) } + .to raise_error(RuntimeError) + .with_message(a_string_including(expected_error_message)) + end + end + + context 'when Duo Pro AddOn purchase is found' do + let(:add_on) { create(:gitlab_subscription_add_on) } + + before do + add_on_purchase = create(:gitlab_subscription_add_on_purchase, :self_managed, quantity: 10, add_on: add_on) + allow_next_instance_of(DuoPro::BulkUserAssignment, %w[user1 user2 user3], add_on_purchase) do |instance| + response = { successful_assignments: ['success'], failed_assignments: ['Failed'] } + allow(instance).to receive(:execute).and_return(response) + end + end + + it 'outputs success and failed assignments' do + expected_output = "\nSuccessful Assignments:\nsuccess\n" \ + "\nFailed Assignments:\nFailed\n" + + expect { run_rake_task('duo_pro:bulk_user_assignment', csv_file_path) } + .to output(a_string_including(expected_output)).to_stdout + end + + context 'with an env variable' do + it 'outputs success and failed assignments' do + stub_env('DUO_PRO_BULK_USER_FILE_PATH' => csv_file_path) + expected_output = "\nSuccessful Assignments:\nsuccess\n" \ + "\nFailed Assignments:\nFailed\n" + + expect { run_rake_task('duo_pro:bulk_user_assignment') } + .to output(a_string_including(expected_output)).to_stdout + end + end + end + end +end diff --git a/spec/fixtures/duo_pro/bulk_user_assignment.csv b/spec/fixtures/duo_pro/bulk_user_assignment.csv new file mode 100644 index 0000000000000000000000000000000000000000..8d2a4346aa29a845fe2d2e90e71fa8b39a38938b --- /dev/null +++ b/spec/fixtures/duo_pro/bulk_user_assignment.csv @@ -0,0 +1,4 @@ +username +user1 +user2 +user3 \ No newline at end of file