Merge pull request #52 from ansible/devel

Rebase
This commit is contained in:
Sean Sullivan
2021-03-10 11:25:03 -06:00
committed by GitHub
27 changed files with 1096 additions and 133 deletions

View File

@@ -16,6 +16,11 @@ This is a list of high-level changes for each release of AWX. A full list of com
- Added ability to relaunch against failed hosts: https://github.com/ansible/awx/pull/9225 - Added ability to relaunch against failed hosts: https://github.com/ansible/awx/pull/9225
- Added pending workflow approval count to the application header https://github.com/ansible/awx/pull/9334 - Added pending workflow approval count to the application header https://github.com/ansible/awx/pull/9334
- Added user interface for management jobs: https://github.com/ansible/awx/pull/9224 - Added user interface for management jobs: https://github.com/ansible/awx/pull/9224
- Added toast message to show notification template test result to notification templates list https://github.com/ansible/awx/pull/9318
# 17.1.0 (March 9th, 2021)
- Addressed a security issue in AWX (CVE-2021-20253)
- Fixed a bug permissions error related to redis in K8S-based deployments: https://github.com/ansible/awx/issues/9401
# 17.0.1 (January 26, 2021) # 17.0.1 (January 26, 2021)
- Fixed pgdocker directory permissions issue with Local Docker installer: https://github.com/ansible/awx/pull/9152 - Fixed pgdocker directory permissions issue with Local Docker installer: https://github.com/ansible/awx/pull/9152

View File

