foreman_remote_execution 4.7.0 → 4.8.0

Sign up to get free protection for your applications and to get access to all the features.
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
+ };