Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions app/controllers/foreman_puppet/api/v2/hosts_bulk_actions_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
module ForemanPuppet
module Api
module V2
class HostsBulkActionsController < ::ForemanPuppet::Api::V2::PuppetBaseController
include ::Api::V2::BulkHostsExtension
before_action :find_editable_hosts, only: %i[change_puppet_proxy remove_puppet_proxy]
before_action :find_smart_proxy, only: %i[change_puppet_proxy]

def_param_group :bulk_params do
param :organization_id, :number, required: true, desc: N_('ID of the organization')
param :included, Hash, required: true, action_aware: true do
param :search, String, required: false, desc: N_('Search string for hosts to perform an action on')
param :ids, Array, required: false, desc: N_('List of host ids to perform an action on')
end
param :excluded, Hash, required: true, action_aware: true do
param :ids, Array, required: false, desc: N_('List of host ids to exclude and not run an action on')
end
end

api :PUT, '/hosts/bulk/change_puppet_proxy', N_('Change Puppet (CA) Proxy')
param_group :bulk_params
param :proxy_id, :number, required: true, desc: N_('ID of the Puppet proxy to reassign the hosts to')
param :ca_proxy, :bool, required: true, desc: N_('True, if Puppet CA proxy should be changed instead of the Puppet proxy')
def change_puppet_proxy
error_hosts = ::BulkHostsManager.new(hosts: @hosts).change_puppet_proxy(@proxy, ca_proxy?)
process_bulk_puppet_proxy_response(
Comment thread
nadjaheitmann marked this conversation as resolved.
error_hosts,
success_message: format(n_(
'Updated host: changed %{proxy_type}',
'Updated hosts: changed %{proxy_type}',
@hosts.count
), proxy_type: proxy_type),
error_message: format(n_(
'Failed to change %{proxy_type} for %{count} host',
'Failed to change %{proxy_type} for %{count} hosts',
error_hosts.count
), proxy_type: proxy_type, count: error_hosts.count)
)
Comment thread
nadjaheitmann marked this conversation as resolved.
end

api :PUT, '/hosts/bulk/remove_puppet_proxy', N_('Remove Puppet (CA) Proxy')
param_group :bulk_params
param :ca_proxy, :bool, required: true, desc: N_('True, if Puppet CA proxy should be removed instead of the Puppet proxy')
def remove_puppet_proxy
error_hosts = ::BulkHostsManager.new(hosts: @hosts).change_puppet_proxy(nil, ca_proxy?)
process_bulk_puppet_proxy_response(
error_hosts,
success_message: format(n_(
'Updated host: removed %{proxy_type}',
'Updated hosts: removed %{proxy_type}',
@hosts.count
), proxy_type: proxy_type),
error_message: format(n_(
'Failed to remove %{proxy_type} for %{count} host',
'Failed to remove %{proxy_type} for %{count} hosts',
error_hosts.count
), proxy_type: proxy_type, count: error_hosts.count)
)
end

private

def find_editable_hosts
find_bulk_hosts(:edit_hosts, params)
end

def process_bulk_puppet_proxy_response(error_hosts, success_message:, error_message:)
if error_hosts.empty?
process_response(true, { message: success_message })
else
render_error(:bulk_hosts_error, status: :unprocessable_entity,
locals: { message: error_message, failed_host_ids: error_hosts })
end
end

def find_smart_proxy
@proxy = SmartProxy.find_by(id: params[:proxy_id])
Comment thread
nadjaheitmann marked this conversation as resolved.

if @proxy.nil?
render json: {
error: {
message: format(_('The %{proxy_type} you have provided cannot be found.'), proxy_type: proxy_type),
},
}, status: :unprocessable_entity
false
elsif (ca_proxy? && !@proxy.has_feature?('Puppet CA')) ||
(!ca_proxy? && !@proxy.has_feature?('Puppet'))
render json: {
error: {
message: format(_('The Smart Proxy you have provided does not have the feature %{proxy_type}.'), proxy_type: proxy_type),
},
}, status: :unprocessable_entity
false
else
true
end
Comment thread
nadjaheitmann marked this conversation as resolved.
end

def ca_proxy?
Foreman::Cast.to_bool(params[:ca_proxy])
end

def proxy_type
ca_proxy? ? _('Puppet CA proxy') : _('Puppet proxy')
end
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
module ForemanPuppet
module Extensions
module BulkHostsManager
extend ActiveSupport::Concern

def change_puppet_proxy(proxy, ca_proxy)
error_hosts = []
@hosts.each do |host|
if ca_proxy
host.puppet_ca_proxy = proxy
else
host.puppet_proxy = proxy
end
host.save(validate: false)
rescue StandardError => e
message = format(_('Failed to set proxy for %{host}.'), host: host)
Foreman::Logging.exception(message, e)
error_hosts << host.id
Comment on lines +10 to +18
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

