diff --git a/app/assets/javascripts/foreman_openscap/reports.js b/app/assets/javascripts/foreman_openscap/reports.js
index d4a645ca..ad63be36 100644
--- a/app/assets/javascripts/foreman_openscap/reports.js
+++ b/app/assets/javascripts/foreman_openscap/reports.js
@@ -4,3 +4,8 @@ function showReportDetails(log_id, event) {
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();
+}
diff --git a/app/controllers/arf_reports_controller.rb b/app/controllers/arf_reports_controller.rb
index 4c3e70c5..65c2d9eb 100644
--- a/app/controllers/arf_reports_controller.rb
+++ b/app/controllers/arf_reports_controller.rb
@@ -2,7 +2,7 @@ class ArfReportsController < ApplicationController
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
@@ -72,6 +72,27 @@ def submit_delete_multiple
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
@@ -99,7 +120,7 @@ def find_multiple
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
diff --git a/app/helpers/arf_reports_helper.rb b/app/helpers/arf_reports_helper.rb
index 4c7d792a..2cc1c09d 100644
--- a/app/helpers/arf_reports_helper.rb
+++ b/app/helpers/arf_reports_helper.rb
@@ -62,9 +62,22 @@ def reported_info(arf_report)
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
diff --git a/app/models/foreman_openscap/arf_report.rb b/app/models/foreman_openscap/arf_report.rb
index 10db620d..164586ca 100644
--- a/app/models/foreman_openscap/arf_report.rb
+++ b/app/models/foreman_openscap/arf_report.rb
@@ -131,14 +131,16 @@ def self.create_arf(asset, proxy, params)
: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
@@ -222,12 +224,19 @@ def self.references_links(references)
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
diff --git a/app/views/arf_reports/_output.html.erb b/app/views/arf_reports/_output.html.erb
index 431fcc20..b168a6e4 100644
--- a/app/views/arf_reports/_output.html.erb
+++ b/app/views/arf_reports/_output.html.erb
@@ -25,7 +25,7 @@
<%= log.source %> |
<%= react_component 'RuleSeverity', { :severity => log.message.severity.downcase } %> |
- <%= host_search_by_rule_result_buttons(log.source) %> |
+ <%= host_search_by_rule_result_buttons(log) %> |
<% end %>
0%>>
@@ -35,3 +35,7 @@
+<%= 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 } %>
diff --git a/app/views/job_templates/run_openscap_remediation_-_ansible_default.erb b/app/views/job_templates/run_openscap_remediation_-_ansible_default.erb
new file mode 100644
index 00000000..ef8a4236
--- /dev/null
+++ b/app/views/job_templates/run_openscap_remediation_-_ansible_default.erb
@@ -0,0 +1,27 @@
+<%#
+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 %>
diff --git a/app/views/job_templates/run_openscap_remediation_-_script_default.erb b/app/views/job_templates/run_openscap_remediation_-_script_default.erb
new file mode 100644
index 00000000..1112aa89
--- /dev/null
+++ b/app/views/job_templates/run_openscap_remediation_-_script_default.erb
@@ -0,0 +1,24 @@
+<%#
+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 -%>
diff --git a/config/routes.rb b/config/routes.rb
index fc9c6756..3ac179be 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -9,6 +9,7 @@
get 'parse_html'
get 'parse_bzip'
get 'download_html'
+ get 'show_log'
end
collection do
get 'auto_complete_search'
diff --git a/db/migrate/20230912122310_add_fixes_to_message.rb b/db/migrate/20230912122310_add_fixes_to_message.rb
new file mode 100644
index 00000000..bd412d72
--- /dev/null
+++ b/db/migrate/20230912122310_add_fixes_to_message.rb
@@ -0,0 +1,5 @@
+class AddFixesToMessage < ActiveRecord::Migration[6.1]
+ def change
+ add_column :messages, :fixes, :text
+ end
+end
diff --git a/lib/foreman_openscap/engine.rb b/lib/foreman_openscap/engine.rb
index 94e37e48..2229c45a 100644
--- a/lib/foreman_openscap/engine.rb
+++ b/lib/foreman_openscap/engine.rb
@@ -48,7 +48,7 @@ class Engine < ::Rails::Engine
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"]
@@ -58,7 +58,7 @@ class Engine < ::Rails::Engine
# 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] },
@@ -275,6 +275,16 @@ class Engine < ::Rails::Engine
: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
@@ -282,6 +292,8 @@ class Engine < ::Rails::Engine
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
@@ -303,4 +315,8 @@ def self.use_relative_model_naming?
def self.with_remote_execution?
RemoteExecutionFeature rescue false
end
+
+ def self.with_ansible?
+ ForemanAnsible rescue false
+ end
end
diff --git a/webpack/components/EmptyState.js b/webpack/components/EmptyState.js
index 105cf48e..fc7fda8b 100644
--- a/webpack/components/EmptyState.js
+++ b/webpack/components/EmptyState.js
@@ -56,8 +56,12 @@ EmptyStateIcon.defaultProps = {
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,
diff --git a/webpack/components/OpenscapRemediationWizard/OpenscapRemediationSelectors.js b/webpack/components/OpenscapRemediationWizard/OpenscapRemediationSelectors.js
new file mode 100644
index 00000000..8de47a8f
--- /dev/null
+++ b/webpack/components/OpenscapRemediationWizard/OpenscapRemediationSelectors.js
@@ -0,0 +1,16 @@
+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);
diff --git a/webpack/components/OpenscapRemediationWizard/OpenscapRemediationWizardContext.js b/webpack/components/OpenscapRemediationWizard/OpenscapRemediationWizardContext.js
new file mode 100644
index 00000000..ec5dd749
--- /dev/null
+++ b/webpack/components/OpenscapRemediationWizard/OpenscapRemediationWizardContext.js
@@ -0,0 +1,4 @@
+import { createContext } from 'react';
+
+const OpenscapRemediationWizardContext = createContext({});
+export default OpenscapRemediationWizardContext;
diff --git a/webpack/components/OpenscapRemediationWizard/ViewSelectedHostsLink.js b/webpack/components/OpenscapRemediationWizard/ViewSelectedHostsLink.js
new file mode 100644
index 00000000..31593d2c
--- /dev/null
+++ b/webpack/components/OpenscapRemediationWizard/ViewSelectedHostsLink.js
@@ -0,0 +1,38 @@
+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 (
+ }
+ iconPosition="right"
+ target="_blank"
+ href={url}
+ >
+ {__('View selected hosts')}
+
+ );
+};
+
+ViewSelectedHostsLink.propTypes = {
+ isAllHostsSelected: PropTypes.bool.isRequired,
+ defaultFailedHostsSearch: PropTypes.string.isRequired,
+ hostIdsParam: PropTypes.string.isRequired,
+};
+
+export default ViewSelectedHostsLink;
diff --git a/webpack/components/OpenscapRemediationWizard/WizardHeader.js b/webpack/components/OpenscapRemediationWizard/WizardHeader.js
new file mode 100644
index 00000000..532989e6
--- /dev/null
+++ b/webpack/components/OpenscapRemediationWizard/WizardHeader.js
@@ -0,0 +1,43 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {
+ Grid,
+ TextContent,
+ Text,
+ TextVariants,
+ Flex,
+ FlexItem,
+} from '@patternfly/react-core';
+
+const WizardHeader = ({ title, description }) => (
+
+ {title && (
+
+
+ {title}
+
+
+ )}
+ {description && (
+
+
+
+ {description}
+
+
+
+ )}
+
+);
+
+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;
diff --git a/webpack/components/OpenscapRemediationWizard/constants.js b/webpack/components/OpenscapRemediationWizard/constants.js
new file mode 100644
index 00000000..7f3e4ddb
--- /dev/null
+++ b/webpack/components/OpenscapRemediationWizard/constants.js
@@ -0,0 +1,14 @@
+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';
diff --git a/webpack/components/OpenscapRemediationWizard/helpers.js b/webpack/components/OpenscapRemediationWizard/helpers.js
new file mode 100644
index 00000000..cf127372
--- /dev/null
+++ b/webpack/components/OpenscapRemediationWizard/helpers.js
@@ -0,0 +1,33 @@
+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
+ )
+ );
+};
diff --git a/webpack/components/OpenscapRemediationWizard/index.js b/webpack/components/OpenscapRemediationWizard/index.js
new file mode 100644
index 00000000..1db35e9c
--- /dev/null
+++ b/webpack/components/OpenscapRemediationWizard/index.js
@@ -0,0 +1,160 @@
+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: ,
+ canJumpTo: Boolean(snippet && method === 'job'),
+ enableNext: Boolean(snippet && method),
+ };
+ const steps = [
+ {
+ id: 1,
+ name: __('Select snippet'),
+ component: ,
+ canJumpTo: true,
+ enableNext: Boolean(snippet && method),
+ },
+ ...(snippet && method === 'job' ? [reviewHostsStep] : []),
+ {
+ id: 3,
+ name: __('Review remediation'),
+ component: ,
+ canJumpTo: Boolean(snippet && method),
+ enableNext: method === 'job',
+ nextButtonText: __('Run'),
+ },
+ {
+ id: 4,
+ name: __('Done'),
+ component: ,
+ isFinishedStep: true,
+ },
+ ];
+
+ return (
+ <>
+ {isRemediationWizardOpen && (
+
+
+
+ )}
+
+ );
+ const closeBtn = {__('Close')};
+ const errorComponent =
+ statusCode === 403 ? (
+
+ ) : (
+
+ );
+ const body =
+ status === STATUS.RESOLVED ? (
+
+ {linkToJob}
+
+ >
+ }
+ primaryButton={closeBtn}
+ />
+ ) : (
+ errorComponent
+ );
+
+ return {status === STATUS.PENDING ? : body};
+};
+
+Finish.propTypes = {
+ onClose: PropTypes.func.isRequired,
+};
+
+export default Finish;
diff --git a/webpack/components/OpenscapRemediationWizard/steps/ReviewHosts.js b/webpack/components/OpenscapRemediationWizard/steps/ReviewHosts.js
new file mode 100644
index 00000000..1d37a3a3
--- /dev/null
+++ b/webpack/components/OpenscapRemediationWizard/steps/ReviewHosts.js
@@ -0,0 +1,217 @@
+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 = (
+
+ {
+ 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()}
+ />
+
+ );
+
+ const RowSelectTd = ({ rowData }) => (
+ {
+ 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 }) => {name},
+ isSorted: true,
+ },
+ operatingsystem_name: {
+ title: __('OS'),
+ },
+ };
+
+ return (
+ <>
+
+
+
+
+ {selectionToolbar}
+ {status === STATUS.PENDING && (
+
+
+
+ )}
+
+
+
+
+ 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;
diff --git a/webpack/components/OpenscapRemediationWizard/steps/ReviewRemediation.js b/webpack/components/OpenscapRemediationWizard/steps/ReviewRemediation.js
new file mode 100644
index 00000000..afbda9d3
--- /dev/null
+++ b/webpack/components/OpenscapRemediationWizard/steps/ReviewRemediation.js
@@ -0,0 +1,176 @@
+/* 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 = (
+
+
+ onCopyClick(e, snippetText)}
+ exitDelay={copied ? 1500 : 600}
+ maxWidth="110px"
+ variant="plain"
+ onTooltipHidden={() => setCopied(false)}
+ >
+ {copied
+ ? __('Successfully copied to clipboard!')
+ : __('Copy to clipboard')}
+
+
+
+ );
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+ }
+ iconPosition="right"
+ target="_blank"
+ component="a"
+ href={foremanUrl(`${getHostsPageUrl(true)}/${hostName}`)}
+ >
+ {hostName}
+ {' '}
+
+
+ }
+ iconPosition="right"
+ target="_blank"
+ component="a"
+ href={foremanUrl(
+ `${HOSTS_PATH}/?search=${FAIL_RULE_SEARCH} = ${source}`
+ )}
+ >
+ {__('Other hosts failing this rule')}
+
+
+ {checkForReboot() ? (
+ <>
+
+
+
+ {method === 'manual' ? null : (
+
+ setIsRebootSelected(selected)}
+ />
+
+ )}
+ >
+ ) : null}
+
+
+
+ {snippetText}
+
+
+
+
+ >
+ );
+};
+
+export default ReviewRemediation;
diff --git a/webpack/components/OpenscapRemediationWizard/steps/ReviewRemediation.scss b/webpack/components/OpenscapRemediationWizard/steps/ReviewRemediation.scss
new file mode 100644
index 00000000..6f43dab4
--- /dev/null
+++ b/webpack/components/OpenscapRemediationWizard/steps/ReviewRemediation.scss
@@ -0,0 +1,4 @@
+pre.remediation-code {
+ border: none;
+ border-radius: none;
+}
diff --git a/webpack/components/OpenscapRemediationWizard/steps/SnippetSelect.js b/webpack/components/OpenscapRemediationWizard/steps/SnippetSelect.js
new file mode 100644
index 00000000..0e040a9b
--- /dev/null
+++ b/webpack/components/OpenscapRemediationWizard/steps/SnippetSelect.js
@@ -0,0 +1,160 @@
+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 ? (
+
+ ) : (
+
+ );
+
+ return (
+ <>
+
+ {logStatus === STATUS.PENDING ? : body}
+ >
+ );
+};
+
+export default SnippetSelect;
diff --git a/webpack/components/OpenscapRemediationWizard/steps/index.js b/webpack/components/OpenscapRemediationWizard/steps/index.js
new file mode 100644
index 00000000..dcef2667
--- /dev/null
+++ b/webpack/components/OpenscapRemediationWizard/steps/index.js
@@ -0,0 +1,4 @@
+export { default as SnippetSelect } from './SnippetSelect';
+export { default as ReviewHosts } from './ReviewHosts';
+export { default as ReviewRemediation } from './ReviewRemediation';
+export { default as Finish } from './Finish';
diff --git a/webpack/index.js b/webpack/index.js
index 5d666c39..24ddd207 100644
--- a/webpack/index.js
+++ b/webpack/index.js
@@ -1,8 +1,13 @@
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);
});
|