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
@@ -0,0 +1,37 @@
1
+ import React from 'react';
2
+ import { Provider } from 'react-redux';
3
+ import { fireEvent, screen, render, act } from '@testing-library/react';
4
+ import * as api from 'foremanReact/redux/API';
5
+ import { JobWizard } from '../../../JobWizard';
6
+ import * as selectors from '../../../JobWizardSelectors';
7
+ import { testSetup, mockApi } from '../../../__tests__/fixtures';
8
+
9
+ const store = testSetup(selectors, api);
10
+ mockApi(api);
11
+
12
+ describe('TemplateInputs', () => {
13
+ it('should save data between steps for template input fields', async () => {
14
+ render(
15
+ <Provider store={store}>
16
+ <JobWizard advancedValues={{}} setAdvancedValues={jest.fn()} />
17
+ </Provider>
18
+ );
19
+ await act(async () => {
20
+ await fireEvent.click(
21
+ screen.getByText('Target hosts and inputs', { selector: 'button' })
22
+ );
23
+ });
24
+
25
+ expect(
26
+ screen.getAllByLabelText('host2', { selector: 'button' })
27
+ ).toHaveLength(1);
28
+ const chip1 = screen.getByLabelText('host1', { selector: 'button' });
29
+ fireEvent.click(chip1);
30
+ expect(
31
+ screen.queryAllByLabelText('host1', { selector: 'button' })
32
+ ).toHaveLength(0);
33
+ expect(
34
+ screen.queryAllByLabelText('host2', { selector: 'button' })
35
+ ).toHaveLength(1);
36
+ });
37
+ });
@@ -0,0 +1,50 @@
1
+ import React from 'react';
2
+ import { Provider } from 'react-redux';
3
+ import { fireEvent, screen, render, act } from '@testing-library/react';
4
+ import * as api from 'foremanReact/redux/API';
5
+ import { JobWizard } from '../../../JobWizard';
6
+ import * as selectors from '../../../JobWizardSelectors';
7
+ import { testSetup, mockApi } from '../../../__tests__/fixtures';
8
+ import { WIZARD_TITLES } from '../../../JobWizardConstants';
9
+
10
+ const store = testSetup(selectors, api);
11
+ mockApi(api);
12
+
13
+ describe('TemplateInputs', () => {
14
+ it('should save data between steps for template input fields', async () => {
15
+ render(
16
+ <Provider store={store}>
17
+ <JobWizard />
18
+ </Provider>
19
+ );
20
+ await act(async () => {
21
+ fireEvent.click(screen.getByText(WIZARD_TITLES.hostsAndInputs));
22
+ });
23
+ const textValue = 'I am a plain text';
24
+ const textField = screen.getByLabelText('plain hidden', {
25
+ selector: 'textarea',
26
+ });
27
+
28
+ await act(async () => {
29
+ await fireEvent.change(textField, {
30
+ target: { value: textValue },
31
+ });
32
+ });
33
+ expect(
34
+ screen.getByLabelText('plain hidden', {
35
+ selector: 'textarea',
36
+ }).value
37
+ ).toBe(textValue);
38
+ await act(async () => {
39
+ fireEvent.click(screen.getByText(WIZARD_TITLES.categoryAndTemplate));
40
+ });
41
+ expect(screen.getAllByText(WIZARD_TITLES.categoryAndTemplate)).toHaveLength(
42
+ 3
43
+ );
44
+
45
+ await act(async () => {
46
+ fireEvent.click(screen.getByText(WIZARD_TITLES.hostsAndInputs));
47
+ });
48
+ expect(textField.value).toBe(textValue);
49
+ });
50
+ });
@@ -0,0 +1,66 @@
1
+ import React, { useState } from 'react';
2
+ import { Button, Form, FormGroup } from '@patternfly/react-core';
3
+ import PropTypes from 'prop-types';
4
+ import { useSelector } from 'react-redux';
5
+ import { translate as __ } from 'foremanReact/common/I18n';
6
+ import { selectTemplateInputs } from '../../JobWizardSelectors';
7
+ import { SelectField } from '../form/SelectField';
8
+ import { SelectedChips } from './SelectedChips';
9
+ import { TemplateInputs } from './TemplateInputs';
10
+ import { WIZARD_TITLES } from '../../JobWizardConstants';
11
+ import { WizardTitle } from '../form/WizardTitle';
12
+
13
+ const HostsAndInputs = ({
14
+ templateValues,
15
+ setTemplateValues,
16
+ selectedHosts,
17
+ setSelectedHosts,
18
+ }) => {
19
+ const templateInputs = useSelector(selectTemplateInputs);
20
+ const hostMethods = [
21
+ __('Hosts'),
22
+ __('Host collection'),
23
+ __('Host group'),
24
+ __('Search query'),
25
+ ];
26
+ const [hostMethod, setHostMethod] = useState(hostMethods[0]);
27
+ return (
28
+ <>
29
+ <WizardTitle title={WIZARD_TITLES.hostsAndInputs} />
30
+ <Form>
31
+ <FormGroup fieldId="host_selection">
32
+ <SelectField
33
+ fieldId="host_methods"
34
+ options={hostMethods}
35
+ setValue={setHostMethod}
36
+ value={hostMethod}
37
+ />
38
+ <SelectedChips
39
+ selected={selectedHosts}
40
+ setSelected={setSelectedHosts}
41
+ />
42
+ </FormGroup>
43
+ <span>
44
+ {__('Apply to')}{' '}
45
+ <Button variant="link" isInline>
46
+ {selectedHosts.length} {__('hosts')}
47
+ </Button>
48
+ </span>
49
+ <TemplateInputs
50
+ inputs={templateInputs}
51
+ value={templateValues}
52
+ setValue={setTemplateValues}
53
+ />
54
+ </Form>
55
+ </>
56
+ );
57
+ };
58
+
59
+ HostsAndInputs.propTypes = {
60
+ templateValues: PropTypes.object.isRequired,
61
+ setTemplateValues: PropTypes.func.isRequired,
62
+ selectedHosts: PropTypes.array.isRequired,
63
+ setSelectedHosts: PropTypes.func.isRequired,
64
+ };
65
+
66
+ export default HostsAndInputs;
@@ -1,25 +1,28 @@
1
- import React, { useState } from 'react';
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
2
3
  import { FormGroup, Radio } from '@patternfly/react-core';
