foreman_remote_execution 4.7.0 → 4.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +1 -0
  3. data/app/controllers/api/v2/job_invocations_controller.rb +7 -1
  4. data/app/lib/actions/remote_execution/run_host_job.rb +2 -1
  5. data/app/lib/actions/remote_execution/run_hosts_job.rb +57 -3
  6. data/app/mailers/rex_job_mailer.rb +15 -0
  7. data/app/models/job_invocation.rb +4 -0
  8. data/app/models/job_invocation_composer.rb +20 -12
  9. data/app/models/remote_execution_provider.rb +18 -2
  10. data/app/models/rex_mail_notification.rb +13 -0
  11. data/app/models/setting/remote_execution.rb +7 -1
  12. data/app/services/ui_notifications/remote_execution_jobs/base_job_finish.rb +2 -1
  13. data/app/views/dashboard/_latest-jobs.html.erb +21 -0
  14. data/app/views/rex_job_mailer/job_finished.html.erb +24 -0
  15. data/app/views/rex_job_mailer/job_finished.text.erb +9 -0
  16. data/app/views/template_invocations/show.html.erb +2 -1
  17. data/db/seeds.d/50-notification_blueprints.rb +14 -0
  18. data/db/seeds.d/95-mail_notifications.rb +24 -0
  19. data/foreman_remote_execution.gemspec +1 -1
  20. data/lib/foreman_remote_execution/engine.rb +1 -0
  21. data/lib/foreman_remote_execution/version.rb +1 -1
  22. data/package.json +6 -6
  23. data/test/functional/api/v2/job_invocations_controller_test.rb +10 -0
  24. data/test/unit/actions/run_hosts_job_test.rb +99 -4
  25. data/test/unit/job_invocation_report_template_test.rb +15 -12
  26. data/test/unit/remote_execution_provider_test.rb +46 -0
  27. data/webpack/JobWizard/JobWizard.js +53 -20
  28. data/webpack/JobWizard/JobWizard.scss +33 -4
  29. data/webpack/JobWizard/JobWizardConstants.js +17 -0
  30. data/webpack/JobWizard/__tests__/fixtures.js +8 -0
  31. data/webpack/JobWizard/__tests__/integration.test.js +3 -7
  32. data/webpack/JobWizard/steps/AdvancedFields/AdvancedFields.js +16 -5
  33. data/webpack/JobWizard/steps/AdvancedFields/Fields.js +48 -1
  34. data/webpack/JobWizard/steps/AdvancedFields/__tests__/AdvancedFields.test.js +29 -14
  35. data/webpack/JobWizard/steps/CategoryAndTemplate/CategoryAndTemplate.js +4 -2
  36. data/webpack/JobWizard/steps/CategoryAndTemplate/CategoryAndTemplate.test.js +3 -2
  37. data/webpack/JobWizard/steps/HostsAndInputs/SelectedChips.js +25 -0
  38. data/webpack/JobWizard/steps/HostsAndInputs/TemplateInputs.js +23 -0
  39. data/webpack/JobWizard/steps/HostsAndInputs/__tests__/SelectedChips.test.js +37 -0
  40. data/webpack/JobWizard/steps/HostsAndInputs/__tests__/TemplateInputs.test.js +50 -0
  41. data/webpack/JobWizard/steps/HostsAndInputs/index.js +66 -0
  42. data/webpack/JobWizard/steps/Schedule/ScheduleType.js +24 -21
  43. data/webpack/JobWizard/steps/Schedule/StartEndDates.js +36 -21
  44. data/webpack/JobWizard/steps/Schedule/__tests__/Schedule.test.js +155 -0
  45. data/webpack/JobWizard/steps/Schedule/__tests__/StartEndDates.test.js +9 -8
  46. data/webpack/JobWizard/steps/Schedule/index.js +89 -28
  47. data/webpack/JobWizard/steps/form/DateTimePicker.js +93 -0
  48. data/webpack/JobWizard/steps/form/Formatter.js +10 -9
  49. data/webpack/JobWizard/steps/form/NumberInput.js +2 -0
  50. data/webpack/JobWizard/steps/form/WizardTitle.js +14 -0
  51. data/webpack/react_app/components/RecentJobsCard/RecentJobsCard.js +2 -1
  52. metadata +18 -4