@@ -24,6 +24,8 @@ If you're attempting to migrate an older Docker-based AWX installation, see: [Mi
Starting in version 18.0, the [AWX Operator](https://github.com/ansible/awx-operator) is the preferred way to install AWX. Starting in version 18.0, the [AWX Operator](https://github.com/ansible/awx-operator) is the preferred way to install AWX.
AWX can also alternatively be installed and [run in Docker](./tools/docker-compose/README.md), but this install path is only recommended for development/test-oriented deployments, and has no official published release.
### Quickstart with minikube ### Quickstart with minikube
If you don't have an existing OpenShift or Kubernetes cluster, minikube is a fast and easy way to get up and running. If you don't have an existing OpenShift or Kubernetes cluster, minikube is a fast and easy way to get up and running.

View File

@@ -21,13 +21,6 @@ from split_settings.tools import optional, include
# Load default settings. # Load default settings.
from .defaults import * # NOQA from .defaults import * # NOQA
if "pytest" in sys.modules:
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'unique-{}'.format(str(uuid.uuid4())),
},
}
# awx-manage shell_plus --notebook # awx-manage shell_plus --notebook
NOTEBOOK_ARGUMENTS = [ NOTEBOOK_ARGUMENTS = [
@@ -53,6 +46,10 @@ LOGGING['loggers']['awx.isolated.manager.playbooks']['propagate'] = True # noqa
# celery is annoyingly loud when docker containers start # celery is annoyingly loud when docker containers start
LOGGING['loggers'].pop('celery', None) # noqa LOGGING['loggers'].pop('celery', None) # noqa
# avoid awx.main.dispatch WARNING-level scaling worker up/down messages
LOGGING['loggers']['awx.main.dispatch']['level'] = 'ERROR' # noqa
# suppress the spamminess of the awx.main.scheduler and .tasks loggers
LOGGING['loggers']['awx']['level'] = 'INFO' # noqa
ALLOWED_HOSTS = ['*'] ALLOWED_HOSTS = ['*']
@@ -166,6 +163,27 @@ except ImportError:
traceback.print_exc() traceback.print_exc()
sys.exit(1) sys.exit(1)
# Use SQLite for unit tests instead of PostgreSQL. If the lines below are
# commented out, Django will create the test_awx-dev database in PostgreSQL to
# run unit tests.
if "pytest" in sys.modules:
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'unique-{}'.format(str(uuid.uuid4())),
},
}
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'awx.sqlite3'), # noqa
'TEST': {
# Test database cannot be :memory: for inventory tests.
'NAME': os.path.join(BASE_DIR, 'awx_test.sqlite3'), # noqa
},
}
}
CELERYBEAT_SCHEDULE.update({ # noqa CELERYBEAT_SCHEDULE.update({ # noqa
'isolated_heartbeat': { 'isolated_heartbeat': {

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import styled from 'styled-components';
import { import {
DataListItem, DataListItem,
DataListItemRow, DataListItemRow,
@@ -9,6 +10,14 @@ import {
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import DataListCell from '../DataListCell'; import DataListCell from '../DataListCell';
const Label = styled.label`
${({ isDisabled }) =>
isDisabled &&
`
opacity: 0.5;
`}
`;
const CheckboxListItem = ({ const CheckboxListItem = ({
isDisabled = false, isDisabled = false,
isRadio = false, isRadio = false,
@@ -32,7 +41,7 @@ const CheckboxListItem = ({
aria-label={`check-action-item-${itemId}`} aria-label={`check-action-item-${itemId}`}
aria-labelledby={`check-action-item-${itemId}`} aria-labelledby={`check-action-item-${itemId}`}
checked={isSelected} checked={isSelected}
disabled={isDisabled} isDisabled={isDisabled}
id={`selected-${itemId}`} id={`selected-${itemId}`}
isChecked={isSelected} isChecked={isSelected}
name={name} name={name}
@@ -42,13 +51,14 @@ const CheckboxListItem = ({
<DataListItemCells <DataListItemCells
dataListCells={[ dataListCells={[
<DataListCell key="name"> <DataListCell key="name">
<label <Label
id={`check-action-item-${itemId}`} id={`check-action-item-${itemId}`}
htmlFor={`selected-${itemId}`} htmlFor={`selected-${itemId}`}
className="check-action-item" className="check-action-item"
isDisabled={isDisabled}
> >
<b>{label}</b> <b>{label}</b>
</label> </Label>
</DataListCell>, </DataListCell>,
]} ]}
/> />

View File

@@ -17,6 +17,7 @@ function CopyButton({
onCopyFinish, onCopyFinish,
errorMessage, errorMessage,
i18n, i18n,
ouiaId,
}) { }) {
const { isLoading, error: copyError, request: copyItemToAPI } = useRequest( const { isLoading, error: copyError, request: copyItemToAPI } = useRequest(
copyItem copyItem
@@ -35,6 +36,7 @@ function CopyButton({
<> <>
<Button <Button
id={id} id={id}
ouiaId={ouiaId}
isDisabled={isLoading || isDisabled} isDisabled={isLoading || isDisabled}
aria-label={i18n._(t`Copy`)} aria-label={i18n._(t`Copy`)}
variant="plain" variant="plain"
@@ -62,10 +64,12 @@ CopyButton.propTypes = {
onCopyFinish: PropTypes.func.isRequired, onCopyFinish: PropTypes.func.isRequired,
errorMessage: PropTypes.string.isRequired, errorMessage: PropTypes.string.isRequired,
isDisabled: PropTypes.bool, isDisabled: PropTypes.bool,
ouiaId: PropTypes.string,
}; };
CopyButton.defaultProps = { CopyButton.defaultProps = {
isDisabled: false, isDisabled: false,
ouiaId: null,
}; };
export default withI18n()(CopyButton); export default withI18n()(CopyButton);

View File

@@ -223,7 +223,7 @@ function ScheduleForm({
const { const {
request: loadScheduleData, request: loadScheduleData,
error: contentError, error: contentError,
contentLoading, isLoading: contentLoading,
result: { zoneOptions, credentials }, result: { zoneOptions, credentials },
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {

View File

@@ -447,6 +447,7 @@ describe('<ScheduleForm />', () => {
/> />
); );
}); });
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
}); });
afterAll(() => { afterAll(() => {
wrapper.unmount(); wrapper.unmount();

View File

@@ -1,8 +1,14 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { useLocation, useRouteMatch } from 'react-router-dom'; import { useLocation, useRouteMatch } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Card, PageSection } from '@patternfly/react-core'; import {
Alert,
AlertActionCloseButton,
AlertGroup,
Card,
PageSection,
} from '@patternfly/react-core';
import { NotificationTemplatesAPI } from '../../../api'; import { NotificationTemplatesAPI } from '../../../api';
import PaginatedTable, { import PaginatedTable, {
HeaderRow, HeaderRow,
@@ -29,6 +35,7 @@ const QS_CONFIG = getQSConfig('notification-templates', {
function NotificationTemplatesList({ i18n }) { function NotificationTemplatesList({ i18n }) {
const location = useLocation(); const location = useLocation();
const match = useRouteMatch(); const match = useRouteMatch();
const [testToasts, setTestToasts] = useState([]);
const addUrl = `${match.url}/add`; const addUrl = `${match.url}/add`;
@@ -102,6 +109,16 @@ function NotificationTemplatesList({ i18n }) {
setSelected([]); setSelected([]);
}; };
const addTestToast = useCallback(notification => {
setTestToasts(oldToasts => [...oldToasts, notification]);
}, []);
const removeTestToast = notificationId => {
setTestToasts(oldToasts =>
oldToasts.filter(toast => toast.id !== notificationId)
);
};
const canAdd = actions && actions.POST; const canAdd = actions && actions.POST;
return ( return (
@@ -185,6 +202,7 @@ function NotificationTemplatesList({ i18n }) {
} }
renderRow={(template, index) => ( renderRow={(template, index) => (
<NotificationTemplateListItem <NotificationTemplateListItem
onAddToast={addTestToast}
key={template.id} key={template.id}
fetchTemplates={fetchTemplates} fetchTemplates={fetchTemplates}
template={template} template={template}
@@ -209,6 +227,39 @@ function NotificationTemplatesList({ i18n }) {
{i18n._(t`Failed to delete one or more notification template.`)} {i18n._(t`Failed to delete one or more notification template.`)}
<ErrorDetail error={deletionError} /> <ErrorDetail error={deletionError} />
</AlertModal> </AlertModal>
<AlertGroup ouiaId="notification-template-alerts" isToast>
{testToasts
.filter(notification => notification.status !== 'pending')
.map(notification => (
<Alert
actionClose={
<AlertActionCloseButton
onClose={() => removeTestToast(notification.id)}
/>
}
onTimeout={() => removeTestToast(notification.id)}
timeout={notification.status !== 'failed'}
title={notification.summary_fields.notification_template.name}
variant={notification.status === 'failed' ? 'danger' : 'success'}
key={`notification-template-alert-${notification.id}`}
ouiaId={`notification-template-alert-${notification.id}`}
>
<>
{notification.status === 'successful' && (
<p>{i18n._(t`Notification sent successfully`)}</p>
)}
{notification.status === 'failed' &&
notification?.error === 'timed out' && (
<p>{i18n._(t`Notification timed out`)}</p>
)}
{notification.status === 'failed' &&
notification?.error !== 'timed out' && (
<p>{notification.error}</p>
)}
</>
</Alert>
))}
</AlertGroup>
</> </>
); );
} }

View File

@@ -1,11 +1,17 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { OrganizationsAPI } from '../../../api'; import {
NotificationsAPI,
NotificationTemplatesAPI,
OrganizationsAPI,
} from '../../../api';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import NotificationTemplateList from './NotificationTemplateList'; import NotificationTemplateList from './NotificationTemplateList';
jest.mock('../../../api'); jest.mock('../../../api');
jest.useFakeTimers();
const mockTemplates = { const mockTemplates = {
data: { data: {
count: 3, count: 3,
@@ -197,6 +203,43 @@ describe('<NotificationTemplateList />', () => {
expect(wrapper.find('ToolbarAddButton').length).toBe(1); expect(wrapper.find('ToolbarAddButton').length).toBe(1);
}); });
test('should show toast after test resolves', async () => {
NotificationTemplatesAPI.test.mockResolvedValueOnce({
data: {
notification: 9182,
},
});
NotificationsAPI.readDetail.mockResolvedValueOnce({
data: {
id: 9182,
status: 'failed',
error: 'There was an error with the notification',
summary_fields: {
notification_template: {
name: 'foobar',
},
},
},
});
await act(async () => {
wrapper = mountWithContexts(<NotificationTemplateList />);
});
wrapper.update();
expect(wrapper.find('Alert').length).toBe(0);
await act(async () => {
wrapper
.find('button[aria-label="Test Notification"]')
.at(0)
.simulate('click');
});
wrapper.update();
await act(async () => {
jest.runAllTimers();
});
wrapper.update();
expect(wrapper.find('Alert').length).toBe(1);
});
test('should hide add button (rbac)', async () => { test('should hide add button (rbac)', async () => {
OrganizationsAPI.readOptions.mockResolvedValue({ OrganizationsAPI.readOptions.mockResolvedValue({
data: { data: {

View File

@@ -11,13 +11,16 @@ import { timeOfDay } from '../../../util/dates';
import { NotificationTemplatesAPI, NotificationsAPI } from '../../../api'; import { NotificationTemplatesAPI, NotificationsAPI } from '../../../api';
import StatusLabel from '../../../components/StatusLabel'; import StatusLabel from '../../../components/StatusLabel';
import CopyButton from '../../../components/CopyButton'; import CopyButton from '../../../components/CopyButton';
import useRequest from '../../../util/useRequest'; import AlertModal from '../../../components/AlertModal';
import ErrorDetail from '../../../components/ErrorDetail';
import useRequest, { useDismissableError } from '../../../util/useRequest';
import { NOTIFICATION_TYPES } from '../constants'; import { NOTIFICATION_TYPES } from '../constants';
const NUM_RETRIES = 25; const NUM_RETRIES = 25;
const RETRY_TIMEOUT = 5000; const RETRY_TIMEOUT = 5000;
function NotificationTemplateListItem({ function NotificationTemplateListItem({
onAddToast,
template, template,
detailUrl, detailUrl,
fetchTemplates, fetchTemplates,
@@ -66,6 +69,7 @@ function NotificationTemplateListItem({
notificationId notificationId
); );
if (notification.status !== 'pending') { if (notification.status !== 'pending') {
onAddToast(notification);
setStatus(notification.status); setStatus(notification.status);
return; return;
} }
@@ -76,9 +80,11 @@ function NotificationTemplateListItem({
} }
setTimeout(pollForStatusChange, RETRY_TIMEOUT); setTimeout(pollForStatusChange, RETRY_TIMEOUT);
}, [template.id]) }, [template.id, onAddToast])
); );
const { error: sendTestError, dismissError } = useDismissableError(error);
useEffect(() => { useEffect(() => {
if (error) { if (error) {
setStatus('error'); setStatus('error');
@@ -88,65 +94,81 @@ function NotificationTemplateListItem({
const labelId = `template-name-${template.id}`; const labelId = `template-name-${template.id}`;
return ( return (
<Tr id={`notification-template-row-${template.id}`}> <>
<Td <Tr id={`notification-template-row-${template.id}`}>
select={{ <Td
rowIndex, select={{
isSelected, rowIndex,
onSelect, isSelected,
}} onSelect,
dataLabel={i18n._(t`Selected`)} }}
/> dataLabel={i18n._(t`Selected`)}
<Td id={labelId} dataLabel={i18n._(t`Name`)}> />
<Link to={`${detailUrl}`}> <Td id={labelId} dataLabel={i18n._(t`Name`)}>
<b>{template.name}</b> <Link to={`${detailUrl}`}>
</Link> <b>{template.name}</b>
</Td> </Link>
<Td dataLabel={i18n._(t`Status`)}> </Td>
{status && <StatusLabel status={status} />} <Td dataLabel={i18n._(t`Status`)}>
</Td> {status && <StatusLabel status={status} />}
<Td dataLabel={i18n._(t`Type`)}> </Td>
{NOTIFICATION_TYPES[template.notification_type] || <Td dataLabel={i18n._(t`Type`)}>
template.notification_type} {NOTIFICATION_TYPES[template.notification_type] ||
</Td> template.notification_type}
<ActionsTd dataLabel={i18n._(t`Actions`)}> </Td>
<ActionItem visible tooltip={i18n._(t`Test notification`)}> <ActionsTd dataLabel={i18n._(t`Actions`)}>
<Button <ActionItem visible tooltip={i18n._(t`Test notification`)}>
aria-label={i18n._(t`Test Notification`)} <Button
variant="plain" ouiaId={`notification-test-button-${template.id}`}
onClick={sendTestNotification} aria-label={i18n._(t`Test Notification`)}
isDisabled={isLoading || status === 'running'} variant="plain"
onClick={sendTestNotification}
isDisabled={isLoading || status === 'running'}
>
<BellIcon />
</Button>
</ActionItem>
<ActionItem
visible={template.summary_fields.user_capabilities.edit}
tooltip={i18n._(t`Edit`)}
> >
<BellIcon /> <Button
</Button> ouiaId={`notification-edit-button-${template.id}`}
</ActionItem> aria-label={i18n._(t`Edit Notification Template`)}
<ActionItem variant="plain"
visible={template.summary_fields.user_capabilities.edit} component={Link}
tooltip={i18n._(t`Edit`)} to={`/notification_templates/${template.id}/edit`}
> >
<Button <PencilAltIcon />
aria-label={i18n._(t`Edit Notification Template`)} </Button>
variant="plain" </ActionItem>
component={Link} <ActionItem
to={`/notification_templates/${template.id}/edit`} visible={template.summary_fields.user_capabilities.copy}
tooltip={i18n._(t`Copy Notification Template`)}
> >
<PencilAltIcon /> <CopyButton
</Button> ouiaId={`notification-copy-button-${template.id}`}
</ActionItem> copyItem={copyTemplate}
<ActionItem isCopyDisabled={isCopyDisabled}
visible={template.summary_fields.user_capabilities.copy} onCopyStart={handleCopyStart}
tooltip={i18n._(t`Copy Notification Template`)} onCopyFinish={handleCopyFinish}
errorMessage={i18n._(t`Failed to copy template.`)}
/>
</ActionItem>
</ActionsTd>
</Tr>
{sendTestError && (
<AlertModal
isOpen
variant="error"
title={i18n._(t`Error!`)}
onClose={dismissError}
> >
<CopyButton {i18n._(t`Failed to send test notification.`)}
copyItem={copyTemplate} <ErrorDetail error={sendTestError} />
isCopyDisabled={isCopyDisabled} </AlertModal>
onCopyStart={handleCopyStart} )}
onCopyFinish={handleCopyFinish} </>
errorMessage={i18n._(t`Failed to copy template.`)}
/>
</ActionItem>
</ActionsTd>
</Tr>
); );
} }

View File

@@ -49,6 +49,7 @@ function JobTemplateEdit({ template }) {
webhook_credential, webhook_credential,
webhook_key, webhook_key,
webhook_url, webhook_url,
execution_environment,
...remainingValues ...remainingValues
} = values; } = values;
@@ -56,11 +57,9 @@ function JobTemplateEdit({ template }) {
setIsLoading(true); setIsLoading(true);
remainingValues.project = values.project.id; remainingValues.project = values.project.id;
remainingValues.webhook_credential = webhook_credential?.id || null; remainingValues.webhook_credential = webhook_credential?.id || null;
remainingValues.execution_environment = execution_environment?.id || null;
try { try {
await JobTemplatesAPI.update(template.id, { await JobTemplatesAPI.update(template.id, remainingValues);
...remainingValues,
execution_environment: values.execution_environment?.id,
});
await Promise.all([ await Promise.all([
submitLabels(labels, template?.organization), submitLabels(labels, template?.organization),
submitInstanceGroups(instanceGroups, initialInstanceGroups), submitInstanceGroups(instanceGroups, initialInstanceGroups),

View File

@@ -13,6 +13,7 @@ import {
LabelsAPI, LabelsAPI,
ProjectsAPI, ProjectsAPI,
InventoriesAPI, InventoriesAPI,
ExecutionEnvironmentsAPI,
} from '../../../api'; } from '../../../api';
import JobTemplateEdit from './JobTemplateEdit'; import JobTemplateEdit from './JobTemplateEdit';
@@ -49,6 +50,12 @@ const mockJobTemplate = {
scm_branch: '', scm_branch: '',
skip_tags: '', skip_tags: '',
summary_fields: { summary_fields: {
execution_environment: {
id: 1,
name: 'Default EE',
description: '',
image: 'quay.io/ansible/awx-ee',
},
user_capabilities: { user_capabilities: {
edit: true, edit: true,
}, },
@@ -81,6 +88,7 @@ const mockJobTemplate = {
related: { related: {
webhook_receiver: '/api/v2/workflow_job_templates/57/gitlab/', webhook_receiver: '/api/v2/workflow_job_templates/57/gitlab/',
}, },
execution_environment: 1,
}; };
const mockRelatedCredentials = { const mockRelatedCredentials = {
@@ -176,6 +184,15 @@ const mockInstanceGroups = [
}, },
]; ];
const mockExecutionEnvironment = [
{
id: 1,
name: 'Default EE',
description: '',
image: 'quay.io/ansible/awx-ee',
},
];
JobTemplatesAPI.readCredentials.mockResolvedValue({ JobTemplatesAPI.readCredentials.mockResolvedValue({
data: mockRelatedCredentials, data: mockRelatedCredentials,
}); });
@@ -197,6 +214,10 @@ CredentialsAPI.read.mockResolvedValue({
}); });
CredentialTypesAPI.loadAllTypes.mockResolvedValue([]); CredentialTypesAPI.loadAllTypes.mockResolvedValue([]);
ExecutionEnvironmentsAPI.read.mockResolvedValue({
data: mockExecutionEnvironment,
});
describe('<JobTemplateEdit />', () => { describe('<JobTemplateEdit />', () => {
beforeEach(() => { beforeEach(() => {
LabelsAPI.read.mockResolvedValue({ data: { results: [] } }); LabelsAPI.read.mockResolvedValue({ data: { results: [] } });
@@ -266,6 +287,8 @@ describe('<JobTemplateEdit />', () => {
id: 1, id: 1,
organization: 1, organization: 1,
}); });
wrapper.find('ExecutionEnvironmentLookup').invoke('onChange')(null);
}); });
wrapper.update(); wrapper.update();
await act(async () => { await act(async () => {
@@ -277,6 +300,7 @@ describe('<JobTemplateEdit />', () => {
...mockJobTemplate, ...mockJobTemplate,
project: mockJobTemplate.project, project: mockJobTemplate.project,
...updatedTemplateData, ...updatedTemplateData,
execution_environment: null,
}; };
delete expected.summary_fields; delete expected.summary_fields;
delete expected.id; delete expected.id;

View File

@@ -17,11 +17,13 @@ function WorkflowJobTemplateEdit({ template }) {
organization, organization,
webhook_credential, webhook_credential,
webhook_key, webhook_key,
execution_environment,
...templatePayload ...templatePayload
} = values; } = values;
templatePayload.inventory = inventory?.id || null; templatePayload.inventory = inventory?.id || null;
templatePayload.organization = organization?.id || null; templatePayload.organization = organization?.id || null;
templatePayload.webhook_credential = webhook_credential?.id || null; templatePayload.webhook_credential = webhook_credential?.id || null;
templatePayload.execution_environment = execution_environment?.id || null;
const formOrgId = const formOrgId =
organization?.id || inventory?.summary_fields?.organization.id || null; organization?.id || inventory?.summary_fields?.organization.id || null;
@@ -29,10 +31,7 @@ function WorkflowJobTemplateEdit({ template }) {
await Promise.all( await Promise.all(
await submitLabels(labels, formOrgId, template.organization) await submitLabels(labels, formOrgId, template.organization)
); );
await WorkflowJobTemplatesAPI.update(template.id, { await WorkflowJobTemplatesAPI.update(template.id, templatePayload);
...templatePayload,
execution_environment: values.execution_environment?.id,
});
history.push(`/templates/workflow_job_template/${template.id}/details`); history.push(`/templates/workflow_job_template/${template.id}/details`);
} catch (err) { } catch (err) {
setFormSubmitError(err); setFormSubmitError(err);

View File

@@ -6,6 +6,7 @@ import {
WorkflowJobTemplatesAPI, WorkflowJobTemplatesAPI,
OrganizationsAPI, OrganizationsAPI,
LabelsAPI, LabelsAPI,
ExecutionEnvironmentsAPI,
} from '../../../api'; } from '../../../api';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import WorkflowJobTemplateEdit from './WorkflowJobTemplateEdit'; import WorkflowJobTemplateEdit from './WorkflowJobTemplateEdit';
@@ -14,12 +15,19 @@ jest.mock('../../../api/models/WorkflowJobTemplates');
jest.mock('../../../api/models/Labels'); jest.mock('../../../api/models/Labels');
jest.mock('../../../api/models/Organizations'); jest.mock('../../../api/models/Organizations');
jest.mock('../../../api/models/Inventories'); jest.mock('../../../api/models/Inventories');
jest.mock('../../../api/models/ExecutionEnvironments');
const mockTemplate = { const mockTemplate = {
id: 6, id: 6,
name: 'Foo', name: 'Foo',
description: 'Foo description', description: 'Foo description',
summary_fields: { summary_fields: {
execution_environment: {
id: 1,
name: 'Default EE',
description: '',
image: 'quay.io/ansible/awx-ee',
},
inventory: { id: 1, name: 'Inventory 1' }, inventory: { id: 1, name: 'Inventory 1' },
organization: { id: 1, name: 'Organization 1' }, organization: { id: 1, name: 'Organization 1' },
labels: { labels: {
@@ -32,7 +40,18 @@ const mockTemplate = {
scm_branch: 'devel', scm_branch: 'devel',
limit: '5000', limit: '5000',
variables: '---', variables: '---',
execution_environment: 1,
}; };
const mockExecutionEnvironment = [
{
id: 1,
name: 'Default EE',
description: '',
image: 'quay.io/ansible/awx-ee',
},
];
describe('<WorkflowJobTemplateEdit/>', () => { describe('<WorkflowJobTemplateEdit/>', () => {
let wrapper; let wrapper;
let history; let history;
@@ -48,6 +67,9 @@ describe('<WorkflowJobTemplateEdit/>', () => {
}, },
}); });
OrganizationsAPI.read.mockResolvedValue({ results: [{ id: 1 }] }); OrganizationsAPI.read.mockResolvedValue({ results: [{ id: 1 }] });
ExecutionEnvironmentsAPI.read.mockResolvedValue({
data: mockExecutionEnvironment,
});
await act(async () => { await act(async () => {
history = createMemoryHistory({ history = createMemoryHistory({
@@ -100,6 +122,7 @@ describe('<WorkflowJobTemplateEdit/>', () => {
.find('LabelSelect') .find('LabelSelect')
.find('SelectToggle') .find('SelectToggle')
.simulate('click'); .simulate('click');
wrapper.find('ExecutionEnvironmentLookup').invoke('onChange')(null);
}); });
wrapper.update(); wrapper.update();
@@ -142,6 +165,7 @@ describe('<WorkflowJobTemplateEdit/>', () => {
ask_limit_on_launch: false, ask_limit_on_launch: false,
ask_scm_branch_on_launch: false, ask_scm_branch_on_launch: false,
ask_variables_on_launch: false, ask_variables_on_launch: false,
execution_environment: null,
}); });
wrapper.update(); wrapper.update();
await expect(WorkflowJobTemplatesAPI.disassociateLabel).toBeCalledWith(6, { await expect(WorkflowJobTemplatesAPI.disassociateLabel).toBeCalledWith(6, {

View File

@@ -94,6 +94,7 @@ const mockJobTemplate = {
}, },
related: { webhook_receiver: '' }, related: { webhook_receiver: '' },
inventory: 1, inventory: 1,
project: 5,
}; };
describe('NodeModal', () => { describe('NodeModal', () => {

View File

@@ -3,6 +3,7 @@ import { useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { func, shape } from 'prop-types'; import { func, shape } from 'prop-types';
import { Tooltip } from '@patternfly/react-core';
import { JobTemplatesAPI } from '../../../../../../api'; import { JobTemplatesAPI } from '../../../../../../api';
import { getQSConfig, parseQueryString } from '../../../../../../util/qs'; import { getQSConfig, parseQueryString } from '../../../../../../util/qs';
import useRequest from '../../../../../../util/useRequest'; import useRequest from '../../../../../../util/useRequest';
@@ -56,26 +57,56 @@ function JobTemplatesList({ i18n, nodeResource, onUpdateNodeResource }) {
fetchJobTemplates(); fetchJobTemplates();
}, [fetchJobTemplates]); }, [fetchJobTemplates]);
const onSelectRow = row => {
if (
row.project &&
row.project !== null &&
((row.inventory && row.inventory !== null) || row.ask_inventory_on_launch)
) {
onUpdateNodeResource(row);
}
};
return ( return (
<PaginatedDataList <PaginatedDataList
contentError={error} contentError={error}
hasContentLoading={isLoading} hasContentLoading={isLoading}
itemCount={count} itemCount={count}
items={jobTemplates} items={jobTemplates}
onRowClick={row => onUpdateNodeResource(row)} onRowClick={row => onSelectRow(row)}
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
renderItem={item => ( renderItem={item => {
<CheckboxListItem const isDisabled =
isSelected={!!(nodeResource && nodeResource.id === item.id)} !item.project ||
itemId={item.id} item.project === null ||
key={item.id} ((!item.inventory || item.inventory === null) &&
name={item.name} !item.ask_inventory_on_launch);
label={item.name} const listItem = (
onSelect={() => onUpdateNodeResource(item)} <CheckboxListItem
onDeselect={() => onUpdateNodeResource(null)} isDisabled={isDisabled}
isRadio isSelected={!!(nodeResource && nodeResource.id === item.id)}
/> itemId={item.id}
)} key={`${item.id}-listItem`}
name={item.name}
label={item.name}
onSelect={() => onSelectRow(item)}
onDeselect={() => onUpdateNodeResource(null)}
isRadio
/>
);
return isDisabled ? (
<Tooltip
key={`${item.id}-tooltip`}
content={i18n._(
t`Job Templates with a missing inventory or project cannot be selected when creating or editing nodes`
)}
>
{listItem}
</Tooltip>
) : (
listItem
);
}}
renderToolbar={props => <DataListToolbar {...props} fillWidth />} renderToolbar={props => <DataListToolbar {...props} fillWidth />}
showPageSizeOptions={false} showPageSizeOptions={false}
toolbarSearchColumns={[ toolbarSearchColumns={[

View File

@@ -16,6 +16,7 @@ const onUpdateNodeResource = jest.fn();
describe('JobTemplatesList', () => { describe('JobTemplatesList', () => {
let wrapper; let wrapper;
afterEach(() => { afterEach(() => {
jest.clearAllMocks();
wrapper.unmount(); wrapper.unmount();
}); });
test('Row selected when nodeResource id matches row id and clicking new row makes expected callback', async () => { test('Row selected when nodeResource id matches row id and clicking new row makes expected callback', async () => {
@@ -28,12 +29,16 @@ describe('JobTemplatesList', () => {
name: 'Test Job Template', name: 'Test Job Template',
type: 'job_template', type: 'job_template',
url: '/api/v2/job_templates/1', url: '/api/v2/job_templates/1',
inventory: 1,
project: 2,
}, },
{ {
id: 2, id: 2,
name: 'Test Job Template 2', name: 'Test Job Template 2',
type: 'job_template', type: 'job_template',
url: '/api/v2/job_templates/2', url: '/api/v2/job_templates/2',
inventory: 1,
project: 2,
}, },
], ],
}, },
@@ -60,10 +65,18 @@ describe('JobTemplatesList', () => {
wrapper.find('CheckboxListItem[name="Test Job Template"]').props() wrapper.find('CheckboxListItem[name="Test Job Template"]').props()
.isSelected .isSelected
).toBe(true); ).toBe(true);
expect(
wrapper.find('CheckboxListItem[name="Test Job Template"]').props()
.isDisabled
).toBe(false);
expect( expect(
wrapper.find('CheckboxListItem[name="Test Job Template 2"]').props() wrapper.find('CheckboxListItem[name="Test Job Template 2"]').props()
.isSelected .isSelected
).toBe(false); ).toBe(false);
expect(
wrapper.find('CheckboxListItem[name="Test Job Template 2"]').props()
.isDisabled
).toBe(false);
wrapper wrapper
.find('CheckboxListItem[name="Test Job Template 2"]') .find('CheckboxListItem[name="Test Job Template 2"]')
.simulate('click'); .simulate('click');
@@ -72,8 +85,75 @@ describe('JobTemplatesList', () => {
name: 'Test Job Template 2', name: 'Test Job Template 2',
type: 'job_template', type: 'job_template',
url: '/api/v2/job_templates/2', url: '/api/v2/job_templates/2',
inventory: 1,
project: 2,
}); });
}); });
test('Row disabled when job template missing inventory or project', async () => {
JobTemplatesAPI.read.mockResolvedValueOnce({
data: {
count: 2,
results: [
{
id: 1,
name: 'Test Job Template',
type: 'job_template',
url: '/api/v2/job_templates/1',
inventory: 1,
project: null,
ask_inventory_on_launch: false,
},
{
id: 2,
name: 'Test Job Template 2',
type: 'job_template',
url: '/api/v2/job_templates/2',
inventory: null,
project: 2,
ask_inventory_on_launch: false,
},
],
},
});
JobTemplatesAPI.readOptions.mockResolvedValue({
data: {
actions: {
GET: {},
POST: {},
},
related_search_fields: [],
},
});
await act(async () => {
wrapper = mountWithContexts(
<JobTemplatesList
nodeResource={nodeResource}
onUpdateNodeResource={onUpdateNodeResource}
/>
);
});
wrapper.update();
expect(
wrapper.find('CheckboxListItem[name="Test Job Template"]').props()
.isSelected
).toBe(true);
expect(
wrapper.find('CheckboxListItem[name="Test Job Template"]').props()
.isDisabled
).toBe(true);
expect(
wrapper.find('CheckboxListItem[name="Test Job Template 2"]').props()
.isSelected
).toBe(false);
expect(
wrapper.find('CheckboxListItem[name="Test Job Template 2"]').props()
.isDisabled
).toBe(true);
wrapper
.find('CheckboxListItem[name="Test Job Template 2"]')
.simulate('click');
expect(onUpdateNodeResource).not.toHaveBeenCalled();
});
test('Error shown when read() request errors', async () => { test('Error shown when read() request errors', async () => {
JobTemplatesAPI.read.mockRejectedValue(new Error()); JobTemplatesAPI.read.mockRejectedValue(new Error());
JobTemplatesAPI.readOptions.mockResolvedValue({ JobTemplatesAPI.readOptions.mockResolvedValue({

View File

@@ -64,10 +64,10 @@ const getAggregatedCredentials = (
templateDefaultCred.credential_type === overrideCred.credential_type templateDefaultCred.credential_type === overrideCred.credential_type
) { ) {
if ( if (
(!templateDefaultCred.vault_id && !overrideCred.inputs.vault_id) || (!templateDefaultCred.vault_id && !overrideCred.inputs?.vault_id) ||
(templateDefaultCred.vault_id && (templateDefaultCred.vault_id &&
overrideCred.inputs.vault_id && overrideCred.inputs?.vault_id &&
templateDefaultCred.vault_id === overrideCred.inputs.vault_id) templateDefaultCred.vault_id === overrideCred.inputs?.vault_id)
) { ) {
credentialHasOverride = true; credentialHasOverride = true;
} }
@@ -405,16 +405,7 @@ function Visualizer({ template, i18n }) {
failure_nodes: [], failure_nodes: [],
always_nodes: [], always_nodes: [],
}; };
if (node.promptValues?.removedCredentials?.length > 0) {
node.promptValues.removedCredentials.forEach(cred => {
disassociateCredentialRequests.push(
WorkflowJobTemplateNodesAPI.disassociateCredentials(
data.id,
cred.id
)
);
});
}
if (node.promptValues?.addedCredentials?.length > 0) { if (node.promptValues?.addedCredentials?.length > 0) {
node.promptValues.addedCredentials.forEach(cred => { node.promptValues.addedCredentials.forEach(cred => {
associateCredentialRequests.push( associateCredentialRequests.push(
@@ -583,8 +574,9 @@ function Visualizer({ template, i18n }) {
<AlertModal <AlertModal
isOpen isOpen
variant="error" variant="error"
title={i18n._(t`Error!`)} title={i18n._(t`Error saving the workflow!`)}
onClose={dismissNodeRequestError} onClose={dismissNodeRequestError}
aria-label={i18n._(t`Error saving the workflow!`)}
> >
{i18n._(t`There was an error saving the workflow.`)} {i18n._(t`There was an error saving the workflow.`)}
<ErrorDetail error={nodeRequestError} /> <ErrorDetail error={nodeRequestError} />

View File

@@ -2,13 +2,37 @@ import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { import {
WorkflowApprovalTemplatesAPI,
WorkflowJobTemplateNodesAPI, WorkflowJobTemplateNodesAPI,
WorkflowJobTemplatesAPI, WorkflowJobTemplatesAPI,
} from '../../../api'; } from '../../../api';
import Visualizer from './Visualizer'; import Visualizer from './Visualizer';
import workflowReducer from '../../../components/Workflow/workflowReducer';
jest.mock('../../../components/Workflow/workflowReducer');
const realWorkflowReducer = jest.requireActual(
'../../../components/Workflow/workflowReducer'
).default;
jest.mock('../../../api'); jest.mock('../../../api');
const startNode = {
id: 1,
fullUnifiedJobTemplate: {
name: 'START',
},
};
const defaultLinks = [
{
linkType: 'always',
source: { id: 1 },
target: { id: 2 },
},
];
const template = { const template = {
id: 1, id: 1,
name: 'Foo WFJT', name: 'Foo WFJT',
@@ -117,7 +141,6 @@ describe('Visualizer', () => {
}); });
afterAll(() => { afterAll(() => {
jest.clearAllMocks();
wrapper.unmount(); wrapper.unmount();
delete window.SVGElement.prototype.getBBox; delete window.SVGElement.prototype.getBBox;
delete window.SVGElement.prototype.getBoundingClientRect; delete window.SVGElement.prototype.getBoundingClientRect;
@@ -125,6 +148,12 @@ describe('Visualizer', () => {
delete window.SVGElement.prototype.width; delete window.SVGElement.prototype.width;
}); });
beforeEach(() => {
jest.clearAllMocks();
jest.resetModules();
workflowReducer.mockImplementation(realWorkflowReducer);
});
test('Renders successfully', async () => { test('Renders successfully', async () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
@@ -185,7 +214,7 @@ describe('Visualizer', () => {
wrapper.find('button#link-confirm').simulate('click'); wrapper.find('button#link-confirm').simulate('click');
expect(wrapper.find('LinkEditModal').length).toBe(0); expect(wrapper.find('LinkEditModal').length).toBe(0);
await act(async () => { await act(async () => {
wrapper.find('button[aria-label="Save"]').simulate('click'); wrapper.find('Button#visualizer-save').simulate('click');
}); });
expect( expect(
WorkflowJobTemplateNodesAPI.disassociateAlwaysNode WorkflowJobTemplateNodesAPI.disassociateAlwaysNode
@@ -219,6 +248,633 @@ describe('Visualizer', () => {
).toBe(true); ).toBe(true);
}); });
test('Error shown when saving fails due to node add error', async () => {
workflowReducer.mockImplementation(state => {
const newState = {
...state,
isLoading: false,
};
if (newState.nodes.length === 0) {
newState.nodes = [
startNode,
{
id: 2,
fullUnifiedJobTemplate: {
id: 3,
name: 'PING',
type: 'job_template',
},
},
];
newState.links = defaultLinks;
}
return newState;
});
WorkflowJobTemplatesAPI.readNodes.mockResolvedValue({
data: {
count: 0,
results: [],
},
});
WorkflowJobTemplatesAPI.createNode.mockRejectedValue(new Error());
await act(async () => {
wrapper = mountWithContexts(
<svg>
<Visualizer template={template} />
</svg>
);
});
wrapper.update();
expect(
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
).toBe(0);
await act(async () => {
wrapper.find('Button#visualizer-save').simulate('click');
});
wrapper.update();
expect(WorkflowJobTemplatesAPI.createNode).toHaveBeenCalledTimes(1);
expect(
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
).toBe(1);
});
test('Error shown when saving fails due to node edit error', async () => {
workflowReducer.mockImplementation(state => {
const newState = {
...state,
isLoading: false,
};
if (newState.nodes.length === 0) {
newState.nodes = [
startNode,
{
id: 2,
isEdited: true,
fullUnifiedJobTemplate: {
id: 3,
name: 'PING',
type: 'job_template',
},
originalNodeObject: {
id: 9000,
},
},
];
newState.links = defaultLinks;
}
return newState;
});
WorkflowJobTemplatesAPI.readNodes.mockResolvedValue({
data: {
count: 0,
results: [],
},
});
WorkflowJobTemplateNodesAPI.replace.mockRejectedValue(new Error());
await act(async () => {
wrapper = mountWithContexts(
<svg>
<Visualizer template={template} />
</svg>
);
});
wrapper.update();
expect(
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
).toBe(0);
await act(async () => {
wrapper.find('Button#visualizer-save').simulate('click');
});
wrapper.update();
expect(WorkflowJobTemplateNodesAPI.replace).toHaveBeenCalledTimes(1);
expect(
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
).toBe(1);
});
test('Error shown when saving fails due to approval template add error', async () => {
workflowReducer.mockImplementation(state => {
const newState = {
...state,
isLoading: false,
};
if (newState.nodes.length === 0) {
newState.nodes = [
startNode,
{
id: 2,
fullUnifiedJobTemplate: {
id: 3,
name: 'Approval',
timeout: 1000,
type: 'workflow_approval_template',
},
},
];
newState.links = defaultLinks;
}
return newState;
});
WorkflowJobTemplatesAPI.readNodes.mockResolvedValue({
data: {
count: 0,
results: [],
},
});
WorkflowJobTemplatesAPI.createNode.mockResolvedValue({
data: {
id: 9001,
},
});
WorkflowJobTemplateNodesAPI.createApprovalTemplate.mockRejectedValue(
new Error()
);
await act(async () => {
wrapper = mountWithContexts(
<svg>
<Visualizer template={template} />
</svg>
);
});
wrapper.update();
expect(
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
).toBe(0);
await act(async () => {
wrapper.find('Button#visualizer-save').simulate('click');
});
wrapper.update();
expect(WorkflowJobTemplatesAPI.createNode).toHaveBeenCalledTimes(1);
expect(
WorkflowJobTemplateNodesAPI.createApprovalTemplate
).toHaveBeenCalledTimes(1);
expect(
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
).toBe(1);
});
test('Error shown when saving fails due to approval template edit error', async () => {
workflowReducer.mockImplementation(state => {
const newState = {
...state,
isLoading: false,
};
if (newState.nodes.length === 0) {
newState.nodes = [
startNode,
{
id: 2,
isEdited: true,
fullUnifiedJobTemplate: {
id: 3,
name: 'Approval',
timeout: 1000,
type: 'workflow_approval_template',
},
originalNodeObject: {
id: 9000,
summary_fields: {
unified_job_template: {
unified_job_type: 'workflow_approval',
},
},
},
},
];
newState.links = defaultLinks;
}
return newState;
});
WorkflowJobTemplatesAPI.readNodes.mockResolvedValue({
data: {
count: 0,
results: [],
},
});
WorkflowApprovalTemplatesAPI.update.mockRejectedValue(new Error());
await act(async () => {
wrapper = mountWithContexts(
<svg>
<Visualizer template={template} />
</svg>
);
});
wrapper.update();
expect(
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
).toBe(0);
await act(async () => {
wrapper.find('Button#visualizer-save').simulate('click');
});
wrapper.update();
expect(WorkflowApprovalTemplatesAPI.update).toHaveBeenCalledTimes(1);
expect(
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
).toBe(1);
});
test('Error shown when saving fails due to node disassociate failure', async () => {
workflowReducer.mockImplementation(state => {
const newState = {
...state,
isLoading: false,
};
if (newState.nodes.length === 0) {
newState.nodes = [
startNode,
{
id: 2,
fullUnifiedJobTemplate: {
id: 3,
name: 'Approval',
timeout: 1000,
type: 'workflow_approval_template',
},
originalNodeObject: {
id: 9000,
summary_fields: {
unified_job_template: {
unified_job_type: 'workflow_approval',
},
},
success_nodes: [],
failure_nodes: [3],
always_nodes: [],
},
success_nodes: [3],
failure_nodes: [],
always_nodes: [],
},
{
id: 3,
fullUnifiedJobTemplate: {
id: 4,
name: 'Approval 2',
timeout: 1000,
type: 'workflow_approval_template',
},
originalNodeObject: {
id: 9001,
summary_fields: {
unified_job_template: {
unified_job_type: 'workflow_approval',
},
},
success_nodes: [],
failure_nodes: [],
always_nodes: [],
},
success_nodes: [],
failure_nodes: [],
always_nodes: [],
},
];
newState.links = [
{
linkType: 'always',
source: { id: 1 },
target: { id: 2 },
},
{
linkType: 'success',
source: { id: 2 },
target: { id: 3 },
},
];
}
return newState;
});
WorkflowJobTemplatesAPI.readNodes.mockResolvedValue({
data: {
count: 0,
results: [],
},
});
WorkflowJobTemplateNodesAPI.disassociateFailuresNode.mockRejectedValue(
new Error()
);
await act(async () => {
wrapper = mountWithContexts(
<svg>
<Visualizer template={template} />
</svg>
);
});
wrapper.update();
expect(
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
).toBe(0);
await act(async () => {
wrapper.find('Button#visualizer-save').simulate('click');
});
wrapper.update();
expect(
WorkflowJobTemplateNodesAPI.disassociateFailuresNode
).toHaveBeenCalledTimes(1);
expect(
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
).toBe(1);
});
test('Error shown when saving fails due to node associate failure', async () => {
workflowReducer.mockImplementation(state => {
const newState = {
...state,
isLoading: false,
};
if (newState.nodes.length === 0) {
newState.nodes = [
startNode,
{
id: 2,
fullUnifiedJobTemplate: {
id: 3,
name: 'Approval',
timeout: 1000,
type: 'workflow_approval_template',
},
originalNodeObject: {
id: 9000,
summary_fields: {
unified_job_template: {
unified_job_type: 'workflow_approval',
},
},
success_nodes: [],
failure_nodes: [3],
always_nodes: [],
},
success_nodes: [3],
failure_nodes: [],
always_nodes: [],
},
{
id: 3,
fullUnifiedJobTemplate: {
id: 4,
name: 'Approval 2',
timeout: 1000,
type: 'workflow_approval_template',
},
originalNodeObject: {
id: 9001,
summary_fields: {
unified_job_template: {
unified_job_type: 'workflow_approval',
},
},
success_nodes: [],
failure_nodes: [],
always_nodes: [],
},
success_nodes: [],
failure_nodes: [],
always_nodes: [],
},
];
newState.links = [
{
linkType: 'always',
source: { id: 1 },
target: { id: 2 },
},
{
linkType: 'success',
source: { id: 2 },
target: { id: 3 },
},
];
}
return newState;
});
WorkflowJobTemplatesAPI.readNodes.mockResolvedValue({
data: {
count: 0,
results: [],
},
});
WorkflowJobTemplateNodesAPI.disassociateFailuresNode.mockResolvedValue();
WorkflowJobTemplateNodesAPI.associateSuccessNode.mockRejectedValue(
new Error()
);
await act(async () => {
wrapper = mountWithContexts(
<svg>
<Visualizer template={template} />
</svg>
);
});
wrapper.update();
expect(
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
).toBe(0);
await act(async () => {
wrapper.find('Button#visualizer-save').simulate('click');
});
wrapper.update();
expect(
WorkflowJobTemplateNodesAPI.associateSuccessNode
).toHaveBeenCalledTimes(1);
expect(
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
).toBe(1);
});
test('Error shown when saving fails due to credential disassociate failure', async () => {
workflowReducer.mockImplementation(state => {
const newState = {
...state,
isLoading: false,
};
if (newState.nodes.length === 0) {
newState.nodes = [
startNode,
{
id: 2,
isEdited: true,
fullUnifiedJobTemplate: {
id: 3,
name: 'Ping',
type: 'job_template',
},
originalNodeObject: {
id: 9000,
success_nodes: [],
failure_nodes: [],
always_nodes: [],
},
originalNodeCredentials: [
{
id: 456,
credential_type: 1,
},
],
promptValues: {
credentials: [
{
id: 123,
credential_type: 1,
},
],
},
launchConfig: {
defaults: {
credentials: [
{
id: 456,
credential_type: 1,
},
],
},
},
success_nodes: [],
failure_nodes: [],
always_nodes: [],
},
];
newState.links = defaultLinks;
}
return newState;
});
WorkflowJobTemplatesAPI.readNodes.mockResolvedValue({
data: {
count: 0,
results: [],
},
});
WorkflowJobTemplateNodesAPI.replace.mockResolvedValue();
WorkflowJobTemplateNodesAPI.disassociateCredentials.mockRejectedValue(
new Error()
);
await act(async () => {
wrapper = mountWithContexts(
<svg>
<Visualizer template={template} />
</svg>
);
});
wrapper.update();
expect(
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
).toBe(0);
await act(async () => {
wrapper.find('Button#visualizer-save').simulate('click');
});
wrapper.update();
expect(
WorkflowJobTemplateNodesAPI.disassociateCredentials
).toHaveBeenCalledTimes(1);
expect(
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
).toBe(1);
});
test('Error shown when saving fails due to credential associate failure', async () => {
workflowReducer.mockImplementation(state => {
const newState = {
...state,
isLoading: false,
};
if (newState.nodes.length === 0) {
newState.nodes = [
startNode,
{
id: 2,
isEdited: true,
fullUnifiedJobTemplate: {
id: 3,
name: 'Ping',
type: 'job_template',
},
originalNodeObject: {
id: 9000,
success_nodes: [],
failure_nodes: [],
always_nodes: [],
},
originalNodeCredentials: [
{
id: 456,
credential_type: 1,
},
],
promptValues: {
credentials: [
{
id: 123,
credential_type: 1,
},
],
},
launchConfig: {
defaults: {
credentials: [
{
id: 456,
credential_type: 1,
},
],
},
},
success_nodes: [],
failure_nodes: [],
always_nodes: [],
},
];
newState.links = defaultLinks;
}
return newState;
});
WorkflowJobTemplatesAPI.readNodes.mockResolvedValue({
data: {
count: 0,
results: [],
},
});
WorkflowJobTemplateNodesAPI.replace.mockResolvedValue();
WorkflowJobTemplateNodesAPI.disassociateCredentials.mockResolvedValue();
WorkflowJobTemplateNodesAPI.associateCredentials.mockRejectedValue(
new Error()
);
await act(async () => {
wrapper = mountWithContexts(
<svg>
<Visualizer template={template} />
</svg>
);
});
wrapper.update();
expect(
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
).toBe(0);
await act(async () => {
wrapper.find('Button#visualizer-save').simulate('click');
});
wrapper.update();
expect(
WorkflowJobTemplateNodesAPI.associateCredentials
).toHaveBeenCalledTimes(1);
expect(
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
).toBe(1);
});
test('Error shown to user when error thrown fetching workflow nodes', async () => { test('Error shown to user when error thrown fetching workflow nodes', async () => {
WorkflowJobTemplatesAPI.readNodes.mockRejectedValue(new Error()); WorkflowJobTemplatesAPI.readNodes.mockRejectedValue(new Error());
await act(async () => { await act(async () => {

View File

@@ -5,13 +5,13 @@ Django Debug Toolbar (DDT)
---------------- ----------------
This is a useful tool for examining SQL queries, performance, headers, requests, signals, cache, logging, and more. This is a useful tool for examining SQL queries, performance, headers, requests, signals, cache, logging, and more.
To enable DDT, you need to set your `INTERNAL_IPS` to the IP address of your load balancer. This can be overriden in `local_settings`. To enable DDT, you need to set your `INTERNAL_IPS` to the IP address of your load balancer. This can be overridden by creating a new settings file beginning with `local_` in `awx/settings/` (e.g. `local_overrides.py`).
This IP address can be found by making a GET to any page on the browsable API and looking for a like this in the standard output: This IP address can be found by making a GET to any page on the browsable API and looking for a like this in the standard output:
``` ```
awx_1 | 14:42:08 uwsgi.1 | 172.18.0.1 GET /api/v2/tokens/ - HTTP/1.1 200 awx_1 | 14:42:08 uwsgi.1 | 172.18.0.1 GET /api/v2/tokens/ - HTTP/1.1 200
``` ```
Allow this IP address by adding it to the `INTERNAL_IPS` variable in `local_settings`, then navigate to the API and you should see DDT on the Allow this IP address by adding it to the `INTERNAL_IPS` variable in your new override local settings file, then navigate to the API and you should see DDT on the
right side. If you don't see it, make sure to set `DEBUG=True`. right side. If you don't see it, make sure to set `DEBUG=True`.
> Note that enabling DDT is detrimental to the performance of AWX and adds overhead to every API request. It is > Note that enabling DDT is detrimental to the performance of AWX and adds overhead to every API request. It is
recommended to keep this turned off when you are not using it. recommended to keep this turned off when you are not using it.

View File

@@ -1,5 +1,5 @@
--- ---
- log-level: debug - log-level: info
- control-service: - control-service:
service: control service: control

View File

@@ -1,5 +1,5 @@
--- ---
- log-level: debug - log-level: info
- control-service: - control-service:
service: control service: control

View File

@@ -1,5 +1,5 @@
--- ---
- log-level: debug - log-level: info
- control-service: - control-service:
service: control service: control

View File

@@ -1,5 +1,23 @@
# Docker Compose for Development # Docker Compose for Development
## Getting started
### Clone the repo
If you have not already done so, you will need to clone, or create a local copy, of the [AWX repo](https://github.com/ansible/awx). We generally recommend that you view the releases page:
https://github.com/ansible/awx/releases/latest
...and clone the latest stable tag, e.g.,
`git clone -b x.y.z https://github.com/ansible/awx.git`
Please note that deploying from `HEAD` (or the latest commit) is **not** stable, and that if you want to do this, you should proceed at your own risk.
For more on how to clone the repo, view [git clone help](https://git-scm.com/docs/git-clone).
Once you have a local copy, run the commands in the following sections from the root of the project tree.
## Overview ## Overview
Here are the main make targets: Here are the main make targets:

View File

@@ -11,8 +11,6 @@
############################################################################### ###############################################################################
# MISC PROJECT SETTINGS # MISC PROJECT SETTINGS
############################################################################### ###############################################################################
import os
import sys
# Enable the following lines and install the browser extension to use Django debug toolbar # Enable the following lines and install the browser extension to use Django debug toolbar
# if your deployment method is not VMWare of Docker-for-Mac you may # if your deployment method is not VMWare of Docker-for-Mac you may
@@ -20,21 +18,6 @@ import sys
# INTERNAL_IPS = ('172.19.0.1', '172.18.0.1', '192.168.100.1') # INTERNAL_IPS = ('172.19.0.1', '172.18.0.1', '192.168.100.1')
# ALLOWED_HOSTS = ['*'] # ALLOWED_HOSTS = ['*']
# Use SQLite for unit tests instead of PostgreSQL. If the lines below are
# commented out, Django will create the test_awx-dev database in PostgreSQL to
# run unit tests.
if "pytest" in sys.modules:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'awx.sqlite3'),
'TEST': {
# Test database cannot be :memory: for inventory tests.
'NAME': os.path.join(BASE_DIR, 'awx_test.sqlite3'),
},
}
}
# Location for cross-development of inventory plugins # Location for cross-development of inventory plugins
AWX_ANSIBLE_COLLECTIONS_PATHS = '/var/lib/awx/vendor/awx_ansible_collections' AWX_ANSIBLE_COLLECTIONS_PATHS = '/var/lib/awx/vendor/awx_ansible_collections'

View File

@@ -1,6 +1,6 @@
# Migrating Data from Local Docker # Migrating Data from Local Docker
If you are migrating data from a Local Docker installation (17.0.1 and prior), you can If you are migrating data from a Local Docker installation (17.0.1 and prior) to AWX 18.0 or higher, you can
migrate your data to the development environment via the migrate.yml playbook. migrate your data to the development environment via the migrate.yml playbook.
> Note: This will also convert your postgresql bind-mount into a docker volume. > Note: This will also convert your postgresql bind-mount into a docker volume.

View File

@@ -1,5 +1,5 @@
--- ---
- log-level: debug - log-level: info
- control-service: - control-service:
service: control service: control