Merge pull request #32 from ansible/devel

Rebase
This commit is contained in:
Sean Sullivan 2021-01-27 12:47:08 -06:00 committed by GitHub
commit 012189cb10
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 1394 additions and 128 deletions

3
.gitignore vendored
View File

@ -149,3 +149,6 @@ use_dev_supervisor.txt
/tools/docker-compose/overrides/
/awx/ui_next/.ui-built
/Dockerfile
/_build/
/_build_kube_dev/
/Dockerfile.kube-dev

View File

@ -2,6 +2,10 @@
This is a list of high-level changes for each release of AWX. A full list of commits can be found at `https://github.com/ansible/awx/releases/tag/<version>`.
# 17.0.1 (January 26, 2021)
- Fixed pgdocker directory permissions issue with Local Docker installer: https://github.com/ansible/awx/pull/9152
- Fixed a bug in the UI which caused toggle settings to not be changed when clicked: https://github.com/ansible/awx/pull/9093
# 17.0.0 (January 22, 2021)
- AWX now requires PostgreSQL 12 by default: https://github.com/ansible/awx/pull/8943
**Note:** users who encounter permissions errors at upgrade time should `chown -R ~/.awx/pgdocker` to ensure it's owned by the user running the install playbook

View File

@ -267,11 +267,27 @@ collectstatic:
fi; \
mkdir -p awx/public/static && $(PYTHON) manage.py collectstatic --clear --noinput > /dev/null 2>&1
UWSGI_DEV_RELOAD_COMMAND ?= supervisorctl restart tower-processes:awx-dispatcher tower-processes:awx-receiver
uwsgi: collectstatic
@if [ "$(VENV_BASE)" ]; then \
. $(VENV_BASE)/awx/bin/activate; \
fi; \
uwsgi -b 32768 --socket 127.0.0.1:8050 --module=awx.wsgi:application --home=/var/lib/awx/venv/awx --chdir=/awx_devel/ --vacuum --processes=5 --harakiri=120 --master --no-orphans --py-autoreload 1 --max-requests=1000 --stats /tmp/stats.socket --lazy-apps --logformat "%(addr) %(method) %(uri) - %(proto) %(status)" --hook-accepting1="exec:supervisorctl restart tower-processes:awx-dispatcher tower-processes:awx-receiver"
uwsgi -b 32768 \
--socket 127.0.0.1:8050 \
--module=awx.wsgi:application \
--home=/var/lib/awx/venv/awx \
--chdir=/awx_devel/ \
--vacuum \
--processes=5 \
--harakiri=120 --master \
--no-orphans \
--py-autoreload 1 \
--max-requests=1000 \
--stats /tmp/stats.socket \
--lazy-apps \
--logformat "%(addr) %(method) %(uri) - %(proto) %(status)" \
--hook-accepting1="exec: $(UWSGI_DEV_RELOAD_COMMAND)"
daphne:
@if [ "$(VENV_BASE)" ]; then \
@ -579,15 +595,18 @@ docker-compose-clean: awx/projects
# Base development image build
docker-compose-build:
ansible localhost -m template -a "src=installer/roles/image_build/templates/Dockerfile.j2 dest=tools/docker-compose/Dockerfile" -e build_dev=True
docker build -t ansible/awx_devel -f tools/docker-compose/Dockerfile \
--cache-from=$(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) .
ansible-playbook installer/dockerfile.yml -e build_dev=True
docker build -t ansible/awx_devel \
--build-arg BUILDKIT_INLINE_CACHE=1 \
--cache-from=$(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) .
docker tag ansible/awx_devel $(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG)
#docker push $(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG)
# For use when developing on "isolated" AWX deployments
docker-compose-isolated-build: docker-compose-build
docker build -t ansible/awx_isolated -f tools/docker-isolated/Dockerfile .
docker build -t ansible/awx_isolated \
--build-arg BUILDKIT_INLINE_CACHE=1 \
-f tools/docker-isolated/Dockerfile .
docker tag ansible/awx_isolated $(DEV_DOCKER_TAG_BASE)/awx_isolated:$(COMPOSE_TAG)
#docker push $(DEV_DOCKER_TAG_BASE)/awx_isolated:$(COMPOSE_TAG)
@ -624,5 +643,16 @@ psql-container:
VERSION:
@echo "awx: $(VERSION)"
Dockerfile: installer/roles/image_build/templates/Dockerfile.j2
ansible localhost -m template -a "src=installer/roles/image_build/templates/Dockerfile.j2 dest=Dockerfile"
Dockerfile: installer/roles/dockerfile/templates/Dockerfile.j2
ansible-playbook installer/dockerfile.yml
Dockerfile.kube-dev: installer/roles/dockerfile/templates/Dockerfile.j2
ansible-playbook installer/dockerfile.yml \
-e dockerfile_name=Dockerfile.kube-dev \
-e kube_dev=True \
-e template_dest=_build_kube_dev
awx-kube-dev-build: Dockerfile.kube-dev
docker build -f Dockerfile.kube-dev \
--build-arg BUILDKIT_INLINE_CACHE=1 \
-t $(DEV_DOCKER_TAG_BASE)/awx_kube_devel:$(COMPOSE_TAG) .

View File

@ -1 +1 @@
17.0.0
17.0.1

View File

