diff --git a/CHANGELOG.md b/CHANGELOG.md index a5c527b004..17e1829cc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/INSTALL.md b/INSTALL.md index 590ddb4d98..a3dc14b333 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -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. diff --git a/awx/settings/development.py b/awx/settings/development.py index d181ca10fc..21b5bb6b4a 100644 --- a/awx/settings/development.py +++ b/awx/settings/development.py @@ -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': { diff --git a/awx/ui_next/src/components/CheckboxListItem/CheckboxListItem.jsx b/awx/ui_next/src/components/CheckboxListItem/CheckboxListItem.jsx index befaa10674..6ac79cc811 100644 --- a/awx/ui_next/src/components/CheckboxListItem/CheckboxListItem.jsx +++ b/awx/ui_next/src/components/CheckboxListItem/CheckboxListItem.jsx @@ -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 = ({ - + , ]} /> diff --git a/awx/ui_next/src/components/CopyButton/CopyButton.jsx b/awx/ui_next/src/components/CopyButton/CopyButton.jsx index 2856c69c0c..1f5dd9cff4 100644 --- a/awx/ui_next/src/components/CopyButton/CopyButton.jsx +++ b/awx/ui_next/src/components/CopyButton/CopyButton.jsx @@ -17,6 +17,7 @@ function CopyButton({ onCopyFinish, errorMessage, i18n, + ouiaId, }) { const { isLoading, error: copyError, request: copyItemToAPI } = useRequest( copyItem @@ -35,6 +36,7 @@ function CopyButton({ <> + + - - - - - + + - - - - + + + + {sendTestError && ( + - - - - + {i18n._(t`Failed to send test notification.`)} + + + )} + ); } diff --git a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx index 6f109604b5..fa0ad134cb 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx @@ -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), diff --git a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx index 642c679969..0836d35bd5 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx @@ -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('', () => { beforeEach(() => { LabelsAPI.read.mockResolvedValue({ data: { results: [] } }); @@ -266,6 +287,8 @@ describe('', () => { id: 1, organization: 1, }); + + wrapper.find('ExecutionEnvironmentLookup').invoke('onChange')(null); }); wrapper.update(); await act(async () => { @@ -277,6 +300,7 @@ describe('', () => { ...mockJobTemplate, project: mockJobTemplate.project, ...updatedTemplateData, + execution_environment: null, }; delete expected.summary_fields; delete expected.id; diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateEdit/WorkflowJobTemplateEdit.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateEdit/WorkflowJobTemplateEdit.jsx index cb963e634f..dd47118316 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateEdit/WorkflowJobTemplateEdit.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateEdit/WorkflowJobTemplateEdit.jsx @@ -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); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateEdit/WorkflowJobTemplateEdit.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateEdit/WorkflowJobTemplateEdit.test.jsx index 81ae43e817..3e5d0fb0e3 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateEdit/WorkflowJobTemplateEdit.test.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateEdit/WorkflowJobTemplateEdit.test.jsx @@ -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('', () => { let wrapper; let history; @@ -48,6 +67,9 @@ describe('', () => { }, }); OrganizationsAPI.read.mockResolvedValue({ results: [{ id: 1 }] }); + ExecutionEnvironmentsAPI.read.mockResolvedValue({ + data: mockExecutionEnvironment, + }); await act(async () => { history = createMemoryHistory({ @@ -100,6 +122,7 @@ describe('', () => { .find('LabelSelect') .find('SelectToggle') .simulate('click'); + wrapper.find('ExecutionEnvironmentLookup').invoke('onChange')(null); }); wrapper.update(); @@ -142,6 +165,7 @@ describe('', () => { 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, { diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.test.jsx index 7bcf1ea6ee..946718079a 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.test.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.test.jsx @@ -94,6 +94,7 @@ const mockJobTemplate = { }, related: { webhook_receiver: '' }, inventory: 1, + project: 5, }; describe('NodeModal', () => { diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.jsx index 3de9f280b5..77a6de336b 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.jsx @@ -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 ( onUpdateNodeResource(row)} + onRowClick={row => onSelectRow(row)} qsConfig={QS_CONFIG} - renderItem={item => ( - 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 = ( + onSelectRow(item)} + onDeselect={() => onUpdateNodeResource(null)} + isRadio + /> + ); + return isDisabled ? ( + + {listItem} + + ) : ( + listItem + ); + }} renderToolbar={props => } showPageSizeOptions={false} toolbarSearchColumns={[ diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.test.jsx index 3b9fdec0e9..e59b9a7b48 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.test.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.test.jsx @@ -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( + + ); + }); + 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({ diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx index 1deb449890..5e0b8e2006 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx @@ -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 }) { {i18n._(t`There was an error saving the workflow.`)} diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.test.jsx index 26770f16f9..c9a5795929 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.test.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.test.jsx @@ -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( + + + + ); + }); + 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( + + + + ); + }); + 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( + + + + ); + }); + 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( + + + + ); + }); + 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( + + + + ); + }); + 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( + + + + ); + }); + 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( + + + + ); + }); + 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( + + + + ); + }); + 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 () => { diff --git a/docs/debugging.md b/docs/debugging.md index b6f2652962..a82d8b7d41 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -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. diff --git a/tools/docker-compose-cluster/awx-1-receptor.conf b/tools/docker-compose-cluster/awx-1-receptor.conf index dcaca8263f..8ade3b027f 100644 --- a/tools/docker-compose-cluster/awx-1-receptor.conf +++ b/tools/docker-compose-cluster/awx-1-receptor.conf @@ -1,5 +1,5 @@ --- -- log-level: debug +- log-level: info - control-service: service: control diff --git a/tools/docker-compose-cluster/awx-2-receptor.conf b/tools/docker-compose-cluster/awx-2-receptor.conf index bf9d4889a0..639e8c8057 100644 --- a/tools/docker-compose-cluster/awx-2-receptor.conf +++ b/tools/docker-compose-cluster/awx-2-receptor.conf @@ -1,5 +1,5 @@ --- -- log-level: debug +- log-level: info - control-service: service: control diff --git a/tools/docker-compose-cluster/awx-3-receptor.conf b/tools/docker-compose-cluster/awx-3-receptor.conf index ac5db0d284..cab92d6def 100644 --- a/tools/docker-compose-cluster/awx-3-receptor.conf +++ b/tools/docker-compose-cluster/awx-3-receptor.conf @@ -1,5 +1,5 @@ --- -- log-level: debug +- log-level: info - control-service: service: control diff --git a/tools/docker-compose/README.md b/tools/docker-compose/README.md index d381614a75..3987e596d2 100644 --- a/tools/docker-compose/README.md +++ b/tools/docker-compose/README.md @@ -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: diff --git a/tools/docker-compose/ansible/roles/sources/files/local_settings.py b/tools/docker-compose/ansible/roles/sources/files/local_settings.py index f480f7fdb8..02af47c192 100644 --- a/tools/docker-compose/ansible/roles/sources/files/local_settings.py +++ b/tools/docker-compose/ansible/roles/sources/files/local_settings.py @@ -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' diff --git a/tools/docker-compose/docs/data_migration.md b/tools/docker-compose/docs/data_migration.md index 2196f49221..67fc7070b4 100644 --- a/tools/docker-compose/docs/data_migration.md +++ b/tools/docker-compose/docs/data_migration.md @@ -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. diff --git a/tools/docker-compose/receptor.conf b/tools/docker-compose/receptor.conf index d5ac25cf2d..7192be28dc 100644 --- a/tools/docker-compose/receptor.conf +++ b/tools/docker-compose/receptor.conf @@ -1,5 +1,5 @@ --- -- log-level: debug +- log-level: info - control-service: service: control