3
4
  import { translate as __ } from 'foremanReact/common/I18n';
4
5
 
5
- export const ScheduleType = () => {
6
- const [isFuture, setIsFuture] = useState(false);
7
- return (
8
- <FormGroup label={__('Schedule type')} fieldId="schedule-type">
9
- <Radio
10
- isChecked={!isFuture}
11
- name="schedule-type"
12
- onChange={() => setIsFuture(false)}
13
- id="schedule-type-now"
14
- label={__('Execute now')}
15
- />
16
- <Radio
17
- isChecked={isFuture}
18
- name="schedule-type"
19
- onChange={() => setIsFuture(true)}
20
- id="schedule-type-future"
21
- label={__('Schedule for future execution')}
22
- />
23
- </FormGroup>
24
- );
6
+ export const ScheduleType = ({ isFuture, setIsFuture }) => (
7
+ <FormGroup label={__('Schedule type')} fieldId="schedule-type">
8
+ <Radio
9
+ isChecked={!isFuture}
10
+ name="schedule-type"
11
+ onChange={() => setIsFuture(false)}
12
+ id="schedule-type-now"
13
+ label={__('Execute now')}
14
+ />
15
+ <Radio
16
+ isChecked={isFuture}
17
+ name="schedule-type"
18
+ onChange={() => setIsFuture(true)}
19
+ id="schedule-type-future"
20
+ label={__('Schedule for future execution')}
21
+ />
22
+ </FormGroup>
23
+ );
24
+
25
+ ScheduleType.propTypes = {
26
+ isFuture: PropTypes.bool.isRequired,
27
+ setIsFuture: PropTypes.func.isRequired,
25
28
  };
@@ -1,35 +1,48 @@
1
- import React, { useState } from 'react';
1
+ import React from 'react';
2
2
  import PropTypes from 'prop-types';
3
- import { FormGroup, TextInput, Checkbox } from '@patternfly/react-core';
3
+ import { FormGroup, Checkbox } from '@patternfly/react-core';
4
4
  import { translate as __ } from 'foremanReact/common/I18n';
5
+ import { DateTimePicker } from '../form/DateTimePicker';
5
6
 
6
- // TODO: change to datepicker
7
- export const StartEndDates = ({ starts, setStarts, ends, setEnds }) => {
8
- const [isNeverEnds, setIsNeverEnds] = useState(false);
7
+ export const StartEndDates = ({
8
+ starts,
9
+ setStarts,
10
+ ends,
11
+ setEnds,
12
+ isNeverEnds,
13
+ setIsNeverEnds,
14
+ }) => {
9
15
  const toggleIsNeverEnds = (checked, event) => {
10
16
  const value = event?.target?.checked;
11
17
  setIsNeverEnds(value);
12
- setEnds('');
18
+ };
19
+ const validateEndDate = () => {
20
+ if (isNeverEnds) return 'success';
21
+ if (!starts || !ends) return 'success';
22
+ if (new Date(starts).getTime() <= new Date(ends).getTime())
23
+ return 'success';
24
+ return 'error';
13
25
  };
14
26
  return (
15
27
  <>
16
- <FormGroup label={__('Starts')} fieldId="start-date">
17
- <TextInput
18
- id="start-date"
19
- value={starts}
20
- type="text"
21
- onChange={newValue => setStarts(newValue)}
22
- placeholder="mm/dd/yy, hh:mm UTC"
23
- />
28
+ <FormGroup
29
+ className="start-date"
30
+ label={__('Starts')}
31
+ fieldId="start-date"
32
+ >
33
+ <DateTimePicker dateTime={starts} setDateTime={setStarts} />
24
34
  </FormGroup>
25
- <FormGroup label={__('Ends')} fieldId="end-date">
26
- <TextInput
35
+ <FormGroup
36
+ className="end-date"
37
+ label={__('Ends')}
38
+ fieldId="end-date"
39
+ helperTextInvalid={__('End time needs to be after start time')}
40
+ validated={validateEndDate()}
41
+ >
42
+ <DateTimePicker
43
+ dateTime={ends}
44
+ setDateTime={setEnds}
27
45
  isDisabled={isNeverEnds}
28
- id="end-date"
29
- value={ends}
30
- type="text"
31
- onChange={newValue => setEnds(newValue)}
32
- placeholder="mm/dd/yy, hh:mm UTC"
33
46
  />
34
47
  <Checkbox
35
48
  label={__('Never ends')}
@@ -48,4 +61,6 @@ StartEndDates.propTypes = {
48
61
  setStarts: PropTypes.func.isRequired,
49
62
  ends: PropTypes.string.isRequired,
50
63
  setEnds: PropTypes.func.isRequired,
64
+ isNeverEnds: PropTypes.bool.isRequired,
65
+ setIsNeverEnds: PropTypes.func.isRequired,
51
66
  };
@@ -0,0 +1,155 @@
1
+ import React from 'react';
2
+ import { Provider } from 'react-redux';
3
+ import configureMockStore from 'redux-mock-store';
4
+ import { fireEvent, screen, render, act } from '@testing-library/react';
5
+ import * as api from 'foremanReact/redux/API';
6
+ import { JobWizard } from '../../../JobWizard';
7
+ import * as selectors from '../../../JobWizardSelectors';
8
+ import { jobTemplate, jobTemplateResponse } from '../../../__tests__/fixtures';
9
+
10
+ const lodash = require('lodash');
11
+
12
+ lodash.debounce = fn => fn;
13
+ jest.spyOn(api, 'get');
14
+ jest.spyOn(selectors, 'selectJobTemplate');
15
+ jest.spyOn(selectors, 'selectJobTemplates');
16
+ jest.spyOn(selectors, 'selectJobCategories');
17
+
18
+ const jobCategories = ['Ansible Commands', 'Puppet', 'Services'];
19
+
20
+ selectors.selectJobCategories.mockImplementation(() => jobCategories);
21
+
22
+ selectors.selectJobTemplates.mockImplementation(() => [
23
+ jobTemplate,
24
+ { ...jobTemplate, id: 2, name: 'template2' },
25
+ ]);
26
+ selectors.selectJobTemplate.mockImplementation(() => jobTemplateResponse);
27
+ api.get.mockImplementation(({ handleSuccess, ...action }) => {
28
+ if (action.key === 'JOB_CATEGORIES') {
29
+ handleSuccess && handleSuccess({ data: { job_categories: jobCategories } });
30
+ } else if (action.key === 'JOB_TEMPLATE') {
31
+ handleSuccess &&
32
+ handleSuccess({
33
+ data: jobTemplateResponse,
34
+ });
35
+ } else if (action.key === 'JOB_TEMPLATES') {
36
+ handleSuccess &&
37
+ handleSuccess({
38
+ data: { results: [jobTemplateResponse.job_template] },
39
+ });
40
+ }
41
+ return { type: 'get', ...action };
42
+ });
43
+
44
+ const mockStore = configureMockStore([]);
45
+ const store = mockStore({});
46
+ jest.useFakeTimers();
47
+
48
+ describe('Schedule', () => {
49
+ it('should save date time between steps ', async () => {
50
+ render(
51
+ <Provider store={store}>
52
+ <JobWizard />
53
+ </Provider>
54
+ );
55
+ await act(async () => {
56
+ fireEvent.click(screen.getByText('Schedule'));
57
+ });
58
+ const newStartDate = '2020/03/12';
59
+ const newStartTime = '12:03';
60
+ const newEndsDate = '2030/03/12';
61
+ const newEndsTime = '17:34';
62
+ const [startsDateField, endsDateField] = screen.getAllByPlaceholderText(
63
+ 'yyyy/mm/dd'
64
+ );
65
+ const [startsTimeField, endsTimeField] = screen.getAllByPlaceholderText(
66
+ 'hh:mm'
67
+ );
68
+ await act(async () => {
69
+ await fireEvent.change(startsDateField, {
70
+ target: { value: newStartDate },
71
+ });
72
+ await fireEvent.change(startsTimeField, {
73
+ target: { value: newStartTime },
74
+ });
75
+ await fireEvent.change(endsDateField, { target: { value: newEndsDate } });
76
+ await fireEvent.change(endsTimeField, { target: { value: newEndsTime } });
77
+ jest.runAllTimers(); // to handle pf4 date picket popover useTimer
78
+ });
79
+ await act(async () => {
80
+ fireEvent.click(screen.getByText('Category and Template'));
81
+ });
82
+ expect(screen.getAllByText('Category and Template')).toHaveLength(3);
83
+
84
+ await act(async () => {
85
+ fireEvent.click(screen.getByText('Schedule'));
86
+ jest.runAllTimers();
87
+ });
88
+ expect(startsDateField.value).toBe(newStartDate);
89
+ expect(startsTimeField.value).toBe(newStartTime);
90
+ expect(endsDateField.value).toBe(newEndsDate);
91
+ expect(endsTimeField.value).toBe(newEndsTime);
92
+ });
93
+ it('should remove start date time on execute now', async () => {
94
+ render(
95
+ <Provider store={store}>
96
+ <JobWizard />
97
+ </Provider>
98
+ );
99
+ await act(async () => {
100
+ fireEvent.click(screen.getByText('Schedule'));
101
+ });
102
+ const executeNow = screen.getByLabelText('Execute now');
103
+ const executeFuture = screen.getByLabelText(
104
+ 'Schedule for future execution'
105
+ );
106
+ expect(executeNow.checked).toBeTruthy();
107
+ const newStartDate = '2020/03/12';
108
+ const newStartTime = '12:03';
109
+ const [startsDateField] = screen.getAllByPlaceholderText('yyyy/mm/dd');
110
+ const [startsTimeField] = screen.getAllByPlaceholderText('hh:mm');
111
+ await act(async () => {
112
+ await fireEvent.change(startsDateField, {
113
+ target: { value: newStartDate },
114
+ });
115
+ await fireEvent.change(startsTimeField, {
116
+ target: { value: newStartTime },
117
+ });
118
+ await jest.runOnlyPendingTimers();
119
+ });
120
+ expect(startsDateField.value).toBe(newStartDate);
121
+ expect(startsTimeField.value).toBe(newStartTime);
122
+ expect(executeFuture.checked).toBeTruthy();
123
+ await act(async () => {
124
+ await fireEvent.click(executeNow);
125
+ });
126
+ expect(executeNow.checked).toBeTruthy();
127
+ expect(startsDateField.value).toBe('');
128
+ expect(startsTimeField.value).toBe('');
129
+ });
130
+
131
+ it('should disable end date on never ends', async () => {
132
+ render(
133
+ <Provider store={store}>
134
+ <JobWizard />
135
+ </Provider>
136
+ );
137
+ await act(async () => {
138
+ await fireEvent.click(screen.getByText('Schedule'));
139
+ jest.runAllTimers();
140
+ });
141
+ const neverEnds = screen.getByLabelText('Never ends');
142
+ expect(neverEnds.checked).toBeFalsy();
143
+
144
+ const [, endsDateField] = screen.getAllByPlaceholderText('yyyy/mm/dd');
145
+ const [, endsTimeField] = screen.getAllByPlaceholderText('hh:mm');
146
+ expect(endsDateField.disabled).toBeFalsy();
147
+ expect(endsTimeField.disabled).toBeFalsy();
148
+ await act(async () => {
149
+ fireEvent.click(neverEnds);
150
+ });
151
+ expect(neverEnds.checked).toBeTruthy();
152
+ expect(endsDateField.disabled).toBeTruthy();
153
+ expect(endsTimeField.disabled).toBeTruthy();
154
+ });
155
+ });
@@ -1,22 +1,23 @@
1
1
  import React from 'react';
2
- import { render, fireEvent, screen } from '@testing-library/react';
2
+ import { render, fireEvent, screen, act } from '@testing-library/react';
3
3
  import { StartEndDates } from '../StartEndDates';
4
4
 
5
5
  const setEnds = jest.fn();
6
+ const setIsNeverEnds = jest.fn();
6
7
  const props = {
7
8
  starts: '',
8
9
  setStarts: jest.fn(),
9
10
  ends: 'some-end-date',
10
11
  setEnds,
12
+ setIsNeverEnds,
13
+ isNeverEnds: false,
11
14
  };
12
15
 
13
16
  describe('StartEndDates', () => {
14
- it('never ends', () => {
15
- render(<StartEndDates {...props} />);
16
- const neverEnds = screen.getByLabelText('Never ends', {
17
- selector: 'input',
18
- });
19
- fireEvent.click(neverEnds);
20
- expect(setEnds).toBeCalledWith('');
17
+ it('never ends', async () => {
18
+ await act(async () => render(<StartEndDates {...props} />));
19
+ const neverEnds = screen.getByRole('checkbox', { name: 'Never ends' });
20
+ await act(async () => fireEvent.click(neverEnds));
21
+ expect(setIsNeverEnds).toBeCalledWith(true);
21
22
  });
22
23
  });