diff --git a/lib/foreman_rh_cloud/version.rb b/lib/foreman_rh_cloud/version.rb index f1dd451b3..113ca094f 100644 --- a/lib/foreman_rh_cloud/version.rb +++ b/lib/foreman_rh_cloud/version.rb @@ -1,3 +1,3 @@ module ForemanRhCloud - VERSION = '12.2.18'.freeze + VERSION = '12.2.19'.freeze end diff --git a/lib/insights_cloud.rb b/lib/insights_cloud.rb index 3a710a0b1..cb52849f1 100644 --- a/lib/insights_cloud.rb +++ b/lib/insights_cloud.rb @@ -44,4 +44,8 @@ def self.enable_client_param_inventory def self.enable_cloud_remediations_param 'enable_cloud_remediations' end + + def self.vmaas_reposcan_sync_url + ForemanRhCloud.iop_smart_proxy.url + '/api/vmaas-reposcan/sync' + end end diff --git a/lib/insights_cloud/async/vmaas_reposcan_sync.rb b/lib/insights_cloud/async/vmaas_reposcan_sync.rb new file mode 100644 index 000000000..6dbc09131 --- /dev/null +++ b/lib/insights_cloud/async/vmaas_reposcan_sync.rb @@ -0,0 +1,93 @@ +require 'rest-client' + +module InsightsCloud + module Async + # Triggers VMaaS reposcan sync via IoP gateway when repositories are synced + class VmaasReposcanSync < ::Actions::EntryAction + include ::ForemanRhCloud::CertAuth + + HTTP_TOO_MANY_REQUESTS = 429 + + # Subscribe to Katello repository sync hook action, if available + def self.subscribe + 'Actions::Katello::Repository::SyncHook'.constantize + rescue NameError + Rails.logger.debug('VMaaS reposcan sync: Repository::SyncHook action not found') + nil + end + + def plan(repo, *_args) + return unless ::ForemanRhCloud.with_iop_smart_proxy? + + repo_id = repo.is_a?(Hash) ? (repo[:id] || repo['id']) : nil + unless repo_id + logger.error("VMaaS reposcan sync: missing repository id in SyncHook plan parameters: #{repo.inspect}") + return + end + + organization_id = Katello::Repository.find(repo_id).organization_id + + plan_self(organization_id: organization_id) + end + + def run + url = ::InsightsCloud.vmaas_reposcan_sync_url + + response = execute_cloud_request( + organization: organization, + method: :put, + url: url, + headers: { 'Content-Type' => 'application/json' } + ) + + if response.code >= 200 && response.code < 300 + message = "VMaaS reposcan sync triggered successfully: #{response.code}" + logger.info(message) + else + message = "VMaaS reposcan sync failed with status: #{response.code}, body: #{response.body}" + logger.error(message) + end + output[:message] = message + + response + rescue RestClient::ExceptionWithResponse => e + handle_rest_client_error(e) + rescue StandardError => e + handle_standard_error(e) + end + + def rescue_strategy_for_self + Dynflow::Action::Rescue::Skip + end + + def organization + @organization ||= Organization.find(input[:organization_id]) + end + + private + + def handle_rest_client_error(exception) + if exception.response&.code == HTTP_TOO_MANY_REQUESTS + message = "VMaaS reposcan sync skipped: another sync already in progress (#{HTTP_TOO_MANY_REQUESTS})" + logger.warn(message) + else + message = "VMaaS reposcan sync failed: #{exception.response&.code} - #{exception.response&.body}" + logger.error(message) + end + output[:message] = message + # Do NOT raise - let rescue_strategy_for_self Skip handle this + end + + def handle_standard_error(exception) + message = "Error triggering VMaaS reposcan sync: #{exception.message}" + logger.error(message) + output[:message] = message + # Do NOT raise - let rescue_strategy_for_self Skip handle this + end + + def logger + action_logger + end + end + end +end diff --git a/package.json b/package.json index 0bdfbffec..2fe7d0ef5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "foreman_rh_cloud", - "version": "12.2.18", + "version": "12.2.19", "description": "Inventory Upload =============", "main": "index.js", "scripts": { diff --git a/test/unit/lib/insights_cloud/async/vmaas_reposcan_sync_test.rb b/test/unit/lib/insights_cloud/async/vmaas_reposcan_sync_test.rb new file mode 100644 index 000000000..c39518a72 --- /dev/null +++ b/test/unit/lib/insights_cloud/async/vmaas_reposcan_sync_test.rb @@ -0,0 +1,202 @@ +require 'test_plugin_helper' +require 'foreman_tasks/test_helpers' + +class VmaasReposcanSyncTest < ActiveSupport::TestCase + include ForemanTasks::TestHelpers::WithInThreadExecutor + + setup do + @organization = FactoryBot.create(:organization) + # Create a simple repository - we only need id and organization_id for the action + @repo = ::Katello::Repository.new(id: 1) + @repo.stubs(:organization_id).returns(@organization.id) + ::Katello::Repository.stubs(:find).with(1).returns(@repo) + + @repo_payload = { id: @repo.id } + @expected_url = 'https://example.com/api/v1/vmaas/reposcan/sync' + InsightsCloud.stubs(:vmaas_reposcan_sync_url).returns(@expected_url) + ForemanRhCloud.stubs(:with_iop_smart_proxy?).returns(true) + Organization.stubs(:find).with(@organization.id).returns(@organization) + end + + # Planning behavior + test 'plan plans_self when repo payload has id and IoP is available' do + InsightsCloud::Async::VmaasReposcanSync.any_instance.expects(:plan_self).once + + ForemanTasks.sync_task(InsightsCloud::Async::VmaasReposcanSync, @repo_payload) + end + + test 'plan does not plan_self when repo payload is missing id' do + payload_without_id = {} + + mock_logger = mock('logger') + mock_logger.expects(:error).with { |msg| msg =~ /missing repository id/i } + InsightsCloud::Async::VmaasReposcanSync.any_instance.stubs(:logger).returns(mock_logger) + InsightsCloud::Async::VmaasReposcanSync.any_instance.expects(:plan_self).never + + ForemanTasks.sync_task(InsightsCloud::Async::VmaasReposcanSync, payload_without_id) + end + + test 'plan does not plan_self when IoP smart proxy is not available' do + ForemanRhCloud.stubs(:with_iop_smart_proxy?).returns(false) + + InsightsCloud::Async::VmaasReposcanSync.any_instance.expects(:plan_self).never + + ForemanTasks.sync_task(InsightsCloud::Async::VmaasReposcanSync, @repo_payload) + end + + test 'plan skips repo_id validation when IoP smart proxy is not available' do + ForemanRhCloud.stubs(:with_iop_smart_proxy?).returns(false) + payload_without_id = {} + + # Logger should not be called since IoP check returns early + InsightsCloud::Async::VmaasReposcanSync.any_instance.expects(:logger).never + InsightsCloud::Async::VmaasReposcanSync.any_instance.expects(:plan_self).never + + ForemanTasks.sync_task(InsightsCloud::Async::VmaasReposcanSync, payload_without_id) + end + + test 'plan does not plan_self when repository is nil' do + mock_logger = mock('logger') + mock_logger.expects(:error).with { |msg| msg =~ /missing repository id/i } + InsightsCloud::Async::VmaasReposcanSync.any_instance.stubs(:logger).returns(mock_logger) + InsightsCloud::Async::VmaasReposcanSync.any_instance.expects(:plan_self).never + + ForemanTasks.sync_task(InsightsCloud::Async::VmaasReposcanSync, nil) + end + + # Run behavior + test 'run triggers VMaaS reposcan sync successfully' do + mock_response = mock('response') + mock_response.stubs(:code).returns(200) + + InsightsCloud::Async::VmaasReposcanSync.any_instance + .expects(:execute_cloud_request) + .with do |params| + params[:method] == :put && + params[:url] == @expected_url && + params[:headers].is_a?(Hash) && + params[:headers]['Content-Type'] == 'application/json' && + params[:organization] == @organization + end + .returns(mock_response) + + task = ForemanTasks.sync_task(InsightsCloud::Async::VmaasReposcanSync, @repo_payload) + + assert_equal 'VMaaS reposcan sync triggered successfully: 200', task.output[:message] + end + + test 'run sets error message in task output for failed response' do + mock_response = mock('response') + mock_response.stubs(:code).returns(500) + mock_response.stubs(:body).returns('Internal Server Error') + + InsightsCloud::Async::VmaasReposcanSync.any_instance + .stubs(:execute_cloud_request) + .returns(mock_response) + + task = ForemanTasks.sync_task(InsightsCloud::Async::VmaasReposcanSync, @repo_payload) + + assert_equal 'VMaaS reposcan sync failed with status: 500, body: Internal Server Error', task.output[:message] + end + + test 'run sets error message in task output for RestClient exception' do + error_response = mock('error_response') + error_response.stubs(:code).returns(500) + error_response.stubs(:body).returns('Server Error') + exception = RestClient::ExceptionWithResponse.new(error_response) + + InsightsCloud::Async::VmaasReposcanSync.any_instance + .stubs(:execute_cloud_request) + .raises(exception) + + task = ForemanTasks.sync_task(InsightsCloud::Async::VmaasReposcanSync, @repo_payload) + + assert_equal 'VMaaS reposcan sync failed: 500 - Server Error', task.output[:message] + end + + test 'run sets error message in task output for StandardError exception' do + mock_logger = mock('logger') + mock_logger.expects(:error).with('Error triggering VMaaS reposcan sync: Network timeout') + InsightsCloud::Async::VmaasReposcanSync.any_instance.stubs(:logger).returns(mock_logger) + + InsightsCloud::Async::VmaasReposcanSync.any_instance + .stubs(:execute_cloud_request) + .raises(StandardError.new('Network timeout')) + + task = ForemanTasks.sync_task(InsightsCloud::Async::VmaasReposcanSync, @repo_payload) + + assert_equal 'Error triggering VMaaS reposcan sync: Network timeout', task.output[:message] + end + + test 'run logs and handles error response without raising' do + error_response = mock('error_response') + error_response.stubs(:code).returns(500) + error_response.stubs(:body).returns('error') + exception = RestClient::ExceptionWithResponse.new(error_response) + + InsightsCloud::Async::VmaasReposcanSync.any_instance + .stubs(:execute_cloud_request) + .raises(exception) + + task = ForemanTasks.sync_task(InsightsCloud::Async::VmaasReposcanSync, @repo_payload) + + assert_equal 'VMaaS reposcan sync failed: 500 - error', task.output[:message] + end + + test 'run handles 429 error with warning log level' do + error_response = mock('error_response') + error_response.stubs(:code).returns(429) + error_response.stubs(:body).returns('{"msg": "Another task already in progress"}') + exception = RestClient::ExceptionWithResponse.new(error_response) + + mock_logger = mock('logger') + mock_logger.expects(:warn).with('VMaaS reposcan sync skipped: another sync already in progress (429)') + InsightsCloud::Async::VmaasReposcanSync.any_instance.stubs(:logger).returns(mock_logger) + + InsightsCloud::Async::VmaasReposcanSync.any_instance + .stubs(:execute_cloud_request) + .raises(exception) + + task = ForemanTasks.sync_task(InsightsCloud::Async::VmaasReposcanSync, @repo_payload) + + assert_equal 'VMaaS reposcan sync skipped: another sync already in progress (429)', + task.output[:message] + end + + test 'run handles non-429 errors with error log level' do + error_response = mock('error_response') + error_response.stubs(:code).returns(500) + error_response.stubs(:body).returns('Internal Server Error') + exception = RestClient::ExceptionWithResponse.new(error_response) + + mock_logger = mock('logger') + mock_logger.expects(:error).with('VMaaS reposcan sync failed: 500 - Internal Server Error') + InsightsCloud::Async::VmaasReposcanSync.any_instance.stubs(:logger).returns(mock_logger) + + InsightsCloud::Async::VmaasReposcanSync.any_instance + .stubs(:execute_cloud_request) + .raises(exception) + + task = ForemanTasks.sync_task(InsightsCloud::Async::VmaasReposcanSync, @repo_payload) + + assert_equal 'VMaaS reposcan sync failed: 500 - Internal Server Error', + task.output[:message] + end + + test 'run handles RestClient::ExceptionWithResponse with nil response' do + exception = RestClient::ExceptionWithResponse.new(nil) + + mock_logger = mock('logger') + mock_logger.expects(:error).with('VMaaS reposcan sync failed: - ') + InsightsCloud::Async::VmaasReposcanSync.any_instance.stubs(:logger).returns(mock_logger) + + InsightsCloud::Async::VmaasReposcanSync.any_instance + .stubs(:execute_cloud_request) + .raises(exception) + + task = ForemanTasks.sync_task(InsightsCloud::Async::VmaasReposcanSync, @repo_payload) + + refute_nil task.output[:message] + assert_equal 'VMaaS reposcan sync failed: - ', task.output[:message] + end +end