mirror of
https://github.com/ansible/awx.git
synced 2026-01-24 07:51:23 -03:30
commit
10c357d0f1
@ -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 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 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)
|
||||
- Fixed pgdocker directory permissions issue with Local Docker installer: https://github.com/ansible/awx/pull/9152
|
||||
|
||||
@ -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.
|
||||
|
||||
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
|
||||
|
||||
If you don't have an existing OpenShift or Kubernetes cluster, minikube is a fast and easy way to get up and running.
|
||||
|
||||
@ -21,13 +21,6 @@ from split_settings.tools import optional, include
|
||||
# Load default settings.
|
||||
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
|
||||
NOTEBOOK_ARGUMENTS = [
|
||||
@ -53,6 +46,10 @@ LOGGING['loggers']['awx.isolated.manager.playbooks']['propagate'] = True # noqa
|
||||
|
||||
# celery is annoyingly loud when docker containers start
|
||||
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 = ['*']
|
||||
|
||||
@ -166,6 +163,27 @@ except ImportError:
|
||||
traceback.print_exc()
|
||||
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
|
||||
'isolated_heartbeat': {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled from 'styled-components';
|
||||
import {
|
||||
DataListItem,
|
||||
DataListItemRow,
|
||||
@ -9,6 +10,14 @@ import {
|
||||
} from '@patternfly/react-core';
|
||||
import DataListCell from '../DataListCell';
|
||||
|
||||
const Label = styled.label`
|
||||
${({ isDisabled }) =>
|
||||
isDisabled &&
|
||||
`
|
||||
opacity: 0.5;
|
||||
`}
|
||||
`;
|
||||
|
||||
const CheckboxListItem = ({
|
||||
isDisabled = false,
|
||||
isRadio = false,
|
||||
@ -32,7 +41,7 @@ const CheckboxListItem = ({
|
||||
aria-label={`check-action-item-${itemId}`}
|
||||
aria-labelledby={`check-action-item-${itemId}`}
|
||||
checked={isSelected}
|
||||
disabled={isDisabled}
|
||||
isDisabled={isDisabled}
|
||||
id={`selected-${itemId}`}
|
||||
isChecked={isSelected}
|
||||
name={name}
|
||||
@ -42,13 +51,14 @@ const CheckboxListItem = ({
|
||||
<DataListItemCells
|
||||
dataListCells={[
|
||||
<DataListCell key="name">
|
||||
<label
|
||||
<Label
|
||||
id={`check-action-item-${itemId}`}
|
||||
htmlFor={`selected-${itemId}`}
|
||||
className="check-action-item"
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
<b>{label}</b>
|
||||
</label>
|
||||
</Label>
|
||||
</DataListCell>,
|
||||
]}
|
||||
/>
|
||||
|
||||
@ -17,6 +17,7 @@ function CopyButton({
|
||||
onCopyFinish,
|
||||
errorMessage,
|
||||
i18n,
|
||||
ouiaId,
|
||||
}) {
|
||||
const { isLoading, error: copyError, request: copyItemToAPI } = useRequest(
|
||||
copyItem
|
||||
@ -35,6 +36,7 @@ function CopyButton({
|
||||
<>
|
||||
<Button
|
||||
id={id}
|
||||
ouiaId={ouiaId}
|
||||
isDisabled={isLoading || isDisabled}
|
||||
aria-label={i18n._(t`Copy`)}
|
||||
variant="plain"
|
||||
@ -62,10 +64,12 @@ CopyButton.propTypes = {
|
||||
onCopyFinish: PropTypes.func.isRequired,
|
||||
errorMessage: PropTypes.string.isRequired,
|
||||
isDisabled: PropTypes.bool,
|
||||
ouiaId: PropTypes.string,
|
||||
};
|
||||
|
||||
CopyButton.defaultProps = {
|
||||
isDisabled: false,
|
||||
ouiaId: null,
|
||||
};
|
||||
|
||||
export default withI18n()(CopyButton);
|
||||
|
||||
@ -223,7 +223,7 @@ function ScheduleForm({
|
||||
const {
|
||||
request: loadScheduleData,
|
||||
error: contentError,
|
||||
contentLoading,
|
||||
isLoading: contentLoading,
|
||||
result: { zoneOptions, credentials },
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
|
||||
@ -447,6 +447,7 @@ describe('<ScheduleForm />', () => {
|
||||
/>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
afterAll(() => {
|
||||
wrapper.unmount();
|
||||
|
||||
@ -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 { withI18n } from '@lingui/react';
|
||||
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 PaginatedTable, {
|
||||
HeaderRow,
|
||||
@ -29,6 +35,7 @@ const QS_CONFIG = getQSConfig('notification-templates', {
|
||||
function NotificationTemplatesList({ i18n }) {
|
||||
const location = useLocation();
|
||||
const match = useRouteMatch();
|
||||
const [testToasts, setTestToasts] = useState([]);
|
||||
|
||||
const addUrl = `${match.url}/add`;
|
||||
|
||||
@ -102,6 +109,16 @@ function NotificationTemplatesList({ i18n }) {
|
||||
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;
|
||||
|
||||
return (
|
||||
@ -185,6 +202,7 @@ function NotificationTemplatesList({ i18n }) {
|
||||
}
|
||||
renderRow={(template, index) => (
|
||||
<NotificationTemplateListItem
|
||||
onAddToast={addTestToast}
|
||||
key={template.id}
|
||||
fetchTemplates={fetchTemplates}
|
||||
template={template}
|
||||
@ -209,6 +227,39 @@ function NotificationTemplatesList({ i18n }) {
|
||||
{i18n._(t`Failed to delete one or more notification template.`)}
|
||||
<ErrorDetail error={deletionError} />
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,11 +1,17 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { OrganizationsAPI } from '../../../api';
|
||||
import {
|
||||
NotificationsAPI,
|
||||
NotificationTemplatesAPI,
|
||||
OrganizationsAPI,
|
||||
} from '../../../api';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import NotificationTemplateList from './NotificationTemplateList';
|
||||
|
||||
jest.mock('../../../api');
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
const mockTemplates = {
|
||||
data: {
|
||||
count: 3,
|
||||
@ -197,6 +203,43 @@ describe('<NotificationTemplateList />', () => {
|
||||
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 () => {
|
||||
OrganizationsAPI.readOptions.mockResolvedValue({
|
||||
data: {
|
||||
|
||||
@ -11,13 +11,16 @@ import { timeOfDay } from '../../../util/dates';
|
||||
import { NotificationTemplatesAPI, NotificationsAPI } from '../../../api';
|
||||
import StatusLabel from '../../../components/StatusLabel';
|
||||
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';
|
||||
|
||||
const NUM_RETRIES = 25;
|
||||
const RETRY_TIMEOUT = 5000;
|
||||
|
||||
function NotificationTemplateListItem({
|
||||
onAddToast,
|
||||
template,
|
||||
detailUrl,
|
||||
fetchTemplates,
|
||||
@ -66,6 +69,7 @@ function NotificationTemplateListItem({
|
||||
notificationId
|
||||
);
|
||||
if (notification.status !== 'pending') {
|
||||
onAddToast(notification);
|
||||
setStatus(notification.status);
|
||||
return;
|
||||
}
|
||||
@ -76,9 +80,11 @@ function NotificationTemplateListItem({
|
||||
}
|
||||
|
||||
setTimeout(pollForStatusChange, RETRY_TIMEOUT);
|
||||
}, [template.id])
|
||||
}, [template.id, onAddToast])
|
||||
);
|
||||
|
||||
const { error: sendTestError, dismissError } = useDismissableError(error);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
setStatus('error');
|
||||
@ -88,65 +94,81 @@ function NotificationTemplateListItem({
|
||||
const labelId = `template-name-${template.id}`;
|
||||
|
||||
return (
|
||||
<Tr id={`notification-template-row-${template.id}`}>
|
||||
<Td
|
||||
select={{
|
||||
rowIndex,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}}
|
||||
dataLabel={i18n._(t`Selected`)}
|
||||
/>
|
||||
<Td id={labelId} dataLabel={i18n._(t`Name`)}>
|
||||
<Link to={`${detailUrl}`}>
|
||||
<b>{template.name}</b>
|
||||
</Link>
|
||||
</Td>
|
||||
<Td dataLabel={i18n._(t`Status`)}>
|
||||
{status && <StatusLabel status={status} />}
|
||||
</Td>
|
||||
<Td dataLabel={i18n._(t`Type`)}>
|
||||
{NOTIFICATION_TYPES[template.notification_type] ||
|
||||
template.notification_type}
|
||||
</Td>
|
||||
<ActionsTd dataLabel={i18n._(t`Actions`)}>
|
||||
<ActionItem visible tooltip={i18n._(t`Test notification`)}>
|
||||
<Button
|
||||
aria-label={i18n._(t`Test Notification`)}
|
||||
variant="plain"
|
||||
onClick={sendTestNotification}
|
||||
isDisabled={isLoading || status === 'running'}
|
||||
<>
|
||||
<Tr id={`notification-template-row-${template.id}`}>
|
||||
<Td
|
||||
select={{
|
||||
rowIndex,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}}
|
||||
dataLabel={i18n._(t`Selected`)}
|
||||
/>
|
||||
<Td id={labelId} dataLabel={i18n._(t`Name`)}>
|
||||
<Link to={`${detailUrl}`}>
|
||||
<b>{template.name}</b>
|
||||
</Link>
|
||||
</Td>
|
||||
<Td dataLabel={i18n._(t`Status`)}>
|
||||
{status && <StatusLabel status={status} />}
|
||||
</Td>
|
||||
<Td dataLabel={i18n._(t`Type`)}>
|
||||
{NOTIFICATION_TYPES[template.notification_type] ||
|
||||
template.notification_type}
|
||||
</Td>
|
||||
<ActionsTd dataLabel={i18n._(t`Actions`)}>
|
||||
<ActionItem visible tooltip={i18n._(t`Test notification`)}>
|
||||
<Button
|
||||
ouiaId={`notification-test-button-${template.id}`}
|
||||
aria-label={i18n._(t`Test Notification`)}
|
||||
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>
|
||||
</ActionItem>
|
||||
<ActionItem
|
||||
visible={template.summary_fields.user_capabilities.edit}
|
||||
tooltip={i18n._(t`Edit`)}
|
||||
>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit Notification Template`)}
|
||||
variant="plain"
|
||||
component={Link}
|
||||
to={`/notification_templates/${template.id}/edit`}
|
||||
<Button
|
||||
ouiaId={`notification-edit-button-${template.id}`}
|
||||
aria-label={i18n._(t`Edit Notification Template`)}
|
||||
variant="plain"
|
||||
component={Link}
|
||||
to={`/notification_templates/${template.id}/edit`}
|
||||
>
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</ActionItem>
|
||||
<ActionItem
|
||||
visible={template.summary_fields.user_capabilities.copy}
|
||||
tooltip={i18n._(t`Copy Notification Template`)}
|
||||
>
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</ActionItem>
|
||||
<ActionItem
|
||||
visible={template.summary_fields.user_capabilities.copy}
|
||||
tooltip={i18n._(t`Copy Notification Template`)}
|
||||
<CopyButton
|
||||
ouiaId={`notification-copy-button-${template.id}`}
|
||||
copyItem={copyTemplate}
|
||||
isCopyDisabled={isCopyDisabled}
|
||||
onCopyStart={handleCopyStart}
|
||||
onCopyFinish={handleCopyFinish}
|
||||
errorMessage={i18n._(t`Failed to copy template.`)}
|
||||
/>
|
||||
</ActionItem>
|
||||
</ActionsTd>
|
||||
</Tr>
|
||||
{sendTestError && (
|
||||
<AlertModal
|
||||
isOpen
|
||||
variant="error"
|
||||
title={i18n._(t`Error!`)}
|
||||
onClose={dismissError}
|
||||
>
|
||||
<CopyButton
|
||||
copyItem={copyTemplate}
|
||||
isCopyDisabled={isCopyDisabled}
|
||||
onCopyStart={handleCopyStart}
|
||||
onCopyFinish={handleCopyFinish}
|
||||
errorMessage={i18n._(t`Failed to copy template.`)}
|
||||
/>
|
||||
</ActionItem>
|
||||
</ActionsTd>
|
||||
</Tr>
|
||||
{i18n._(t`Failed to send test notification.`)}
|
||||
<ErrorDetail error={sendTestError} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -49,6 +49,7 @@ function JobTemplateEdit({ template }) {
|
||||
webhook_credential,
|
||||
webhook_key,
|
||||
webhook_url,
|
||||
execution_environment,
|
||||
...remainingValues
|
||||
} = values;
|
||||
|
||||
@ -56,11 +57,9 @@ function JobTemplateEdit({ template }) {
|
||||
setIsLoading(true);
|
||||
remainingValues.project = values.project.id;
|
||||
remainingValues.webhook_credential = webhook_credential?.id || null;
|
||||
remainingValues.execution_environment = execution_environment?.id || null;
|
||||
try {
|
||||
await JobTemplatesAPI.update(template.id, {
|
||||
...remainingValues,
|
||||
execution_environment: values.execution_environment?.id,
|
||||
});
|
||||
await JobTemplatesAPI.update(template.id, remainingValues);
|
||||
await Promise.all([
|
||||
submitLabels(labels, template?.organization),
|
||||
submitInstanceGroups(instanceGroups, initialInstanceGroups),
|
||||
|
||||
@ -13,6 +13,7 @@ import {
|
||||
LabelsAPI,
|
||||
ProjectsAPI,
|
||||
InventoriesAPI,
|
||||
ExecutionEnvironmentsAPI,
|
||||
} from '../../../api';
|
||||
import JobTemplateEdit from './JobTemplateEdit';
|
||||
|
||||
@ -49,6 +50,12 @@ const mockJobTemplate = {
|
||||
scm_branch: '',
|
||||
skip_tags: '',
|
||||
summary_fields: {
|
||||
execution_environment: {
|
||||
id: 1,
|
||||
name: 'Default EE',
|
||||
description: '',
|
||||
image: 'quay.io/ansible/awx-ee',
|
||||
},
|
||||
user_capabilities: {
|
||||
edit: true,
|
||||
},
|
||||
@ -81,6 +88,7 @@ const mockJobTemplate = {
|
||||
related: {
|
||||
webhook_receiver: '/api/v2/workflow_job_templates/57/gitlab/',
|
||||
},
|
||||
execution_environment: 1,
|
||||
};
|
||||
|
||||
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({
|
||||
data: mockRelatedCredentials,
|
||||
});
|
||||
@ -197,6 +214,10 @@ CredentialsAPI.read.mockResolvedValue({
|
||||
});
|
||||
CredentialTypesAPI.loadAllTypes.mockResolvedValue([]);
|
||||
|
||||
ExecutionEnvironmentsAPI.read.mockResolvedValue({
|
||||
data: mockExecutionEnvironment,
|
||||
});
|
||||
|
||||
describe('<JobTemplateEdit />', () => {
|
||||
beforeEach(() => {
|
||||
LabelsAPI.read.mockResolvedValue({ data: { results: [] } });
|
||||
@ -266,6 +287,8 @@ describe('<JobTemplateEdit />', () => {
|
||||
id: 1,
|
||||
organization: 1,
|
||||
});
|
||||
|
||||
wrapper.find('ExecutionEnvironmentLookup').invoke('onChange')(null);
|
||||
});
|
||||
wrapper.update();
|
||||
await act(async () => {
|
||||
@ -277,6 +300,7 @@ describe('<JobTemplateEdit />', () => {
|
||||
...mockJobTemplate,
|
||||
project: mockJobTemplate.project,
|
||||
...updatedTemplateData,
|
||||
execution_environment: null,
|
||||
};
|
||||
delete expected.summary_fields;
|
||||
delete expected.id;
|
||||
|
||||
@ -17,11 +17,13 @@ function WorkflowJobTemplateEdit({ template }) {
|
||||
organization,
|
||||
webhook_credential,
|
||||
webhook_key,
|
||||
execution_environment,
|
||||
...templatePayload
|
||||
} = values;
|
||||
templatePayload.inventory = inventory?.id || null;
|
||||
templatePayload.organization = organization?.id || null;
|
||||
templatePayload.webhook_credential = webhook_credential?.id || null;
|
||||
templatePayload.execution_environment = execution_environment?.id || null;
|
||||
|
||||
const formOrgId =
|
||||
organization?.id || inventory?.summary_fields?.organization.id || null;
|
||||
@ -29,10 +31,7 @@ function WorkflowJobTemplateEdit({ template }) {
|
||||
await Promise.all(
|
||||
await submitLabels(labels, formOrgId, template.organization)
|
||||
);
|
||||
await WorkflowJobTemplatesAPI.update(template.id, {
|
||||
...templatePayload,
|
||||
execution_environment: values.execution_environment?.id,
|
||||
});
|
||||
await WorkflowJobTemplatesAPI.update(template.id, templatePayload);
|
||||
history.push(`/templates/workflow_job_template/${template.id}/details`);
|
||||
} catch (err) {
|
||||
setFormSubmitError(err);
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
WorkflowJobTemplatesAPI,
|
||||
OrganizationsAPI,
|
||||
LabelsAPI,
|
||||
ExecutionEnvironmentsAPI,
|
||||
} from '../../../api';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import WorkflowJobTemplateEdit from './WorkflowJobTemplateEdit';
|
||||
@ -14,12 +15,19 @@ jest.mock('../../../api/models/WorkflowJobTemplates');
|
||||
jest.mock('../../../api/models/Labels');
|
||||
jest.mock('../../../api/models/Organizations');
|
||||
jest.mock('../../../api/models/Inventories');
|
||||
jest.mock('../../../api/models/ExecutionEnvironments');
|
||||
|
||||
const mockTemplate = {
|
||||
id: 6,
|
||||
name: 'Foo',
|
||||
description: 'Foo description',
|
||||
summary_fields: {
|
||||
execution_environment: {
|
||||
id: 1,
|
||||
name: 'Default EE',
|
||||
description: '',
|
||||
image: 'quay.io/ansible/awx-ee',
|
||||
},
|
||||
inventory: { id: 1, name: 'Inventory 1' },
|
||||
organization: { id: 1, name: 'Organization 1' },
|
||||
labels: {
|
||||
@ -32,7 +40,18 @@ const mockTemplate = {
|
||||
scm_branch: 'devel',
|
||||
limit: '5000',
|
||||
variables: '---',
|
||||
execution_environment: 1,
|
||||
};
|
||||
|
||||
const mockExecutionEnvironment = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Default EE',
|
||||
description: '',
|
||||
image: 'quay.io/ansible/awx-ee',
|
||||
},
|
||||
];
|
||||
|
||||
describe('<WorkflowJobTemplateEdit/>', () => {
|
||||
let wrapper;
|
||||
let history;
|
||||
@ -48,6 +67,9 @@ describe('<WorkflowJobTemplateEdit/>', () => {
|
||||
},
|
||||
});
|
||||
OrganizationsAPI.read.mockResolvedValue({ results: [{ id: 1 }] });
|
||||
ExecutionEnvironmentsAPI.read.mockResolvedValue({
|
||||
data: mockExecutionEnvironment,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
history = createMemoryHistory({
|
||||
@ -100,6 +122,7 @@ describe('<WorkflowJobTemplateEdit/>', () => {
|
||||
.find('LabelSelect')
|
||||
.find('SelectToggle')
|
||||
.simulate('click');
|
||||
wrapper.find('ExecutionEnvironmentLookup').invoke('onChange')(null);
|
||||
});
|
||||
|
||||
wrapper.update();
|
||||
@ -142,6 +165,7 @@ describe('<WorkflowJobTemplateEdit/>', () => {
|
||||
ask_limit_on_launch: false,
|
||||
ask_scm_branch_on_launch: false,
|
||||
ask_variables_on_launch: false,
|
||||
execution_environment: null,
|
||||
});
|
||||
wrapper.update();
|
||||
await expect(WorkflowJobTemplatesAPI.disassociateLabel).toBeCalledWith(6, {
|
||||
|
||||
@ -94,6 +94,7 @@ const mockJobTemplate = {
|
||||
},
|
||||
related: { webhook_receiver: '' },
|
||||
inventory: 1,
|
||||
project: 5,
|
||||
};
|
||||
|
||||
describe('NodeModal', () => {
|
||||
|
||||
@ -3,6 +3,7 @@ import { useLocation } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { func, shape } from 'prop-types';
|
||||
import { Tooltip } from '@patternfly/react-core';
|
||||
import { JobTemplatesAPI } from '../../../../../../api';
|
||||
import { getQSConfig, parseQueryString } from '../../../../../../util/qs';
|
||||
import useRequest from '../../../../../../util/useRequest';
|
||||
@ -56,26 +57,56 @@ function JobTemplatesList({ i18n, nodeResource, onUpdateNodeResource }) {
|
||||
fetchJobTemplates();
|
||||
}, [fetchJobTemplates]);
|
||||
|
||||
const onSelectRow = row => {
|
||||
if (
|
||||
row.project &&
|
||||
row.project !== null &&
|
||||
((row.inventory && row.inventory !== null) || row.ask_inventory_on_launch)
|
||||
) {
|
||||
onUpdateNodeResource(row);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PaginatedDataList
|
||||
contentError={error}
|
||||
hasContentLoading={isLoading}
|
||||
itemCount={count}
|
||||
items={jobTemplates}
|
||||
onRowClick={row => onUpdateNodeResource(row)}
|
||||
onRowClick={row => onSelectRow(row)}
|
||||
qsConfig={QS_CONFIG}
|
||||
renderItem={item => (
|
||||
<CheckboxListItem
|
||||
isSelected={!!(nodeResource && nodeResource.id === item.id)}
|
||||
itemId={item.id}
|
||||
key={item.id}
|
||||
name={item.name}
|
||||
label={item.name}
|
||||
onSelect={() => onUpdateNodeResource(item)}
|
||||
onDeselect={() => onUpdateNodeResource(null)}
|
||||
isRadio
|
||||
/>
|
||||
)}
|
||||
renderItem={item => {
|
||||
const isDisabled =
|
||||
!item.project ||
|
||||
item.project === null ||
|
||||
((!item.inventory || item.inventory === null) &&
|
||||
!item.ask_inventory_on_launch);
|
||||
const listItem = (
|
||||
<CheckboxListItem
|
||||
isDisabled={isDisabled}
|
||||
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 />}
|
||||
showPageSizeOptions={false}
|
||||
toolbarSearchColumns={[
|
||||
|
||||
@ -16,6 +16,7 @@ const onUpdateNodeResource = jest.fn();
|
||||
describe('JobTemplatesList', () => {
|
||||
let wrapper;
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
wrapper.unmount();
|
||||
});
|
||||
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',
|
||||
type: 'job_template',
|
||||
url: '/api/v2/job_templates/1',
|
||||
inventory: 1,
|
||||
project: 2,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Test Job Template 2',
|
||||
type: 'job_template',
|
||||
url: '/api/v2/job_templates/2',
|
||||
inventory: 1,
|
||||
project: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -60,10 +65,18 @@ describe('JobTemplatesList', () => {
|
||||
wrapper.find('CheckboxListItem[name="Test Job Template"]').props()
|
||||
.isSelected
|
||||
).toBe(true);
|
||||
expect(
|
||||
wrapper.find('CheckboxListItem[name="Test Job Template"]').props()
|
||||
.isDisabled
|
||||
).toBe(false);
|
||||
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(false);
|
||||
wrapper
|
||||
.find('CheckboxListItem[name="Test Job Template 2"]')
|
||||
.simulate('click');
|
||||
@ -72,8 +85,75 @@ describe('JobTemplatesList', () => {
|
||||
name: 'Test Job Template 2',
|
||||
type: 'job_template',
|
||||
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 () => {
|
||||
JobTemplatesAPI.read.mockRejectedValue(new Error());
|
||||
JobTemplatesAPI.readOptions.mockResolvedValue({
|
||||
|
||||
@ -64,10 +64,10 @@ const getAggregatedCredentials = (
|
||||
templateDefaultCred.credential_type === overrideCred.credential_type
|
||||
) {
|
||||
if (
|
||||
(!templateDefaultCred.vault_id && !overrideCred.inputs.vault_id) ||
|
||||
(!templateDefaultCred.vault_id && !overrideCred.inputs?.vault_id) ||
|
||||
(templateDefaultCred.vault_id &&
|
||||
overrideCred.inputs.vault_id &&
|
||||
templateDefaultCred.vault_id === overrideCred.inputs.vault_id)
|
||||
overrideCred.inputs?.vault_id &&
|
||||
templateDefaultCred.vault_id === overrideCred.inputs?.vault_id)
|
||||
) {
|
||||
credentialHasOverride = true;
|
||||
}
|
||||
@ -405,16 +405,7 @@ function Visualizer({ template, i18n }) {
|
||||
failure_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) {
|
||||
node.promptValues.addedCredentials.forEach(cred => {
|
||||
associateCredentialRequests.push(
|
||||
@ -583,8 +574,9 @@ function Visualizer({ template, i18n }) {
|
||||
<AlertModal
|
||||
isOpen
|
||||
variant="error"
|
||||
title={i18n._(t`Error!`)}
|
||||
title={i18n._(t`Error saving the workflow!`)}
|
||||
onClose={dismissNodeRequestError}
|
||||
aria-label={i18n._(t`Error saving the workflow!`)}
|
||||
>
|
||||
{i18n._(t`There was an error saving the workflow.`)}
|
||||
<ErrorDetail error={nodeRequestError} />
|
||||
|
||||
@ -2,13 +2,37 @@ import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import {
|
||||
WorkflowApprovalTemplatesAPI,
|
||||
WorkflowJobTemplateNodesAPI,
|
||||
WorkflowJobTemplatesAPI,
|
||||
} from '../../../api';
|
||||
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');
|
||||
|
||||
const startNode = {
|
||||
id: 1,
|
||||
fullUnifiedJobTemplate: {
|
||||
name: 'START',
|
||||
},
|
||||
};
|
||||
|
||||
const defaultLinks = [
|
||||
{
|
||||
linkType: 'always',
|
||||
source: { id: 1 },
|
||||
target: { id: 2 },
|
||||
},
|
||||
];
|
||||
|
||||
const template = {
|
||||
id: 1,
|
||||
name: 'Foo WFJT',
|
||||
@ -117,7 +141,6 @@ describe('Visualizer', () => {
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks();
|
||||
wrapper.unmount();
|
||||
delete window.SVGElement.prototype.getBBox;
|
||||
delete window.SVGElement.prototype.getBoundingClientRect;
|
||||
@ -125,6 +148,12 @@ describe('Visualizer', () => {
|
||||
delete window.SVGElement.prototype.width;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.resetModules();
|
||||
workflowReducer.mockImplementation(realWorkflowReducer);
|
||||
});
|
||||
|
||||
test('Renders successfully', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
@ -185,7 +214,7 @@ describe('Visualizer', () => {
|
||||
wrapper.find('button#link-confirm').simulate('click');
|
||||
expect(wrapper.find('LinkEditModal').length).toBe(0);
|
||||
await act(async () => {
|
||||
wrapper.find('button[aria-label="Save"]').simulate('click');
|
||||
wrapper.find('Button#visualizer-save').simulate('click');
|
||||
});
|
||||
expect(
|
||||
WorkflowJobTemplateNodesAPI.disassociateAlwaysNode
|
||||
@ -219,6 +248,633 @@ describe('Visualizer', () => {
|
||||
).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 () => {
|
||||
WorkflowJobTemplatesAPI.readNodes.mockRejectedValue(new Error());
|
||||
await act(async () => {
|
||||
|
||||
@ -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.
|
||||
|
||||
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:
|
||||
```
|
||||
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`.
|
||||
> 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.
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
---
|
||||
- log-level: debug
|
||||
- log-level: info
|
||||
|
||||
- control-service:
|
||||
service: control
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
---
|
||||
- log-level: debug
|
||||
- log-level: info
|
||||
|
||||
- control-service:
|
||||
service: control
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
---
|
||||
- log-level: debug
|
||||
- log-level: info
|
||||
|
||||
- control-service:
|
||||
service: control
|
||||
|
||||
@ -1,5 +1,23 @@
|
||||
# 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
|
||||
|
||||
Here are the main make targets:
|
||||
|
||||
@ -11,8 +11,6 @@
|
||||
###############################################################################
|
||||
# MISC PROJECT SETTINGS
|
||||
###############################################################################
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 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
|
||||
@ -20,21 +18,6 @@ import sys
|
||||
# INTERNAL_IPS = ('172.19.0.1', '172.18.0.1', '192.168.100.1')
|
||||
# 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
|
||||
AWX_ANSIBLE_COLLECTIONS_PATHS = '/var/lib/awx/vendor/awx_ansible_collections'
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
|
||||
> Note: This will also convert your postgresql bind-mount into a docker volume.
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
---
|
||||
- log-level: debug
|
||||
- log-level: info
|
||||
|
||||
- control-service:
|
||||
service: control
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user