@@ -1,5 +1,10 @@
1
1
  .job-wizard {
2
+ .wizard-title {
3
+ margin-bottom: 25px;
4
+ }
5
+
2
6
  .pf-c-wizard__main {
7
+ overflow: visible;
3
8
  z-index: calc(
4
9
  var(--pf-c-wizard__footer--ZIndex) + 1
5
10
  ); // So the select box can be shown above the wizard footer
@@ -12,6 +17,9 @@
12
17
  }
13
18
  #advanced-fields-job-template {
14
19
  .foreman-search-field {
20
+ .rbt-input-hint input{
21
+ display: none;
22
+ }
15
23
  // Giving pf3 search bar a pf4 look
16
24
  .search-bar {
17
25
  display: block;
@@ -41,13 +49,34 @@
41
49
  }
42
50
  }
43
51
 
52
+ .hosts-chip-group {
53
+ margin-top: 8px;
54
+ }
55
+ input[type='radio'],
56
+ input[type='checkbox'] {
57
+ margin: 0;
58
+ }
44
59
  .schedule-tab {
45
- input[type='radio'],
46
- input[type='checkbox'] {
47
- margin: 0;
48
- }
49
60
  .advanced-scheduling-button {
50
61
  text-align: start;
51
62
  }
52
63
  }
64
+
65
+ .pf-c-date-picker {
66
+ vertical-align: top;
67
+ }
68
+
69
+ .time-picker {
70
+ width: 150px;
71
+ }
72
+
73
+ input[type='radio'],
74
+ input[type='checkbox'] {
75
+ // overwriting bootstrap/_forms.scss margin: 4px 0 0;
76
+ margin: 0;
77
+ }
78
+ textarea {
79
+ min-height: 40px;
80
+ min-width: 100px;
81
+ }
53
82
  }
@@ -14,3 +14,20 @@ export const repeatTypes = {
14
14
  daily: __('Daily'),
15
15
  hourly: __('Hourly'),
16
16
  };