host.save(validate: false) can return false (e.g., aborted callbacks) without raising, so failures may be silently treated as success and never added to error_hosts. Consider using save! (and rescuing) or explicitly checking the boolean return value and pushing the host id into error_hosts when the save fails.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One could argue that with validate: false this shouldn't really happen?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would assume it just works. Not sure what would happen if you e.g. assign a proxy and it is deleted between the check in the API and call the to host.save.

Copy link
Copy Markdown
Collaborator Author

@nadjaheitmann nadjaheitmann May 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@adamruzicka Any preference what to do with this code? The validation is in line with the original implementation: https://github.com/theforeman/foreman_puppet/blob/master/app/controllers/concerns/foreman_puppet/extensions/hosts_controller_extensions.rb#L73

Not sure what would happen if we change the behavior now. I agree that having the error catching does not make much sense from this perspective. However, removing it also feels wrong somehow.

Can you please take another look? I would like to finish this up and it just keeps growing and growing :)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd be fine with keeping it as it was. Sometimes copilot gets overly zealous

end
error_hosts
end
end
end
end
11 changes: 11 additions & 0 deletions config/api_routes.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
Foreman::Application.routes.draw do
scope module: 'foreman_puppet' do
namespace :api, defaults: { format: 'json' } do
scope '(:apiv)', module: :v2, defaults: { apiv: 'v2' }, apiv: /v1|v2/, constraints: ApiConstraints.new(version: 2, default: true) do
match 'hosts/bulk/change_puppet_proxy', to: 'hosts_bulk_actions#change_puppet_proxy', via: [:put]
match 'hosts/bulk/remove_puppet_proxy', to: 'hosts_bulk_actions#remove_puppet_proxy', via: [:put]
end
end
end
end

ForemanPuppet::Engine.routes.draw do
namespace :api, defaults: { format: 'json' } do
scope '(:apiv)', module: :v2, defaults: { apiv: 'v2' }, apiv: /v1|v2/, constraints: ApiConstraints.new(version: 2, default: true) do
Comment thread
nadjaheitmann marked this conversation as resolved.
Expand Down
1 change: 1 addition & 0 deletions lib/foreman_puppet/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class Engine < ::Rails::Engine
::ProvisioningTemplate.include ForemanPuppet::Extensions::ProvisioningTemplate

::HostCounter.prepend ForemanPuppet::Extensions::HostCounter
::BulkHostsManager.include ForemanPuppet::Extensions::BulkHostsManager

::Api::V2::BaseController.include ForemanPuppet::Extensions::ApiBaseController
::Api::V2::HostsController.include ForemanPuppet::Extensions::ApiV2HostsController
Expand Down
2 changes: 2 additions & 0 deletions lib/foreman_puppet/register.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@
p.actions << 'hosts/update_multiple_environment'
p.actions << 'hosts/select_multiple_puppet_proxy'
p.actions << 'hosts/update_multiple_puppet_proxy'
p.actions << 'foreman_puppet/api/v2/hosts_bulk_actions/change_puppet_proxy'
p.actions << 'foreman_puppet/api/v2/hosts_bulk_actions/remove_puppet_proxy'
end
p.actions << 'foreman_puppet/puppetclasses/parameters'
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
require 'test_puppet_helper'

module ForemanPuppet
module Api
module V2
class HostsBulkActionsControllerTest < ActionController::TestCase
tests ::ForemanPuppet::Api::V2::HostsBulkActionsController

setup do
@routes = ::Foreman::Application.routes
end

let(:host) { FactoryBot.create(:host, :with_puppet_enc) }
let(:host2) do
FactoryBot.create(:host, :with_puppet_enc,
organization: host.organization,
location: host.location)
end
let(:proxy) { FactoryBot.create(:puppet_and_ca_smart_proxy, organizations: [host.organization], locations: [host.location]) }

test 'changes puppet proxy for selected hosts' do
put :change_puppet_proxy,
params: bulk_params.merge(proxy_id: proxy.id, ca_proxy: false)

assert_response :success
assert_equal proxy.id, host2.reload.puppet_proxy_id
assert_equal proxy.id, host.reload.puppet_proxy_id
end

test 'changes puppet ca proxy for selected hosts' do
put :change_puppet_proxy,
params: bulk_params.merge(proxy_id: proxy.id, ca_proxy: true)

assert_response :success
assert_equal proxy.id, host2.reload.puppet_ca_proxy_id
assert_equal proxy.id, host.reload.puppet_ca_proxy_id
end

test 'removes puppet proxy for selected hosts' do
host.update!(puppet_proxy: proxy)
host2.update!(puppet_proxy: proxy)

assert_equal proxy, host.reload.puppet_proxy

put :remove_puppet_proxy,
params: bulk_params.merge(ca_proxy: false),
session: set_session_user

assert_response :success
assert_nil host.reload.puppet_proxy
assert_nil host2.reload.puppet_proxy
end

test 'returns error when puppet proxy is missing' do
put :change_puppet_proxy,
params: bulk_params.merge(proxy_id: 999_999, ca_proxy: false),
session: set_session_user

