Add EE to the settings page

Allow a system admin to set the global default execution environment.

See: https://github.com/ansible/awx/issues/9088

This PR is also addressing the issue: https://github.com/ansible/awx/issues/9669
This commit is contained in:
nixocio
2021-03-18 16:54:56 -04:00
parent a7992d06e3
commit b110a4a94e
9 changed files with 233 additions and 38 deletions

View File

@@ -186,7 +186,7 @@ register(
default=None, default=None,
queryset=ExecutionEnvironment.objects.all(), queryset=ExecutionEnvironment.objects.all(),
label=_('Global default execution environment'), label=_('Global default execution environment'),
help_text=_('.'), help_text=_('The Execution Environment to be used when one has not been configured for a job template.'),
category=_('System'), category=_('System'),
category_slug='system', category_slug='system',
) )

View File

@@ -25,6 +25,7 @@ function ExecutionEnvironmentLookup({
globallyAvailable, globallyAvailable,
i18n, i18n,
isDefaultEnvironment, isDefaultEnvironment,
isGlobalDefaultEnvironment,
isDisabled, isDisabled,
onBlur, onBlur,
onChange, onChange,
@@ -154,17 +155,26 @@ function ExecutionEnvironmentLookup({
</> </>
); );
const renderLabel = (
globalDefaultEnvironment,
defaultExecutionEnvironment
) => {
if (globalDefaultEnvironment) {
return i18n._(t`Global Default Execution Environment`);
}
if (defaultExecutionEnvironment) {
return i18n._(t`Default Execution Environment`);
}
return i18n._(t`Execution Environment`);
};
return ( return (
<FormGroup <FormGroup
fieldId="execution-environment-lookup" fieldId="execution-environment-lookup"
label={ label={renderLabel(isGlobalDefaultEnvironment, isDefaultEnvironment)}
isDefaultEnvironment
? i18n._(t`Default Execution Environment`)
: i18n._(t`Execution Environment`)
}
labelIcon={popoverContent && <Popover content={popoverContent} />} labelIcon={popoverContent && <Popover content={popoverContent} />}
> >
{isDisabled ? ( {tooltip ? (
<Tooltip content={tooltip}>{renderLookup()}</Tooltip> <Tooltip content={tooltip}>{renderLookup()}</Tooltip>
) : ( ) : (
renderLookup() renderLookup()
@@ -180,6 +190,7 @@ ExecutionEnvironmentLookup.propTypes = {
popoverContent: string, popoverContent: string,
onChange: func.isRequired, onChange: func.isRequired,
isDefaultEnvironment: bool, isDefaultEnvironment: bool,
isGlobalDefaultEnvironment: bool,
projectId: oneOfType([number, string]), projectId: oneOfType([number, string]),
organizationId: oneOfType([number, string]), organizationId: oneOfType([number, string]),
}; };
@@ -187,6 +198,7 @@ ExecutionEnvironmentLookup.propTypes = {
ExecutionEnvironmentLookup.defaultProps = { ExecutionEnvironmentLookup.defaultProps = {
popoverContent: '', popoverContent: '',
isDefaultEnvironment: false, isDefaultEnvironment: false,
isGlobalDefaultEnvironment: false,
value: null, value: null,
projectId: null, projectId: null,
organizationId: null, organizationId: null,

View File

@@ -9,7 +9,7 @@ import ContentError from '../../../../components/ContentError';
import ContentLoading from '../../../../components/ContentLoading'; import ContentLoading from '../../../../components/ContentLoading';
import { DetailList } from '../../../../components/DetailList'; import { DetailList } from '../../../../components/DetailList';
import RoutedTabs from '../../../../components/RoutedTabs'; import RoutedTabs from '../../../../components/RoutedTabs';
import { SettingsAPI } from '../../../../api'; import { SettingsAPI, ExecutionEnvironmentsAPI } from '../../../../api';
import useRequest from '../../../../util/useRequest'; import useRequest from '../../../../util/useRequest';
import { useConfig } from '../../../../contexts/Config'; import { useConfig } from '../../../../contexts/Config';
import { useSettings } from '../../../../contexts/Settings'; import { useSettings } from '../../../../contexts/Settings';
@@ -23,7 +23,15 @@ function MiscSystemDetail({ i18n }) {
const { isLoading, error, request, result: system } = useRequest( const { isLoading, error, request, result: system } = useRequest(
useCallback(async () => { useCallback(async () => {
const { data } = await SettingsAPI.readCategory('all'); const { data } = await SettingsAPI.readCategory('all');
let DEFAULT_EXECUTION_ENVIRONMENT = '';
if (data.DEFAULT_EXECUTION_ENVIRONMENT) {
const {
data: { name },
} = await ExecutionEnvironmentsAPI.readDetail(
data.DEFAULT_EXECUTION_ENVIRONMENT
);
DEFAULT_EXECUTION_ENVIRONMENT = name;
}
const { const {
OAUTH2_PROVIDER: { OAUTH2_PROVIDER: {
ACCESS_TOKEN_EXPIRE_SECONDS, ACCESS_TOKEN_EXPIRE_SECONDS,
@@ -49,19 +57,17 @@ function MiscSystemDetail({ i18n }) {
'SESSION_COOKIE_AGE', 'SESSION_COOKIE_AGE',
'TOWER_URL_BASE' 'TOWER_URL_BASE'
); );
const systemData = { const systemData = {
...pluckedSystemData, ...pluckedSystemData,
ACCESS_TOKEN_EXPIRE_SECONDS, ACCESS_TOKEN_EXPIRE_SECONDS,
REFRESH_TOKEN_EXPIRE_SECONDS, REFRESH_TOKEN_EXPIRE_SECONDS,
AUTHORIZATION_CODE_EXPIRE_SECONDS, AUTHORIZATION_CODE_EXPIRE_SECONDS,
DEFAULT_EXECUTION_ENVIRONMENT,
}; };
const { const {
OAUTH2_PROVIDER: OAUTH2_PROVIDER_OPTIONS, OAUTH2_PROVIDER: OAUTH2_PROVIDER_OPTIONS,
...options ...options
} = allOptions; } = allOptions;
const systemOptions = { const systemOptions = {
...options, ...options,
ACCESS_TOKEN_EXPIRE_SECONDS: { ACCESS_TOKEN_EXPIRE_SECONDS: {
@@ -80,7 +86,6 @@ function MiscSystemDetail({ i18n }) {
label: i18n._(t`Authorization Code Expiration`), label: i18n._(t`Authorization Code Expiration`),
}, },
}; };
const mergedData = {}; const mergedData = {};
Object.keys(systemData).forEach(key => { Object.keys(systemData).forEach(key => {
mergedData[key] = systemOptions[key]; mergedData[key] = systemOptions[key];

View File

@@ -5,7 +5,7 @@ import {
waitForElement, waitForElement,
} from '../../../../../testUtils/enzymeHelpers'; } from '../../../../../testUtils/enzymeHelpers';
import { SettingsProvider } from '../../../../contexts/Settings'; import { SettingsProvider } from '../../../../contexts/Settings';
import { SettingsAPI } from '../../../../api'; import { SettingsAPI, ExecutionEnvironmentsAPI } from '../../../../api';
import { import {
assertDetail, assertDetail,
assertVariableDetail, assertVariableDetail,
@@ -14,13 +14,14 @@ import mockAllOptions from '../../shared/data.allSettingOptions.json';
import MiscSystemDetail from './MiscSystemDetail'; import MiscSystemDetail from './MiscSystemDetail';
jest.mock('../../../../api/models/Settings'); jest.mock('../../../../api/models/Settings');
jest.mock('../../../../api/models/ExecutionEnvironments');
SettingsAPI.readCategory.mockResolvedValue({ SettingsAPI.readCategory.mockResolvedValue({
data: { data: {
ALLOW_OAUTH2_FOR_EXTERNAL_USERS: false, ALLOW_OAUTH2_FOR_EXTERNAL_USERS: false,
AUTH_BASIC_ENABLED: true, AUTH_BASIC_ENABLED: true,
AUTOMATION_ANALYTICS_GATHER_INTERVAL: 14400, AUTOMATION_ANALYTICS_GATHER_INTERVAL: 14400,
AUTOMATION_ANALYTICS_URL: 'https://example.com', AUTOMATION_ANALYTICS_URL: 'https://example.com',
CUSTOM_VENV_PATHS: [],
INSIGHTS_TRACKING_STATE: false, INSIGHTS_TRACKING_STATE: false,
LOGIN_REDIRECT_OVERRIDE: 'https://redirect.com', LOGIN_REDIRECT_OVERRIDE: 'https://redirect.com',
MANAGE_ORGANIZATION_AUTH: true, MANAGE_ORGANIZATION_AUTH: true,
@@ -36,6 +37,16 @@ SettingsAPI.readCategory.mockResolvedValue({
SESSIONS_PER_USER: -1, SESSIONS_PER_USER: -1,
SESSION_COOKIE_AGE: 30000000000, SESSION_COOKIE_AGE: 30000000000,
TOWER_URL_BASE: 'https://towerhost', TOWER_URL_BASE: 'https://towerhost',
DEFAULT_EXECUTION_ENVIRONMENT: 1,
},
});
ExecutionEnvironmentsAPI.readDetail.mockResolvedValue({
data: {
id: 1,
name: 'Foo',
image: 'quay.io/ansible/awx-ee',
pull: 'missing',
}, },
}); });
@@ -110,6 +121,33 @@ describe('<MiscSystemDetail />', () => {
assertDetail(wrapper, 'Red Hat customer username', 'mock name'); assertDetail(wrapper, 'Red Hat customer username', 'mock name');
assertDetail(wrapper, 'Refresh Token Expiration', '3 seconds'); assertDetail(wrapper, 'Refresh Token Expiration', '3 seconds');
assertVariableDetail(wrapper, 'Remote Host Headers', '[]'); assertVariableDetail(wrapper, 'Remote Host Headers', '[]');
assertDetail(wrapper, 'Global default execution environment', 'Foo');
});
test('should render execution environment as not configured', async () => {
ExecutionEnvironmentsAPI.readDetail.mockResolvedValue({
data: {},
});
let newWrapper;
await act(async () => {
newWrapper = mountWithContexts(
<SettingsProvider
value={{
...mockAllOptions.actions,
DEFAULT_EXECUTION_ENVIRONMENT: null,
}}
>
<MiscSystemDetail />
</SettingsProvider>
);
});
await waitForElement(newWrapper, 'ContentLoading', el => el.length === 0);
assertDetail(
newWrapper,
'Global default execution environment',
'Not configured'
);
}); });
test('should hide edit button from non-superusers', async () => { test('should hide edit button from non-superusers', async () => {

View File

@@ -9,6 +9,7 @@ import ContentError from '../../../../components/ContentError';
import ContentLoading from '../../../../components/ContentLoading'; import ContentLoading from '../../../../components/ContentLoading';
import { FormSubmitError } from '../../../../components/FormField'; import { FormSubmitError } from '../../../../components/FormField';
import { FormColumnLayout } from '../../../../components/FormLayout'; import { FormColumnLayout } from '../../../../components/FormLayout';
import { ExecutionEnvironmentLookup } from '../../../../components/Lookup';
import { useSettings } from '../../../../contexts/Settings'; import { useSettings } from '../../../../contexts/Settings';
import { import {
BooleanField, BooleanField,
@@ -20,7 +21,7 @@ import {
} from '../../shared'; } from '../../shared';
import useModal from '../../../../util/useModal'; import useModal from '../../../../util/useModal';
import useRequest from '../../../../util/useRequest'; import useRequest from '../../../../util/useRequest';
import { SettingsAPI } from '../../../../api'; import { SettingsAPI, ExecutionEnvironmentsAPI } from '../../../../api';
import { pluck, formatJson } from '../../shared/settingUtils'; import { pluck, formatJson } from '../../shared/settingUtils';
function MiscSystemEdit({ i18n }) { function MiscSystemEdit({ i18n }) {
@@ -44,7 +45,6 @@ function MiscSystemEdit({ i18n }) {
'AUTH_BASIC_ENABLED', 'AUTH_BASIC_ENABLED',
'AUTOMATION_ANALYTICS_GATHER_INTERVAL', 'AUTOMATION_ANALYTICS_GATHER_INTERVAL',
'AUTOMATION_ANALYTICS_URL', 'AUTOMATION_ANALYTICS_URL',
'CUSTOM_VENV_PATHS',
'INSIGHTS_TRACKING_STATE', 'INSIGHTS_TRACKING_STATE',
'LOGIN_REDIRECT_OVERRIDE', 'LOGIN_REDIRECT_OVERRIDE',
'MANAGE_ORGANIZATION_AUTH', 'MANAGE_ORGANIZATION_AUTH',
@@ -55,7 +55,8 @@ function MiscSystemEdit({ i18n }) {
'REMOTE_HOST_HEADERS', 'REMOTE_HOST_HEADERS',
'SESSIONS_PER_USER', 'SESSIONS_PER_USER',
'SESSION_COOKIE_AGE', 'SESSION_COOKIE_AGE',
'TOWER_URL_BASE' 'TOWER_URL_BASE',
'DEFAULT_EXECUTION_ENVIRONMENT'
); );
const systemData = { const systemData = {
@@ -128,6 +129,7 @@ function MiscSystemEdit({ i18n }) {
AUTHORIZATION_CODE_EXPIRE_SECONDS, AUTHORIZATION_CODE_EXPIRE_SECONDS,
...formData ...formData
} = form; } = form;
await submitForm({ await submitForm({
...formData, ...formData,
REMOTE_HOST_HEADERS: formatJson(formData.REMOTE_HOST_HEADERS), REMOTE_HOST_HEADERS: formatJson(formData.REMOTE_HOST_HEADERS),
@@ -136,6 +138,8 @@ function MiscSystemEdit({ i18n }) {
REFRESH_TOKEN_EXPIRE_SECONDS, REFRESH_TOKEN_EXPIRE_SECONDS,
AUTHORIZATION_CODE_EXPIRE_SECONDS, AUTHORIZATION_CODE_EXPIRE_SECONDS,
}, },
DEFAULT_EXECUTION_ENVIRONMENT:
formData.DEFAULT_EXECUTION_ENVIRONMENT?.id || null,
}); });
}; };
@@ -178,16 +182,73 @@ function MiscSystemEdit({ i18n }) {
return acc; return acc;
}, {}); }, {});
const executionEnvironmentId =
system?.DEFAULT_EXECUTION_ENVIRONMENT?.value || null;
const {
isLoading: isLoadingExecutionEnvironment,
error: errorExecutionEnvironment,
request: fetchExecutionEnvironment,
result: executionEnvironment,
} = useRequest(
useCallback(async () => {
if (!executionEnvironmentId) {
return '';
}
const { data } = await ExecutionEnvironmentsAPI.readDetail(
executionEnvironmentId
);
return data;
}, [executionEnvironmentId])
);
useEffect(() => {
fetchExecutionEnvironment();
}, [fetchExecutionEnvironment]);
return ( return (
<CardBody> <CardBody>
{isLoading && <ContentLoading />} {(isLoading || isLoadingExecutionEnvironment) && <ContentLoading />}
{!isLoading && error && <ContentError error={error} />} {!(isLoading || isLoadingExecutionEnvironment) && error && (
{!isLoading && system && ( <ContentError error={error || errorExecutionEnvironment} />
<Formik initialValues={initialValues(system)} onSubmit={handleSubmit}> )}
{!(isLoading || isLoadingExecutionEnvironment) && system && (
<Formik
initialValues={{
...initialValues(system),
DEFAULT_EXECUTION_ENVIRONMENT: executionEnvironment
? { id: executionEnvironment.id, name: executionEnvironment.name }
: null,
}}
onSubmit={handleSubmit}
>
{formik => { {formik => {
return ( return (
<Form autoComplete="off" onSubmit={formik.handleSubmit}> <Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout> <FormColumnLayout>
<ExecutionEnvironmentLookup
helperTextInvalid={
formik.errors.DEFAULT_EXECUTION_ENVIRONMENT
}
isValid={
!formik.touched.DEFAULT_EXECUTION_ENVIRONMENT ||
!formik.errors.DEFAULT_EXECUTION_ENVIRONMENT
}
onBlur={() =>
formik.setFieldTouched('DEFAULT_EXECUTION_ENVIRONMENT')
}
value={formik.values.DEFAULT_EXECUTION_ENVIRONMENT}
onChange={value =>
formik.setFieldValue(
'DEFAULT_EXECUTION_ENVIRONMENT',
value
)
}
popoverContent={i18n._(
t`The Execution Environment to be used when one has not been configured for a job template.`
)}
isGlobalDefaultEnvironment
/>
<InputField <InputField
name="TOWER_URL_BASE" name="TOWER_URL_BASE"
config={system.TOWER_URL_BASE} config={system.TOWER_URL_BASE}

View File

@@ -8,14 +8,55 @@ import {
import mockAllOptions from '../../shared/data.allSettingOptions.json'; import mockAllOptions from '../../shared/data.allSettingOptions.json';
import mockAllSettings from '../../shared/data.allSettings.json'; import mockAllSettings from '../../shared/data.allSettings.json';
import { SettingsProvider } from '../../../../contexts/Settings'; import { SettingsProvider } from '../../../../contexts/Settings';
import { SettingsAPI } from '../../../../api'; import { SettingsAPI, ExecutionEnvironmentsAPI } from '../../../../api';
import MiscSystemEdit from './MiscSystemEdit'; import MiscSystemEdit from './MiscSystemEdit';
jest.mock('../../../../api/models/Settings'); jest.mock('../../../../api/models/Settings');
jest.mock('../../../../api/models/ExecutionEnvironments');
SettingsAPI.updateAll.mockResolvedValue({}); SettingsAPI.updateAll.mockResolvedValue({});
SettingsAPI.readCategory.mockResolvedValue({ SettingsAPI.readCategory.mockResolvedValue({
data: mockAllSettings, data: mockAllSettings,
}); });
const mockExecutionEnvironment = [
{
id: 1,
name: 'Default EE',
description: '',
image: 'quay.io/ansible/awx-ee',
},
];
ExecutionEnvironmentsAPI.read.mockResolvedValue({
data: {
results: mockExecutionEnvironment,
count: 1,
},
});
const systemData = {
ALLOW_OAUTH2_FOR_EXTERNAL_USERS: false,
AUTH_BASIC_ENABLED: true,
AUTOMATION_ANALYTICS_GATHER_INTERVAL: 14400,
AUTOMATION_ANALYTICS_URL: 'https://example.com',
DEFAULT_EXECUTION_ENVIRONMENT: 1,
INSIGHTS_TRACKING_STATE: false,
LOGIN_REDIRECT_OVERRIDE: '',
MANAGE_ORGANIZATION_AUTH: true,
OAUTH2_PROVIDER: {
ACCESS_TOKEN_EXPIRE_SECONDS: 31536000000,
AUTHORIZATION_CODE_EXPIRE_SECONDS: 600,
REFRESH_TOKEN_EXPIRE_SECONDS: 2628000,
},
ORG_ADMINS_CAN_SEE_ALL_USERS: true,
REDHAT_PASSWORD: '',
REDHAT_USERNAME: '',
REMOTE_HOST_HEADERS: ['REMOTE_ADDR', 'REMOTE_HOST'],
SESSIONS_PER_USER: -1,
SESSION_COOKIE_AGE: 1800,
TOWER_URL_BASE: 'https://localhost:3000',
};
describe('<MiscSystemEdit />', () => { describe('<MiscSystemEdit />', () => {
let wrapper; let wrapper;
let history; let history;
@@ -42,10 +83,40 @@ describe('<MiscSystemEdit />', () => {
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
}); });
test('initially renders without crashing', () => { test('initially renders without crashing', async () => {
expect(wrapper.find('MiscSystemEdit').length).toBe(1); expect(wrapper.find('MiscSystemEdit').length).toBe(1);
}); });
test('save button should call updateAll', async () => {
expect(wrapper.find('MiscSystemEdit').length).toBe(1);
wrapper.find('ExecutionEnvironmentLookup').invoke('onChange')({
id: 1,
name: 'Foo',
});
wrapper.update();
await act(async () => {
wrapper.find('button[aria-label="Save"]').simulate('click');
});
wrapper.update();
expect(SettingsAPI.updateAll).toHaveBeenCalledWith(systemData);
});
test('should remove execution environment', async () => {
expect(wrapper.find('MiscSystemEdit').length).toBe(1);
wrapper.find('ExecutionEnvironmentLookup').invoke('onChange')(null);
wrapper.update();
await act(async () => {
wrapper.find('button[aria-label="Save"]').simulate('click');
});
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
...systemData,
DEFAULT_EXECUTION_ENVIRONMENT: null,
});
});
test('should successfully send default values to api on form revert all', async () => { test('should successfully send default values to api on form revert all', async () => {
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0); expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
expect(wrapper.find('RevertAllAlert')).toHaveLength(0); expect(wrapper.find('RevertAllAlert')).toHaveLength(0);

View File

@@ -88,6 +88,8 @@ export default withI18n()(
); );
break; break;
case 'choice': case 'choice':
case 'field':
case 'string':
detail = ( detail = (
<Detail <Detail
alwaysVisible alwaysVisible
@@ -110,18 +112,6 @@ export default withI18n()(
/> />
); );
break; break;
case 'string':
detail = (
<Detail
alwaysVisible
dataCy={id}
helpText={helpText}
isNotConfigured={!value}
label={label}
value={!value ? i18n._(t`Not configured`) : value}
/>
);
break;
default: default:
detail = null; detail = null;
} }

View File

@@ -2944,7 +2944,15 @@
"child": { "child": {
"type": "field" "type": "field"
} }
} },
"DEFAULT_EXECUTION_ENVIRONMENT": {
"type": "field",
"label": "Global default execution environment",
"help_text": "The Execution Environment to be used when one has not been configured for a job template.",
"category": "System",
"category_slug": "system",
"defined_in_file": false
}
}, },
"PUT": { "PUT": {
"ACTIVITY_STREAM_ENABLED": { "ACTIVITY_STREAM_ENABLED": {
@@ -7049,6 +7057,15 @@
"read_only": false "read_only": false
} }
}, },
"DEFAULT_EXECUTION_ENVIRONMENT": {
"type": "field",
"required": false,
"label": "Global default execution environment",
"help_text": "The Execution Environment to be used when one has not been configured for a job template.",
"category": "System",
"category_slug": "system",
"default": null
},
"SOCIAL_AUTH_SAML_TEAM_ATTR": { "SOCIAL_AUTH_SAML_TEAM_ATTR": {
"type": "nested object", "type": "nested object",
"required": false, "required": false,

View File

@@ -303,5 +303,6 @@
"applications":{"fields":["name"],"adj_list":[["organization","organizations"]]}, "applications":{"fields":["name"],"adj_list":[["organization","organizations"]]},
"users":{"fields":["username"],"adj_list":[]}, "users":{"fields":["username"],"adj_list":[]},
"instances":{"fields":["hostname"],"adj_list":[]} "instances":{"fields":["hostname"],"adj_list":[]}
} },
"DEFAULT_EXECUTION_ENVIRONMENT": 1
} }