diff --git a/app/controllers/api/v2/hosts_controller.rb b/app/controllers/api/v2/hosts_controller.rb index 0dd4a381634..7beb3ed9414 100644 --- a/app/controllers/api/v2/hosts_controller.rb +++ b/app/controllers/api/v2/hosts_controller.rb @@ -280,6 +280,38 @@ def boot render_exception(e, :status => :unprocessable_entity) end + api :PUT, "/hosts/:id/wol", N_("Send Wake on LAN request to host") + param :id, :identifier_dottable, :required => true + + def wol + primary_interface = @host.primary_interface + + unless primary_interface + return render_error :custom_error, :status => :unprocessable_entity, :locals => { :message => _('Host has no primary interface') } + end + + unless primary_interface.mac.present? + return render_error :custom_error, :status => :unprocessable_entity, :locals => { :message => _('Primary interface has no MAC address') } + end + + unless primary_interface.subnet&.dhcp? + return render_error :custom_error, :status => :unprocessable_entity, :locals => { :message => _('Primary interface subnet has no DHCP proxy configured') } + end + + dhcp_proxy = primary_interface.subnet.dhcp + unless dhcp_proxy.has_feature?('WOL') + return render_error :custom_error, :status => :unprocessable_entity, :locals => { :message => _('DHCP proxy does not have WOL feature enabled') } + end + + begin + wol_api = ProxyAPI::Wol.new(:url => dhcp_proxy.url) + result = wol_api.wake(primary_interface.mac) + render :json => { :wol => result }, :status => :ok + rescue => e + render_error :custom_error, :status => :unprocessable_entity, :locals => { :message => e.message } + end + end + api :POST, "/hosts/facts", N_("Upload facts for a host, creating the host if required") param :name, String, :required => true, :desc => N_("hostname of the host") param :facts, Hash, :required => true, :desc => N_("hash containing the facts for the host") @@ -382,6 +414,8 @@ def action_permission :power when 'boot' :ipmi_boot + when 'wol' + :power when 'console' :console when 'disassociate', 'forget_status' @@ -397,7 +431,7 @@ def action_permission def parent_permission(child_permission) case child_permission.to_s - when 'power', 'boot', 'console', 'vm_compute_attributes', 'get_status', 'template', 'enc', 'rebuild_config', 'inherited_parameters' + when 'power', 'boot', 'wol', 'console', 'vm_compute_attributes', 'get_status', 'template', 'enc', 'rebuild_config', 'inherited_parameters' 'view' when 'disassociate' 'edit' diff --git a/app/services/proxy_api/wol.rb b/app/services/proxy_api/wol.rb new file mode 100644 index 00000000000..7c8c15b213c --- /dev/null +++ b/app/services/proxy_api/wol.rb @@ -0,0 +1,20 @@ +module ProxyAPI + class Wol < ProxyAPI::Resource + def initialize(args) + @url = args[:url] + "/wol" + super args + end + + # Send Wake on LAN request + # [+mac+] : MAC address in coloned sextuplet format + # Returns : Boolean status + def wake(mac) + raise "Must define a MAC address" if mac.blank? + + params = { :mac_address => mac } + parse post(params) + rescue => e + raise ProxyException.new(url, e, N_("Unable to send Wake on LAN request for MAC %s"), mac) + end + end +end diff --git a/config/initializers/f_foreman_permissions.rb b/config/initializers/f_foreman_permissions.rb index 6d29d1a8f11..e1a4c22c466 100644 --- a/config/initializers/f_foreman_permissions.rb +++ b/config/initializers/f_foreman_permissions.rb @@ -285,7 +285,7 @@ :"api/v2/hosts" => [:rebuild_config], } map.permission :power_hosts, {:hosts => [:power], - :"api/v2/hosts" => [:power, :power_status] } + :"api/v2/hosts" => [:power, :power_status, :wol] } map.permission :console_hosts, {:hosts => [:console] } map.permission :ipmi_boot_hosts, { :hosts => [:ipmi_boot], :"api/v2/hosts" => [:boot] } diff --git a/config/routes/api/v2.rb b/config/routes/api/v2.rb index 74ce286d37c..688b9bc67c4 100644 --- a/config/routes/api/v2.rb +++ b/config/routes/api/v2.rb @@ -319,6 +319,7 @@ put :boot, :on => :member get :power, :on => :member, :action => :power_status put :power, :on => :member + put :wol, :on => :member put :rebuild_config, :on => :member get :inherited_parameters, :on => :member post :facts, :on => :collection diff --git a/db/seeds.d/110-smart_proxy_features.rb b/db/seeds.d/110-smart_proxy_features.rb index f89624b49eb..07c3703be16 100644 --- a/db/seeds.d/110-smart_proxy_features.rb +++ b/db/seeds.d/110-smart_proxy_features.rb @@ -1,6 +1,6 @@ # Proxy features proxy_features = ["Templates", "TFTP", "DNS", "DHCP", "Puppet CA", "BMC", "Realm", "Facts", "Logs", "HTTPBoot", "External IPAM", - "Registration"] + "Registration", "WOL"] proxy_features.each do |input| f = Feature.where(:name => input).first_or_create diff --git a/test/controllers/api/v2/hosts_controller_test.rb b/test/controllers/api/v2/hosts_controller_test.rb index fdcae6d8111..1cec127e39a 100644 --- a/test/controllers/api/v2/hosts_controller_test.rb +++ b/test/controllers/api/v2/hosts_controller_test.rb @@ -1427,4 +1427,127 @@ def setup assert_equal json_response['facet_param'], 'bar' end end + + context 'Wake-on-LAN (WOL) tests' do + setup do + @dhcp_proxy = FactoryBot.create(:dhcp_smart_proxy) + @dhcp_proxy.features << FactoryBot.create(:feature, :name => 'WOL') + @subnet = FactoryBot.create(:subnet_ipv4, :dhcp => @dhcp_proxy) + @host = FactoryBot.create(:host, :managed) + @primary_interface = FactoryBot.create(:nic_primary_and_provision, + :host => @host, + :subnet => @subnet, + :mac => '00:61:23:18:48:a5') + @host.interfaces = [@primary_interface] + @host.save! + User.current = users(:apiadmin) + end + + test 'should send WOL request successfully' do + ProxyAPI::Wol.any_instance.expects(:wake).with('00:61:23:18:48:a5').returns(true) + + put :wol, params: { :id => @host.to_param } + + assert_response :success + response_body = JSON.parse(@response.body) + assert_equal true, response_body['wol'] + end + + test 'should return error when host has no primary interface' do + @host.interfaces = [] + @host.save! + + put :wol, params: { :id => @host.to_param } + + assert_response :unprocessable_entity + response_body = JSON.parse(@response.body) + assert_match(/Host has no primary interface/, response_body['error']['message']) + end + + test 'should return error when primary interface has no MAC address' do + @primary_interface.update_attribute(:mac, nil) + + put :wol, params: { :id => @host.to_param } + + assert_response :unprocessable_entity + response_body = JSON.parse(@response.body) + assert_match(/Primary interface has no MAC address/, response_body['error']['message']) + end + + test 'should return error when primary interface has empty MAC address' do + @primary_interface.update_attribute(:mac, '') + + put :wol, params: { :id => @host.to_param } + + assert_response :unprocessable_entity + response_body = JSON.parse(@response.body) + assert_match(/Primary interface has no MAC address/, response_body['error']['message']) + end + + test 'should return error when primary interface subnet has no DHCP proxy' do + @primary_interface.subnet.update_attribute(:dhcp, nil) + + put :wol, params: { :id => @host.to_param } + + assert_response :unprocessable_entity + response_body = JSON.parse(@response.body) + assert_match(/Primary interface subnet has no DHCP proxy configured/, response_body['error']['message']) + end + + test 'should return error when DHCP proxy does not have WOL feature' do + @dhcp_proxy.features.delete_all + + put :wol, params: { :id => @host.to_param } + + assert_response :unprocessable_entity + response_body = JSON.parse(@response.body) + assert_match(/DHCP proxy does not have WOL feature enabled/, response_body['error']['message']) + end + + test 'should handle ProxyException with proper error message' do + proxy_exception = ProxyException.new('http://proxy.example.com/wol', + StandardError.new('Network error'), + N_("Unable to send Wake on LAN request for MAC %s"), + '00:61:23:18:48:a5') + ProxyAPI::Wol.any_instance.expects(:wake).with('00:61:23:18:48:a5').raises(proxy_exception) + + put :wol, params: { :id => @host.to_param } + + assert_response :unprocessable_entity + response_body = JSON.parse(@response.body) + assert_match(/Unable to send Wake on LAN request/, response_body['error']['message']) + end + + test 'should return 404 for non-existent host' do + put :wol, params: { :id => 'non-existent-host' } + + assert_response :not_found + end + + context 'permissions' do + setup do + setup_user 'view', 'hosts' + setup_user 'power', 'hosts' + end + + test 'should allow user with power permission to send WOL request' do + ProxyAPI::Wol.any_instance.expects(:wake).with('00:61:23:18:48:a5').returns(true) + + put :wol, params: { :id => @host.to_param }, session: set_session_user.merge(:user => @one.id) + + assert_response :success + response_body = JSON.parse(@response.body) + assert_equal true, response_body['wol'] + end + + test 'should deny user without power permission' do + setup_user 'view', 'hosts' + # Don't add power permission + + put :wol, params: { :id => @host.to_param }, session: set_session_user.merge(:user => @one.id) + + assert_response :forbidden + end + end + end end diff --git a/test/unit/proxy_api/wol_test.rb b/test/unit/proxy_api/wol_test.rb new file mode 100644 index 00000000000..64ca801381b --- /dev/null +++ b/test/unit/proxy_api/wol_test.rb @@ -0,0 +1,69 @@ +require 'test_helper' + +class ProxyApiWolTest < ActiveSupport::TestCase + def setup + @url = "http://dummyproxy.theforeman.org:8443" + @wol_proxy = ProxyAPI::Wol.new({:url => @url}) + end + + test "base url should equal /wol" do + expected = @url + "/wol" + assert_equal(expected, @wol_proxy.url) + end + + test "wake should raise exception when MAC address is nil" do + exception = assert_raise ProxyAPI::ProxyException do + @wol_proxy.wake(nil) + end + assert_includes(exception.message, "Must define a MAC address") + end + + test "wake should handle proxy communication errors" do + mac = "00:11:22:33:44:55" + error = StandardError.new("Connection timeout") + + @wol_proxy.stubs(:post).raises(error) + + exception = assert_raise ProxyAPI::ProxyException do + @wol_proxy.wake(mac) + end + + assert_match(/Unable to send Wake on LAN request for MAC/, exception.message) + assert_match(/#{mac}/, exception.message) + end + + test "wake should handle JSON parsing errors" do + mac = "00:11:22:33:44:55" + expected_params = { :mac_address => mac } + + @wol_proxy.stubs(:post).with(expected_params).returns(fake_rest_client_response("invalid json")) + @wol_proxy.stubs(:parse).raises(JSON::ParserError.new("Invalid JSON")) + + exception = assert_raise ProxyAPI::ProxyException do + @wol_proxy.wake(mac) + end + + assert_match(/Unable to send Wake on LAN request for MAC/, exception.message) + assert_match(/#{mac}/, exception.message) + end + + test "wake should return parsed response on success" do + mac = "00:11:22:33:44:55" + expected_params = { :mac_address => mac } + response_data = { "status" => "success", "result" => true } + + @wol_proxy.stubs(:post).with(expected_params).returns(fake_rest_client_response(response_data)) + @wol_proxy.stubs(:parse).returns(response_data) + + result = @wol_proxy.wake(mac) + assert_equal(response_data, result) + end + + private + + def fake_rest_client_response(data) + response = mock('RestClient::Response') + response.stubs(:body).returns(data.to_json) + response + end +end diff --git a/test/unit/shared/access_permissions_test_base.rb b/test/unit/shared/access_permissions_test_base.rb index d4cfd0ecbbd..56e142bd805 100644 --- a/test/unit/shared/access_permissions_test_base.rb +++ b/test/unit/shared/access_permissions_test_base.rb @@ -27,7 +27,7 @@ def check_routes(app_routes, skipped_actions, skip_patterns: [/^(api\/v2\/)?(dum # Pass if the controller deliberately skips login requirement next if controller < ApplicationController && filters.select { |f| f.filter == :require_login }.empty? - assert_not_empty Foreman::AccessControl.permissions.select { |p| p.actions.include? path }, "permission for #{path} not found, check access_permissions.rb" + assert_not_empty Foreman::AccessControl.permissions.select { |p| p.actions.include? path }, "permission for #{path} not found, check config/initializers/f_foreman_permissions.rb.rb" end end end diff --git a/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/ActionsBar.fixtures.js b/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/ActionsBar.fixtures.js new file mode 100644 index 00000000000..9ae58873ca9 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/ActionsBar.fixtures.js @@ -0,0 +1,43 @@ +export const mockHost = { + hostId: 1, + hostFriendlyId: 'test-host.example.com', + hostName: 'test-host.example.com', + computeId: 123, + isBuild: false, +}; + +export const basePermissions = { + destroy_hosts: true, + create_hosts: true, + edit_hosts: true, + build_hosts: true, + power_hosts: true, +}; + +export const noPermissions = { + destroy_hosts: false, + create_hosts: false, + edit_hosts: false, + build_hosts: false, + power_hosts: false, +}; + +export const noPowerPermissions = { + ...basePermissions, + power_hosts: false, +}; + +export const baseProps = { + ...mockHost, + permissions: basePermissions, +}; + +export const noPowerProps = { + ...mockHost, + permissions: noPowerPermissions, +}; + +export const noPermissionProps = { + ...mockHost, + permissions: noPermissions, +}; diff --git a/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/ActionsBar.test.js b/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/ActionsBar.test.js new file mode 100644 index 00000000000..2c03f0727f0 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/ActionsBar.test.js @@ -0,0 +1,40 @@ +import { shallowRenderComponentWithFixtures } from '../../../../common/testHelpers'; +import ActionsBar from '../index'; +import { + baseProps, + noPowerProps, + noPermissionProps +} from './ActionsBar.fixtures'; + +// Mock the required external dependencies +jest.mock('../../../../../foreman_navigation', () => ({ + visit: jest.fn(), +})); + +jest.mock('../../../../Root/Context/ForemanContext', () => ({ + useForemanSettings: () => ({ destroyVmOnHostDelete: false }), + useForemanHostsPageUrl: () => '/hosts', +})); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(() => []), + useDispatch: () => jest.fn(), + connect: jest.fn(() => (component) => component), +})); + +const fixtures = { + 'renders ActionsBar with all permissions (including power)': baseProps, + 'renders ActionsBar without power permissions': noPowerProps, + 'renders ActionsBar without any permissions': noPermissionProps, +}; + +describe('ActionsBar', () => { + describe('rendering', () => { + const components = shallowRenderComponentWithFixtures(ActionsBar, fixtures); + components.forEach(({ description, component }) => { + it(description, () => { + expect(component).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/__snapshots__/ActionsBar.test.js.snap b/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/__snapshots__/ActionsBar.test.js.snap new file mode 100644 index 00000000000..4096f222345 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/__snapshots__/ActionsBar.test.js.snap @@ -0,0 +1,373 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ActionsBar rendering renders ActionsBar with all permissions (including power) 1`] = ` + + + + + + + + + } + isDisabled={false} + onClick={[Function]} + ouiaId="build-dropdown-item" + > + Build + , + } + isDisabled={false} + onClick={[Function]} + ouiaId="clone-dropdown-item" + > + Clone + , + } + isDisabled={false} + onClick={[Function]} + ouiaId="delete-dropdown-item" + > + Delete + , + , + } + isAriaDisabled={false} + onClick={[Function]} + ouiaId="console-dropdown-item" + > + Console + , + } + onClick={[Function]} + ouiaId="fact-dropdown-item" + > + Facts + , + } + isDisabled={false} + onClick={[Function]} + ouiaId="wol-dropdown-item" + > + Wake on LAN + , + , + } + ouiaId="pre-version-dropdown-item" + > + Legacy UI + , + ] + } + isOpen={false} + isPlain={true} + ouiaId="kebab-dropdown" + toggle={ + + } + /> + + + + +`; + +exports[`ActionsBar rendering renders ActionsBar without any permissions 1`] = ` + + + + + + + + + } + isDisabled={true} + onClick={[Function]} + ouiaId="build-dropdown-item" + > + Build + , + } + isDisabled={true} + onClick={[Function]} + ouiaId="clone-dropdown-item" + > + Clone + , + } + isDisabled={true} + onClick={[Function]} + ouiaId="delete-dropdown-item" + > + Delete + , + , + } + isAriaDisabled={false} + onClick={[Function]} + ouiaId="console-dropdown-item" + > + Console + , + } + onClick={[Function]} + ouiaId="fact-dropdown-item" + > + Facts + , + } + isDisabled={true} + onClick={[Function]} + ouiaId="wol-dropdown-item" + > + Wake on LAN + , + , + } + ouiaId="pre-version-dropdown-item" + > + Legacy UI + , + ] + } + isOpen={false} + isPlain={true} + ouiaId="kebab-dropdown" + toggle={ + + } + /> + + + + +`; + +exports[`ActionsBar rendering renders ActionsBar without power permissions 1`] = ` + + + + + + + + + } + isDisabled={false} + onClick={[Function]} + ouiaId="build-dropdown-item" + > + Build + , + } + isDisabled={false} + onClick={[Function]} + ouiaId="clone-dropdown-item" + > + Clone + , + } + isDisabled={false} + onClick={[Function]} + ouiaId="delete-dropdown-item" + > + Delete + , + , + } + isAriaDisabled={false} + onClick={[Function]} + ouiaId="console-dropdown-item" + > + Console + , + } + onClick={[Function]} + ouiaId="fact-dropdown-item" + > + Facts + , + } + isDisabled={true} + onClick={[Function]} + ouiaId="wol-dropdown-item" + > + Wake on LAN + , + , + } + ouiaId="pre-version-dropdown-item" + > + Legacy UI + , + ] + } + isOpen={false} + isPlain={true} + ouiaId="kebab-dropdown" + toggle={ + + } + /> + + + + +`; diff --git a/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/__snapshots__/actions.test.js.snap b/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/__snapshots__/actions.test.js.snap new file mode 100644 index 00000000000..db6f6ea0427 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/__snapshots__/actions.test.js.snap @@ -0,0 +1,33 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ActionsBar actions wakeOnLan should call Wake on LAN API action 1`] = ` +Array [ + Array [ + Object { + "payload": Object { + "errorToast": [Function], + "key": "1_WOL", + "successToast": [Function], + "url": "/api/hosts/1/wol", + }, + "type": "API_PUT", + }, + ], +] +`; + +exports[`ActionsBar actions wakeOnLan should call Wake on LAN API action with different host 1`] = ` +Array [ + Array [ + Object { + "payload": Object { + "errorToast": [Function], + "key": "42_WOL", + "successToast": [Function], + "url": "/api/hosts/42/wol", + }, + "type": "API_PUT", + }, + ], +] +`; diff --git a/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/actions.test.js b/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/actions.test.js new file mode 100644 index 00000000000..2e8c93ef625 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/actions.test.js @@ -0,0 +1,13 @@ +import { testActionSnapshotWithFixtures } from '../../../../common/testHelpers'; +import { wakeOnLan } from '../actions'; + +const fixtures = { + 'should call Wake on LAN API action': () => wakeOnLan(1, 'test-host.example.com'), + 'should call Wake on LAN API action with different host': () => wakeOnLan(42, 'another-host.example.com'), +}; + +describe('ActionsBar actions', () => { + describe('wakeOnLan', () => { + testActionSnapshotWithFixtures(fixtures); + }); +}); diff --git a/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/actions.js b/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/actions.js index c362380c2d3..135e42af681 100644 --- a/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/actions.js +++ b/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/actions.js @@ -110,3 +110,19 @@ export const isHostTurnOn = store => { const { state } = selectAPIResponse(store, POWER_REQURST_KEY); return state === 'on'; }; + +export const wakeOnLan = (hostId, hostName) => dispatch => { + const successToast = () => __('Wake on LAN signal has been sent'); + const errorToast = ({ message }) => + message || __('Sending Wake on LAN signal has failed'); + const url = foremanUrl(`/api/hosts/${hostId}/wol`); + + dispatch( + APIActions.put({ + url, + key: `${hostId}_WOL`, + successToast, + errorToast, + }) + ); +}; diff --git a/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/index.js b/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/index.js index c3b4622d4f6..2d020e00f57 100644 --- a/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/index.js +++ b/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/index.js @@ -15,12 +15,13 @@ import { UndoIcon, BuildIcon, TerminalIcon, + PlayIcon, } from '@patternfly/react-icons'; import { visit } from '../../../../foreman_navigation'; import { translate as __ } from '../../../common/I18n'; import { selectKebabItems } from './Selectors'; import { foremanUrl } from '../../../common/helpers'; -import { cancelBuild, deleteHost, isHostTurnOn } from './actions'; +import { cancelBuild, deleteHost, isHostTurnOn, wakeOnLan } from './actions'; import { useForemanSettings, useForemanHostsPageUrl, @@ -46,6 +47,7 @@ const ActionsBar = ({ create_hosts: canCreate, edit_hosts: canEdit, build_hosts: canBuild, + power_hosts: canPower, }, }) => { const [kebabIsOpen, setKebab] = useState(false); @@ -62,6 +64,11 @@ const ActionsBar = ({ deleteHost(hostName, computeId, destroyVmOnHostDelete, hostsIndexUrl) ); + const wakeOnLanHandler = () => { + dispatch(wakeOnLan(hostId, hostName)); + setKebab(false); + }; + const isConsoleDisabled = !(computeId && isHostActive); const determineTooltip = () => { if (isConsoleDisabled) { @@ -132,6 +139,16 @@ const ActionsBar = ({ > {__('Facts')} , + } + > + {__('Wake on LAN')} + , ,