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
commit 10c357d0f1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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 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

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.
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.

View File

@ -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': {

View File

@ -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>,
]}
/>

View File

@ -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);

View File

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

View File

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

View File

@ -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: {

View File

@ -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>
)}
</>
);
}

View File

@ -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),

View File

@ -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;

View File

@ -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);

View File

@ -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, {

View File

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

View File

@ -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={[

View File

@ -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({

View File

@ -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} />

View File

@ -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 () => {

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.
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.

View File

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

View File

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

View File

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

View File

@ -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:

View File

@ -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'

View File

@ -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.

View File

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