mirror of
https://github.com/ansible/awx.git
synced 2026-05-06 08:57:35 -02:30
3
.gitignore
vendored
3
.gitignore
vendored
@@ -149,3 +149,6 @@ use_dev_supervisor.txt
|
|||||||
/tools/docker-compose/overrides/
|
/tools/docker-compose/overrides/
|
||||||
/awx/ui_next/.ui-built
|
/awx/ui_next/.ui-built
|
||||||
/Dockerfile
|
/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>`.
|
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)
|
# 17.0.0 (January 22, 2021)
|
||||||
- AWX now requires PostgreSQL 12 by default: https://github.com/ansible/awx/pull/8943
|
- 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
|
**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; \
|
fi; \
|
||||||
mkdir -p awx/public/static && $(PYTHON) manage.py collectstatic --clear --noinput > /dev/null 2>&1
|
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
|
uwsgi: collectstatic
|
||||||
@if [ "$(VENV_BASE)" ]; then \
|
@if [ "$(VENV_BASE)" ]; then \
|
||||||
. $(VENV_BASE)/awx/bin/activate; \
|
. $(VENV_BASE)/awx/bin/activate; \
|
||||||
fi; \
|
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:
|
daphne:
|
||||||
@if [ "$(VENV_BASE)" ]; then \
|
@if [ "$(VENV_BASE)" ]; then \
|
||||||
@@ -579,15 +595,18 @@ docker-compose-clean: awx/projects
|
|||||||
|
|
||||||
# Base development image build
|
# Base development image build
|
||||||
docker-compose-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
|
ansible-playbook installer/dockerfile.yml -e build_dev=True
|
||||||
docker build -t ansible/awx_devel -f tools/docker-compose/Dockerfile \
|
docker build -t ansible/awx_devel \
|
||||||
--cache-from=$(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) .
|
--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 tag ansible/awx_devel $(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG)
|
||||||
#docker push $(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
|
# For use when developing on "isolated" AWX deployments
|
||||||
docker-compose-isolated-build: docker-compose-build
|
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 tag ansible/awx_isolated $(DEV_DOCKER_TAG_BASE)/awx_isolated:$(COMPOSE_TAG)
|
||||||
#docker push $(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:
|
VERSION:
|
||||||
@echo "awx: $(VERSION)"
|
@echo "awx: $(VERSION)"
|
||||||
|
|
||||||
Dockerfile: installer/roles/image_build/templates/Dockerfile.j2
|
Dockerfile: installer/roles/dockerfile/templates/Dockerfile.j2
|
||||||
ansible localhost -m template -a "src=installer/roles/image_build/templates/Dockerfile.j2 dest=Dockerfile"
|
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',
|
'url': 'https://towerhost/#/jobs/playbook/1010',
|
||||||
'approval_status': 'approved',
|
'approval_status': 'approved',
|
||||||
'approval_node_name': 'Approve Me',
|
'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',
|
'job_metadata': """{'url': 'https://towerhost/$/jobs/playbook/13',
|
||||||
'traceback': '',
|
'traceback': '',
|
||||||
'status': 'running',
|
'status': 'running',
|
||||||
|
|||||||
@@ -620,7 +620,7 @@ class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificatio
|
|||||||
return reverse('api:workflow_job_detail', kwargs={'pk': self.pk}, request=request)
|
return reverse('api:workflow_job_detail', kwargs={'pk': self.pk}, request=request)
|
||||||
|
|
||||||
def get_ui_url(self):
|
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):
|
def notification_data(self):
|
||||||
result = super(WorkflowJob, self).notification_data()
|
result = super(WorkflowJob, self).notification_data()
|
||||||
@@ -752,7 +752,7 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def get_ui_url(self):
|
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):
|
def _get_parent_field_name(self):
|
||||||
return 'workflow_approval_template'
|
return 'workflow_approval_template'
|
||||||
@@ -840,7 +840,7 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin):
|
|||||||
return (msg, body)
|
return (msg, body)
|
||||||
|
|
||||||
def context(self, approval_status):
|
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,
|
return {'approval_status': approval_status,
|
||||||
'approval_node_name': self.workflow_approval_template.name,
|
'approval_node_name': self.workflow_approval_template.name,
|
||||||
'workflow_url': workflow_url,
|
'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
|
# default settings for development. If not present, we can still run using
|
||||||
# only the defaults.
|
# only the defaults.
|
||||||
try:
|
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:
|
except ImportError:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
sys.exit(1)
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case 'workflow_job':
|
case 'workflow_job':
|
||||||
url = `/workflows/${obj.id}/`;
|
url = `/jobs/workflow/${obj.id}/`;
|
||||||
break;
|
break;
|
||||||
case 'label':
|
case 'label':
|
||||||
url = null;
|
url = null;
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ function JobDetail({ job, i18n }) {
|
|||||||
workflow_job_template: workflowJobTemplate,
|
workflow_job_template: workflowJobTemplate,
|
||||||
labels,
|
labels,
|
||||||
project,
|
project,
|
||||||
|
source_workflow_job,
|
||||||
} = job.summary_fields;
|
} = job.summary_fields;
|
||||||
const [errorMsg, setErrorMsg] = useState();
|
const [errorMsg, setErrorMsg] = useState();
|
||||||
const history = useHistory();
|
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`Job Type`)} value={jobTypes[job.type]} />
|
||||||
<Detail
|
<Detail
|
||||||
label={i18n._(t`Launched By`)}
|
label={i18n._(t`Launched By`)}
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ describe('<JobDetail />', () => {
|
|||||||
kubernetes: false,
|
kubernetes: false,
|
||||||
credential_type_id: 1,
|
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('Started', '8/8/2019, 7:24:18 PM');
|
||||||
assertDetail('Finished', '8/8/2019, 7:24:50 PM');
|
assertDetail('Finished', '8/8/2019, 7:24:50 PM');
|
||||||
assertDetail('Job Template', mockJobData.summary_fields.job_template.name);
|
assertDetail('Job Template', mockJobData.summary_fields.job_template.name);
|
||||||
|
assertDetail('Source Workflow Job', `1234 - Test Source Workflow`);
|
||||||
assertDetail('Job Type', 'Playbook Run');
|
assertDetail('Job Type', 'Playbook Run');
|
||||||
assertDetail('Launched By', mockJobData.summary_fields.created_by.username);
|
assertDetail('Launched By', mockJobData.summary_fields.created_by.username);
|
||||||
assertDetail('Inventory', mockJobData.summary_fields.inventory.name);
|
assertDetail('Inventory', mockJobData.summary_fields.inventory.name);
|
||||||
|
|||||||
@@ -3,11 +3,31 @@ import { act } from 'react-dom/test-utils';
|
|||||||
import { createMemoryHistory } from 'history';
|
import { createMemoryHistory } from 'history';
|
||||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||||
import { SettingsAPI } from '../../../api';
|
import { SettingsAPI } from '../../../api';
|
||||||
|
import { SettingsProvider } from '../../../contexts/Settings';
|
||||||
|
import mockAllOptions from '../shared/data.allSettingOptions.json';
|
||||||
import SAML from './SAML';
|
import SAML from './SAML';
|
||||||
|
|
||||||
jest.mock('../../../api/models/Settings');
|
jest.mock('../../../api/models/Settings');
|
||||||
SettingsAPI.readCategory.mockResolvedValue({
|
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 />', () => {
|
describe('<SAML />', () => {
|
||||||
@@ -23,9 +43,14 @@ describe('<SAML />', () => {
|
|||||||
initialEntries: ['/settings/saml/details'],
|
initialEntries: ['/settings/saml/details'],
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<SAML />, {
|
wrapper = mountWithContexts(
|
||||||
context: { router: { history } },
|
<SettingsProvider value={mockAllOptions.actions}>
|
||||||
});
|
<SAML />
|
||||||
|
</SettingsProvider>,
|
||||||
|
{
|
||||||
|
context: { router: { history } },
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
expect(wrapper.find('SAMLDetail').length).toBe(1);
|
expect(wrapper.find('SAMLDetail').length).toBe(1);
|
||||||
});
|
});
|
||||||
@@ -35,9 +60,14 @@ describe('<SAML />', () => {
|
|||||||
initialEntries: ['/settings/saml/edit'],
|
initialEntries: ['/settings/saml/edit'],
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<SAML />, {
|
wrapper = mountWithContexts(
|
||||||
context: { router: { history } },
|
<SettingsProvider value={mockAllOptions.actions}>
|
||||||
});
|
<SAML />
|
||||||
|
</SettingsProvider>,
|
||||||
|
{
|
||||||
|
context: { router: { history } },
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
expect(wrapper.find('SAMLEdit').length).toBe(1);
|
expect(wrapper.find('SAMLEdit').length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { SettingDetail } from '../../shared';
|
|||||||
function SAMLDetail({ i18n }) {
|
function SAMLDetail({ i18n }) {
|
||||||
const { me } = useConfig();
|
const { me } = useConfig();
|
||||||
const { GET: options } = useSettings();
|
const { GET: options } = useSettings();
|
||||||
|
options.SOCIAL_AUTH_SAML_SP_PUBLIC_CERT.type = 'certificate';
|
||||||
|
|
||||||
const { isLoading, error, request, result: saml } = useRequest(
|
const { isLoading, error, request, result: saml } = useRequest(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ SettingsAPI.readCategory.mockResolvedValue({
|
|||||||
SOCIAL_AUTH_SAML_TEAM_MAP: {},
|
SOCIAL_AUTH_SAML_TEAM_MAP: {},
|
||||||
SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {},
|
SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {},
|
||||||
SOCIAL_AUTH_SAML_TEAM_ATTR: {},
|
SOCIAL_AUTH_SAML_TEAM_ATTR: {},
|
||||||
|
SAML_AUTO_CREATE_OBJECTS: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,6 +60,11 @@ describe('<SAMLDetail />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should render expected details', () => {
|
test('should render expected details', () => {
|
||||||
|
assertDetail(
|
||||||
|
wrapper,
|
||||||
|
'Automatically Create Organizations and Teams on SAML Login',
|
||||||
|
'Off'
|
||||||
|
);
|
||||||
assertDetail(
|
assertDetail(
|
||||||
wrapper,
|
wrapper,
|
||||||
'SAML Assertion Consumer Service (ACS) URL',
|
'SAML Assertion Consumer Service (ACS) URL',
|
||||||
@@ -70,7 +76,7 @@ describe('<SAMLDetail />', () => {
|
|||||||
'https://towerhost/sso/metadata/saml/'
|
'https://towerhost/sso/metadata/saml/'
|
||||||
);
|
);
|
||||||
assertDetail(wrapper, 'SAML Service Provider Entity ID', 'mock_id');
|
assertDetail(wrapper, 'SAML Service Provider Entity ID', 'mock_id');
|
||||||
assertDetail(
|
assertVariableDetail(
|
||||||
wrapper,
|
wrapper,
|
||||||
'SAML Service Provider Public Certificate',
|
'SAML Service Provider Public Certificate',
|
||||||
'mock_cert'
|
'mock_cert'
|
||||||
|
|||||||
@@ -1,25 +1,208 @@
|
|||||||
import React from 'react';
|
import React, { useCallback, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import { withI18n } from '@lingui/react';
|
import { Formik } from 'formik';
|
||||||
import { t } from '@lingui/macro';
|
import { Form } from '@patternfly/react-core';
|
||||||
import { Button } from '@patternfly/react-core';
|
import { CardBody } from '../../../../components/Card';
|
||||||
import { CardBody, CardActionsRow } 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 (
|
return (
|
||||||
<CardBody>
|
<CardBody>
|
||||||
{i18n._(t`Edit form coming soon :)`)}
|
{isLoading && <ContentLoading />}
|
||||||
<CardActionsRow>
|
{!isLoading && error && <ContentError error={error} />}
|
||||||
<Button
|
{!isLoading && saml && (
|
||||||
aria-label={i18n._(t`Cancel`)}
|
<Formik initialValues={initialValues(saml)} onSubmit={handleSubmit}>
|
||||||
component={Link}
|
{formik => (
|
||||||
to="/settings/saml/details"
|
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||||
>
|
<FormColumnLayout>
|
||||||
{i18n._(t`Cancel`)}
|
<InputField
|
||||||
</Button>
|
name="SOCIAL_AUTH_SAML_SP_ENTITY_ID"
|
||||||
</CardActionsRow>
|
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>
|
</CardBody>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withI18n()(SAMLEdit);
|
export default SAMLEdit;
|
||||||
|
|||||||
@@ -1,16 +1,251 @@
|
|||||||
import React from 'react';
|
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';
|
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 />', () => {
|
describe('<SAMLEdit />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
beforeEach(() => {
|
let history;
|
||||||
wrapper = mountWithContexts(<SAMLEdit />);
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
wrapper.unmount();
|
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', () => {
|
test('initially renders without crashing', () => {
|
||||||
expect(wrapper.find('SAMLEdit').length).toBe(1);
|
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,
|
waitForElement,
|
||||||
} from '../../../../testUtils/enzymeHelpers';
|
} from '../../../../testUtils/enzymeHelpers';
|
||||||
import { SettingsAPI } from '../../../api';
|
import { SettingsAPI } from '../../../api';
|
||||||
|
import { SettingsProvider } from '../../../contexts/Settings';
|
||||||
|
import mockAllOptions from '../shared/data.allSettingOptions.json';
|
||||||
import UI from './UI';
|
import UI from './UI';
|
||||||
|
|
||||||
jest.mock('../../../api/models/Settings');
|
jest.mock('../../../api/models/Settings');
|
||||||
SettingsAPI.readCategory.mockResolvedValue({
|
SettingsAPI.readCategory.mockResolvedValue({
|
||||||
data: {},
|
data: {
|
||||||
|
CUSTOM_LOGIN_INFO: '',
|
||||||
|
CUSTOM_LOGO: '',
|
||||||
|
PENDO_TRACKING_STATE: 'off',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('<UI />', () => {
|
describe('<UI />', () => {
|
||||||
@@ -26,9 +32,14 @@ describe('<UI />', () => {
|
|||||||
initialEntries: ['/settings/ui/details'],
|
initialEntries: ['/settings/ui/details'],
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<UI />, {
|
wrapper = mountWithContexts(
|
||||||
context: { router: { history } },
|
<SettingsProvider value={mockAllOptions.actions}>
|
||||||
});
|
<UI />
|
||||||
|
</SettingsProvider>,
|
||||||
|
{
|
||||||
|
context: { router: { history } },
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
expect(wrapper.find('UIDetail').length).toBe(1);
|
expect(wrapper.find('UIDetail').length).toBe(1);
|
||||||
@@ -39,9 +50,14 @@ describe('<UI />', () => {
|
|||||||
initialEntries: ['/settings/ui/edit'],
|
initialEntries: ['/settings/ui/edit'],
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<UI />, {
|
wrapper = mountWithContexts(
|
||||||
context: { router: { history } },
|
<SettingsProvider value={mockAllOptions.actions}>
|
||||||
});
|
<UI />
|
||||||
|
</SettingsProvider>,
|
||||||
|
{
|
||||||
|
context: { router: { history } },
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
expect(wrapper.find('UIEdit').length).toBe(1);
|
expect(wrapper.find('UIEdit').length).toBe(1);
|
||||||
|
|||||||
@@ -1,25 +1,128 @@
|
|||||||
import React from 'react';
|
import React, { useCallback, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import { withI18n } from '@lingui/react';
|
import { Formik } from 'formik';
|
||||||
import { t } from '@lingui/macro';
|
import { Form } from '@patternfly/react-core';
|
||||||
import { Button } from '@patternfly/react-core';
|
import { CardBody } from '../../../../components/Card';
|
||||||
import { CardBody, CardActionsRow } 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 (
|
return (
|
||||||
<CardBody>
|
<CardBody>
|
||||||
{i18n._(t`Edit form coming soon :)`)}
|
{isLoading && <ContentLoading />}
|
||||||
<CardActionsRow>
|
{!isLoading && error && <ContentError error={error} />}
|
||||||
<Button
|
{!isLoading && uiData && (
|
||||||
aria-label={i18n._(t`Cancel`)}
|
<Formik
|
||||||
component={Link}
|
initialValues={{
|
||||||
to="/settings/ui/details"
|
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`)}
|
{formik => (
|
||||||
</Button>
|
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||||
</CardActionsRow>
|
<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>
|
</CardBody>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withI18n()(UIEdit);
|
export default UIEdit;
|
||||||
|
|||||||
@@ -1,16 +1,151 @@
|
|||||||
import React from 'react';
|
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';
|
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 />', () => {
|
describe('<UIEdit />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
beforeEach(() => {
|
let history;
|
||||||
wrapper = mountWithContexts(<UIEdit />);
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
wrapper.unmount();
|
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', () => {
|
test('initially renders without crashing', () => {
|
||||||
expect(wrapper.find('UIEdit').length).toBe(1);
|
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 [field, meta, helpers] = useField(id);
|
||||||
const initialValue = meta.initialValue ?? '';
|
const initialValue = meta.initialValue ?? '';
|
||||||
const currentValue = field.value;
|
const currentValue = field.value;
|
||||||
@@ -30,6 +36,7 @@ function RevertButton({ i18n, id, defaultValue, isDisabled = false }) {
|
|||||||
|
|
||||||
function handleConfirm() {
|
function handleConfirm() {
|
||||||
helpers.setValue(isRevertable ? defaultValue : initialValue);
|
helpers.setValue(isRevertable ? defaultValue : initialValue);
|
||||||
|
onRevertCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
const revertTooltipContent = isRevertable
|
const revertTooltipContent = isRevertable
|
||||||
|
|||||||
@@ -34,6 +34,18 @@ export default withI18n()(
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case 'certificate':
|
||||||
|
detail = (
|
||||||
|
<CodeDetail
|
||||||
|
dataCy={id}
|
||||||
|
helpText={helpText}
|
||||||
|
label={label}
|
||||||
|
mode="javascript"
|
||||||
|
rows={4}
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
case 'image':
|
case 'image':
|
||||||
detail = (
|
detail = (
|
||||||
<Detail
|
<Detail
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { bool, oneOf, shape, string } from 'prop-types';
|
import { bool, oneOf, shape, string } from 'prop-types';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { useField } from 'formik';
|
import { useField } from 'formik';
|
||||||
import {
|
import {
|
||||||
|
FileUpload,
|
||||||
FormGroup as PFFormGroup,
|
FormGroup as PFFormGroup,
|
||||||
InputGroup,
|
InputGroup,
|
||||||
TextInput,
|
|
||||||
Switch,
|
Switch,
|
||||||
|
TextArea,
|
||||||
|
TextInput,
|
||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
|
import FileUploadIcon from '@patternfly/react-icons/dist/js/icons/file-upload-icon';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import AnsibleSelect from '../../../components/AnsibleSelect';
|
import AnsibleSelect from '../../../components/AnsibleSelect';
|
||||||
import CodeMirrorInput from '../../../components/CodeMirrorInput';
|
import CodeMirrorInput from '../../../components/CodeMirrorInput';
|
||||||
@@ -42,16 +45,17 @@ const SettingGroup = withI18n()(
|
|||||||
isDisabled,
|
isDisabled,
|
||||||
isRequired,
|
isRequired,
|
||||||
label,
|
label,
|
||||||
|
onRevertCallback,
|
||||||
popoverContent,
|
popoverContent,
|
||||||
validated,
|
validated,
|
||||||
}) => (
|
}) => (
|
||||||
<FormGroup
|
<FormGroup
|
||||||
fieldId={fieldId}
|
fieldId={fieldId}
|
||||||
helperTextInvalid={helperTextInvalid}
|
helperTextInvalid={helperTextInvalid}
|
||||||
|
id={`${fieldId}-field`}
|
||||||
isRequired={isRequired}
|
isRequired={isRequired}
|
||||||
label={label}
|
label={label}
|
||||||
validated={validated}
|
validated={validated}
|
||||||
id={fieldId}
|
|
||||||
labelIcon={
|
labelIcon={
|
||||||
<>
|
<>
|
||||||
<Popover
|
<Popover
|
||||||
@@ -62,6 +66,7 @@ const SettingGroup = withI18n()(
|
|||||||
id={fieldId}
|
id={fieldId}
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
|
onRevertCallback={onRevertCallback}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -220,6 +225,44 @@ InputField.propTypes = {
|
|||||||
isRequired: bool,
|
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 ObjectField = withI18n()(({ i18n, name, config, isRequired = false }) => {
|
||||||
const validate = isRequired ? required(null, i18n) : null;
|
const validate = isRequired ? required(null, i18n) : null;
|
||||||
const [field, meta, helpers] = useField({ name, validate });
|
const [field, meta, helpers] = useField({ name, validate });
|
||||||
@@ -261,4 +304,77 @@ ObjectField.propTypes = {
|
|||||||
isRequired: bool,
|
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 React from 'react';
|
||||||
|
import { mount } from 'enzyme';
|
||||||
import { Formik } from 'formik';
|
import { Formik } from 'formik';
|
||||||
|
import { I18nProvider } from '@lingui/react';
|
||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||||
import {
|
import {
|
||||||
BooleanField,
|
BooleanField,
|
||||||
ChoiceField,
|
ChoiceField,
|
||||||
EncryptedField,
|
EncryptedField,
|
||||||
|
FileUploadField,
|
||||||
InputField,
|
InputField,
|
||||||
ObjectField,
|
ObjectField,
|
||||||
|
TextAreaField,
|
||||||
} from './SharedFields';
|
} from './SharedFields';
|
||||||
|
|
||||||
describe('Setting form fields', () => {
|
describe('Setting form fields', () => {
|
||||||
test('BooleanField renders the expected content', async () => {
|
test('BooleanField renders the expected content', async () => {
|
||||||
const wrapper = mountWithContexts(
|
const outerNode = document.createElement('div');
|
||||||
<Formik
|
document.body.appendChild(outerNode);
|
||||||
initialValues={{
|
const wrapper = mount(
|
||||||
boolean: true,
|
<I18nProvider>
|
||||||
}}
|
<Formik
|
||||||
>
|
initialValues={{
|
||||||
{() => (
|
boolean: true,
|
||||||
<BooleanField
|
}}
|
||||||
name="boolean"
|
>
|
||||||
config={{
|
{() => (
|
||||||
label: 'test',
|
<BooleanField
|
||||||
help_text: 'test',
|
name="boolean"
|
||||||
}}
|
config={{
|
||||||
/>
|
label: 'test',
|
||||||
)}
|
help_text: 'test',
|
||||||
</Formik>
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
</I18nProvider>,
|
||||||
|
{
|
||||||
|
attachTo: outerNode,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
expect(wrapper.find('Switch')).toHaveLength(1);
|
expect(wrapper.find('Switch')).toHaveLength(1);
|
||||||
expect(wrapper.find('Switch').prop('isChecked')).toBe(true);
|
expect(wrapper.find('Switch').prop('isChecked')).toBe(true);
|
||||||
expect(wrapper.find('Switch').prop('isDisabled')).toBe(false);
|
expect(wrapper.find('Switch').prop('isDisabled')).toBe(false);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper.find('Switch').invoke('onChange')(false);
|
wrapper
|
||||||
|
.find('Switch label')
|
||||||
|
.instance()
|
||||||
|
.dispatchEvent(new Event('click'));
|
||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
expect(wrapper.find('Switch').prop('isChecked')).toBe(false);
|
expect(wrapper.find('Switch').prop('isChecked')).toBe(false);
|
||||||
@@ -119,6 +133,38 @@ describe('Setting form fields', () => {
|
|||||||
expect(wrapper.find('TextInputBase').prop('value')).toEqual('foo');
|
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 () => {
|
test('ObjectField renders the expected content', async () => {
|
||||||
const wrapper = mountWithContexts(
|
const wrapper = mountWithContexts(
|
||||||
<Formik
|
<Formik
|
||||||
@@ -149,4 +195,46 @@ describe('Setting form fields', () => {
|
|||||||
wrapper.update();
|
wrapper.update();
|
||||||
expect(wrapper.find('CodeMirrorInput').prop('value')).toBe('[]');
|
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",
|
"category_slug": "system",
|
||||||
"default": true
|
"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": {
|
"MANAGE_ORGANIZATION_AUTH": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"required": true,
|
"required": true,
|
||||||
|
|||||||
@@ -98,8 +98,7 @@ function UserRolesList({ i18n, user }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const canAdd =
|
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 detailUrl = role => {
|
||||||
const { resource_id, resource_type } = role.summary_fields;
|
const { resource_id, resource_type } = role.summary_fields;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ jest.mock('../../../api/models/Roles');
|
|||||||
|
|
||||||
UsersAPI.readOptions.mockResolvedValue({
|
UsersAPI.readOptions.mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
actions: { GET: {} },
|
actions: { GET: {}, POST: {} },
|
||||||
related_search_fields: [],
|
related_search_fields: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -151,7 +151,15 @@ import json
|
|||||||
|
|
||||||
def update_survey(module, last_request):
|
def update_survey(module, last_request):
|
||||||
spec_endpoint = last_request.get('related', {}).get('survey_spec')
|
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)
|
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
|
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
|
@pytest.mark.django_db
|
||||||
def test_job_template_with_survey_encrypted_default(run_module, admin_user, project, inventory, silence_warning):
|
def test_job_template_with_survey_encrypted_default(run_module, admin_user, project, inventory, silence_warning):
|
||||||
spec = {
|
spec = {
|
||||||
|
|||||||
@@ -81,6 +81,34 @@ def test_survey_spec_only_changed(run_module, admin_user, organization, survey_s
|
|||||||
assert wfjt.survey_spec == survey_spec
|
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
|
@pytest.mark.django_db
|
||||||
def test_associate_only_on_success(run_module, admin_user, organization, project):
|
def test_associate_only_on_success(run_module, admin_user, organization, project):
|
||||||
wfjt = WorkflowJobTemplate.objects.create(
|
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
|
hosts: localhost
|
||||||
gather_facts: true
|
gather_facts: true
|
||||||
roles:
|
roles:
|
||||||
|
- {role: dockerfile}
|
||||||
- {role: image_build}
|
- {role: image_build}
|
||||||
- {role: image_push, when: "docker_registry is defined"}
|
- {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
|
rm /tmp/passwd
|
||||||
fi
|
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
|
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
|
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
|
rm /tmp/passwd
|
||||||
fi
|
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
|
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
|
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
|
### This file is generated from
|
||||||
### installer/roles/image_build/templates/Dockerfile.j2
|
### installer/roles/dockerfile/templates/Dockerfile.j2
|
||||||
###
|
###
|
||||||
### DO NOT EDIT
|
### DO NOT EDIT
|
||||||
###
|
###
|
||||||
{% else %}
|
|
||||||
{% set build_dev = False %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
# Locations - set globally to be used across stages
|
# Locations - set globally to be used across stages
|
||||||
ARG COLLECTION_BASE="/var/lib/awx/vendor/awx_ansible_collections"
|
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_awx requirements_ansible_py3
|
||||||
RUN cd /tmp && make requirements_collections
|
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
|
ADD requirements/requirements_dev.txt /tmp/requirements
|
||||||
RUN cd /tmp && make requirements_awx_dev requirements_ansible_dev
|
RUN cd /tmp && make requirements_awx_dev requirements_ansible_dev
|
||||||
{% endif %}
|
{% else %}
|
||||||
|
|
||||||
{% if not build_dev|bool %}
|
|
||||||
# Use the distro provided npm to bootstrap our required version of node
|
# 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
|
RUN npm install -g n && n 14.15.1 && dnf remove -y nodejs
|
||||||
|
|
||||||
@@ -81,6 +75,7 @@ COPY . /tmp/src/
|
|||||||
WORKDIR /tmp/src/
|
WORKDIR /tmp/src/
|
||||||
RUN make sdist && \
|
RUN make sdist && \
|
||||||
/var/lib/awx/venv/awx/bin/pip install dist/awx-$(cat VERSION).tar.gz
|
/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 %}
|
{% endif %}
|
||||||
|
|
||||||
# Final container(s)
|
# 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 | \
|
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'
|
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
|
# Install development/test requirements
|
||||||
RUN dnf -y install \
|
RUN dnf -y install \
|
||||||
gdb \
|
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" && \
|
-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 && \
|
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}
|
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 %}
|
{% endif %}
|
||||||
|
|
||||||
# Create default awx rsyslog config
|
# 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
|
## File mappings
|
||||||
{% if build_dev|bool %}
|
{% if build_dev|bool %}
|
||||||
ADD tools/docker-compose/launch_awx.sh /usr/bin/launch_awx.sh
|
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.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/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/start_tests.sh /start_tests.sh
|
||||||
ADD tools/docker-compose/bootstrap_development.sh /usr/bin/bootstrap_development.sh
|
ADD tools/docker-compose/bootstrap_development.sh /usr/bin/bootstrap_development.sh
|
||||||
ADD tools/docker-compose/entrypoint.sh /entrypoint.sh
|
ADD tools/docker-compose/entrypoint.sh /entrypoint.sh
|
||||||
ADD tools/scripts/awx-python /usr/bin/awx-python
|
|
||||||
{% else %}
|
{% else %}
|
||||||
ADD installer/roles/image_build/files/launch_awx.sh /usr/bin/launch_awx.sh
|
ADD installer/roles/dockerfile/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/dockerfile/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/dockerfile/files/settings.py /etc/tower/settings.py
|
||||||
ADD installer/roles/image_build/files/supervisor.conf /etc/supervisord.conf
|
ADD {{ template_dest }}/supervisor.conf /etc/supervisord.conf
|
||||||
ADD installer/roles/image_build/files/supervisor_task.conf /etc/supervisord_task.conf
|
ADD {{ template_dest }}/supervisor_task.conf /etc/supervisord_task.conf
|
||||||
ADD tools/scripts/config-watcher /usr/bin/config-watcher
|
ADD tools/scripts/config-watcher /usr/bin/config-watcher
|
||||||
{% endif %}
|
{% 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
|
# Pre-create things we need to access
|
||||||
RUN for dir in \
|
RUN for dir in \
|
||||||
@@ -231,7 +226,7 @@ RUN chmod u+s /usr/bin/bwrap ; \
|
|||||||
chgrp -R root ${COLLECTION_BASE} ; \
|
chgrp -R root ${COLLECTION_BASE} ; \
|
||||||
chmod -R g+rw ${COLLECTION_BASE}
|
chmod -R g+rw ${COLLECTION_BASE}
|
||||||
|
|
||||||
{% if build_dev|bool %}
|
{% if (build_dev|bool) or (kube_dev|bool) %}
|
||||||
RUN for dir in \
|
RUN for dir in \
|
||||||
/var/lib/awx/venv \
|
/var/lib/awx/venv \
|
||||||
/var/lib/awx/venv/awx/lib/python3.6 \
|
/var/lib/awx/venv/awx/lib/python3.6 \
|
||||||
@@ -256,6 +251,7 @@ ENV HOME="/var/lib/awx"
|
|||||||
ENV PATH="/usr/pgsql-10/bin:${PATH}"
|
ENV PATH="/usr/pgsql-10/bin:${PATH}"
|
||||||
|
|
||||||
{% if build_dev|bool %}
|
{% if build_dev|bool %}
|
||||||
|
|
||||||
EXPOSE 8043 8013 8080 22
|
EXPOSE 8043 8013 8080 22
|
||||||
|
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
@@ -6,7 +6,12 @@ logfile_maxbytes = 0
|
|||||||
pidfile = /var/run/supervisor/supervisor.web.pid
|
pidfile = /var/run/supervisor/supervisor.web.pid
|
||||||
|
|
||||||
[program:nginx]
|
[program:nginx]
|
||||||
|
{% if kube_dev | bool %}
|
||||||
|
command = make nginx
|
||||||
|
directory = /awx_devel
|
||||||
|
{% else %}
|
||||||
command = nginx -g "daemon off;"
|
command = nginx -g "daemon off;"
|
||||||
|
{% endif %}
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
stopwaitsecs = 5
|
stopwaitsecs = 5
|
||||||
@@ -16,34 +21,59 @@ stderr_logfile=/dev/stderr
|
|||||||
stderr_logfile_maxbytes=0
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
[program:uwsgi]
|
[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
|
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
|
directory = /var/lib/awx
|
||||||
|
{% endif %}
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
stopwaitsecs = 15
|
stopwaitsecs = 15
|
||||||
stopsignal = INT
|
stopasgroup=true
|
||||||
|
killasgroup=true
|
||||||
|
stopsignal=KILL
|
||||||
stdout_logfile=/dev/stdout
|
stdout_logfile=/dev/stdout
|
||||||
stdout_logfile_maxbytes=0
|
stdout_logfile_maxbytes=0
|
||||||
stderr_logfile=/dev/stderr
|
stderr_logfile=/dev/stderr
|
||||||
stderr_logfile_maxbytes=0
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
[program:daphne]
|
[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
|
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
|
directory = /var/lib/awx
|
||||||
|
{% endif %}
|
||||||
autostart = true
|
autostart = true
|
||||||
|
stopsignal=KILL
|
||||||
autorestart = true
|
autorestart = true
|
||||||
stopwaitsecs = 5
|
stopwaitsecs = 5
|
||||||
|
stopasgroup=true
|
||||||
|
killasgroup=true
|
||||||
stdout_logfile=/dev/stdout
|
stdout_logfile=/dev/stdout
|
||||||
stdout_logfile_maxbytes=0
|
stdout_logfile_maxbytes=0
|
||||||
stderr_logfile=/dev/stderr
|
stderr_logfile=/dev/stderr
|
||||||
stderr_logfile_maxbytes=0
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
[program:wsbroadcast]
|
[program:wsbroadcast]
|
||||||
|
{% if kube_dev | bool %}
|
||||||
|
command = make wsbroadcast
|
||||||
|
directory = /awx_devel
|
||||||
|
{% else %}
|
||||||
command = awx-manage run_wsbroadcast
|
command = awx-manage run_wsbroadcast
|
||||||
directory = /var/lib/awx
|
directory = /var/lib/awx
|
||||||
|
{% endif %}
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
stopwaitsecs = 5
|
stopwaitsecs = 5
|
||||||
|
stopasgroup=true
|
||||||
|
killasgroup=true
|
||||||
stdout_logfile=/dev/stdout
|
stdout_logfile=/dev/stdout
|
||||||
stdout_logfile_maxbytes=0
|
stdout_logfile_maxbytes=0
|
||||||
stderr_logfile=/dev/stderr
|
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
|
command = rsyslogd -n -i /var/run/awx-rsyslog/rsyslog.pid -f /var/lib/awx/rsyslog/rsyslog.conf
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
|
startretries = 10
|
||||||
stopwaitsecs = 5
|
stopwaitsecs = 5
|
||||||
stopsignal=TERM
|
stopsignal=TERM
|
||||||
stopasgroup=true
|
stopasgroup=true
|
||||||
@@ -6,8 +6,13 @@ logfile_maxbytes = 0
|
|||||||
pidfile = /var/run/supervisor/supervisor.pid
|
pidfile = /var/run/supervisor/supervisor.pid
|
||||||
|
|
||||||
[program:dispatcher]
|
[program:dispatcher]
|
||||||
|
{% if kube_dev | bool %}
|
||||||
|
command = make dispatcher
|
||||||
|
directory = /awx_devel
|
||||||
|
{% else %}
|
||||||
command = awx-manage run_dispatcher
|
command = awx-manage run_dispatcher
|
||||||
directory = /var/lib/awx
|
directory = /var/lib/awx
|
||||||
|
{% endif %}
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
stopwaitsecs = 5
|
stopwaitsecs = 5
|
||||||
@@ -17,8 +22,13 @@ stderr_logfile=/dev/stderr
|
|||||||
stderr_logfile_maxbytes=0
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
[program:callback-receiver]
|
[program:callback-receiver]
|
||||||
|
{% if kube_dev | bool %}
|
||||||
|
command = make receiver
|
||||||
|
directory = /awx_devel
|
||||||
|
{% else %}
|
||||||
command = awx-manage run_callback_receiver
|
command = awx-manage run_callback_receiver
|
||||||
directory = /var/lib/awx
|
directory = /var/lib/awx
|
||||||
|
{% endif %}
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
stopwaitsecs = 5
|
stopwaitsecs = 5
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
---
|
---
|
||||||
create_preload_data: true
|
create_preload_data: true
|
||||||
build_dev: false
|
|
||||||
|
|
||||||
# Helper vars to construct the proper download URL for the current architecture
|
# Helper vars to construct the proper download URL for the current architecture
|
||||||
tini_architecture: '{{ { "x86_64": "amd64", "aarch64": "arm64", "armv7": "arm" }[ansible_facts.architecture] }}'
|
tini_architecture: '{{ { "x86_64": "amd64", "aarch64": "arm64", "armv7": "arm" }[ansible_facts.architecture] }}'
|
||||||
|
|||||||
@@ -21,11 +21,6 @@
|
|||||||
set_fact:
|
set_fact:
|
||||||
awx_image: "{{ awx_image|default('awx') }}"
|
awx_image: "{{ awx_image|default('awx') }}"
|
||||||
|
|
||||||
- name: Render Dockerfile
|
|
||||||
template:
|
|
||||||
src: Dockerfile.j2
|
|
||||||
dest: ../Dockerfile
|
|
||||||
|
|
||||||
# Calling Docker directly because docker-py doesnt support BuildKit
|
# Calling Docker directly because docker-py doesnt support BuildKit
|
||||||
- name: Build AWX image
|
- name: Build AWX image
|
||||||
command: docker build -t {{ awx_image }}:{{ awx_version }} ..
|
command: docker build -t {{ awx_image }}:{{ awx_version }} ..
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ data:
|
|||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
stopwaitsecs = 5
|
stopwaitsecs = 5
|
||||||
|
startretries = 10
|
||||||
stopsignal=TERM
|
stopsignal=TERM
|
||||||
stopasgroup=true
|
stopasgroup=true
|
||||||
killasgroup=true
|
killasgroup=true
|
||||||
|
|||||||
Reference in New Issue
Block a user