assert_response :unprocessable_entity
response = JSON.parse(@response.body)
assert_equal 'The Puppet proxy you have provided cannot be found.',
response.dig('error', 'message')
end

test 'returns error when puppet ca proxy is missing' do
put :change_puppet_proxy,
params: bulk_params.merge(proxy_id: 999_999, ca_proxy: true),
session: set_session_user

assert_response :unprocessable_entity
response = JSON.parse(@response.body)
assert_equal 'The Puppet CA proxy you have provided cannot be found.',
response.dig('error', 'message')
end

test 'returns error when smart proxy is missing puppet feature' do
invalid_proxy = FactoryBot.create(:smart_proxy, organizations: [host.organization], locations: [host.location])
invalid_proxy.smart_proxy_feature_by_name('Puppet')&.destroy!

put :change_puppet_proxy,
params: bulk_params.merge(proxy_id: invalid_proxy.id, ca_proxy: false),
session: set_session_user

assert_response :unprocessable_entity
response = JSON.parse(@response.body)
assert_equal 'The Smart Proxy you have provided does not have the feature Puppet proxy.',
response.dig('error', 'message')
end

test 'returns error when smart proxy is missing puppet ca feature' do
invalid_proxy = FactoryBot.create(:puppet_smart_proxy, organizations: [host.organization], locations: [host.location])

put :change_puppet_proxy,
params: bulk_params.merge(proxy_id: invalid_proxy.id, ca_proxy: true),
session: set_session_user

assert_response :unprocessable_entity
response = JSON.parse(@response.body)
assert_equal 'The Smart Proxy you have provided does not have the feature Puppet CA proxy.',
response.dig('error', 'message')
end

test 'returns error when changing puppet proxy fails for some hosts' do
::BulkHostsManager.any_instance.expects(:change_puppet_proxy)
.with(proxy, false)
.returns([host2.id])

put :change_puppet_proxy,
params: bulk_params.merge(proxy_id: proxy.id, ca_proxy: false),
session: set_session_user

assert_response :unprocessable_entity
response = JSON.parse(@response.body)
assert_equal 'Failed to change Puppet proxy for 1 host',
response.dig('error', 'message')
assert_equal [host2.id], response.dig('error', 'failed_host_ids')
end

test 'returns error when removing puppet proxy fails for some hosts' do
host.update!(puppet_proxy: proxy)
host2.update!(puppet_proxy: proxy)
::BulkHostsManager.any_instance.expects(:change_puppet_proxy)
.with(nil, false)
.returns([host.id])

put :remove_puppet_proxy,
params: bulk_params.merge(ca_proxy: false),
session: set_session_user

assert_response :unprocessable_entity
response = JSON.parse(@response.body)
assert_equal 'Failed to remove Puppet proxy for 1 host',
response.dig('error', 'message')
assert_equal [host.id], response.dig('error', 'failed_host_ids')
assert_equal proxy.id, host.reload.puppet_proxy_id
end

test 'returns error when removing puppet ca proxy fails for some hosts' do
host.update!(puppet_ca_proxy: proxy)
host2.update!(puppet_ca_proxy: proxy)
::BulkHostsManager.any_instance.expects(:change_puppet_proxy)
.with(nil, true)
.returns([host.id])

put :remove_puppet_proxy,
params: bulk_params.merge(ca_proxy: true),
session: set_session_user

assert_response :unprocessable_entity
response = JSON.parse(@response.body)
assert_equal 'Failed to remove Puppet CA proxy for 1 host',
response.dig('error', 'message')
assert_equal [host.id], response.dig('error', 'failed_host_ids')
assert_equal proxy.id, host.reload.puppet_ca_proxy_id
end

def bulk_params
{
organization_id: host.organization_id,
included: { ids: [host.id, host2.id] },
excluded: { ids: [] },
}
end
end
end
end
end
33 changes: 33 additions & 0 deletions test/services/foreman_puppet/bulk_hosts_manager_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
require 'test_puppet_helper'

class BulkHostsManagerTest < ActiveSupport::TestCase
let(:hosts) { FactoryBot.create_list(:host, 2, :with_puppet_enc) }
let(:manager) { ::BulkHostsManager.new(hosts: hosts) }
let(:proxy) { FactoryBot.create(:puppet_smart_proxy) }

test 'changes puppet proxy for hosts' do
manager.change_puppet_proxy(proxy, false)

hosts.each do |host|
assert_equal proxy.id, host.reload.puppet_proxy_id
end
end

test 'changes puppet ca proxy for hosts' do
manager.change_puppet_proxy(proxy, true)

hosts.each do |host|
assert_equal proxy.id, host.reload.puppet_ca_proxy_id
end
end

test 'clears puppet proxy when proxy is nil' do
hosts.each { |host| host.update!(puppet_proxy: proxy) }

manager.change_puppet_proxy(nil, false)

hosts.each do |host|
assert_nil host.reload.puppet_proxy
end
end
end
Loading
Loading