17
+
18
+ export const WIZARD_TITLES = {
19
+ categoryAndTemplate: __('Category and Template'),
20
+ hostsAndInputs: __('Target hosts and inputs'),
21
+ advanced: __('Advanced Fields'),
22
+ schedule: __('Schedule'),
23
+ review: __('Review Details'),
24
+ };
25
+
26
+ export const initialScheduleState = {
27
+ repeatType: repeatTypes.noRepeat,
28
+ repeatAmount: '',
29
+ starts: '',
30
+ ends: '',
31
+ isFuture: false,
32
+ isNeverEnds: false,
33
+ };
@@ -93,6 +93,14 @@ export const testSetup = (selectors, api) => {
93
93
  jest.spyOn(selectors, 'selectJobCategories');
94
94
  jest.spyOn(selectors, 'selectJobCategoriesStatus');
95
95
 
96
+ jest.spyOn(selectors, 'selectTemplateInputs');
97
+ jest.spyOn(selectors, 'selectAdvancedTemplateInputs');
98
+ selectors.selectTemplateInputs.mockImplementation(
99
+ () => jobTemplateResponse.template_inputs
100
+ );
101
+ selectors.selectAdvancedTemplateInputs.mockImplementation(
102
+ () => jobTemplateResponse.advanced_template_inputs
103
+ );
96
104
  selectors.selectJobCategories.mockImplementation(() => jobCategories);
97
105
  selectors.selectJobTemplates.mockImplementation(() => [
98
106
  jobTemplate,
@@ -5,6 +5,7 @@ import { render, fireEvent, screen, act } from '@testing-library/react';
5
5
  import * as api from 'foremanReact/redux/API';
6
6
  import { JobWizard } from '../JobWizard';
7
7
  import * as selectors from '../JobWizardSelectors';
8
+ import { WIZARD_TITLES } from '../JobWizardConstants';
8
9
  import {
9
10
  testSetup,
10
11
  mockApi,
@@ -62,13 +63,8 @@ describe('Job wizard fill', () => {
62
63
  <JobWizard />
63
64
  </Provider>
64
65
  );
65
- const steps = [
66
- 'Target Hosts',
67
- 'Advanced Fields',
68
- 'Schedule',
69
- 'Review Details',
70
- 'Category and Template',
71
- ];
66
+ const titles = Object.values(WIZARD_TITLES);
67
+ const steps = [titles[1], titles[0], ...titles.slice(2)]; // the first title is selected at the beggining
72
68
  // eslint-disable-next-line no-unused-vars
73
69
  for await (const step of steps) {
74
70
  const stepSelector = screen.getByText(step);
@@ -1,8 +1,7 @@
1
1
  import React from 'react';
2
2
  import PropTypes from 'prop-types';
3
3
  import { useSelector } from 'react-redux';
4
- import { Title, Form } from '@patternfly/react-core';
5
- import { translate as __ } from 'foremanReact/common/I18n';
4
+ import { Form } from '@patternfly/react-core';
6
5
  import {
7
6
  selectEffectiveUser,
8
7
  selectAdvancedTemplateInputs,
@@ -17,8 +16,11 @@ import {
17
16
  ConcurrencyLevelField,
18
17
  TimeSpanLevelField,
19
18
  TemplateInputsFields,
19
+ ExecutionOrderingField,
20
20
  } from './Fields';
21
21
  import { DescriptionField } from './DescriptionField';
22
+ import { WIZARD_TITLES } from '../../JobWizardConstants';
23
+ import { WizardTitle } from '../form/WizardTitle';
22
24
 
23
25
  export const AdvancedFields = ({ advancedValues, setAdvancedValues }) => {
24
26
  const effectiveUser = useSelector(selectEffectiveUser);
@@ -26,9 +28,10 @@ export const AdvancedFields = ({ advancedValues, setAdvancedValues }) => {
26
28
  const templateInputs = useSelector(selectTemplateInputs);
27
29
  return (
28
30
  <>
29
- <Title headingLevel="h2" className="advanced-fields-title">
30
- {__('Advanced Fields')}
31
- </Title>
31
+ <WizardTitle
32
+ title={WIZARD_TITLES.advanced}
33
+ className="advanced-fields-title"
34
+ />
32
35
  <Form id="advanced-fields-job-template" autoComplete="off">
33
36
  <TemplateInputsFields
34
37
  inputs={advancedTemplateInputs}
@@ -98,6 +101,14 @@ export const AdvancedFields = ({ advancedValues, setAdvancedValues }) => {
98
101
  })
99
102
  }
100
103
  />
104
+ <ExecutionOrderingField
105
+ isRandomizedOrdering={advancedValues.isRandomizedOrdering}
106
+ setValue={newValue =>
107
+ setAdvancedValues({
108
+ isRandomizedOrdering: newValue,
109
+ })
110
+ }
111
+ />
101
112
  </Form>
102
113
  </>
103
114
  );
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import PropTypes from 'prop-types';
3
- import { FormGroup, TextInput } from '@patternfly/react-core';
3
+ import { FormGroup, TextInput, Radio } from '@patternfly/react-core';
4
4
  import { translate as __ } from 'foremanReact/common/I18n';
5
5
  import { helpLabel } from '../form/FormHelpers';
6
6
  import { formatter } from '../form/Formatter';
@@ -18,6 +18,7 @@ export const EffectiveUserField = ({ value, setValue }) => (
18
18
  fieldId="effective-user"
19
19
  >
20
20
  <TextInput
21
+ aria-label="effective user"
21
22
  autoComplete="effective-user"
22
23
  id="effective-user"
23
24
  type="text"
@@ -61,6 +62,7 @@ export const PasswordField = ({ value, setValue }) => (
61
62
  fieldId="job-password"
62
63
  >
63
64
  <TextInput
65
+ aria-label="job password"
64
66
  autoComplete="new-password" // to prevent firefox from autofilling the user password
65
67
  id="job-password"
66
68
  type="password"
@@ -83,6 +85,7 @@ export const KeyPassphraseField = ({ value, setValue }) => (
83
85
  fieldId="key-passphrase"
84
86
  >
85
87
  <TextInput
88
+ aria-label="key passphrase"
86
89
  autoComplete="key-passphrase"
87
90
  id="key-passphrase"
88
91
  type="password"
@@ -105,6 +108,7 @@ export const EffectiveUserPasswordField = ({ value, setValue }) => (
105
108
  fieldId="effective-user-password"
106
109
  >
107
110
  <TextInput
111
+ aria-label="effective userpassword"
108
112
  autoComplete="effective-user-password"
109
113
  id="effective-user-password"
110
114
  type="password"
@@ -161,6 +165,41 @@ export const TimeSpanLevelField = ({ value, setValue }) => (
161
165
  />
162
166
  );
163
167
 
168
+ export const ExecutionOrderingField = ({ isRandomizedOrdering, setValue }) => (
169
+ <FormGroup
170
+ label={__('Execution ordering')}
171
+ fieldId="schedule-type"
172
+ labelIcon={helpLabel(
173
+ <div
174
+ dangerouslySetInnerHTML={{
175
+ __html: __(
176
+ 'Execution ordering determines whether the jobs should be executed on hosts in alphabetical order or in randomized order.<br><ul><li><b>Ordered</b> - executes the jobs on hosts in alphabetical order</li><li><b>Randomized</b> - randomizes the order in which jobs are executed on hosts</li></ul>'
177
+ ),
178
+ }}
179
+ />,
180
+ 'effective-user-password'
181
+ )}
182
+ isInline
183
+ >
184
+ <Radio
185
+ aria-label="execution order alphabetical"
186
+ isChecked={!isRandomizedOrdering}
187
+ name="execution-order"
188
+ onChange={() => setValue(false)}
189
+ id="execution-order-alphabetical"
190
+ label={__('Alphabetical')}
191
+ />
192
+ <Radio
193
+ aria-label="execution order randomized"
194
+ isChecked={isRandomizedOrdering}
195
+ name="execution-order"
196
+ onChange={() => setValue(true)}
197
+ id="execution-order-randomized"
198
+ label={__('Randomized')}
199
+ />
200
+ </FormGroup>
201
+ );
202
+
164
203
  export const TemplateInputsFields = ({ inputs, value, setValue }) => (
165
204
  <>{inputs?.map(input => formatter(input, value, setValue))}</>
166
205
  );
@@ -184,6 +223,14 @@ ConcurrencyLevelField.propTypes = EffectiveUserField.propTypes;
184
223
  ConcurrencyLevelField.defaultProps = EffectiveUserField.defaultProps;
185
224
  TimeSpanLevelField.propTypes = EffectiveUserField.propTypes;
186
225
  TimeSpanLevelField.defaultProps = EffectiveUserField.defaultProps;
226
+ ExecutionOrderingField.propTypes = {
227
+ isRandomizedOrdering: PropTypes.bool,
228
+ setValue: PropTypes.func.isRequired,
229
+ };
230
+ ExecutionOrderingField.defaultProps = {
231
+ isRandomizedOrdering: false,
232
+ };
233
+
187
234
  TemplateInputsFields.propTypes = {
188
235
  inputs: PropTypes.array.isRequired,
189
236
  value: PropTypes.object,
@@ -10,24 +10,16 @@ import {
10
10
  testSetup,
11
11
  mockApi,
12
12
  } from '../../../__tests__/fixtures';
13
+ import { WIZARD_TITLES } from '../../../JobWizardConstants';
13
14
 
14
15
  const store = testSetup(selectors, api);
15
16
  mockApi(api);
16
17
 
17
18
  jest.spyOn(selectors, 'selectEffectiveUser');
18
- jest.spyOn(selectors, 'selectTemplateInputs');
19
- jest.spyOn(selectors, 'selectAdvancedTemplateInputs');
20
19
 
21
20
  selectors.selectEffectiveUser.mockImplementation(
22
21
  () => jobTemplate.effective_user
23
22
  );
24
- selectors.selectTemplateInputs.mockImplementation(
25
- () => jobTemplate.template_inputs
26
- );
27
-
28
- selectors.selectAdvancedTemplateInputs.mockImplementation(
29
- () => jobTemplate.advanced_template_inputs
30
- );
31
23
  describe('AdvancedFields', () => {
32
24
  it('should save data between steps for advanced fields', async () => {
33
25
  const wrapper = mount(
@@ -72,7 +64,7 @@ describe('AdvancedFields', () => {
72
64
  .simulate('click');
73
65
 
74
66
  expect(wrapper.find('.pf-c-wizard__nav-link.pf-m-current').text()).toEqual(
75
- 'Target Hosts'
67
+ 'Target hosts and inputs'
76
68
  );
77
69
  wrapper
78
70
  .find('.pf-c-wizard__nav-link')
@@ -91,7 +83,7 @@ describe('AdvancedFields', () => {
91
83
  </Provider>
92
84
  );
93
85
  await act(async () => {
94
- fireEvent.click(screen.getByText('Advanced Fields'));
86
+ fireEvent.click(screen.getByText(WIZARD_TITLES.advanced));
95
87
  });
96
88
  const searchValue = 'search test';
97
89
  const textValue = 'I am a text';
@@ -108,7 +100,7 @@ describe('AdvancedFields', () => {
108
100
  fireEvent.click(selectField);
109
101
  await act(async () => {
110
102
  await fireEvent.click(screen.getByText('option 2'));
111
- fireEvent.click(screen.getAllByText('Advanced Fields')[0]); // to remove focus
103
+ fireEvent.click(screen.getAllByText(WIZARD_TITLES.advanced)[0]); // to remove focus
112
104
  await fireEvent.change(textField, {
113
105
  target: { value: textValue },
114
106
  });
@@ -128,9 +120,11 @@ describe('AdvancedFields', () => {
128
120
  expect(searchField.value).toBe(searchValue);
129
121
  expect(dateField.value).toBe(dateValue);
130
122
  await act(async () => {
131
- fireEvent.click(screen.getByText('Category and Template'));
123
+ fireEvent.click(screen.getByText(WIZARD_TITLES.categoryAndTemplate));
132
124
  });
133
- expect(screen.getAllByText('Category and Template')).toHaveLength(3);
125
+ expect(screen.getAllByText(WIZARD_TITLES.categoryAndTemplate)).toHaveLength(
126
+ 3
127
+ );
134
128
 
135
129
  await act(async () => {
136
130
  fireEvent.click(screen.getByText('Advanced Fields'));
@@ -141,4 +135,25 @@ describe('AdvancedFields', () => {
141
135
  expect(screen.queryAllByText('option 1')).toHaveLength(0);
142
136
  expect(screen.queryAllByText('option 2')).toHaveLength(1);
143
137
  });
138
+ it('fill defaults into fields', async () => {
139
+ render(
140
+ <Provider store={store}>
141
+ <JobWizard />
142
+ </Provider>
143
+ );
144
+ await act(async () => {
145
+ fireEvent.click(screen.getByText('Advanced Fields'));
146
+ });
147
+
148
+ expect(
149
+ screen.getByLabelText('effective user', {
150
+ selector: 'input',
151
+ }).value
152
+ ).toBe('default effective user');
153
+ expect(
154
+ screen.getByLabelText('timeout to kill', {
155
+ selector: 'input',
156
+ }).value
157
+ ).toBe('2');
158
+ });
144
159
  });
@@ -1,9 +1,11 @@
1
1
  import React from 'react';
2
2
  import PropTypes from 'prop-types';
3
- import { Title, Text, TextVariants, Form, Alert } from '@patternfly/react-core';
3
+ import { Text, TextVariants, Form, Alert } from '@patternfly/react-core';
4
4
  import { translate as __ } from 'foremanReact/common/I18n';
5
5
  import { SelectField } from '../form/SelectField';
6
6
  import { GroupedSelectField } from '../form/GroupedSelectField';
7
+ import { WizardTitle } from '../form/WizardTitle';
8
+ import { WIZARD_TITLES } from '../../JobWizardConstants';
7
9
 
8
10
  export const CategoryAndTemplate = ({
9
11
  jobCategories,
@@ -40,7 +42,7 @@ export const CategoryAndTemplate = ({
40
42
  const isError = !!(categoryError || allTemplatesError || templateError);
41
43
  return (
42
44
  <>
43
- <Title headingLevel="h2">{__('Category and Template')}</Title>
45
+ <WizardTitle title={WIZARD_TITLES.categoryAndTemplate} />
44
46
  <Text component={TextVariants.p}>{__('All fields are required.')}</Text>
45
47
  <Form>
46
48
  <SelectField
@@ -5,6 +5,7 @@ import * as api from 'foremanReact/redux/API';
5
5
  import { JobWizard } from '../../JobWizard';
6
6
  import * as selectors from '../../JobWizardSelectors';
7
7
  import { testSetup, mockApi } from '../../__tests__/fixtures';
8
+ import { WIZARD_TITLES } from '../../JobWizardConstants';
8
9
 
9
10
  const store = testSetup(selectors, api);
10
11
  mockApi(api);
@@ -32,7 +33,7 @@ describe('Category And Template', () => {
32
33
  await act(async () => {
33
34
  await fireEvent.click(screen.getByText('Puppet'));
34
35
  });
35
- fireEvent.click(screen.getAllByText('Category and Template')[0]); // to remove focus
36
+ fireEvent.click(screen.getAllByText(WIZARD_TITLES.categoryAndTemplate)[0]); // to remove focus
36
37
  expect(
37
38
  screen.queryAllByLabelText('Ansible Commands', { selector: 'button' })
38
39
  ).toHaveLength(0);
@@ -47,7 +48,7 @@ describe('Category And Template', () => {
47
48
  await act(async () => {
48
49
  await fireEvent.click(screen.getByText('template2'));
49
50
  });
50
- fireEvent.click(screen.getAllByText('Category and Template')[0]); // to remove focus
51
+ fireEvent.click(screen.getAllByText(WIZARD_TITLES.categoryAndTemplate)[0]); // to remove focus
51
52
  expect(
52
53
  screen.queryAllByDisplayValue('template1', { selector: 'button' })
53
54
  ).toHaveLength(0);
@@ -0,0 +1,25 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import { Chip, ChipGroup } from '@patternfly/react-core';
4
+
5
+ export const SelectedChips = ({ selected, setSelected }) => {
6
+ const deleteItem = itemToRemove => {
7
+ setSelected(oldSelected =>
8
+ oldSelected.filter(item => item !== itemToRemove)
9
+ );
10
+ };
11
+ return (
12
+ <ChipGroup className="hosts-chip-group">
13
+ {selected.map(chip => (
14
+ <Chip key={chip} id={chip} onClick={() => deleteItem(chip)}>
15
+ {chip}
16
+ </Chip>
17
+ ))}
18
+ </ChipGroup>
19
+ );
20
+ };
21
+
22
+ SelectedChips.propTypes = {
23
+ selected: PropTypes.array.isRequired,
24
+ setSelected: PropTypes.func.isRequired,
25
+ };
@@ -0,0 +1,23 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import { translate as __ } from 'foremanReact/common/I18n';
4
+ import { formatter } from '../form/Formatter';
5
+
6
+ export const TemplateInputs = ({ inputs, value, setValue }) => {
7
+ if (inputs.length)
8
+ return inputs.map(input => formatter(input, value, setValue));
9
+ return (
10
+ <p className="gray-text">
11
+ {__('There are no available input fields for the selected template.')}
12
+ </p>
13
+ );
14
+ };
15
+ TemplateInputs.propTypes = {
16
+ inputs: PropTypes.array.isRequired,
17
+ value: PropTypes.object,
18
+ setValue: PropTypes.func.isRequired,
19
+ };
20
+
21
+ TemplateInputs.defaultProps = {
22
+ value: {},
23
+ };