@ -357,7 +357,7 @@ class JobNotificationMixin(object):
'url': 'https://towerhost/#/jobs/playbook/1010',
'approval_status': 'approved',
'approval_node_name': 'Approve Me',
'workflow_url': 'https://towerhost/#/workflows/1010',
'workflow_url': 'https://towerhost/#/jobs/workflow/1010',
'job_metadata': """{'url': 'https://towerhost/$/jobs/playbook/13',
'traceback': '',
'status': 'running',

View File

@ -620,7 +620,7 @@ class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificatio
return reverse('api:workflow_job_detail', kwargs={'pk': self.pk}, request=request)
def get_ui_url(self):
return urljoin(settings.TOWER_URL_BASE, '/#/workflows/{}'.format(self.pk))
return urljoin(settings.TOWER_URL_BASE, '/#/jobs/workflow/{}'.format(self.pk))
def notification_data(self):
result = super(WorkflowJob, self).notification_data()
@ -752,7 +752,7 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin):
return None
def get_ui_url(self):
return urljoin(settings.TOWER_URL_BASE, '/#/workflows/{}'.format(self.workflow_job.id))
return urljoin(settings.TOWER_URL_BASE, '/#/jobs/workflow/{}'.format(self.workflow_job.id))
def _get_parent_field_name(self):
return 'workflow_approval_template'
@ -840,7 +840,7 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin):
return (msg, body)
def context(self, approval_status):
workflow_url = urljoin(settings.TOWER_URL_BASE, '/#/workflows/{}'.format(self.workflow_job.id))
workflow_url = urljoin(settings.TOWER_URL_BASE, '/#/jobs/workflow/{}'.format(self.workflow_job.id))
return {'approval_status': approval_status,
'approval_node_name': self.workflow_approval_template.name,
'workflow_url': workflow_url,

View File

@ -158,7 +158,10 @@ AWX_VENV_PATH = os.path.join(BASE_VENV_PATH, "awx")
# default settings for development. If not present, we can still run using
# only the defaults.
try:
include(optional('local_*.py'), scope=locals())
if os.getenv('AWX_KUBE_DEVEL', False):
include(optional('minikube.py'), scope=locals())
else:
include(optional('local_*.py'), scope=locals())
except ImportError:
traceback.print_exc()
sys.exit(1)

4
awx/settings/minikube.py Normal file
View File

@ -0,0 +1,4 @@
BROADCAST_WEBSOCKET_SECRET = '🤖starscream🤖'
BROADCAST_WEBSOCKET_PORT = 8013
BROADCAST_WEBSOCKET_VERIFY_CERT = False
BROADCAST_WEBSOCKET_PROTOCOL = 'http'

View File

@ -94,7 +94,7 @@ const buildAnchor = (obj, resource, activity) => {
break;
}
case 'workflow_job':
url = `/workflows/${obj.id}/`;
url = `/jobs/workflow/${obj.id}/`;
break;
case 'label':
url = null;

View File

@ -93,6 +93,7 @@ function JobDetail({ job, i18n }) {
workflow_job_template: workflowJobTemplate,
labels,
project,
source_workflow_job,
} = job.summary_fields;
const [errorMsg, setErrorMsg] = useState();
const history = useHistory();
@ -195,6 +196,16 @@ function JobDetail({ job, i18n }) {
}
/>
)}
{source_workflow_job && (
<Detail
label={i18n._(t`Source Workflow Job`)}
value={
<Link to={`/jobs/workflow/${source_workflow_job.id}`}>
{source_workflow_job.id} - {source_workflow_job.name}
</Link>
}
/>
)}
<Detail label={i18n._(t`Job Type`)} value={jobTypes[job.type]} />
<Detail
label={i18n._(t`Launched By`)}

View File

@ -35,6 +35,10 @@ describe('<JobDetail />', () => {
kubernetes: false,
credential_type_id: 1,
},
source_workflow_job: {
id: 1234,
name: 'Test Source Workflow',
},
},
}}
/>
@ -45,6 +49,7 @@ describe('<JobDetail />', () => {
assertDetail('Started', '8/8/2019, 7:24:18 PM');
assertDetail('Finished', '8/8/2019, 7:24:50 PM');
assertDetail('Job Template', mockJobData.summary_fields.job_template.name);
assertDetail('Source Workflow Job', `1234 - Test Source Workflow`);
assertDetail('Job Type', 'Playbook Run');
assertDetail('Launched By', mockJobData.summary_fields.created_by.username);
assertDetail('Inventory', mockJobData.summary_fields.inventory.name);

View File

@ -3,11 +3,31 @@ import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { SettingsAPI } from '../../../api';
import { SettingsProvider } from '../../../contexts/Settings';
import mockAllOptions from '../shared/data.allSettingOptions.json';
import SAML from './SAML';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {},
data: {
SOCIAL_AUTH_SAML_CALLBACK_URL: 'https://towerhost/sso/complete/saml/',
SOCIAL_AUTH_SAML_METADATA_URL: 'https://towerhost/sso/metadata/saml/',
SOCIAL_AUTH_SAML_SP_ENTITY_ID: '',
SOCIAL_AUTH_SAML_SP_PUBLIC_CERT: '',
SOCIAL_AUTH_SAML_SP_PRIVATE_KEY: '',
SOCIAL_AUTH_SAML_ORG_INFO: {},
SOCIAL_AUTH_SAML_TECHNICAL_CONTACT: {},
SOCIAL_AUTH_SAML_SUPPORT_CONTACT: {},
SOCIAL_AUTH_SAML_ENABLED_IDPS: {},
SOCIAL_AUTH_SAML_SECURITY_CONFIG: {},
SOCIAL_AUTH_SAML_SP_EXTRA: {},
SOCIAL_AUTH_SAML_EXTRA_DATA: [],
SOCIAL_AUTH_SAML_ORGANIZATION_MAP: {},
SOCIAL_AUTH_SAML_TEAM_MAP: {},
SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {},
SOCIAL_AUTH_SAML_TEAM_ATTR: {},
SAML_AUTO_CREATE_OBJECTS: false,
},
});
describe('<SAML />', () => {
@ -23,9 +43,14 @@ describe('<SAML />', () => {
initialEntries: ['/settings/saml/details'],
});
await act(async () => {
wrapper = mountWithContexts(<SAML />, {
context: { router: { history } },
});
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<SAML />
</SettingsProvider>,
{
context: { router: { history } },
}
);
});
expect(wrapper.find('SAMLDetail').length).toBe(1);
});
@ -35,9 +60,14 @@ describe('<SAML />', () => {
initialEntries: ['/settings/saml/edit'],
});
await act(async () => {
wrapper = mountWithContexts(<SAML />, {
context: { router: { history } },
});
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<SAML />
</SettingsProvider>,
{
context: { router: { history } },
}
);
});
expect(wrapper.find('SAMLEdit').length).toBe(1);
});

View File

@ -18,6 +18,7 @@ import { SettingDetail } from '../../shared';
function SAMLDetail({ i18n }) {
const { me } = useConfig();
const { GET: options } = useSettings();
options.SOCIAL_AUTH_SAML_SP_PUBLIC_CERT.type = 'certificate';
const { isLoading, error, request, result: saml } = useRequest(
useCallback(async () => {

View File

@ -32,6 +32,7 @@ SettingsAPI.readCategory.mockResolvedValue({
SOCIAL_AUTH_SAML_TEAM_MAP: {},
SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {},
SOCIAL_AUTH_SAML_TEAM_ATTR: {},
SAML_AUTO_CREATE_OBJECTS: false,
},
});
@ -59,6 +60,11 @@ describe('<SAMLDetail />', () => {
});
test('should render expected details', () => {
assertDetail(
wrapper,
'Automatically Create Organizations and Teams on SAML Login',
'Off'
);
assertDetail(
wrapper,
'SAML Assertion Consumer Service (ACS) URL',
@ -70,7 +76,7 @@ describe('<SAMLDetail />', () => {
'https://towerhost/sso/metadata/saml/'
);
assertDetail(wrapper, 'SAML Service Provider Entity ID', 'mock_id');
assertDetail(
assertVariableDetail(
wrapper,
'SAML Service Provider Public Certificate',
'mock_cert'

View File

@ -1,25 +1,208 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core';
import { CardBody, CardActionsRow } from '../../../../components/Card';
import React, { useCallback, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import { Formik } from 'formik';
import { Form } from '@patternfly/react-core';
import { CardBody } from '../../../../components/Card';
import ContentError from '../../../../components/ContentError';
import ContentLoading from '../../../../components/ContentLoading';
import { FormSubmitError } from '../../../../components/FormField';
import { FormColumnLayout } from '../../../../components/FormLayout';
import { useSettings } from '../../../../contexts/Settings';
import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
import {
BooleanField,
FileUploadField,
InputField,
ObjectField,
} from '../../shared/SharedFields';
import { formatJson } from '../../shared/settingUtils';
import useModal from '../../../../util/useModal';
import useRequest from '../../../../util/useRequest';
import { SettingsAPI } from '../../../../api';
function SAMLEdit() {
const history = useHistory();
const { isModalOpen, toggleModal, closeModal } = useModal();
const { PUT: options } = useSettings();
const { isLoading, error, request: fetchSAML, result: saml } = useRequest(
useCallback(async () => {
const { data } = await SettingsAPI.readCategory('saml');
const mergedData = {};
Object.keys(data).forEach(key => {
if (!options[key]) {
return;
}
mergedData[key] = options[key];
mergedData[key].value = data[key];
});
return mergedData;
}, [options]),
null
);
useEffect(() => {
fetchSAML();
}, [fetchSAML]);
const { error: submitError, request: submitForm } = useRequest(
useCallback(
async values => {
await SettingsAPI.updateAll(values);
history.push('/settings/saml/details');
},
[history]
),
null
);
const handleSubmit = async form => {
await submitForm({
...form,
SOCIAL_AUTH_SAML_ORG_INFO: formatJson(form.SOCIAL_AUTH_SAML_ORG_INFO),
SOCIAL_AUTH_SAML_TECHNICAL_CONTACT: formatJson(
form.SOCIAL_AUTH_SAML_TECHNICAL_CONTACT
),
SOCIAL_AUTH_SAML_SUPPORT_CONTACT: formatJson(
form.SOCIAL_AUTH_SAML_SUPPORT_CONTACT
),
SOCIAL_AUTH_SAML_ENABLED_IDPS: formatJson(
form.SOCIAL_AUTH_SAML_ENABLED_IDPS
),
SOCIAL_AUTH_SAML_ORGANIZATION_MAP: formatJson(
form.SOCIAL_AUTH_SAML_ORGANIZATION_MAP
),
SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: formatJson(
form.SOCIAL_AUTH_SAML_ORGANIZATION_ATTR
),
SOCIAL_AUTH_SAML_TEAM_MAP: formatJson(form.SOCIAL_AUTH_SAML_TEAM_MAP),
SOCIAL_AUTH_SAML_TEAM_ATTR: formatJson(form.SOCIAL_AUTH_SAML_TEAM_ATTR),
SOCIAL_AUTH_SAML_SECURITY_CONFIG: formatJson(
form.SOCIAL_AUTH_SAML_SECURITY_CONFIG
),
SOCIAL_AUTH_SAML_SP_EXTRA: formatJson(form.SOCIAL_AUTH_SAML_SP_EXTRA),
SOCIAL_AUTH_SAML_EXTRA_DATA: formatJson(form.SOCIAL_AUTH_SAML_EXTRA_DATA),
});
};
const handleRevertAll = async () => {
const defaultValues = Object.assign(
...Object.entries(saml).map(([key, value]) => ({
[key]: value.default,
}))
);
await submitForm(defaultValues);
closeModal();
};
const handleCancel = () => {
history.push('/settings/saml/details');
};
const initialValues = fields =>
Object.keys(fields).reduce((acc, key) => {
if (fields[key].type === 'list' || fields[key].type === 'nested object') {
const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
acc[key] = fields[key].value
? JSON.stringify(fields[key].value, null, 2)
: emptyDefault;
} else {
acc[key] = fields[key].value ?? '';
}
return acc;
}, {});
function SAMLEdit({ i18n }) {
return (
<CardBody>
{i18n._(t`Edit form coming soon :)`)}
<CardActionsRow>
<Button
aria-label={i18n._(t`Cancel`)}
component={Link}
to="/settings/saml/details"
>
{i18n._(t`Cancel`)}
</Button>
</CardActionsRow>
{isLoading && <ContentLoading />}
{!isLoading && error && <ContentError error={error} />}
{!isLoading && saml && (
<Formik initialValues={initialValues(saml)} onSubmit={handleSubmit}>
{formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<InputField
name="SOCIAL_AUTH_SAML_SP_ENTITY_ID"
config={saml.SOCIAL_AUTH_SAML_SP_ENTITY_ID}
isRequired
/>
<BooleanField
name="SAML_AUTO_CREATE_OBJECTS"
config={saml.SAML_AUTO_CREATE_OBJECTS}
/>
<FileUploadField
name="SOCIAL_AUTH_SAML_SP_PUBLIC_CERT"
config={saml.SOCIAL_AUTH_SAML_SP_PUBLIC_CERT}
isRequired
/>
<FileUploadField
name="SOCIAL_AUTH_SAML_SP_PRIVATE_KEY"
config={saml.SOCIAL_AUTH_SAML_SP_PRIVATE_KEY}
isRequired
/>
<ObjectField
name="SOCIAL_AUTH_SAML_ORG_INFO"
config={saml.SOCIAL_AUTH_SAML_ORG_INFO}
/>
<ObjectField
name="SOCIAL_AUTH_SAML_TECHNICAL_CONTACT"
config={saml.SOCIAL_AUTH_SAML_TECHNICAL_CONTACT}
/>
<ObjectField
name="SOCIAL_AUTH_SAML_SUPPORT_CONTACT"
config={saml.SOCIAL_AUTH_SAML_SUPPORT_CONTACT}
/>
<ObjectField
name="SOCIAL_AUTH_SAML_ENABLED_IDPS"
config={saml.SOCIAL_AUTH_SAML_ENABLED_IDPS}
/>
<ObjectField
name="SOCIAL_AUTH_SAML_ORGANIZATION_MAP"
config={saml.SOCIAL_AUTH_SAML_ORGANIZATION_MAP}
/>
<ObjectField
name="SOCIAL_AUTH_SAML_ORGANIZATION_ATTR"
config={saml.SOCIAL_AUTH_SAML_ORGANIZATION_ATTR}
/>
<ObjectField
name="SOCIAL_AUTH_SAML_TEAM_MAP"
config={saml.SOCIAL_AUTH_SAML_TEAM_MAP}
/>
<ObjectField
name="SOCIAL_AUTH_SAML_TEAM_ATTR"
config={saml.SOCIAL_AUTH_SAML_TEAM_ATTR}
/>
<ObjectField
name="SOCIAL_AUTH_SAML_SECURITY_CONFIG"
config={saml.SOCIAL_AUTH_SAML_SECURITY_CONFIG}
/>
<ObjectField
name="SOCIAL_AUTH_SAML_SP_EXTRA"
config={saml.SOCIAL_AUTH_SAML_SP_EXTRA}
/>
<ObjectField
name="SOCIAL_AUTH_SAML_EXTRA_DATA"
config={saml.SOCIAL_AUTH_SAML_EXTRA_DATA}
/>
{submitError && <FormSubmitError error={submitError} />}
</FormColumnLayout>
<RevertFormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
onRevert={toggleModal}
/>
{isModalOpen && (
<RevertAllAlert
onClose={closeModal}
onRevertAll={handleRevertAll}
/>
)}
</Form>
)}
</Formik>
)}
</CardBody>
);
}
export default withI18n()(SAMLEdit);
export default SAMLEdit;

View File

@ -1,16 +1,251 @@
import React from 'react';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import {
mountWithContexts,
waitForElement,
} from '../../../../../testUtils/enzymeHelpers';
import mockAllOptions from '../../shared/data.allSettingOptions.json';
import { SettingsProvider } from '../../../../contexts/Settings';
import { SettingsAPI } from '../../../../api';
import SAMLEdit from './SAMLEdit';
jest.mock('../../../../api/models/Settings');
SettingsAPI.updateAll.mockResolvedValue({});
SettingsAPI.readCategory.mockResolvedValue({
data: {
SAML_AUTO_CREATE_OBJECTS: true,
SOCIAL_AUTH_SAML_CALLBACK_URL: 'https://towerhost/sso/complete/saml/',
SOCIAL_AUTH_SAML_METADATA_URL: 'https://towerhost/sso/metadata/saml/',
SOCIAL_AUTH_SAML_SP_ENTITY_ID: 'mock_id',
SOCIAL_AUTH_SAML_SP_PUBLIC_CERT: 'mock_cert',
SOCIAL_AUTH_SAML_SP_PRIVATE_KEY: '$encrypted$',
SOCIAL_AUTH_SAML_ORG_INFO: {},
SOCIAL_AUTH_SAML_TECHNICAL_CONTACT: {
givenName: 'Mock User',
emailAddress: 'mockuser@example.com',
},
SOCIAL_AUTH_SAML_SUPPORT_CONTACT: {},
SOCIAL_AUTH_SAML_ENABLED_IDPS: {},
SOCIAL_AUTH_SAML_SP_EXTRA: {},
SOCIAL_AUTH_SAML_EXTRA_DATA: [],
SOCIAL_AUTH_SAML_ORGANIZATION_MAP: {},
SOCIAL_AUTH_SAML_TEAM_MAP: {},
SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {},
SOCIAL_AUTH_SAML_TEAM_ATTR: {},
SOCIAL_AUTH_SAML_SECURITY_CONFIG: {
requestedAuthnContext: false,
},
},
});
describe('<SAMLEdit />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<SAMLEdit />);
});
let history;
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
beforeEach(async () => {
history = createMemoryHistory({
initialEntries: ['/settings/saml/edit'],
});
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<SAMLEdit />
</SettingsProvider>,
{
context: { router: { history } },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
test('initially renders without crashing', () => {
expect(wrapper.find('SAMLEdit').length).toBe(1);
});
test('should display expected form fields', async () => {
expect(
wrapper.find('FormGroup[label="SAML Service Provider Entity ID"]').length
).toBe(1);
expect(
wrapper.find(
'FormGroup[label="Automatically Create Organizations and Teams on SAML Login"]'
).length
).toBe(1);
expect(
wrapper.find(
'FormGroup[label="SAML Service Provider Public Certificate"]'
).length
).toBe(1);
expect(
wrapper.find('FormGroup[label="SAML Service Provider Private Key"]')
.length
).toBe(1);
expect(
wrapper.find('FormGroup[label="SAML Service Provider Organization Info"]')
.length
).toBe(1);
expect(
wrapper.find('FormGroup[label="SAML Service Provider Technical Contact"]')
.length
).toBe(1);
expect(
wrapper.find('FormGroup[label="SAML Service Provider Support Contact"]')
.length
).toBe(1);
expect(
wrapper.find('FormGroup[label="SAML Enabled Identity Providers"]').length
).toBe(1);
expect(
wrapper.find('FormGroup[label="SAML Organization Map"]').length
).toBe(1);
expect(wrapper.find('FormGroup[label="SAML Team Map"]').length).toBe(1);
expect(
wrapper.find('FormGroup[label="SAML Organization Attribute Mapping"]')
.length
).toBe(1);
expect(
wrapper.find('FormGroup[label="SAML Team Attribute Mapping"]').length
).toBe(1);
expect(wrapper.find('FormGroup[label="SAML Security Config"]').length).toBe(
1
);
expect(
wrapper.find(
'FormGroup[label="SAML Service Provider extra configuration data"]'
).length
).toBe(1);
expect(
wrapper.find(
'FormGroup[label="SAML IDP to extra_data attribute mapping"]'
).length
).toBe(1);
});
test('should successfully send default values to api on form revert all', async () => {
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
expect(wrapper.find('RevertAllAlert')).toHaveLength(0);
await act(async () => {
wrapper
.find('button[aria-label="Revert all to default"]')
.invoke('onClick')();
});
wrapper.update();
expect(wrapper.find('RevertAllAlert')).toHaveLength(1);
await act(async () => {
wrapper
.find('RevertAllAlert button[aria-label="Confirm revert all"]')
.invoke('onClick')();
});
wrapper.update();
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
SAML_AUTO_CREATE_OBJECTS: true,
SOCIAL_AUTH_SAML_ENABLED_IDPS: {},
SOCIAL_AUTH_SAML_EXTRA_DATA: null,
SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {},
SOCIAL_AUTH_SAML_ORGANIZATION_MAP: null,
SOCIAL_AUTH_SAML_ORG_INFO: {},
SOCIAL_AUTH_SAML_SP_ENTITY_ID: '',
SOCIAL_AUTH_SAML_SP_EXTRA: null,
SOCIAL_AUTH_SAML_SP_PRIVATE_KEY: '',
SOCIAL_AUTH_SAML_SP_PUBLIC_CERT: '',
SOCIAL_AUTH_SAML_SUPPORT_CONTACT: {},
SOCIAL_AUTH_SAML_TEAM_ATTR: {},
SOCIAL_AUTH_SAML_TEAM_MAP: null,
SOCIAL_AUTH_SAML_TECHNICAL_CONTACT: {},
SOCIAL_AUTH_SAML_SECURITY_CONFIG: {
requestedAuthnContext: false,
},
});
});
test('should successfully send request to api on form submission', async () => {
act(() => {
wrapper.find('input#SOCIAL_AUTH_SAML_SP_ENTITY_ID').simulate('change', {
target: { value: 'new_id', name: 'SOCIAL_AUTH_SAML_SP_ENTITY_ID' },
});
wrapper
.find(
'FormGroup[fieldId="SOCIAL_AUTH_SAML_TECHNICAL_CONTACT"] button[aria-label="Revert"]'
)
.invoke('onClick')();
});
wrapper.update();
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
SAML_AUTO_CREATE_OBJECTS: true,
SOCIAL_AUTH_SAML_ENABLED_IDPS: {},
SOCIAL_AUTH_SAML_EXTRA_DATA: [],
SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {},
SOCIAL_AUTH_SAML_ORGANIZATION_MAP: {},
SOCIAL_AUTH_SAML_ORG_INFO: {},
SOCIAL_AUTH_SAML_SP_ENTITY_ID: 'new_id',
SOCIAL_AUTH_SAML_SP_EXTRA: {},
SOCIAL_AUTH_SAML_SP_PRIVATE_KEY: '$encrypted$',
SOCIAL_AUTH_SAML_SP_PUBLIC_CERT: 'mock_cert',
SOCIAL_AUTH_SAML_SUPPORT_CONTACT: {},
SOCIAL_AUTH_SAML_TEAM_ATTR: {},
SOCIAL_AUTH_SAML_TEAM_MAP: {},
SOCIAL_AUTH_SAML_TECHNICAL_CONTACT: {},
SOCIAL_AUTH_SAML_SECURITY_CONFIG: {
requestedAuthnContext: false,
},
});
});
test('should navigate to saml detail on successful submission', async () => {
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(history.location.pathname).toEqual('/settings/saml/details');
});
test('should navigate to saml detail when cancel is clicked', async () => {
await act(async () => {
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
});
expect(history.location.pathname).toEqual('/settings/saml/details');
});
test('should display error message on unsuccessful submission', async () => {
const error = {
response: {
data: { detail: 'An error occurred' },
},
};
SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error));
expect(wrapper.find('FormSubmitError').length).toBe(0);
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
wrapper.update();
expect(wrapper.find('FormSubmitError').length).toBe(1);
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
});
test('should display ContentError on throw', async () => {
SettingsAPI.readCategory.mockImplementationOnce(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<SAMLEdit />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@ -6,11 +6,17 @@ import {
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import { SettingsAPI } from '../../../api';
import { SettingsProvider } from '../../../contexts/Settings';
import mockAllOptions from '../shared/data.allSettingOptions.json';
import UI from './UI';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {},
data: {
CUSTOM_LOGIN_INFO: '',
CUSTOM_LOGO: '',
PENDO_TRACKING_STATE: 'off',
},
});
describe('<UI />', () => {
@ -26,9 +32,14 @@ describe('<UI />', () => {
initialEntries: ['/settings/ui/details'],
});
await act(async () => {
wrapper = mountWithContexts(<UI />, {
context: { router: { history } },
});
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<UI />
</SettingsProvider>,
{
context: { router: { history } },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('UIDetail').length).toBe(1);
@ -39,9 +50,14 @@ describe('<UI />', () => {
initialEntries: ['/settings/ui/edit'],
});
await act(async () => {
wrapper = mountWithContexts(<UI />, {
context: { router: { history } },
});
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<UI />
</SettingsProvider>,
{
context: { router: { history } },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('UIEdit').length).toBe(1);

View File

@ -1,25 +1,128 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core';
import { CardBody, CardActionsRow } from '../../../../components/Card';
import React, { useCallback, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import { Formik } from 'formik';
import { Form } from '@patternfly/react-core';
import { CardBody } from '../../../../components/Card';
import ContentError from '../../../../components/ContentError';
import ContentLoading from '../../../../components/ContentLoading';
import { FormSubmitError } from '../../../../components/FormField';
import { FormColumnLayout } from '../../../../components/FormLayout';
import { useSettings } from '../../../../contexts/Settings';
import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
import {
ChoiceField,
FileUploadField,
TextAreaField,
} from '../../shared/SharedFields';
import useModal from '../../../../util/useModal';
import useRequest from '../../../../util/useRequest';
import { SettingsAPI } from '../../../../api';
function UIEdit() {
const history = useHistory();
const { isModalOpen, toggleModal, closeModal } = useModal();
const { PUT: options } = useSettings();
const { isLoading, error, request: fetchUI, result: uiData } = useRequest(
useCallback(async () => {
const { data } = await SettingsAPI.readCategory('ui');
const mergedData = {};
Object.keys(data).forEach(key => {
if (!options[key]) {
return;
}
mergedData[key] = options[key];
mergedData[key].value = data[key];
});
return mergedData;
}, [options]),
null
);
useEffect(() => {
fetchUI();
}, [fetchUI]);
const { error: submitError, request: submitForm } = useRequest(
useCallback(
async values => {
await SettingsAPI.updateAll(values);
history.push('/settings/ui/details');
},
[history]
),
null
);
const handleSubmit = async form => {
await submitForm(form);
};
const handleRevertAll = async () => {
const defaultValues = Object.assign(
...Object.entries(uiData).map(([key, value]) => ({
[key]: value.default,
}))
);
await submitForm(defaultValues);
closeModal();
};
const handleCancel = () => {
history.push('/settings/ui/details');
};
function UIEdit({ i18n }) {
return (
<CardBody>
{i18n._(t`Edit form coming soon :)`)}
<CardActionsRow>
<Button
aria-label={i18n._(t`Cancel`)}
component={Link}
to="/settings/ui/details"
{isLoading && <ContentLoading />}
{!isLoading && error && <ContentError error={error} />}
{!isLoading && uiData && (
<Formik
initialValues={{
PENDO_TRACKING_STATE: uiData?.PENDO_TRACKING_STATE?.value ?? 'off',
CUSTOM_LOGIN_INFO: uiData?.CUSTOM_LOGIN_INFO?.value ?? '',
CUSTOM_LOGO: uiData?.CUSTOM_LOGO?.value ?? '',
}}
onSubmit={handleSubmit}
>
{i18n._(t`Cancel`)}
</Button>
</CardActionsRow>
{formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
{uiData?.PENDO_TRACKING_STATE?.value !== 'off' && (
<ChoiceField
name="PENDO_TRACKING_STATE"
config={uiData.PENDO_TRACKING_STATE}
isRequired
/>
)}
<TextAreaField
name="CUSTOM_LOGIN_INFO"
config={uiData.CUSTOM_LOGIN_INFO}
/>
<FileUploadField
name="CUSTOM_LOGO"
config={uiData.CUSTOM_LOGO}
type="dataURL"
/>
{submitError && <FormSubmitError error={submitError} />}
</FormColumnLayout>
<RevertFormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
onRevert={toggleModal}
/>
{isModalOpen && (
<RevertAllAlert
onClose={closeModal}
onRevertAll={handleRevertAll}
/>
)}
</Form>
)}
</Formik>
)}
</CardBody>
);
}
export default withI18n()(UIEdit);
export default UIEdit;

View File

@ -1,16 +1,151 @@
import React from 'react';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import {
mountWithContexts,
waitForElement,
} from '../../../../../testUtils/enzymeHelpers';
import mockAllOptions from '../../shared/data.allSettingOptions.json';
import { SettingsProvider } from '../../../../contexts/Settings';
import { SettingsAPI } from '../../../../api';
import UIEdit from './UIEdit';
jest.mock('../../../../api/models/Settings');
SettingsAPI.updateAll.mockResolvedValue({});
SettingsAPI.readCategory.mockResolvedValue({
data: {
CUSTOM_LOGIN_INFO: 'mock info',
CUSTOM_LOGO: 'data:mock/jpeg;',
PENDO_TRACKING_STATE: 'detailed',
},
});
describe('<UIEdit />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<UIEdit />);
});
let history;
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
beforeEach(async () => {
history = createMemoryHistory({
initialEntries: ['/settings/ui/edit'],
});
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<UIEdit />
</SettingsProvider>,
{
context: { router: { history } },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
test('initially renders without crashing', () => {
expect(wrapper.find('UIEdit').length).toBe(1);
});
test('should display expected form fields', async () => {
expect(wrapper.find('FormGroup[label="Custom Login Info"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="Custom Logo"]').length).toBe(1);
expect(
wrapper.find('FormGroup[label="User Analytics Tracking State"]').length
).toBe(1);
});
test('should successfully send default values to api on form revert all', async () => {
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
expect(wrapper.find('RevertAllAlert')).toHaveLength(0);
await act(async () => {
wrapper
.find('button[aria-label="Revert all to default"]')
.invoke('onClick')();
});
wrapper.update();
expect(wrapper.find('RevertAllAlert')).toHaveLength(1);
await act(async () => {
wrapper
.find('RevertAllAlert button[aria-label="Confirm revert all"]')
.invoke('onClick')();
});
wrapper.update();
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
CUSTOM_LOGIN_INFO: '',
CUSTOM_LOGO: '',
PENDO_TRACKING_STATE: 'off',
});
});
test('should successfully send request to api on form submission', async () => {
act(() => {
wrapper.find('textarea#CUSTOM_LOGIN_INFO').simulate('change', {
target: { value: 'new login info', name: 'CUSTOM_LOGIN_INFO' },
});
wrapper
.find('FormGroup[fieldId="CUSTOM_LOGO"] button[aria-label="Revert"]')
.invoke('onClick')();
});
wrapper.update();
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
CUSTOM_LOGIN_INFO: 'new login info',
CUSTOM_LOGO: '',
PENDO_TRACKING_STATE: 'detailed',
});
});
test('should navigate to ui detail on successful submission', async () => {
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(history.location.pathname).toEqual('/settings/ui/details');
});
test('should navigate to ui detail when cancel is clicked', async () => {
await act(async () => {
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
});
expect(history.location.pathname).toEqual('/settings/ui/details');
});
test('should display error message on unsuccessful submission', async () => {
const error = {
response: {
data: { detail: 'An error occurred' },
},
};
SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error));
expect(wrapper.find('FormSubmitError').length).toBe(0);
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
wrapper.update();
expect(wrapper.find('FormSubmitError').length).toBe(1);
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
});
test('should display ContentError on throw', async () => {
SettingsAPI.readCategory.mockImplementationOnce(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<UIEdit />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@ -13,7 +13,13 @@ const ButtonWrapper = styled.div`
}
`;
function RevertButton({ i18n, id, defaultValue, isDisabled = false }) {
function RevertButton({
i18n,
id,
defaultValue,
isDisabled = false,
onRevertCallback = () => null,
}) {
const [field, meta, helpers] = useField(id);
const initialValue = meta.initialValue ?? '';
const currentValue = field.value;
@ -30,6 +36,7 @@ function RevertButton({ i18n, id, defaultValue, isDisabled = false }) {
function handleConfirm() {
helpers.setValue(isRevertable ? defaultValue : initialValue);
onRevertCallback();
}
const revertTooltipContent = isRevertable

View File

@ -34,6 +34,18 @@ export default withI18n()(
/>
);
break;
case 'certificate':
detail = (
<CodeDetail
dataCy={id}
helpText={helpText}
label={label}
mode="javascript"
rows={4}
value={value}
/>
);
break;
case 'image':
detail = (
<Detail

View File

@ -1,14 +1,17 @@
import React from 'react';
import React, { useState } from 'react';
import { bool, oneOf, shape, string } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { useField } from 'formik';
import {
FileUpload,
FormGroup as PFFormGroup,
InputGroup,
TextInput,
Switch,
TextArea,
TextInput,
} from '@patternfly/react-core';
import FileUploadIcon from '@patternfly/react-icons/dist/js/icons/file-upload-icon';
import styled from 'styled-components';
import AnsibleSelect from '../../../components/AnsibleSelect';
import CodeMirrorInput from '../../../components/CodeMirrorInput';
@ -42,16 +45,17 @@ const SettingGroup = withI18n()(
isDisabled,
isRequired,
label,
onRevertCallback,
popoverContent,
validated,
}) => (
<FormGroup
fieldId={fieldId}
helperTextInvalid={helperTextInvalid}
id={`${fieldId}-field`}
isRequired={isRequired}
label={label}
validated={validated}
id={fieldId}
labelIcon={
<>
<Popover
@ -62,6 +66,7 @@ const SettingGroup = withI18n()(
id={fieldId}
defaultValue={defaultValue}
isDisabled={isDisabled}
onRevertCallback={onRevertCallback}
/>
</>
}
@ -220,6 +225,44 @@ InputField.propTypes = {
isRequired: bool,
};
const TextAreaField = withI18n()(
({ i18n, name, config, isRequired = false }) => {
const validate = isRequired ? required(null, i18n) : null;
const [field, meta] = useField({ name, validate });
const isValid = !(meta.touched && meta.error);
return config ? (
<SettingGroup
defaultValue={config.default || ''}
fieldId={name}
helperTextInvalid={meta.error}
isRequired={isRequired}
label={config.label}
popoverContent={config.help_text}
validated={isValid ? 'default' : 'error'}
>
<TextArea
id={name}
isRequired={isRequired}
placeholder={config.placeholder}
validated={isValid ? 'default' : 'error'}
value={field.value}
onBlur={field.onBlur}
onChange={(value, event) => {
field.onChange(event);
}}
resizeOrientation="vertical"
/>
</SettingGroup>
) : null;
}
);
TextAreaField.propTypes = {
name: string.isRequired,
config: shape({}).isRequired,
isRequired: bool,
};
const ObjectField = withI18n()(({ i18n, name, config, isRequired = false }) => {
const validate = isRequired ? required(null, i18n) : null;
const [field, meta, helpers] = useField({ name, validate });
@ -261,4 +304,77 @@ ObjectField.propTypes = {
isRequired: bool,
};
export { BooleanField, ChoiceField, EncryptedField, InputField, ObjectField };
const FileUploadIconWrapper = styled.div`
margin: var(--pf-global--spacer--md);
`;
const FileUploadField = withI18n()(
({ i18n, name, config, type = 'text', isRequired = false }) => {
const validate = isRequired ? required(null, i18n) : null;
const [filename, setFilename] = useState('');
const [fileIsUploading, setFileIsUploading] = useState(false);
const [field, meta, helpers] = useField({ name, validate });
const isValid = !(meta.touched && meta.error);
return config ? (
<FormFullWidthLayout>
<SettingGroup
defaultValue={config.default ?? ''}
fieldId={name}
helperTextInvalid={meta.error}
isRequired={isRequired}
label={config.label}
popoverContent={config.help_text}
validated={isValid ? 'default' : 'error'}
onRevertCallback={() => setFilename('')}
>
<FileUpload
{...field}
id={name}
type={type}
filename={filename}
onChange={(value, title) => {
helpers.setValue(value);
setFilename(title);
}}
onReadStarted={() => setFileIsUploading(true)}
onReadFinished={() => setFileIsUploading(false)}
isLoading={fileIsUploading}
allowEditingUploadedText
validated={isValid ? 'default' : 'error'}
hideDefaultPreview={type === 'dataURL'}
>
{type === 'dataURL' && (
<FileUploadIconWrapper>
{field.value ? (
<img
src={field.value}
alt={filename}
height="200px"
width="200px"
/>
) : (
<FileUploadIcon size="lg" />
)}
</FileUploadIconWrapper>
)}
</FileUpload>
</SettingGroup>
</FormFullWidthLayout>
) : null;
}
);
FileUploadField.propTypes = {
name: string.isRequired,
config: shape({}).isRequired,
isRequired: bool,
};
export {
BooleanField,
ChoiceField,
EncryptedField,
FileUploadField,
InputField,
ObjectField,
TextAreaField,
};

View File

@ -1,39 +1,53 @@
import React from 'react';
import { mount } from 'enzyme';
import { Formik } from 'formik';
import { I18nProvider } from '@lingui/react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import {
BooleanField,
ChoiceField,
EncryptedField,
FileUploadField,
InputField,
ObjectField,
TextAreaField,
} from './SharedFields';
describe('Setting form fields', () => {
test('BooleanField renders the expected content', async () => {
const wrapper = mountWithContexts(
<Formik
initialValues={{
boolean: true,
}}
>
{() => (
<BooleanField
name="boolean"
config={{
label: 'test',
help_text: 'test',
}}
/>
)}
</Formik>
const outerNode = document.createElement('div');
document.body.appendChild(outerNode);
const wrapper = mount(
<I18nProvider>
<Formik
initialValues={{
boolean: true,
}}
>
{() => (
<BooleanField
name="boolean"
config={{
label: 'test',
help_text: 'test',
}}
/>
)}
</Formik>
</I18nProvider>,
{
attachTo: outerNode,
}
);
expect(wrapper.find('Switch')).toHaveLength(1);
expect(wrapper.find('Switch').prop('isChecked')).toBe(true);
expect(wrapper.find('Switch').prop('isDisabled')).toBe(false);
await act(async () => {
wrapper.find('Switch').invoke('onChange')(false);
wrapper
.find('Switch label')
.instance()
.dispatchEvent(new Event('click'));
});
wrapper.update();
expect(wrapper.find('Switch').prop('isChecked')).toBe(false);
@ -119,6 +133,38 @@ describe('Setting form fields', () => {
expect(wrapper.find('TextInputBase').prop('value')).toEqual('foo');
});
test('TextAreaField renders the expected content', async () => {
const wrapper = mountWithContexts(
<Formik
initialValues={{
mock_textarea: '',
}}
>
{() => (
<TextAreaField
name="mock_textarea"
config={{
label: 'mock textarea',
help_text: 'help text',
default: '',
}}
/>
)}
</Formik>
);
expect(wrapper.find('textarea')).toHaveLength(1);
expect(wrapper.find('textarea#mock_textarea').prop('value')).toEqual('');
await act(async () => {
wrapper.find('textarea#mock_textarea').simulate('change', {
target: { value: 'new textarea value', name: 'mock_textarea' },
});
});
wrapper.update();
expect(wrapper.find('textarea').prop('value')).toEqual(
'new textarea value'
);
});
test('ObjectField renders the expected content', async () => {
const wrapper = mountWithContexts(
<Formik
@ -149,4 +195,46 @@ describe('Setting form fields', () => {
wrapper.update();
expect(wrapper.find('CodeMirrorInput').prop('value')).toBe('[]');
});
test('FileUploadField renders the expected content', async () => {
const wrapper = mountWithContexts(
<Formik
initialValues={{
mock_file: 'mock file value',
}}
>
{() => (
<FileUploadField
name="mock_file"
config={{
label: 'mock file label',
help_text: 'mock file help',
default: '',
}}
/>
)}
</Formik>
);
expect(wrapper.find('FileUploadField')).toHaveLength(1);
expect(wrapper.find('label').text()).toEqual('mock file label');
expect(wrapper.find('input#mock_file-filename').prop('value')).toEqual('');
await act(async () => {
wrapper.find('FileUpload').invoke('onChange')(
{
text: () =>
'-----BEGIN PRIVATE KEY-----\\nAAAAAAAAAAAAAA\\n-----END PRIVATE KEY-----\\n',
},
'new file name'
);
});
wrapper.update();
expect(wrapper.find('input#mock_file-filename').prop('value')).toEqual(
'new file name'
);
await act(async () => {
wrapper.find('button[aria-label="Revert"]').invoke('onClick')();
});
wrapper.update();
expect(wrapper.find('input#mock_file-filename').prop('value')).toEqual('');
});
});

View File

@ -2745,6 +2745,29 @@
"category_slug": "system",
"default": true
},
"PENDO_TRACKING_STATE": {
"default": "off",
"type": "choice",
"required": true,
"label": "User Analytics Tracking State",
"help_text": "Enable or Disable User Analytics Tracking.",
"category": "UI",
"category_slug": "ui",
"choices": [
[
"off",
"Off"
],
[
"anonymous",
"Anonymous"
],
[
"detailed",
"Detailed"
]
]
},
"MANAGE_ORGANIZATION_AUTH": {
"type": "boolean",
"required": true,

View File

@ -98,8 +98,7 @@ function UserRolesList({ i18n, user }) {
);
const canAdd =
user?.summary_fields?.user_capabilities?.edit ||
(actions && Object.prototype.hasOwnProperty.call(actions, 'POST'));
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
const detailUrl = role => {
const { resource_id, resource_type } = role.summary_fields;

View File

@ -12,7 +12,7 @@ jest.mock('../../../api/models/Roles');
UsersAPI.readOptions.mockResolvedValue({
data: {
actions: { GET: {} },
actions: { GET: {}, POST: {} },
related_search_fields: [],
},
});

View File

@ -151,7 +151,15 @@ import json
def update_survey(module, last_request):
spec_endpoint = last_request.get('related', {}).get('survey_spec')
module.post_endpoint(spec_endpoint, **{'data': module.params.get('survey_spec')})
if module.params.get('survey_spec') == {}:
response = module.delete_endpoint(spec_endpoint)
if response['status_code'] != 200:
# Not sure how to make this actually return a non 200 to test what to dump in the respinse
module.fail_json(msg="Failed to delete survey: {0}".format(response['json']))
else:
response = module.post_endpoint(spec_endpoint, **{'data': module.params.get('survey_spec')})
if response['status_code'] != 200:
module.fail_json(msg="Failed to update survey: {0}".format(response['json']['error']))
module.exit_json(**module.json_output)

View File

@ -177,6 +177,38 @@ def test_job_template_with_survey_spec(run_module, admin_user, project, inventor
assert ActivityStream.objects.count() == prior_ct
@pytest.mark.django_db
def test_job_template_with_wrong_survey_spec(run_module, admin_user, project, inventory, survey_spec):
result = run_module('tower_job_template', dict(
name='foo',
playbook='helloworld.yml',
project=project.name,
inventory=inventory.name,
survey_spec=survey_spec,
survey_enabled=True
), admin_user)
assert not result.get('failed', False), result.get('msg', result)
assert result.get('changed', False), result
jt = JobTemplate.objects.get(pk=result['id'])
assert jt.survey_spec == survey_spec
prior_ct = ActivityStream.objects.count()
del survey_spec['description']
result = run_module('tower_job_template', dict(
name='foo',
playbook='helloworld.yml',
project=project.name,
inventory=inventory.name,
survey_spec=survey_spec,
survey_enabled=True
), admin_user)
assert result.get('failed', True)
assert result.get('msg') == "Failed to update survey: Field 'description' is missing from survey spec."
@pytest.mark.django_db
def test_job_template_with_survey_encrypted_default(run_module, admin_user, project, inventory, silence_warning):
spec = {

View File

@ -81,6 +81,34 @@ def test_survey_spec_only_changed(run_module, admin_user, organization, survey_s
assert wfjt.survey_spec == survey_spec
@pytest.mark.django_db
def test_survey_spec_only_changed(run_module, admin_user, organization, survey_spec):
wfjt = WorkflowJobTemplate.objects.create(
organization=organization, name='foo-workflow',
survey_enabled=True, survey_spec=survey_spec
)
result = run_module('tower_workflow_job_template', {
'name': 'foo-workflow',
'organization': organization.name,
'state': 'present'
}, admin_user)
assert not result.get('failed', False), result.get('msg', result)
assert not result.get('changed', True), result
wfjt.refresh_from_db()
assert wfjt.survey_spec == survey_spec
del survey_spec['description']
result = run_module('tower_workflow_job_template', {
'name': 'foo-workflow',
'organization': organization.name,
'survey_spec': survey_spec,
'state': 'present'
}, admin_user)
assert result.get('failed', True)
assert result.get('msg') == "Failed to update survey: Field 'description' is missing from survey spec."
@pytest.mark.django_db
def test_associate_only_on_success(run_module, admin_user, organization, project):
wfjt = WorkflowJobTemplate.objects.create(

View File

@ -1 +1 @@
17.0.0
17.0.1

View File

@ -0,0 +1,104 @@
# Running Development Environment in Kubernetes
## Start Minikube
If you do not already have Minikube, install it from:
https://minikube.sigs.k8s.io/docs/start/
Note: This environment has only been tested on Linux.
```
$ minikube start \
--mount \
--mount-string="/path/to/awx:/awx_devel" \
--cpus=4 \
--memory=8g \
--addons=ingress
```
### Verify
Ensure that your AWX source code is properly mounted inside of the minikube node:
```
$ minikube ssh
$ ls -la /awx_devel
```
## Deploy the AWX Operator
Clone the [awx-operator](https://github.com/ansible/awx-operator).
For the following playbooks to work, you will need to:
```
$ pip install openshift
```
If you are not changing any code in the operator itself, simply run:
```
$ ansible-playbook ansible/deploy-operator.yml
```
If making changes to the operator itself, run the following command in the root
of the awx-operator repo. If not, continue to the next section.
### Building and Deploying a Custom AWX Operator Image
```
$ operator-sdk build quay.io/<username>/awx-operator
$ docker push quay.io/<username>/awx-operator
$ ansible-playbook ansible/deploy-operator.yml \
-e pull_policy=Always \
-e operator_image=quay.io/<username>/awx-operator \
-e operator_version=latest
```
## Deploy AWX into Minikube using the AWX Operator
If have have not made any changes to the AWX Dockerfile, run the following
command. If you need to test out changes to the Dockerfile, see the
"Custom AWX Development Image for Kubernetes" section below.
In the root of awx-operator:
```
$ ansible-playbook ansible/instantiate-awx-deployment.yml \
-e development_mode=yes \
-e tower_image=gcr.io/ansible-tower-engineering/awx_kube_devel:devel \
-e tower_image_pull_policy=Always \
-e tower_ingress_type=ingress
```
### Custom AWX Development Image for Kubernetes
I have found `minikube cache add` to be unacceptably slow for larger images such
as this. A faster workflow involves building the image and pushing it to a
registry:
In the root of the AWX repo:
```
$ make awx-kube-dev-build
$ docker push gcr.io/ansible-tower-engineering/awx_kube_devel:${COMPOSE_TAG}
```
In the root of awx-operator:
```
$ ansible-playbook ansible/instantiate-awx-deployment.yml \
-e development_mode=yes \
-e tower_image=gcr.io/ansible-tower-engineering/awx_kube_devel:${COMPOSE_TAG} \
-e tower_image_pull_policy=Always \
-e tower_ingress_type=ingress
```
To iterate on changes to the Dockerfile, rebuild and push the image, then delete
the AWX Pod. A new Pod will respawn with the latest revision.
## Accessing AWX
```
$ minikube service awx-service --url
```

View File

@ -3,5 +3,6 @@
hosts: localhost
gather_facts: true
roles:
- {role: dockerfile}
- {role: image_build}
- {role: image_push, when: "docker_registry is defined"}

6
installer/dockerfile.yml Normal file
View File

@ -0,0 +1,6 @@
---
- name: Render AWX Dockerfile and sources
hosts: localhost
gather_facts: true
roles:
- {role: dockerfile}

View File

@ -0,0 +1,6 @@
---
build_dev: false
kube_dev: false
dockerfile_dest: '..'
dockerfile_name: 'Dockerfile'
template_dest: '_build'

View File

@ -5,6 +5,14 @@ if [ `id -u` -ge 500 ]; then
rm /tmp/passwd
fi
if [ -n "${AWX_KUBE_DEVEL}" ]; then
pushd /awx_devel
make awx-link
popd
export SDB_NOTIFY_HOST=$(ip route | head -n1 | awk '{print $3}')
fi
source /etc/tower/conf.d/environment.sh
ANSIBLE_REMOTE_TEMP=/tmp ANSIBLE_LOCAL_TEMP=/tmp ansible -i "127.0.0.1," -c local -v -m wait_for -a "host=$DATABASE_HOST port=$DATABASE_PORT" all

View File

@ -5,6 +5,14 @@ if [ `id -u` -ge 500 ]; then
rm /tmp/passwd
fi
if [ -n "${AWX_KUBE_DEVEL}" ]; then
pushd /awx_devel
make awx-link
popd
export SDB_NOTIFY_HOST=$(ip route | head -n1 | awk '{print $3}')
fi
source /etc/tower/conf.d/environment.sh
ANSIBLE_REMOTE_TEMP=/tmp ANSIBLE_LOCAL_TEMP=/tmp ansible -i "127.0.0.1," -c local -v -m wait_for -a "host=$DATABASE_HOST port=$DATABASE_PORT" all

View File

@ -0,0 +1,19 @@
---
- name: Create .build directory
file:
path: "{{ dockerfile_dest }}/{{ template_dest }}"
state: directory
- name: Render supervisor configs
template:
src: "{{ item }}.j2"
dest: "{{ dockerfile_dest }}/{{ template_dest }}/{{ item }}"
with_items:
- "supervisor.conf"
- "supervisor_task.conf"
- name: Render Dockerfile
template:
src: Dockerfile.j2
dest: "{{ dockerfile_dest }}/{{ dockerfile_name }}"

View File

@ -1,12 +1,8 @@
{% if build_dev|default(False)|bool %}
### This file is generated from
### installer/roles/image_build/templates/Dockerfile.j2
### installer/roles/dockerfile/templates/Dockerfile.j2
###
### DO NOT EDIT
###
{% else %}
{% set build_dev = False %}
{% endif %}
# Locations - set globally to be used across stages
ARG COLLECTION_BASE="/var/lib/awx/vendor/awx_ansible_collections"
@ -67,12 +63,10 @@ ADD requirements/requirements_ansible.txt \
RUN cd /tmp && make requirements_awx requirements_ansible_py3
RUN cd /tmp && make requirements_collections
{% if build_dev|bool %}
{% if (build_dev|bool) or (kube_dev|bool) %}
ADD requirements/requirements_dev.txt /tmp/requirements
RUN cd /tmp && make requirements_awx_dev requirements_ansible_dev
{% endif %}
{% if not build_dev|bool %}
{% else %}
# Use the distro provided npm to bootstrap our required version of node
RUN npm install -g n && n 14.15.1 && dnf remove -y nodejs
@ -81,6 +75,7 @@ COPY . /tmp/src/
WORKDIR /tmp/src/
RUN make sdist && \
/var/lib/awx/venv/awx/bin/pip install dist/awx-$(cat VERSION).tar.gz
RUN ln -s /var/lib/awx/venv/awx/bin/awx-manage /usr/bin/awx-manage
{% endif %}
# Final container(s)
@ -146,7 +141,7 @@ RUN cd /usr/local/bin && \
curl -L https://github.com/openshift/origin/releases/download/v3.11.0/openshift-origin-client-tools-v3.11.0-0cbc58b-linux-64bit.tar.gz | \
tar -xz --strip-components=1 --wildcards --no-anchored 'oc'
{% if build_dev|bool %}
{% if (build_dev|bool) or (kube_dev|bool) %}
# Install development/test requirements
RUN dnf -y install \
gdb \
@ -183,32 +178,32 @@ RUN openssl req -nodes -newkey rsa:2048 -keyout /etc/nginx/nginx.key -out /etc/n
-subj "/C=US/ST=North Carolina/L=Durham/O=Ansible/OU=AWX Development/CN=awx.localhost" && \
openssl x509 -req -days 365 -in /etc/nginx/nginx.csr -signkey /etc/nginx/nginx.key -out /etc/nginx/nginx.crt && \
chmod 640 /etc/nginx/nginx.{csr,key,crt}
{% else %}
RUN ln -s /var/lib/awx/venv/awx/bin/awx-manage /usr/bin/awx-manage
{% endif %}
# Create default awx rsyslog config
ADD installer/roles/image_build/files/rsyslog.conf /var/lib/awx/rsyslog/rsyslog.conf
ADD installer/roles/dockerfile/files/rsyslog.conf /var/lib/awx/rsyslog/rsyslog.conf
## File mappings
{% if build_dev|bool %}
ADD tools/docker-compose/launch_awx.sh /usr/bin/launch_awx.sh
ADD tools/docker-compose/awx-manage /usr/local/bin/awx-manage
ADD tools/docker-compose/awx.egg-link /tmp/awx.egg-link
ADD tools/docker-compose/nginx.conf /etc/nginx/nginx.conf
ADD tools/docker-compose/nginx.vh.default.conf /etc/nginx/conf.d/nginx.vh.default.conf
ADD tools/docker-compose/start_tests.sh /start_tests.sh
ADD tools/docker-compose/bootstrap_development.sh /usr/bin/bootstrap_development.sh
ADD tools/docker-compose/entrypoint.sh /entrypoint.sh
ADD tools/scripts/awx-python /usr/bin/awx-python
{% else %}
ADD installer/roles/image_build/files/launch_awx.sh /usr/bin/launch_awx.sh
ADD installer/roles/image_build/files/launch_awx_task.sh /usr/bin/launch_awx_task.sh
ADD installer/roles/image_build/files/settings.py /etc/tower/settings.py
ADD installer/roles/image_build/files/supervisor.conf /etc/supervisord.conf
ADD installer/roles/image_build/files/supervisor_task.conf /etc/supervisord_task.conf
ADD installer/roles/dockerfile/files/launch_awx.sh /usr/bin/launch_awx.sh
ADD installer/roles/dockerfile/files/launch_awx_task.sh /usr/bin/launch_awx_task.sh
ADD installer/roles/dockerfile/files/settings.py /etc/tower/settings.py
ADD {{ template_dest }}/supervisor.conf /etc/supervisord.conf
ADD {{ template_dest }}/supervisor_task.conf /etc/supervisord_task.conf
ADD tools/scripts/config-watcher /usr/bin/config-watcher
{% endif %}
{% if (build_dev|bool) or (kube_dev|bool) %}
ADD tools/docker-compose/awx.egg-link /tmp/awx.egg-link
ADD tools/docker-compose/awx-manage /usr/local/bin/awx-manage
ADD tools/scripts/awx-python /usr/bin/awx-python
{% endif %}
# Pre-create things we need to access
RUN for dir in \
@ -231,7 +226,7 @@ RUN chmod u+s /usr/bin/bwrap ; \
chgrp -R root ${COLLECTION_BASE} ; \
chmod -R g+rw ${COLLECTION_BASE}
{% if build_dev|bool %}
{% if (build_dev|bool) or (kube_dev|bool) %}
RUN for dir in \
/var/lib/awx/venv \
/var/lib/awx/venv/awx/lib/python3.6 \
@ -256,6 +251,7 @@ ENV HOME="/var/lib/awx"
ENV PATH="/usr/pgsql-10/bin:${PATH}"
{% if build_dev|bool %}
EXPOSE 8043 8013 8080 22
ENTRYPOINT ["/entrypoint.sh"]

View File

@ -6,7 +6,12 @@ logfile_maxbytes = 0
pidfile = /var/run/supervisor/supervisor.web.pid
[program:nginx]
{% if kube_dev | bool %}
command = make nginx
directory = /awx_devel
{% else %}
command = nginx -g "daemon off;"
{% endif %}
autostart = true
autorestart = true
stopwaitsecs = 5
@ -16,34 +21,59 @@ stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:uwsgi]
{% if kube_dev | bool %}
command = make uwsgi
directory = /awx_devel
environment =
UWSGI_DEV_RELOAD_COMMAND='supervisorctl -c /etc/supervisord_task.conf restart all; supervisorctl restart tower-processes:daphne tower-processes:wsbroadcast'
{% else %}
command = /var/lib/awx/venv/awx/bin/uwsgi --socket 127.0.0.1:8050 --module=awx.wsgi:application --vacuum --processes=5 --harakiri=120 --no-orphans --master --max-requests=1000 --master-fifo=/var/lib/awx/awxfifo --lazy-apps -b 32768
directory = /var/lib/awx
{% endif %}
autostart = true
autorestart = true
stopwaitsecs = 15
stopsignal = INT
stopasgroup=true
killasgroup=true
stopsignal=KILL
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:daphne]
{% if kube_dev | bool %}
command = make daphne
directory = /awx_devel
{% else %}
command = /var/lib/awx/venv/awx/bin/daphne -b 127.0.0.1 -p 8051 --websocket_timeout -1 awx.asgi:channel_layer
directory = /var/lib/awx
{% endif %}
autostart = true
stopsignal=KILL
autorestart = true
stopwaitsecs = 5
stopasgroup=true
killasgroup=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:wsbroadcast]
{% if kube_dev | bool %}
command = make wsbroadcast
directory = /awx_devel
{% else %}
command = awx-manage run_wsbroadcast
directory = /var/lib/awx
{% endif %}
autostart = true
autorestart = true
stopwaitsecs = 5
stopasgroup=true
killasgroup=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
@ -53,6 +83,7 @@ stderr_logfile_maxbytes=0
command = rsyslogd -n -i /var/run/awx-rsyslog/rsyslog.pid -f /var/lib/awx/rsyslog/rsyslog.conf
autostart = true
autorestart = true
startretries = 10
stopwaitsecs = 5
stopsignal=TERM
stopasgroup=true

View File

@ -6,8 +6,13 @@ logfile_maxbytes = 0
pidfile = /var/run/supervisor/supervisor.pid
[program:dispatcher]
{% if kube_dev | bool %}
command = make dispatcher
directory = /awx_devel
{% else %}
command = awx-manage run_dispatcher
directory = /var/lib/awx
{% endif %}
autostart = true
autorestart = true
stopwaitsecs = 5
@ -17,8 +22,13 @@ stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:callback-receiver]
{% if kube_dev | bool %}
command = make receiver
directory = /awx_devel
{% else %}
command = awx-manage run_callback_receiver
directory = /var/lib/awx
{% endif %}
autostart = true
autorestart = true
stopwaitsecs = 5

View File

@ -1,6 +1,5 @@
---
create_preload_data: true
build_dev: false
# Helper vars to construct the proper download URL for the current architecture
tini_architecture: '{{ { "x86_64": "amd64", "aarch64": "arm64", "armv7": "arm" }[ansible_facts.architecture] }}'

View File

@ -21,11 +21,6 @@
set_fact:
awx_image: "{{ awx_image|default('awx') }}"
- name: Render Dockerfile
template:
src: Dockerfile.j2
dest: ../Dockerfile
# Calling Docker directly because docker-py doesnt support BuildKit
- name: Build AWX image
command: docker build -t {{ awx_image }}:{{ awx_version }} ..

View File

@ -61,6 +61,7 @@ data:
autostart = true
autorestart = true
stopwaitsecs = 5
startretries = 10
stopsignal=TERM
stopasgroup=true
killasgroup=true