mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 18:09:57 -03:30
commit
012189cb10
3
.gitignore
vendored
3
.gitignore
vendored
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
44
Makefile
44
Makefile
@ -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) .
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
4
awx/settings/minikube.py
Normal file
@ -0,0 +1,4 @@
|
||||
BROADCAST_WEBSOCKET_SECRET = '🤖starscream🤖'
|
||||
BROADCAST_WEBSOCKET_PORT = 8013
|
||||
BROADCAST_WEBSOCKET_VERIFY_CERT = False
|
||||
BROADCAST_WEBSOCKET_PROTOCOL = 'http'
|
||||
@ -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;
|
||||
|
||||
@ -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`)}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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('');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -12,7 +12,7 @@ jest.mock('../../../api/models/Roles');
|
||||
|
||||
UsersAPI.readOptions.mockResolvedValue({
|
||||
data: {
|
||||
actions: { GET: {} },
|
||||
actions: { GET: {}, POST: {} },
|
||||
related_search_fields: [],
|
||||
},
|
||||
});
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -1 +1 @@
|
||||
17.0.0
|
||||
17.0.1
|
||||
|
||||
104
docs/development/minikube.md
Normal file
104
docs/development/minikube.md
Normal 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
|
||||
```
|
||||
@ -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
6
installer/dockerfile.yml
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
- name: Render AWX Dockerfile and sources
|
||||
hosts: localhost
|
||||
gather_facts: true
|
||||
roles:
|
||||
- {role: dockerfile}
|
||||
6
installer/roles/dockerfile/defaults/main.yml
Normal file
6
installer/roles/dockerfile/defaults/main.yml
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
build_dev: false
|
||||
kube_dev: false
|
||||
dockerfile_dest: '..'
|
||||
dockerfile_name: 'Dockerfile'
|
||||
template_dest: '_build'
|
||||
@ -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
|
||||
@ -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
|
||||
19
installer/roles/dockerfile/tasks/main.yml
Normal file
19
installer/roles/dockerfile/tasks/main.yml
Normal 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 }}"
|
||||
@ -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"]
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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] }}'
|
||||
|
||||
@ -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 }} ..
|
||||
|
||||
@ -61,6 +61,7 @@ data:
|
||||
autostart = true
|
||||
autorestart = true
|
||||
stopwaitsecs = 5
|
||||
startretries = 10
|
||||
stopsignal=TERM
|
||||
stopasgroup=true
|
||||
killasgroup=true
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user