Project

General

Profile

« Previous | Next » 

Revision 85d4141b

Added by Oleh Fedorenko about 1 month ago

Fixes #36738 - Add remediation wizard (#546)

  • Fixes #36738 - Add remediation wizard
  • Refs #36738 - Re-use host selection box from core
  • Refs #36738 - Remove searchbar
  • Refs #36738 - Return SelectNone option
  • Refs #36738 - Use relative import for ExternalLink icon
  • Refs #36738 - Return SelectAll option
  • Refs #36738 - Change Select none to Select default
  • Refs #36738 - Improve help strings

View differences:

app/assets/javascripts/foreman_openscap/reports.js
showDetails.is(':visible') ? $(event).find('span').attr('class', 'glyphicon glyphicon-collapse-up') : $(event).find('span').attr('class', 'glyphicon glyphicon-collapse-down');
}
function showRemediationWizard(log_id) {
var wizard_button = $('#openscapRemediationWizardButton');
wizard_button.attr('data-log-id', log_id);
wizard_button.click();
}
app/controllers/arf_reports_controller.rb
include Foreman::Controller::AutoCompleteSearch
include ForemanOpenscap::ArfReportsControllerCommonExtensions
before_action :find_arf_report, :only => %i[show show_html destroy parse_html parse_bzip download_html]
before_action :find_arf_report, :only => %i[show show_html destroy parse_html parse_bzip download_html show_log]
before_action :find_multiple, :only => %i[delete_multiple submit_delete_multiple]
def model_of_controller
......
end
end
def show_log
return not_found unless @arf_report # TODO: use Message/Log model directly instead?
log = @arf_report.logs.find(params[:log_id])
return not_found unless log
respond_to do |format|
format.json do
render json: {
log: {
source: log.source,
message: {
value: log.message.value,
fixes: log.message.fixes,
}
},
}, status: :ok
end
end
end
private
def find_arf_report
......
def action_permission
case params[:action]
when 'show_html', 'parse_html', 'parse_bzip', 'download_html'
when 'show_html', 'parse_html', 'parse_bzip', 'download_html', 'show_log'
:view
when 'delete_multiple', 'submit_delete_multiple'
:destroy
app/helpers/arf_reports_helper.rb
msg.html_safe
end
def host_search_by_rule_result_buttons(source)
action_buttons(display_link_if_authorized(_('Hosts failing this rule'), hash_for_hosts_path(:search => "fails_xccdf_rule = #{source}")),
display_link_if_authorized(_('Hosts passing this rule'), hash_for_hosts_path(:search => "passes_xccdf_rule = #{source}")),
display_link_if_authorized(_('Hosts othering this rule'), hash_for_hosts_path(:search => "others_xccdf_rule = #{source}")))
def host_search_by_rule_result_buttons(log)
buttons = [
display_link_if_authorized(_('Hosts failing this rule'), hash_for_hosts_path(:search => "fails_xccdf_rule = #{log.source}")),
display_link_if_authorized(_('Hosts passing this rule'), hash_for_hosts_path(:search => "passes_xccdf_rule = #{log.source}")),
display_link_if_authorized(_('Hosts othering this rule'), hash_for_hosts_path(:search => "others_xccdf_rule = #{log.source}")),
]
if log.result == 'fail' && log.message.fixes.present?
buttons << link_to_function_if_authorized(_('Remediation'), "showRemediationWizard(#{log.id})", hash_for_show_log_arf_report_path(id: log.report.id))
end
action_buttons(buttons)
end
def supported_remediation_snippets
snippets = []
snippets << 'urn:xccdf:fix:script:sh' if ForemanOpenscap.with_remote_execution?
snippets << 'urn:xccdf:fix:script:ansible' if ForemanOpenscap.with_ansible?
snippets
end
end
app/models/foreman_openscap/arf_report.rb
:severity => log[:severity],
:description => newline_to_space(log[:description]),
:rationale => newline_to_space(log[:rationale]),
:scap_references => references_links(log[:references])
:scap_references => references_links(log[:references]),
:fixes => fixes(log[:fixes])
}
else
msg = Message.new(:value => N_(log[:title]),
:severity => log[:severity],
:description => newline_to_space(log[:description]),
:rationale => newline_to_space(log[:rationale]),
:scap_references => references_links(log[:references]))
:scap_references => references_links(log[:references]),
:fixes => fixes(log[:fixes]))
end
msg.save!
end
......
html_links.join(', ')
end
def self.fixes(raw_fixes)
return if raw_fixes.empty?
JSON.fast_generate(raw_fixes)
end
def self.update_msg_with_changes(msg, incoming_data)
msg.severity = incoming_data['severity']
msg.description = incoming_data['description']
msg.rationale = incoming_data['rationale']
msg.scap_references = incoming_data['references']
msg.value = incoming_data['title']
msg.fixes = fixes(incoming_data['fixes'])
return unless msg.changed?
msg.save
app/views/arf_reports/_output.html.erb
</td>
<td><%= log.source %></td>
<td><%= react_component 'RuleSeverity', { :severity => log.message.severity.downcase } %></td>
<td><%= host_search_by_rule_result_buttons(log.source) %></td>
<td><%= host_search_by_rule_result_buttons(log) %></td>
</tr>
<% end %>
<tr id='ntsh' <%= "style='display: none;'".html_safe if logs.size > 0%>>
......
</tr>
</tbody>
</table>
<%= react_component 'OpenscapRemediationWizard',
{ report_id: @arf_report.id,
host: { name: @arf_report.host.name, id: @arf_report.host.id },
supported_remediation_snippets: supported_remediation_snippets } %>
app/views/job_templates/run_openscap_remediation_-_ansible_default.erb
<%#
name: Run OpenSCAP remediation - Ansible Default
job_category: OpenSCAP Ansible Commands
description_format: Run OpenSCAP remediation on given host. Please note, it is not meant to be used directly.
snippet: false
provider_type: Ansible
kind: job_template
model: JobTemplate
feature: ansible_run_openscap_remediation
template_inputs:
- name: tasks
description: Tasks to run on the host
input_type: user
required: true
- name: reboot
description: Indicate wether the host should be rebooted after all the remediation
input_type: user
required: false
%>
---
- hosts: all
tasks:
<%= indent(4) { input('tasks') } -%>
<% if truthy?(input('reboot')) %>
- name: Reboot the machine
reboot:
<% end %>
app/views/job_templates/run_openscap_remediation_-_script_default.erb
<%#
name: Run OpenSCAP remediation - Script Default
job_category: OpenSCAP
description_format: Run OpenSCAP remediation on given host. Please note, it is not meant to be used directly.
snippet: false
provider_type: script
kind: job_template
model: JobTemplate
feature: script_run_openscap_remediation
template_inputs:
- name: command
description: Command to run on the host
input_type: user
required: true
- name: reboot
description: Indicate wether the host should be rebooted after the remediation
input_type: user
required: false
%>
<%= input('command') %>
<% if truthy?(input('reboot')) -%>
echo "A reboot is required to finish the remediation. The system is going to reboot now."
<%= render_template('Power Action - Script Default', action: 'restart') %>
<% end -%>
config/routes.rb
get 'parse_html'
get 'parse_bzip'
get 'download_html'
get 'show_log'
end
collection do
get 'auto_complete_search'
db/migrate/20230912122310_add_fixes_to_message.rb
class AddFixesToMessage < ActiveRecord::Migration[6.1]
def change
add_column :messages, :fixes, :text
end
end
lib/foreman_openscap/engine.rb
initializer 'foreman_openscap.register_plugin', :before => :finisher_hook do |app|
Foreman::Plugin.register :foreman_openscap do
requires_foreman '>= 3.7'
requires_foreman '>= 3.11'
register_gettext
apipie_documented_controllers ["#{ForemanOpenscap::Engine.root}/app/controllers/api/v2/compliance/*.rb"]
......
# Add permissions
security_block :foreman_openscap do
permission :view_arf_reports, { :arf_reports => %i[index show parse_html show_html
permission :view_arf_reports, { :arf_reports => %i[index show parse_html show_html show_log
parse_bzip auto_complete_search download_html],
'api/v2/compliance/arf_reports' => %i[index show download download_html],
:compliance_hosts => [:show] },
......
:description => N_("Run OVAL scan")
}
ansible_remediation_options = {
:description => N_("Run OpenSCAP remediation with Ansible"),
:provided_inputs => ["tasks", "reboot"]
}
script_remediation_options = {
:description => N_("Run OpenSCAP remediation with Shell"),
:provided_inputs => ["command", "reboot"]
}
if Gem::Version.new(ForemanRemoteExecution::VERSION) >= Gem::Version.new('1.2.3')
options[:host_action_button] = true
oval_options[:host_action_button] = (!::Foreman.in_rake? && ActiveRecord::Base.connection.table_exists?(:settings)) ? (Setting.find_by(:name => 'lab_features')&.value || false) : false
......
RemoteExecutionFeature.register(:foreman_openscap_run_scans, N_("Run OpenSCAP scan"), options)
RemoteExecutionFeature.register(:foreman_openscap_run_oval_scans, N_("Run OVAL scan"), oval_options)
RemoteExecutionFeature.register(:ansible_run_openscap_remediation, N_("Run OpenSCAP remediation with Ansible"), ansible_remediation_options)
RemoteExecutionFeature.register(:script_run_openscap_remediation, N_("Run OpenSCAP remediation with Shell"), script_remediation_options)
end
end
......
def self.with_remote_execution?
RemoteExecutionFeature rescue false
end
def self.with_ansible?
ForemanAnsible rescue false
end
end
webpack/components/EmptyState.js
EmptyState.propTypes = {
title: PropTypes.string,
body: PropTypes.string,
error: PropTypes.oneOfType([PropTypes.shape({}), PropTypes.string]),
body: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
error: PropTypes.oneOfType([
PropTypes.shape({}),
PropTypes.string,
PropTypes.bool,
]),
search: PropTypes.bool,
lock: PropTypes.bool,
primaryButton: PropTypes.node,
webpack/components/OpenscapRemediationWizard/OpenscapRemediationSelectors.js
import {
selectAPIError,
selectAPIResponse,
selectAPIStatus,
} from 'foremanReact/redux/API/APISelectors';
import { STATUS } from 'foremanReact/constants';
import { REPORT_LOG_REQUEST_KEY } from './constants';
export const selectLogResponse = state =>
selectAPIResponse(state, REPORT_LOG_REQUEST_KEY);
export const selectLogStatus = state =>
selectAPIStatus(state, REPORT_LOG_REQUEST_KEY) || STATUS.PENDING;
export const selectLogError = state =>
selectAPIError(state, REPORT_LOG_REQUEST_KEY);
webpack/components/OpenscapRemediationWizard/OpenscapRemediationWizardContext.js
import { createContext } from 'react';
const OpenscapRemediationWizardContext = createContext({});
export default OpenscapRemediationWizardContext;
webpack/components/OpenscapRemediationWizard/ViewSelectedHostsLink.js
import React from 'react';
import PropTypes from 'prop-types';
import { ExternalLinkSquareAltIcon } from '@patternfly/react-icons';
import { Button } from '@patternfly/react-core';
import { translate as __ } from 'foremanReact/common/I18n';
import { foremanUrl } from 'foremanReact/common/helpers';
import { getHostsPageUrl } from 'foremanReact/Root/Context/ForemanContext';
const ViewSelectedHostsLink = ({
hostIdsParam,
isAllHostsSelected,
defaultFailedHostsSearch,
}) => {
const search = isAllHostsSelected ? defaultFailedHostsSearch : hostIdsParam;
const url = foremanUrl(`${getHostsPageUrl(false)}?search=${search}`);
return (
<Button
component="a"
variant="link"
icon={<ExternalLinkSquareAltIcon />}
iconPosition="right"
target="_blank"
href={url}
>
{__('View selected hosts')}
</Button>
);
};
ViewSelectedHostsLink.propTypes = {
isAllHostsSelected: PropTypes.bool.isRequired,
defaultFailedHostsSearch: PropTypes.string.isRequired,
hostIdsParam: PropTypes.string.isRequired,
};
export default ViewSelectedHostsLink;
webpack/components/OpenscapRemediationWizard/WizardHeader.js
import React from 'react';
import PropTypes from 'prop-types';
import {
Grid,
TextContent,
Text,
TextVariants,
Flex,
FlexItem,
} from '@patternfly/react-core';
const WizardHeader = ({ title, description }) => (
<Grid style={{ gridGap: '24px' }}>
{title && (
<TextContent>
<Text ouiaId="wizard-header-text" component={TextVariants.h2}>
{title}
</Text>
</TextContent>
)}
{description && (
<TextContent>
<Flex flex={{ default: 'inlineFlex' }}>
<FlexItem>
<TextContent>{description}</TextContent>
</FlexItem>
</Flex>
</TextContent>
)}
</Grid>
);
WizardHeader.propTypes = {
title: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
description: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
};
WizardHeader.defaultProps = {
title: undefined,
description: undefined,
};
export default WizardHeader;
webpack/components/OpenscapRemediationWizard/constants.js
export const OPENSCAP_REMEDIATION_MODAL_ID = 'openscapRemediationModal';
export const HOSTS_PATH = '/hosts';
export const FAIL_RULE_SEARCH = 'fails_xccdf_rule';
export const HOSTS_API_PATH = '/api/hosts';
export const HOSTS_API_REQUEST_KEY = 'HOSTS';
export const REPORT_LOG_REQUEST_KEY = 'ARF_REPORT_LOG';
export const JOB_INVOCATION_PATH = '/job_invocations';
export const JOB_INVOCATION_API_PATH = '/api/job_invocations';
export const JOB_INVOCATION_API_REQUEST_KEY = 'OPENSCAP_REX_JOB_INVOCATIONS';
export const SNIPPET_SH = 'urn:xccdf:fix:script:sh';
export const SNIPPET_ANSIBLE = 'urn:xccdf:fix:script:ansible';
webpack/components/OpenscapRemediationWizard/helpers.js
import { join, find, map, compact, includes, filter, isString } from 'lodash';
const getResponseErrorMsgs = ({ data } = {}) => {
if (data) {
const messages =
data.displayMessage || data.message || data.errors || data.error?.message;
return Array.isArray(messages) ? messages : [messages];
}
return [];
};
export const errorMsg = data => {
if (isString(data)) return data;
return join(getResponseErrorMsgs({ data }), '\n');
};
export const findFixBySnippet = (fixes, snippet) =>
find(fixes, fix => fix.system === snippet);
export const supportedRemediationSnippets = (
fixes,
meth,
supportedJobSnippets
) => {
if (meth === 'manual') return map(fixes, f => f.system);
return compact(
map(
filter(fixes, fix => includes(supportedJobSnippets, fix.system)),
f => f.system
)
);
};
webpack/components/OpenscapRemediationWizard/index.js
import React, { useState, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { Button, Wizard } from '@patternfly/react-core';
import { sprintf, translate as __ } from 'foremanReact/common/I18n';
import { API_OPERATIONS, get } from 'foremanReact/redux/API';
import OpenscapRemediationWizardContext from './OpenscapRemediationWizardContext';
import {
selectLogResponse,
selectLogError,
selectLogStatus,
} from './OpenscapRemediationSelectors';
import { REPORT_LOG_REQUEST_KEY, FAIL_RULE_SEARCH } from './constants';
import { SnippetSelect, ReviewHosts, ReviewRemediation, Finish } from './steps';
const OpenscapRemediationWizard = ({
report_id: reportId,
host: { id: hostId, name: hostName },
supported_remediation_snippets: supportedJobSnippets,
}) => {
const dispatch = useDispatch();
const log = useSelector(state => selectLogResponse(state))?.log;
const logStatus = useSelector(state => selectLogStatus(state));
const logError = useSelector(state => selectLogError(state));
const fixes = JSON.parse(log?.message?.fixes || null) || [];
const source = log?.source?.value || '';
const title = log?.message?.value || '';
const defaultHostIdsParam = `id ^ (${hostId})`;
const defaultFailedHostsSearch = `${FAIL_RULE_SEARCH} = ${source}`;
const [isRemediationWizardOpen, setIsRemediationWizardOpen] = useState(false);
const [snippet, setSnippet] = useState('');
const [method, setMethod] = useState('job');
const [hostIdsParam, setHostIdsParam] = useState(defaultHostIdsParam);
const [isRebootSelected, setIsRebootSelected] = useState(false);
const [isAllHostsSelected, setIsAllHostsSelected] = useState(false);
const savedHostSelectionsRef = useRef({});
const onModalButtonClick = e => {
e.preventDefault();
const logId = e.target.getAttribute('data-log-id');
dispatch(
get({
type: API_OPERATIONS.GET,
key: REPORT_LOG_REQUEST_KEY,
url: `/compliance/arf_reports/${reportId}/show_log`,
params: { log_id: logId },
})
);
setIsRemediationWizardOpen(true);
};
const onWizardClose = () => {
// Reset to defaults
setHostIdsParam(defaultHostIdsParam);
setSnippet('');
setMethod('job');
setIsRebootSelected(false);
setIsRemediationWizardOpen(false);
savedHostSelectionsRef.current = {};
};
const reviewHostsStep = {
id: 2,
name: __('Review hosts'),
component: <ReviewHosts />,
canJumpTo: Boolean(snippet && method === 'job'),
enableNext: Boolean(snippet && method),
};
const steps = [
{
id: 1,
name: __('Select snippet'),
component: <SnippetSelect />,
canJumpTo: true,
enableNext: Boolean(snippet && method),
},
...(snippet && method === 'job' ? [reviewHostsStep] : []),
{
id: 3,
name: __('Review remediation'),
component: <ReviewRemediation />,
canJumpTo: Boolean(snippet && method),
enableNext: method === 'job',
nextButtonText: __('Run'),
},
{
id: 4,
name: __('Done'),
component: <Finish onClose={onWizardClose} />,
isFinishedStep: true,
},
];
return (
<>
{isRemediationWizardOpen && (
<OpenscapRemediationWizardContext.Provider
value={{
fixes,
snippet,
setSnippet,
method,
setMethod,
hostName,
source,
logStatus,
logError,
supportedJobSnippets,
isRebootSelected,
setIsRebootSelected,
hostId,
hostIdsParam,
setHostIdsParam,
isAllHostsSelected,
setIsAllHostsSelected,
defaultFailedHostsSearch,
savedHostSelectionsRef,
}}
>
<Wizard
title={title}
description={sprintf(__('Remediate %s rule'), source)}
isOpen={isRemediationWizardOpen}
steps={steps}
onClose={onWizardClose}
/>
</OpenscapRemediationWizardContext.Provider>
)}
<Button
id="openscapRemediationWizardButton"
variant="link"
isInline
component="span"
onClick={e => onModalButtonClick(e)}
/>
</>
);
};
OpenscapRemediationWizard.propTypes = {
report_id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
host: PropTypes.shape({
id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
name: PropTypes.string,
}),
supported_remediation_snippets: PropTypes.array,
};
OpenscapRemediationWizard.defaultProps = {
report_id: '',
host: {},
supported_remediation_snippets: [],
};
export default OpenscapRemediationWizard;
webpack/components/OpenscapRemediationWizard/steps/Finish.js
/* eslint-disable camelcase */
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { Button, Bullseye } from '@patternfly/react-core';
import { ExternalLinkSquareAltIcon } from '@patternfly/react-icons';
import { translate as __ } from 'foremanReact/common/I18n';
import { foremanUrl } from 'foremanReact/common/helpers';
import { STATUS } from 'foremanReact/constants';
import { useAPI } from 'foremanReact/common/hooks/API/APIHooks';
import Loading from 'foremanReact/components/Loading';
import PermissionDenied from 'foremanReact/components/PermissionDenied';
import OpenscapRemediationWizardContext from '../OpenscapRemediationWizardContext';
import EmptyState from '../../EmptyState';
import ViewSelectedHostsLink from '../ViewSelectedHostsLink';
import { errorMsg, findFixBySnippet } from '../helpers';
import {
JOB_INVOCATION_PATH,
JOB_INVOCATION_API_PATH,
JOB_INVOCATION_API_REQUEST_KEY,
SNIPPET_SH,
SNIPPET_ANSIBLE,
} from '../constants';
const Finish = ({ onClose }) => {
const {
fixes,
snippet,
isRebootSelected,
hostIdsParam,
isAllHostsSelected,
defaultFailedHostsSearch,
} = useContext(OpenscapRemediationWizardContext);
const snippetText = findFixBySnippet(fixes, snippet)?.full_text;
const remediationJobParams = () => {
let feature = 'script_run_openscap_remediation';
const inputs = {};
switch (snippet) {
case SNIPPET_ANSIBLE:
feature = 'ansible_run_openscap_remediation';
inputs.tasks = snippetText;
inputs.reboot = isRebootSelected;
break;
case SNIPPET_SH:
default:
feature = 'script_run_openscap_remediation';
inputs.command = snippetText;
inputs.reboot = isRebootSelected;
}
return {
job_invocation: {
feature,
inputs,
search_query: isAllHostsSelected
? defaultFailedHostsSearch
: hostIdsParam,
},
};
};
const response = useAPI('post', JOB_INVOCATION_API_PATH, {
key: JOB_INVOCATION_API_REQUEST_KEY,
params: remediationJobParams(),
});
const {
response: { response: { status: statusCode, data } = {} },
status = STATUS.PENDING,
} = response;
const linkToJob = (
<Button
variant="link"
icon={<ExternalLinkSquareAltIcon />}
iconPosition="right"
target="_blank"
component="a"
href={foremanUrl(`${JOB_INVOCATION_PATH}/${response?.response?.id}`)}
>
{__('Job details')}
</Button>
);
const closeBtn = <Button onClick={onClose}>{__('Close')}</Button>;
const errorComponent =
statusCode === 403 ? (
<PermissionDenied
missingPermissions={data?.error?.missing_permissions}
primaryButton={closeBtn}
/>
) : (
<EmptyState
error
title={__('Error!')}
body={errorMsg(data)}
primaryButton={closeBtn}
/>
);
const body =
status === STATUS.RESOLVED ? (
<EmptyState
title={__(
'The job has started on selected host(s), you can check the status on the job details page.'
)}
body={
<>
{linkToJob}
<ViewSelectedHostsLink
isAllHostsSelected={isAllHostsSelected}
hostIdsParam={hostIdsParam}
defaultFailedHostsSearch={defaultFailedHostsSearch}
/>
</>
}
primaryButton={closeBtn}
/>
) : (
errorComponent
);
return <Bullseye>{status === STATUS.PENDING ? <Loading /> : body}</Bullseye>;
};
Finish.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default Finish;
webpack/components/OpenscapRemediationWizard/steps/ReviewHosts.js
import React, { useContext, useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import {
Spinner,
Toolbar,
ToolbarContent,
ToolbarGroup,
ToolbarItem,
} from '@patternfly/react-core';
import { Td } from '@patternfly/react-table';
import { toArray } from 'lodash';
import { foremanUrl } from 'foremanReact/common/helpers';
import { translate as __ } from 'foremanReact/common/I18n';
import SelectAllCheckbox from 'foremanReact/components/PF4/TableIndexPage/Table/SelectAllCheckbox';
import { Table } from 'foremanReact/components/PF4/TableIndexPage/Table/Table';
import { useBulkSelect } from 'foremanReact/components/PF4/TableIndexPage/Table/TableHooks';
import { getPageStats } from 'foremanReact/components/PF4/TableIndexPage/Table/helpers';
import { STATUS } from 'foremanReact/constants';
import { useAPI } from 'foremanReact/common/hooks/API/APIHooks';
import OpenscapRemediationWizardContext from '../OpenscapRemediationWizardContext';
import WizardHeader from '../WizardHeader';
import { HOSTS_API_PATH, HOSTS_API_REQUEST_KEY } from '../constants';
const ReviewHosts = () => {
const {
hostId,
setHostIdsParam,
defaultFailedHostsSearch,
setIsAllHostsSelected,
savedHostSelectionsRef,
} = useContext(OpenscapRemediationWizardContext);
const defaultParams = {
search: defaultFailedHostsSearch,
};
const defaultHostsArry = [hostId];
const [params, setParams] = useState(defaultParams);
const response = useAPI('get', `${HOSTS_API_PATH}?include_permissions=true`, {
key: HOSTS_API_REQUEST_KEY,
params: defaultParams,
});
const {
response: {
search: apiSearchQuery,
results,
per_page: perPage,
page,
subtotal,
message: errorMessage,
},
status = STATUS.PENDING,
setAPIOptions,
} = response;
const subtotalCount = Number(subtotal ?? 0);
const setParamsAndAPI = newParams => {
setParams(newParams);
setAPIOptions({ key: HOSTS_API_REQUEST_KEY, params: newParams });
};
const { pageRowCount } = getPageStats({
total: subtotalCount,
page,
perPage,
});
const { fetchBulkParams, ...selectAllOptions } = useBulkSelect({
results,
metadata: { total: subtotalCount, page },
initialSearchQuery: apiSearchQuery || defaultFailedHostsSearch,
isSelectable: () => true,
defaultArry: defaultHostsArry,
initialArry: toArray(
savedHostSelectionsRef.current.inclusionSet || defaultHostsArry
),
initialExclusionArry: toArray(
savedHostSelectionsRef.current.exclusionSet || []
),
initialSelectAllMode: savedHostSelectionsRef.current.selectAllMode || false,
});
const {
selectPage,
selectedCount,
selectOne,
selectNone,
selectDefault,
selectAll,
areAllRowsOnPageSelected,
areAllRowsSelected,
isSelected,
inclusionSet,
exclusionSet,
selectAllMode,
} = selectAllOptions;
useEffect(() => {
if (selectedCount) {
setHostIdsParam(fetchBulkParams());
savedHostSelectionsRef.current = {
inclusionSet,
exclusionSet,
selectAllMode,
};
}
}, [selectedCount, fetchBulkParams, setHostIdsParam]);
const selectionToolbar = (
<ToolbarItem key="selectAll">
<SelectAllCheckbox
{...{
selectAll: () => {
selectAll(true);
setIsAllHostsSelected(true);
},
selectPage: () => {
selectPage();
setIsAllHostsSelected(false);
},
selectDefault: () => {
selectDefault();
setIsAllHostsSelected(false);
},
selectNone: () => {
selectNone();
setIsAllHostsSelected(false);
},
selectedCount,
pageRowCount,
}}
totalCount={subtotalCount}
selectedDefaultCount={1} // The default host (hostId) is always selected
areAllRowsOnPageSelected={areAllRowsOnPageSelected()}
areAllRowsSelected={areAllRowsSelected()}
/>
</ToolbarItem>
);
const RowSelectTd = ({ rowData }) => (
<Td
select={{
rowIndex: rowData.id,
onSelect: (_event, isSelecting) => {
selectOne(isSelecting, rowData.id, rowData);
// If at least one was unselected, then it's not all selected
if (!isSelecting) setIsAllHostsSelected(false);
},
isSelected: rowData.id === hostId || isSelected(rowData.id),
disable: rowData.id === hostId || false,
}}
/>
);
RowSelectTd.propTypes = {
rowData: PropTypes.object.isRequired,
};
const columns = {
name: {
title: __('Name'),
wrapper: ({ id, name }) => <a href={foremanUrl(`hosts/${id}`)}>{name}</a>,
isSorted: true,
},
operatingsystem_name: {
title: __('OS'),
},
};
return (
<>
<WizardHeader
title={__('Review hosts')}
description={__(
'By default, remediation is applied to the current host. Optionally, remediate any additional hosts that fail the rule.'
)}
/>
<Toolbar ouiaId="table-toolbar" className="table-toolbar">
<ToolbarContent>
<ToolbarGroup>
{selectionToolbar}
{status === STATUS.PENDING && (
<ToolbarItem>
<Spinner size="sm" />
</ToolbarItem>
)}
</ToolbarGroup>
</ToolbarContent>
</Toolbar>
<Table
ouiaId="hosts-review-table"
isEmbedded
params={params}
setParams={setParamsAndAPI}
itemCount={subtotalCount}
results={results}
url={HOSTS_API_PATH}
refreshData={() =>
setAPIOptions({
key: HOSTS_API_REQUEST_KEY,
params: { defaultFailedHostsSearch },
})
}
columns={columns}
errorMessage={
status === STATUS.ERROR && errorMessage ? errorMessage : null
}
isPending={status === STATUS.PENDING}
showCheckboxes
rowSelectTd={RowSelectTd}
/>
</>
);
};
export default ReviewHosts;
webpack/components/OpenscapRemediationWizard/steps/ReviewRemediation.js
/* eslint-disable camelcase */
import React, { useContext, useState } from 'react';
import { some } from 'lodash';
import {
CodeBlock,
CodeBlockAction,
CodeBlockCode,
ClipboardCopyButton,
Button,
Grid,
GridItem,
Alert,
Checkbox,
} from '@patternfly/react-core';
import { ExternalLinkSquareAltIcon } from '@patternfly/react-icons';
import { translate as __ } from 'foremanReact/common/I18n';
import { foremanUrl } from 'foremanReact/common/helpers';
import { getHostsPageUrl } from 'foremanReact/Root/Context/ForemanContext';
import OpenscapRemediationWizardContext from '../OpenscapRemediationWizardContext';
import WizardHeader from '../WizardHeader';
import ViewSelectedHostsLink from '../ViewSelectedHostsLink';
import { HOSTS_PATH, FAIL_RULE_SEARCH } from '../constants';
import { findFixBySnippet } from '../helpers';
import './ReviewRemediation.scss';
const ReviewRemediation = () => {
const {
fixes,
snippet,
method,
hostName,
source,
isRebootSelected,
setIsRebootSelected,
isAllHostsSelected,
hostIdsParam,
defaultFailedHostsSearch,
} = useContext(OpenscapRemediationWizardContext);
const [copied, setCopied] = useState(false);
const selectedFix = findFixBySnippet(fixes, snippet);
const snippetText = selectedFix?.full_text;
// can be one of null, "true", "false"
// if null, it may indicate that reboot might be needed
const checkForReboot = () => !some(fixes, { reboot: 'false' });
const isRebootRequired = () => some(fixes, { reboot: 'true' });
const copyToClipboard = (e, text) => {
navigator.clipboard.writeText(text.toString());
};
const onCopyClick = (e, text) => {
copyToClipboard(e, text);
setCopied(true);
};
const description =
method === 'manual'
? __('Review the remediation snippet and apply it to the host manually.')
: __(
'Review the remediation snippet that will be applied to selected host(s).'
);
const rebootAlertTitle = isRebootRequired()
? __('A reboot is required after applying remediation.')
: __('A reboot might be required after applying remediation.');
const actions = (
<React.Fragment>
<CodeBlockAction>
<ClipboardCopyButton
id="basic-copy-button"
textId="code-content"
aria-label="Copy to clipboard"
onClick={e => onCopyClick(e, snippetText)}
exitDelay={copied ? 1500 : 600}
maxWidth="110px"
variant="plain"
onTooltipHidden={() => setCopied(false)}
>
{copied
? __('Successfully copied to clipboard!')
: __('Copy to clipboard')}
</ClipboardCopyButton>
</CodeBlockAction>
</React.Fragment>
);
return (
<>
<WizardHeader
title={__('Review remediation')}
description={description}
/>
<Grid hasGutter>
<br />
<GridItem>
<Alert
ouiaId="review-alert"
variant="danger"
title={`${__(
'Do not implement any of the recommended remedial actions or scripts without first testing them in a non-production environment.'
)}
${__('Remediation might render the system non-functional.')}`}
/>
</GridItem>
<GridItem md={12} span={4} rowSpan={1}>
<ViewSelectedHostsLink
isAllHostsSelected={isAllHostsSelected}
hostIdsParam={hostIdsParam}
defaultFailedHostsSearch={defaultFailedHostsSearch}
/>
</GridItem>
<GridItem md={12} span={4} rowSpan={1}>
<Button
variant="link"
icon={<ExternalLinkSquareAltIcon />}
iconPosition="right"
target="_blank"
component="a"
href={foremanUrl(`${getHostsPageUrl(true)}/${hostName}`)}
>
{hostName}
</Button>{' '}
</GridItem>
<GridItem md={12} span={8} rowSpan={1}>
<Button
variant="link"
icon={<ExternalLinkSquareAltIcon />}
iconPosition="right"
target="_blank"
component="a"
href={foremanUrl(
`${HOSTS_PATH}/?search=${FAIL_RULE_SEARCH} = ${source}`
)}
>
{__('Other hosts failing this rule')}
</Button>
</GridItem>
{checkForReboot() ? (
<>
<GridItem span={12} rowSpan={1}>
<Alert
ouiaId="reboot-alert"
variant={isRebootRequired() ? 'warning' : 'info'}
title={rebootAlertTitle}
/>
</GridItem>
{method === 'manual' ? null : (
<GridItem span={4} rowSpan={1}>
<Checkbox
id="reboot-checkbox"
label={__('Reboot the system(s)')}
name="reboot-checkbox"
isChecked={isRebootSelected}
onChange={selected => setIsRebootSelected(selected)}
/>
</GridItem>
)}
</>
) : null}
<GridItem>
<CodeBlock actions={actions}>
<CodeBlockCode id="code-content" className="remediation-code">
{snippetText}
</CodeBlockCode>
</CodeBlock>
</GridItem>
</Grid>
</>
);
};
export default ReviewRemediation;
webpack/components/OpenscapRemediationWizard/steps/ReviewRemediation.scss
pre.remediation-code {
border: none;
border-radius: none;
}
webpack/components/OpenscapRemediationWizard/steps/SnippetSelect.js
import React, { useContext } from 'react';
import { map, split, capitalize, join, slice, isEmpty } from 'lodash';
import {
Form,
FormGroup,
FormSelect,
FormSelectOption,
Radio,
Alert,
} from '@patternfly/react-core';
import { translate as __ } from 'foremanReact/common/I18n';
import { STATUS } from 'foremanReact/constants';
import Loading from 'foremanReact/components/Loading';
import OpenscapRemediationWizardContext from '../OpenscapRemediationWizardContext';
import WizardHeader from '../WizardHeader';
import EmptyState from '../../EmptyState';
import { errorMsg, supportedRemediationSnippets } from '../helpers';
const SnippetSelect = () => {
const {
fixes,
snippet,
setSnippet,
method,
setMethod,
logStatus,
logError,
supportedJobSnippets,
} = useContext(OpenscapRemediationWizardContext);
const snippetNameMap = {
'urn:xccdf:fix:script:ansible': 'Ansible',
'urn:xccdf:fix:script:puppet': 'Puppet',
'urn:xccdf:fix:script:sh': 'Shell',
'urn:xccdf:fix:script:kubernetes': 'Kubernetes',
'urn:redhat:anaconda:pre': 'Anaconda',
'urn:redhat:osbuild:blueprint': 'OSBuild Blueprint',
};
const snippetName = system => {
const mapped = snippetNameMap[system];
if (mapped) return mapped;
return join(
map(slice(split(system, ':'), -2), n => capitalize(n)),
' '
);
};
const resetSnippet = meth => {
const snip = supportedRemediationSnippets(
fixes,
meth,
supportedJobSnippets
)[0];
setSnippet(snip);
return snip;
};
const setMethodResetSnippet = meth => {
setMethod(meth);
resetSnippet(meth);
};
const body =
logStatus === STATUS.RESOLVED ? (
<Form>
<FormGroup
label={__('Method')}
type="string"
fieldId="method"
isRequired={false}
>
<Radio
label={__('Remote job')}
id="job"
name="job"
ouiaId="job"
aria-label="job"
isChecked={method === 'job'}
onChange={() => setMethodResetSnippet('job')}
/>
<Radio
label={__('Manual')}
id="manual"
name="manual"
ouiaId="manual"
aria-label="manual"
isChecked={method === 'manual'}
onChange={() => setMethodResetSnippet('manual')}
/>
</FormGroup>
{isEmpty(
supportedRemediationSnippets(fixes, method, supportedJobSnippets)
) ? (
<Alert
ouiaId="snippet-alert"
variant="info"
title={__(
'There is no job to remediate with. Please remediate manually.'
)}
/>
) : (
<FormGroup
label={__('Snippet')}
type="string"
fieldId="snippet"
isRequired
>
<FormSelect
ouiaId="snippet-select"
isRequired
value={snippet}
onChange={value => setSnippet(value)}
aria-label="FormSelect Input"
>
<FormSelectOption
isDisabled
key={0}
value=""
label={__('Select snippet')}
/>
{map(
supportedRemediationSnippets(
fixes,
method,
supportedJobSnippets
),
fix => (
<FormSelectOption
key={fix}
value={fix}
label={snippetName(fix)}
/>
)
)}
</FormSelect>
</FormGroup>
)}
</Form>
) : (
<EmptyState error title={__('Error!')} body={errorMsg(logError)} />
);
return (
<>
<WizardHeader
title={__('Select remediation method')}
description={__(
'You can remediate by running a remote job or you can display a snippet for manual remediation.'
)}
/>
{logStatus === STATUS.PENDING ? <Loading /> : body}
</>
);
};
export default SnippetSelect;
webpack/components/OpenscapRemediationWizard/steps/index.js
export { default as SnippetSelect } from './SnippetSelect';
export { default as ReviewHosts } from './ReviewHosts';
export { default as ReviewRemediation } from './ReviewRemediation';
export { default as Finish } from './Finish';
webpack/index.js
import componentRegistry from 'foremanReact/components/componentRegistry';
import RuleSeverity from './components/RuleSeverity';
import OpenscapRemediationWizard from './components/OpenscapRemediationWizard';
componentRegistry.register({
name: 'RuleSeverity',
type: RuleSeverity,
const components = [
{ name: 'RuleSeverity', type: RuleSeverity },
{ name: 'OpenscapRemediationWizard', type: OpenscapRemediationWizard },
];
components.forEach(component => {
componentRegistry.register(component);
});

Also available in: Unified diff