mirror of
https://github.com/ansible/awx.git
synced 2026-02-05 11:34:43 -03:30
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a4c85e473 | ||
|
|
09d883f94a | ||
|
|
9ef57ec510 | ||
|
|
5be006f9d3 | ||
|
|
089bafa5d4 | ||
|
|
fa278f83ad | ||
|
|
0d68ca8f14 | ||
|
|
713079bd70 | ||
|
|
d3b137fbc4 | ||
|
|
5246c842b2 | ||
|
|
1dca4c9098 | ||
|
|
8cb32045f0 | ||
|
|
4962b729de | ||
|
|
ed39a127e7 | ||
|
|
c4b4a4c21a | ||
|
|
bd81fda05c | ||
|
|
83550eeba0 | ||
|
|
4540cb653e | ||
|
|
69597c5654 | ||
|
|
fa61aef194 | ||
|
|
e35f6b2acb | ||
|
|
a8140e86d7 | ||
|
|
4d4ae84e32 | ||
|
|
ae349addfe | ||
|
|
31fdd5e85c | ||
|
|
e4bde24f38 | ||
|
|
9c019e1cc0 | ||
|
|
b3d298269b | ||
|
|
21f7ca21e0 | ||
|
|
43bf370f8c | ||
|
|
6057921e34 | ||
|
|
5095816762 | ||
|
|
c1da74cbc0 |
1
Makefile
1
Makefile
@@ -381,7 +381,6 @@ test:
|
||||
prepare_collection_venv:
|
||||
rm -rf $(COLLECTION_VENV)
|
||||
mkdir $(COLLECTION_VENV)
|
||||
ln -s /usr/lib/python2.7/site-packages/ansible $(COLLECTION_VENV)/ansible
|
||||
$(VENV_BASE)/awx/bin/pip install --target=$(COLLECTION_VENV) git+https://github.com/ansible/tower-cli.git
|
||||
|
||||
COLLECTION_TEST_DIRS ?= awx_collection/test/awx
|
||||
|
||||
@@ -123,8 +123,16 @@ class PoolWorker(object):
|
||||
# if any tasks were finished, removed them from the managed tasks for
|
||||
# this worker
|
||||
for uuid in finished:
|
||||
self.messages_finished += 1
|
||||
del self.managed_tasks[uuid]
|
||||
try:
|
||||
del self.managed_tasks[uuid]
|
||||
self.messages_finished += 1
|
||||
except KeyError:
|
||||
# ansible _sometimes_ appears to send events w/ duplicate UUIDs;
|
||||
# UUIDs for ansible events are *not* actually globally unique
|
||||
# when this occurs, it's _fine_ to ignore this KeyError because
|
||||
# the purpose of self.managed_tasks is to just track internal
|
||||
# state of which events are *currently* being processed.
|
||||
pass
|
||||
|
||||
@property
|
||||
def current_task(self):
|
||||
|
||||
@@ -52,6 +52,9 @@ COLOR_LOGS = True
|
||||
# Pipe management playbook output to console
|
||||
LOGGING['loggers']['awx.isolated.manager.playbooks']['propagate'] = True # noqa
|
||||
|
||||
# celery is annoyingly loud when docker containers start
|
||||
LOGGING['loggers'].pop('celery', None) # noqa
|
||||
|
||||
ALLOWED_HOSTS = ['*']
|
||||
|
||||
mimetypes.add_type("image/svg+xml", ".svg", True)
|
||||
|
||||
@@ -10,7 +10,16 @@ const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
|
||||
`;
|
||||
|
||||
function FormField(props) {
|
||||
const { id, name, label, tooltip, validate, isRequired, ...rest } = props;
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
label,
|
||||
tooltip,
|
||||
tooltipMaxWidth,
|
||||
validate,
|
||||
isRequired,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Field
|
||||
@@ -29,7 +38,11 @@ function FormField(props) {
|
||||
label={label}
|
||||
>
|
||||
{tooltip && (
|
||||
<Tooltip position="right" content={tooltip}>
|
||||
<Tooltip
|
||||
content={tooltip}
|
||||
maxWidth={tooltipMaxWidth}
|
||||
position="right"
|
||||
>
|
||||
<QuestionCircleIcon />
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -58,6 +71,7 @@ FormField.propTypes = {
|
||||
validate: PropTypes.func,
|
||||
isRequired: PropTypes.bool,
|
||||
tooltip: PropTypes.node,
|
||||
tooltipMaxWidth: PropTypes.string,
|
||||
};
|
||||
|
||||
FormField.defaultProps = {
|
||||
@@ -65,6 +79,7 @@ FormField.defaultProps = {
|
||||
validate: () => {},
|
||||
isRequired: false,
|
||||
tooltip: null,
|
||||
tooltipMaxWidth: '',
|
||||
};
|
||||
|
||||
export default FormField;
|
||||
|
||||
@@ -6,6 +6,6 @@ const Row = styled.div`
|
||||
grid-gap: 20px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
`;
|
||||
export default function FormRow({ children }) {
|
||||
return <Row>{children}</Row>;
|
||||
export default function FormRow({ children, className }) {
|
||||
return <Row className={className}>{children}</Row>;
|
||||
}
|
||||
|
||||
68
awx/ui_next/src/components/Lookup/CredentialLookup.jsx
Normal file
68
awx/ui_next/src/components/Lookup/CredentialLookup.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { bool, func, number, string, oneOfType } from 'prop-types';
|
||||
import { CredentialsAPI } from '@api';
|
||||
import { Credential } from '@types';
|
||||
import { mergeParams } from '@util/qs';
|
||||
import { FormGroup } from '@patternfly/react-core';
|
||||
import Lookup from '@components/Lookup';
|
||||
|
||||
function CredentialLookup({
|
||||
helperTextInvalid,
|
||||
label,
|
||||
isValid,
|
||||
onBlur,
|
||||
onChange,
|
||||
required,
|
||||
credentialTypeId,
|
||||
value,
|
||||
}) {
|
||||
const getCredentials = async params =>
|
||||
CredentialsAPI.read(
|
||||
mergeParams(params, { credential_type: credentialTypeId })
|
||||
);
|
||||
|
||||
return (
|
||||
<FormGroup
|
||||
fieldId="credential"
|
||||
isRequired={required}
|
||||
isValid={isValid}
|
||||
label={label}
|
||||
helperTextInvalid={helperTextInvalid}
|
||||
>
|
||||
<Lookup
|
||||
id="credential"
|
||||
lookupHeader={label}
|
||||
name="credential"
|
||||
value={value}
|
||||
onBlur={onBlur}
|
||||
onLookupSave={onChange}
|
||||
getItems={getCredentials}
|
||||
required={required}
|
||||
sortedColumnKey="name"
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
CredentialLookup.propTypes = {
|
||||
credentialTypeId: oneOfType([number, string]).isRequired,
|
||||
helperTextInvalid: string,
|
||||
isValid: bool,
|
||||
label: string.isRequired,
|
||||
onBlur: func,
|
||||
onChange: func.isRequired,
|
||||
required: bool,
|
||||
value: Credential,
|
||||
};
|
||||
|
||||
CredentialLookup.defaultProps = {
|
||||
helperTextInvalid: '',
|
||||
isValid: true,
|
||||
onBlur: () => {},
|
||||
required: false,
|
||||
value: null,
|
||||
};
|
||||
|
||||
export { CredentialLookup as _CredentialLookup };
|
||||
export default withI18n()(CredentialLookup);
|
||||
41
awx/ui_next/src/components/Lookup/CredentialLookup.test.jsx
Normal file
41
awx/ui_next/src/components/Lookup/CredentialLookup.test.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import CredentialLookup, { _CredentialLookup } from './CredentialLookup';
|
||||
import { CredentialsAPI } from '@api';
|
||||
|
||||
jest.mock('@api');
|
||||
|
||||
describe('CredentialLookup', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<CredentialLookup credentialTypeId={1} label="Foo" onChange={() => {}} />
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('initially renders successfully', () => {
|
||||
expect(wrapper.find('CredentialLookup')).toHaveLength(1);
|
||||
});
|
||||
test('should fetch credentials', () => {
|
||||
expect(CredentialsAPI.read).toHaveBeenCalledTimes(1);
|
||||
expect(CredentialsAPI.read).toHaveBeenCalledWith({
|
||||
credential_type: 1,
|
||||
order_by: 'name',
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
});
|
||||
});
|
||||
test('should display label', () => {
|
||||
const title = wrapper.find('FormGroup .pf-c-form__label-text');
|
||||
expect(title.text()).toEqual('Foo');
|
||||
});
|
||||
test('should define default value for function props', () => {
|
||||
expect(_CredentialLookup.defaultProps.onBlur).toBeInstanceOf(Function);
|
||||
expect(_CredentialLookup.defaultProps.onBlur).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -101,7 +101,7 @@ class MultiCredentialsLookup extends React.Component {
|
||||
const { selectedCredentialType, credentialTypes } = this.state;
|
||||
const { tooltip, i18n, credentials } = this.props;
|
||||
return (
|
||||
<FormGroup label={i18n._(t`Credentials`)} fieldId="org-credentials">
|
||||
<FormGroup label={i18n._(t`Credentials`)} fieldId="multiCredential">
|
||||
{tooltip && (
|
||||
<Tooltip position="right" content={tooltip}>
|
||||
<QuestionCircleIcon />
|
||||
@@ -114,7 +114,7 @@ class MultiCredentialsLookup extends React.Component {
|
||||
selectedCategory={selectedCredentialType}
|
||||
onToggleItem={this.toggleCredentialSelection}
|
||||
onloadCategories={this.loadCredentialTypes}
|
||||
id="org-credentials"
|
||||
id="multiCredential"
|
||||
lookupHeader={i18n._(t`Credentials`)}
|
||||
name="credentials"
|
||||
value={credentials}
|
||||
|
||||
62
awx/ui_next/src/components/Lookup/OrganizationLookup.jsx
Normal file
62
awx/ui_next/src/components/Lookup/OrganizationLookup.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { string, func, bool } from 'prop-types';
|
||||
import { OrganizationsAPI } from '@api';
|
||||
import { Organization } from '@types';
|
||||
import { FormGroup } from '@patternfly/react-core';
|
||||
import Lookup from '@components/Lookup';
|
||||
|
||||
const getOrganizations = async params => OrganizationsAPI.read(params);
|
||||
|
||||
function OrganizationLookup({
|
||||
helperTextInvalid,
|
||||
i18n,
|
||||
isValid,
|
||||
onBlur,
|
||||
onChange,
|
||||
required,
|
||||
value,
|
||||
}) {
|
||||
return (
|
||||
<FormGroup
|
||||
fieldId="organization"
|
||||
helperTextInvalid={helperTextInvalid}
|
||||
isRequired={required}
|
||||
isValid={isValid}
|
||||
label={i18n._(t`Organization`)}
|
||||
>
|
||||
<Lookup
|
||||
id="organization"
|
||||
lookupHeader={i18n._(t`Organization`)}
|
||||
name="organization"
|
||||
value={value}
|
||||
onBlur={onBlur}
|
||||
onLookupSave={onChange}
|
||||
getItems={getOrganizations}
|
||||
required={required}
|
||||
sortedColumnKey="name"
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
OrganizationLookup.propTypes = {
|
||||
helperTextInvalid: string,
|
||||
isValid: bool,
|
||||
onBlur: func,
|
||||
onChange: func.isRequired,
|
||||
required: bool,
|
||||
value: Organization,
|
||||
};
|
||||
|
||||
OrganizationLookup.defaultProps = {
|
||||
helperTextInvalid: '',
|
||||
isValid: true,
|
||||
onBlur: () => {},
|
||||
required: false,
|
||||
value: null,
|
||||
};
|
||||
|
||||
export default withI18n()(OrganizationLookup);
|
||||
export { OrganizationLookup as _OrganizationLookup };
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import OrganizationLookup, { _OrganizationLookup } from './OrganizationLookup';
|
||||
import { OrganizationsAPI } from '@api';
|
||||
|
||||
jest.mock('@api');
|
||||
|
||||
describe('OrganizationLookup', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(<OrganizationLookup onChange={() => {}} />);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('initially renders successfully', () => {
|
||||
expect(wrapper).toHaveLength(1);
|
||||
});
|
||||
test('should fetch organizations', () => {
|
||||
expect(OrganizationsAPI.read).toHaveBeenCalledTimes(1);
|
||||
expect(OrganizationsAPI.read).toHaveBeenCalledWith({
|
||||
order_by: 'name',
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
});
|
||||
});
|
||||
test('should display "Organization" label', () => {
|
||||
const title = wrapper.find('FormGroup .pf-c-form__label-text');
|
||||
expect(title.text()).toEqual('Organization');
|
||||
});
|
||||
test('should define default value for function props', () => {
|
||||
expect(_OrganizationLookup.defaultProps.onBlur).toBeInstanceOf(Function);
|
||||
expect(_OrganizationLookup.defaultProps.onBlur).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,8 @@ const DataListCell = styled(PFDataListCell)`
|
||||
const Switch = styled(PFSwitch)`
|
||||
display: flex;
|
||||
flex-wrap: no-wrap;
|
||||
/* workaround PF bug; used in calculating switch width: */
|
||||
--pf-c-switch__toggle-icon--Offset: 0.125rem;
|
||||
`;
|
||||
|
||||
function NotificationListItem(props) {
|
||||
|
||||
@@ -331,9 +331,9 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
|
||||
"componentStyle": ComponentStyle {
|
||||
"componentId": "NotificationListItem__Switch-w674ng-1",
|
||||
"isStatic": true,
|
||||
"lastClassName": "hbNxaH",
|
||||
"lastClassName": "hfzRow",
|
||||
"rules": Array [
|
||||
"display:flex;flex-wrap:no-wrap;",
|
||||
"display:flex;flex-wrap:no-wrap;--pf-c-switch__toggle-icon--Offset:0.125rem;",
|
||||
],
|
||||
},
|
||||
"displayName": "NotificationListItem__Switch",
|
||||
@@ -356,7 +356,7 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
|
||||
>
|
||||
<Component
|
||||
aria-label="Toggle notification start"
|
||||
className="NotificationListItem__Switch-w674ng-1 hbNxaH"
|
||||
className="NotificationListItem__Switch-w674ng-1 hfzRow"
|
||||
id="notification-9000-started-toggle"
|
||||
isChecked={false}
|
||||
isDisabled={false}
|
||||
@@ -369,7 +369,7 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
|
||||
componentProps={
|
||||
Object {
|
||||
"aria-label": "Toggle notification start",
|
||||
"className": "NotificationListItem__Switch-w674ng-1 hbNxaH",
|
||||
"className": "NotificationListItem__Switch-w674ng-1 hfzRow",
|
||||
"id": "notification-9000-started-toggle",
|
||||
"isChecked": false,
|
||||
"isDisabled": false,
|
||||
@@ -382,7 +382,7 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
|
||||
>
|
||||
<Switch
|
||||
aria-label="Toggle notification start"
|
||||
className="NotificationListItem__Switch-w674ng-1 hbNxaH"
|
||||
className="NotificationListItem__Switch-w674ng-1 hfzRow"
|
||||
id="notification-9000-started-toggle"
|
||||
isChecked={false}
|
||||
isDisabled={false}
|
||||
@@ -397,7 +397,7 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
|
||||
}
|
||||
>
|
||||
<label
|
||||
className="pf-c-switch NotificationListItem__Switch-w674ng-1 hbNxaH"
|
||||
className="pf-c-switch NotificationListItem__Switch-w674ng-1 hfzRow"
|
||||
htmlFor="notification-9000-started-toggle"
|
||||
>
|
||||
<input
|
||||
@@ -451,9 +451,9 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
|
||||
"componentStyle": ComponentStyle {
|
||||
"componentId": "NotificationListItem__Switch-w674ng-1",
|
||||
"isStatic": true,
|
||||
"lastClassName": "hbNxaH",
|
||||
"lastClassName": "hfzRow",
|
||||
"rules": Array [
|
||||
"display:flex;flex-wrap:no-wrap;",
|
||||
"display:flex;flex-wrap:no-wrap;--pf-c-switch__toggle-icon--Offset:0.125rem;",
|
||||
],
|
||||
},
|
||||
"displayName": "NotificationListItem__Switch",
|
||||
@@ -476,7 +476,7 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
|
||||
>
|
||||
<Component
|
||||
aria-label="Toggle notification success"
|
||||
className="NotificationListItem__Switch-w674ng-1 hbNxaH"
|
||||
className="NotificationListItem__Switch-w674ng-1 hfzRow"
|
||||
id="notification-9000-success-toggle"
|
||||
isChecked={false}
|
||||
isDisabled={false}
|
||||
@@ -489,7 +489,7 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
|
||||
componentProps={
|
||||
Object {
|
||||
"aria-label": "Toggle notification success",
|
||||
"className": "NotificationListItem__Switch-w674ng-1 hbNxaH",
|
||||
"className": "NotificationListItem__Switch-w674ng-1 hfzRow",
|
||||
"id": "notification-9000-success-toggle",
|
||||
"isChecked": false,
|
||||
"isDisabled": false,
|
||||
@@ -502,7 +502,7 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
|
||||
>
|
||||
<Switch
|
||||
aria-label="Toggle notification success"
|
||||
className="NotificationListItem__Switch-w674ng-1 hbNxaH"
|
||||
className="NotificationListItem__Switch-w674ng-1 hfzRow"
|
||||
id="notification-9000-success-toggle"
|
||||
isChecked={false}
|
||||
isDisabled={false}
|
||||
@@ -517,7 +517,7 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
|
||||
}
|
||||
>
|
||||
<label
|
||||
className="pf-c-switch NotificationListItem__Switch-w674ng-1 hbNxaH"
|
||||
className="pf-c-switch NotificationListItem__Switch-w674ng-1 hfzRow"
|
||||
htmlFor="notification-9000-success-toggle"
|
||||
>
|
||||
<input
|
||||
@@ -571,9 +571,9 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
|
||||
"componentStyle": ComponentStyle {
|
||||
"componentId": "NotificationListItem__Switch-w674ng-1",
|
||||
"isStatic": true,
|
||||
"lastClassName": "hbNxaH",
|
||||
"lastClassName": "hfzRow",
|
||||
"rules": Array [
|
||||
"display:flex;flex-wrap:no-wrap;",
|
||||
"display:flex;flex-wrap:no-wrap;--pf-c-switch__toggle-icon--Offset:0.125rem;",
|
||||
],
|
||||
},
|
||||
"displayName": "NotificationListItem__Switch",
|
||||
@@ -596,7 +596,7 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
|
||||
>
|
||||
<Component
|
||||
aria-label="Toggle notification failure"
|
||||
className="NotificationListItem__Switch-w674ng-1 hbNxaH"
|
||||
className="NotificationListItem__Switch-w674ng-1 hfzRow"
|
||||
id="notification-9000-error-toggle"
|
||||
isChecked={false}
|
||||
isDisabled={false}
|
||||
@@ -609,7 +609,7 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
|
||||
componentProps={
|
||||
Object {
|
||||
"aria-label": "Toggle notification failure",
|
||||
"className": "NotificationListItem__Switch-w674ng-1 hbNxaH",
|
||||
"className": "NotificationListItem__Switch-w674ng-1 hfzRow",
|
||||
"id": "notification-9000-error-toggle",
|
||||
"isChecked": false,
|
||||
"isDisabled": false,
|
||||
@@ -622,7 +622,7 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
|
||||
>
|
||||
<Switch
|
||||
aria-label="Toggle notification failure"
|
||||
className="NotificationListItem__Switch-w674ng-1 hbNxaH"
|
||||
className="NotificationListItem__Switch-w674ng-1 hfzRow"
|
||||
id="notification-9000-error-toggle"
|
||||
isChecked={false}
|
||||
isDisabled={false}
|
||||
@@ -637,7 +637,7 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
|
||||
}
|
||||
>
|
||||
<label
|
||||
className="pf-c-switch NotificationListItem__Switch-w674ng-1 hbNxaH"
|
||||
className="pf-c-switch NotificationListItem__Switch-w674ng-1 hfzRow"
|
||||
htmlFor="notification-9000-error-toggle"
|
||||
>
|
||||
<input
|
||||
|
||||
@@ -1,10 +1,65 @@
|
||||
import React, { Component } from 'react';
|
||||
import { PageSection } from '@patternfly/react-core';
|
||||
import React, { useState } from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import styled from 'styled-components';
|
||||
import {
|
||||
Card as _Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
PageSection,
|
||||
Tooltip,
|
||||
} from '@patternfly/react-core';
|
||||
import CardCloseButton from '@components/CardCloseButton';
|
||||
import ProjectForm from '../shared/ProjectForm';
|
||||
import { ProjectsAPI } from '@api';
|
||||
|
||||
class ProjectAdd extends Component {
|
||||
render() {
|
||||
return <PageSection>Coming soon :)</PageSection>;
|
||||
}
|
||||
const Card = styled(_Card)`
|
||||
--pf-c-card--child--PaddingLeft: 0;
|
||||
--pf-c-card--child--PaddingRight: 0;
|
||||
`;
|
||||
|
||||
function ProjectAdd({ history, i18n }) {
|
||||
const [formSubmitError, setFormSubmitError] = useState(null);
|
||||
|
||||
const handleSubmit = async values => {
|
||||
setFormSubmitError(null);
|
||||
try {
|
||||
const {
|
||||
data: { id },
|
||||
} = await ProjectsAPI.create(values);
|
||||
history.push(`/projects/${id}/details`);
|
||||
} catch (error) {
|
||||
setFormSubmitError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
history.push(`/projects`);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
<CardHeader css="text-align: right">
|
||||
<Tooltip content={i18n._(t`Close`)} position="top">
|
||||
<CardCloseButton onClick={handleCancel} />
|
||||
</Tooltip>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<ProjectForm
|
||||
handleCancel={handleCancel}
|
||||
handleSubmit={handleSubmit}
|
||||
/>
|
||||
</CardBody>
|
||||
{formSubmitError ? (
|
||||
<div className="formSubmitError">formSubmitError</div>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</Card>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProjectAdd;
|
||||
export default withI18n()(withRouter(ProjectAdd));
|
||||
|
||||
161
awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx
Normal file
161
awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
||||
import ProjectAdd from './ProjectAdd';
|
||||
import { ProjectsAPI, CredentialTypesAPI } from '@api';
|
||||
|
||||
jest.mock('@api');
|
||||
|
||||
describe('<ProjectAdd />', () => {
|
||||
let wrapper;
|
||||
const projectData = {
|
||||
name: 'foo',
|
||||
description: 'bar',
|
||||
scm_type: 'git',
|
||||
scm_url: 'https://foo.bar',
|
||||
scm_clean: true,
|
||||
credential: 100,
|
||||
organization: 2,
|
||||
scm_update_on_launch: true,
|
||||
scm_update_cache_timeout: 3,
|
||||
allow_override: false,
|
||||
custom_virtualenv: '/venv/custom-env',
|
||||
};
|
||||
|
||||
const projectOptionsResolve = {
|
||||
data: {
|
||||
actions: {
|
||||
GET: {
|
||||
scm_type: {
|
||||
choices: [
|
||||
['', 'Manual'],
|
||||
['git', 'Git'],
|
||||
['hg', 'Mercurial'],
|
||||
['svn', 'Subversion'],
|
||||
['insights', 'Red Hat Insights'],
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const scmCredentialResolve = {
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
id: 4,
|
||||
name: 'Source Control',
|
||||
kind: 'scm',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const insightsCredentialResolve = {
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
id: 5,
|
||||
name: 'Insights',
|
||||
kind: 'insights',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await ProjectsAPI.readOptions.mockImplementation(
|
||||
() => projectOptionsResolve
|
||||
);
|
||||
await CredentialTypesAPI.read.mockImplementationOnce(
|
||||
() => scmCredentialResolve
|
||||
);
|
||||
await CredentialTypesAPI.read.mockImplementationOnce(
|
||||
() => insightsCredentialResolve
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('initially renders successfully', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<ProjectAdd />);
|
||||
});
|
||||
expect(wrapper.length).toBe(1);
|
||||
});
|
||||
|
||||
test('handleSubmit should post to the api', async () => {
|
||||
ProjectsAPI.create.mockResolvedValueOnce({
|
||||
data: { ...projectData },
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<ProjectAdd />);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
const formik = wrapper.find('Formik').instance();
|
||||
const changeState = new Promise(resolve => {
|
||||
formik.setState(
|
||||
{
|
||||
values: {
|
||||
...projectData,
|
||||
},
|
||||
},
|
||||
() => resolve()
|
||||
);
|
||||
});
|
||||
await changeState;
|
||||
wrapper.find('form').simulate('submit');
|
||||
});
|
||||
|
||||
test('handleSubmit should throw an error', async () => {
|
||||
ProjectsAPI.create.mockImplementation(() => Promise.reject(new Error()));
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<ProjectAdd />);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
const formik = wrapper.find('Formik').instance();
|
||||
const changeState = new Promise(resolve => {
|
||||
formik.setState(
|
||||
{
|
||||
values: {
|
||||
...projectData,
|
||||
},
|
||||
},
|
||||
() => resolve()
|
||||
);
|
||||
});
|
||||
await changeState;
|
||||
await act(async () => {
|
||||
wrapper.find('form').simulate('submit');
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('ProjectAdd .formSubmitError').length).toBe(1);
|
||||
});
|
||||
|
||||
test('CardHeader close button should navigate to projects list', async () => {
|
||||
const history = createMemoryHistory();
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<ProjectAdd />, {
|
||||
context: { router: { history } },
|
||||
}).find('ProjectAdd CardHeader');
|
||||
});
|
||||
wrapper.find('CardCloseButton').simulate('click');
|
||||
expect(history.location.pathname).toEqual('/projects');
|
||||
});
|
||||
|
||||
test('CardBody cancel button should navigate to projects list', async () => {
|
||||
const history = createMemoryHistory();
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<ProjectAdd />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
|
||||
wrapper.find('ProjectAdd button[aria-label="Cancel"]').simulate('click');
|
||||
expect(history.location.pathname).toEqual('/projects');
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ function ProjectDetail({ project, i18n }) {
|
||||
scm_branch,
|
||||
scm_clean,
|
||||
scm_delete_on_update,
|
||||
scm_refspec,
|
||||
scm_type,
|
||||
scm_update_on_launch,
|
||||
scm_update_cache_timeout,
|
||||
@@ -98,6 +99,7 @@ function ProjectDetail({ project, i18n }) {
|
||||
<Detail label={i18n._(t`SCM Type`)} value={scm_type} />
|
||||
<Detail label={i18n._(t`SCM URL`)} value={scm_url} />
|
||||
<Detail label={i18n._(t`SCM Branch`)} value={scm_branch} />
|
||||
<Detail label={i18n._(t`SCM Refspec`)} value={scm_refspec} />
|
||||
{summary_fields.credential && (
|
||||
<Detail
|
||||
label={i18n._(t`SCM Credential`)}
|
||||
|
||||
@@ -88,6 +88,7 @@ describe('<ProjectDetail />', () => {
|
||||
assertDetail('SCM Type', mockProject.scm_type);
|
||||
assertDetail('SCM URL', mockProject.scm_url);
|
||||
assertDetail('SCM Branch', mockProject.scm_branch);
|
||||
assertDetail('SCM Refspec', mockProject.scm_refspec);
|
||||
assertDetail(
|
||||
'SCM Credential',
|
||||
`Scm: ${mockProject.summary_fields.credential.name}`
|
||||
|
||||
@@ -91,7 +91,7 @@ class ProjectListItem extends React.Component {
|
||||
<Link
|
||||
id={labelId}
|
||||
to={`${detailUrl}`}
|
||||
style={{ marginLeft: '10px' }}
|
||||
css={{ marginLeft: '10px' }}
|
||||
>
|
||||
<b>{project.name}</b>
|
||||
</Link>
|
||||
|
||||
319
awx/ui_next/src/screens/Project/shared/ProjectForm.jsx
Normal file
319
awx/ui_next/src/screens/Project/shared/ProjectForm.jsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Formik, Field } from 'formik';
|
||||
import { Config } from '@contexts/Config';
|
||||
import { Form, FormGroup } from '@patternfly/react-core';
|
||||
import AnsibleSelect from '@components/AnsibleSelect';
|
||||
import ContentError from '@components/ContentError';
|
||||
import ContentLoading from '@components/ContentLoading';
|
||||
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
|
||||
import FormField, { FieldTooltip } from '@components/FormField';
|
||||
import FormRow from '@components/FormRow';
|
||||
import OrganizationLookup from '@components/Lookup/OrganizationLookup';
|
||||
import { CredentialTypesAPI, ProjectsAPI } from '@api';
|
||||
import { required } from '@util/validators';
|
||||
import styled from 'styled-components';
|
||||
import {
|
||||
GitSubForm,
|
||||
HgSubForm,
|
||||
SvnSubForm,
|
||||
InsightsSubForm,
|
||||
SubFormTitle,
|
||||
} from './ProjectSubForms';
|
||||
|
||||
const ScmTypeFormRow = styled(FormRow)`
|
||||
background-color: #f5f5f5;
|
||||
grid-column: 1 / -1;
|
||||
margin: 0 -24px;
|
||||
padding: 24px;
|
||||
`;
|
||||
|
||||
function ProjectForm(props) {
|
||||
const { project, handleCancel, handleSubmit, i18n } = props;
|
||||
const [contentError, setContentError] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [organization, setOrganization] = useState(null);
|
||||
const [scmTypeOptions, setScmTypeOptions] = useState(null);
|
||||
const [scmCredential, setScmCredential] = useState({
|
||||
typeId: null,
|
||||
value: null,
|
||||
});
|
||||
const [insightsCredential, setInsightsCredential] = useState({
|
||||
typeId: null,
|
||||
value: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [
|
||||
{
|
||||
data: {
|
||||
results: [scmCredentialType],
|
||||
},
|
||||
},
|
||||
{
|
||||
data: {
|
||||
results: [insightsCredentialType],
|
||||
},
|
||||
},
|
||||
{
|
||||
data: {
|
||||
actions: {
|
||||
GET: {
|
||||
scm_type: { choices },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
] = await Promise.all([
|
||||
CredentialTypesAPI.read({ kind: 'scm' }),
|
||||
CredentialTypesAPI.read({ name: 'Insights' }),
|
||||
ProjectsAPI.readOptions(),
|
||||
]);
|
||||
|
||||
setScmCredential({ typeId: scmCredentialType.id });
|
||||
setInsightsCredential({ typeId: insightsCredentialType.id });
|
||||
setScmTypeOptions(choices);
|
||||
} catch (error) {
|
||||
setContentError(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const resetScmTypeFields = form => {
|
||||
const scmFormFields = [
|
||||
'scm_url',
|
||||
'scm_branch',
|
||||
'scm_refspec',
|
||||
'credential',
|
||||
'scm_clean',
|
||||
'scm_delete_on_update',
|
||||
'scm_update_on_launch',
|
||||
'allow_override',
|
||||
'scm_update_cache_timeout',
|
||||
];
|
||||
|
||||
scmFormFields.forEach(field => {
|
||||
form.setFieldValue(field, form.initialValues[field]);
|
||||
form.setFieldTouched(field, false);
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <ContentLoading />;
|
||||
}
|
||||
|
||||
if (contentError) {
|
||||
return <ContentError error={contentError} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
allow_override: project.allow_override || false,
|
||||
credential: project.credential || '',
|
||||
custom_virtualenv: project.custom_virtualenv || '',
|
||||
description: project.description || '',
|
||||
name: project.name || '',
|
||||
organization: project.organization || '',
|
||||
scm_branch: project.scm_branch || '',
|
||||
scm_clean: project.scm_clean || false,
|
||||
scm_delete_on_update: project.scm_delete_on_update || false,
|
||||
scm_refspec: project.scm_refspec || '',
|
||||
scm_type: project.scm_type || '',
|
||||
scm_update_cache_timeout: project.scm_update_cache_timeout || 0,
|
||||
scm_update_on_launch: project.scm_update_on_launch || false,
|
||||
scm_url: project.scm_url || '',
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
render={formik => (
|
||||
<Form
|
||||
autoComplete="off"
|
||||
onSubmit={formik.handleSubmit}
|
||||
css="padding: 0 24px"
|
||||
>
|
||||
<FormRow>
|
||||
<FormField
|
||||
id="project-name"
|
||||
label={i18n._(t`Name`)}
|
||||
name="name"
|
||||
type="text"
|
||||
validate={required(null, i18n)}
|
||||
isRequired
|
||||
/>
|
||||
<FormField
|
||||
id="project-description"
|
||||
label={i18n._(t`Description`)}
|
||||
name="description"
|
||||
type="text"
|
||||
/>
|
||||
<Field
|
||||
name="organization"
|
||||
validate={required(
|
||||
i18n._(t`Select a value for this field`),
|
||||
i18n
|
||||
)}
|
||||
render={({ form }) => (
|
||||
<OrganizationLookup
|
||||
helperTextInvalid={form.errors.organization}
|
||||
isValid={
|
||||
!form.touched.organization || !form.errors.organization
|
||||
}
|
||||
onBlur={() => form.setFieldTouched('organization')}
|
||||
onChange={value => {
|
||||
form.setFieldValue('organization', value.id);
|
||||
setOrganization(value);
|
||||
}}
|
||||
value={organization}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Field
|
||||
name="scm_type"
|
||||
validate={required(
|
||||
i18n._(t`Select a value for this field`),
|
||||
i18n
|
||||
)}
|
||||
render={({ field, form }) => (
|
||||
<FormGroup
|
||||
fieldId="project-scm-type"
|
||||
helperTextInvalid={form.errors.scm_type}
|
||||
isRequired
|
||||
isValid={!form.touched.scm_type || !form.errors.scm_type}
|
||||
label={i18n._(t`SCM Type`)}
|
||||
>
|
||||
<AnsibleSelect
|
||||
{...field}
|
||||
id="scm_type"
|
||||
data={[
|
||||
{
|
||||
value: '',
|
||||
key: '',
|
||||
label: i18n._(t`Choose an SCM Type`),
|
||||
isDisabled: true,
|
||||
},
|
||||
...scmTypeOptions.map(([value, label]) => {
|
||||
if (label === 'Manual') {
|
||||
value = 'manual';
|
||||
}
|
||||
return {
|
||||
label,
|
||||
value,
|
||||
key: value,
|
||||
};
|
||||
}),
|
||||
]}
|
||||
onChange={(event, value) => {
|
||||
form.setFieldValue('scm_type', value);
|
||||
resetScmTypeFields(form);
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
/>
|
||||
{formik.values.scm_type !== '' && (
|
||||
<ScmTypeFormRow>
|
||||
<SubFormTitle size="md">{i18n._(t`Type Details`)}</SubFormTitle>
|
||||
{
|
||||
{
|
||||
git: (
|
||||
<GitSubForm
|
||||
setScmCredential={setScmCredential}
|
||||
scmCredential={scmCredential}
|
||||
scmUpdateOnLaunch={formik.values.scm_update_on_launch}
|
||||
/>
|
||||
),
|
||||
hg: (
|
||||
<HgSubForm
|
||||
setScmCredential={setScmCredential}
|
||||
scmCredential={scmCredential}
|
||||
scmUpdateOnLaunch={formik.values.scm_update_on_launch}
|
||||
/>
|
||||
),
|
||||
svn: (
|
||||
<SvnSubForm
|
||||
setScmCredential={setScmCredential}
|
||||
scmCredential={scmCredential}
|
||||
scmUpdateOnLaunch={formik.values.scm_update_on_launch}
|
||||
/>
|
||||
),
|
||||
insights: (
|
||||
<InsightsSubForm
|
||||
setInsightsCredential={setInsightsCredential}
|
||||
insightsCredential={insightsCredential}
|
||||
scmUpdateOnLaunch={formik.values.scm_update_on_launch}
|
||||
/>
|
||||
),
|
||||
}[formik.values.scm_type]
|
||||
}
|
||||
</ScmTypeFormRow>
|
||||
)}
|
||||
<Config>
|
||||
{({ custom_virtualenvs }) =>
|
||||
custom_virtualenvs &&
|
||||
custom_virtualenvs.length > 1 && (
|
||||
<Field
|
||||
name="custom_virtualenv"
|
||||
render={({ field }) => (
|
||||
<FormGroup
|
||||
fieldId="project-custom-virtualenv"
|
||||
label={i18n._(t`Ansible Environment`)}
|
||||
>
|
||||
<FieldTooltip
|
||||
content={i18n._(t`Select the playbook to be executed by
|
||||
this job.`)}
|
||||
/>
|
||||
<AnsibleSelect
|
||||
id="project-custom-virtualenv"
|
||||
data={[
|
||||
{
|
||||
label: i18n._(t`Use Default Ansible Environment`),
|
||||
value: '/venv/ansible/',
|
||||
key: 'default',
|
||||
},
|
||||
...custom_virtualenvs
|
||||
.filter(datum => datum !== '/venv/ansible/')
|
||||
.map(datum => ({
|
||||
label: datum,
|
||||
value: datum,
|
||||
key: datum,
|
||||
})),
|
||||
]}
|
||||
{...field}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Config>
|
||||
</FormRow>
|
||||
<FormActionGroup
|
||||
onCancel={handleCancel}
|
||||
onSubmit={formik.handleSubmit}
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
ProjectForm.propTypes = {
|
||||
handleCancel: PropTypes.func.isRequired,
|
||||
handleSubmit: PropTypes.func.isRequired,
|
||||
project: PropTypes.shape({}),
|
||||
};
|
||||
|
||||
ProjectForm.defaultProps = {
|
||||
project: {},
|
||||
};
|
||||
|
||||
export default withI18n()(ProjectForm);
|
||||
297
awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx
Normal file
297
awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
||||
import { sleep } from '@testUtils/testUtils';
|
||||
import ProjectForm from './ProjectForm';
|
||||
import { CredentialTypesAPI, ProjectsAPI } from '@api';
|
||||
|
||||
jest.mock('@api');
|
||||
|
||||
describe('<ProjectAdd />', () => {
|
||||
let wrapper;
|
||||
const mockData = {
|
||||
name: 'foo',
|
||||
description: 'bar',
|
||||
scm_type: 'git',
|
||||
scm_url: 'https://foo.bar',
|
||||
scm_clean: true,
|
||||
credential: 100,
|
||||
organization: 2,
|
||||
scm_update_on_launch: true,
|
||||
scm_update_cache_timeout: 3,
|
||||
allow_override: false,
|
||||
custom_virtualenv: '/venv/custom-env',
|
||||
};
|
||||
|
||||
const projectOptionsResolve = {
|
||||
data: {
|
||||
actions: {
|
||||
GET: {
|
||||
scm_type: {
|
||||
choices: [
|
||||
['', 'Manual'],
|
||||
['git', 'Git'],
|
||||
['hg', 'Mercurial'],
|
||||
['svn', 'Subversion'],
|
||||
['insights', 'Red Hat Insights'],
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const scmCredentialResolve = {
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
id: 4,
|
||||
name: 'Source Control',
|
||||
kind: 'scm',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const insightsCredentialResolve = {
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
id: 5,
|
||||
name: 'Insights',
|
||||
kind: 'insights',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await ProjectsAPI.readOptions.mockImplementation(
|
||||
() => projectOptionsResolve
|
||||
);
|
||||
await CredentialTypesAPI.read.mockImplementationOnce(
|
||||
() => scmCredentialResolve
|
||||
);
|
||||
await CredentialTypesAPI.read.mockImplementationOnce(
|
||||
() => insightsCredentialResolve
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('initially renders successfully', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ProjectForm handleSubmit={jest.fn()} handleCancel={jest.fn()} />
|
||||
);
|
||||
});
|
||||
|
||||
expect(wrapper.find('ProjectForm').length).toBe(1);
|
||||
});
|
||||
|
||||
test('new form displays primary form fields', async () => {
|
||||
const config = {
|
||||
custom_virtualenvs: ['venv/foo', 'venv/bar'],
|
||||
};
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ProjectForm handleSubmit={jest.fn()} handleCancel={jest.fn()} />,
|
||||
{
|
||||
context: { config },
|
||||
}
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('FormGroup[label="Name"]').length).toBe(1);
|
||||
expect(wrapper.find('FormGroup[label="Description"]').length).toBe(1);
|
||||
expect(wrapper.find('FormGroup[label="Organization"]').length).toBe(1);
|
||||
expect(wrapper.find('FormGroup[label="SCM Type"]').length).toBe(1);
|
||||
expect(wrapper.find('FormGroup[label="Ansible Environment"]').length).toBe(
|
||||
1
|
||||
);
|
||||
expect(wrapper.find('FormGroup[label="Options"]').length).toBe(0);
|
||||
});
|
||||
|
||||
test('should display scm subform when scm type select has a value', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ProjectForm handleSubmit={jest.fn()} handleCancel={jest.fn()} />
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
const formik = wrapper.find('Formik').instance();
|
||||
const changeState = new Promise(resolve => {
|
||||
formik.setState(
|
||||
{
|
||||
values: {
|
||||
...mockData,
|
||||
},
|
||||
},
|
||||
() => resolve()
|
||||
);
|
||||
});
|
||||
await changeState;
|
||||
wrapper.update();
|
||||
expect(wrapper.find('FormGroup[label="SCM URL"]').length).toBe(1);
|
||||
expect(
|
||||
wrapper.find('FormGroup[label="SCM Branch/Tag/Commit"]').length
|
||||
).toBe(1);
|
||||
expect(wrapper.find('FormGroup[label="SCM Refspec"]').length).toBe(1);
|
||||
expect(wrapper.find('FormGroup[label="SCM Credential"]').length).toBe(1);
|
||||
expect(wrapper.find('FormGroup[label="Options"]').length).toBe(1);
|
||||
});
|
||||
|
||||
test('inputs should update form value on change', async () => {
|
||||
const project = { ...mockData };
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ProjectForm
|
||||
handleSubmit={jest.fn()}
|
||||
handleCancel={jest.fn()}
|
||||
project={project}
|
||||
/>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
const form = wrapper.find('Formik');
|
||||
act(() => {
|
||||
wrapper.find('OrganizationLookup').invoke('onBlur')();
|
||||
wrapper.find('OrganizationLookup').invoke('onChange')({
|
||||
id: 1,
|
||||
name: 'organization',
|
||||
});
|
||||
});
|
||||
expect(form.state('values').organization).toEqual(1);
|
||||
act(() => {
|
||||
wrapper.find('CredentialLookup').invoke('onBlur')();
|
||||
wrapper.find('CredentialLookup').invoke('onChange')({
|
||||
id: 10,
|
||||
name: 'credential',
|
||||
});
|
||||
});
|
||||
expect(form.state('values').credential).toEqual(10);
|
||||
});
|
||||
|
||||
test('should display insights credential lookup when scm type is "Insights"', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ProjectForm handleSubmit={jest.fn()} handleCancel={jest.fn()} />
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
const formik = wrapper.find('Formik').instance();
|
||||
const changeState = new Promise(resolve => {
|
||||
formik.setState(
|
||||
{
|
||||
values: {
|
||||
...mockData,
|
||||
scm_type: 'insights',
|
||||
},
|
||||
},
|
||||
() => resolve()
|
||||
);
|
||||
});
|
||||
await changeState;
|
||||
wrapper.update();
|
||||
expect(wrapper.find('FormGroup[label="Insights Credential"]').length).toBe(
|
||||
1
|
||||
);
|
||||
act(() => {
|
||||
wrapper.find('CredentialLookup').invoke('onBlur')();
|
||||
wrapper.find('CredentialLookup').invoke('onChange')({
|
||||
id: 123,
|
||||
name: 'credential',
|
||||
});
|
||||
});
|
||||
expect(formik.state.values.credential).toEqual(123);
|
||||
});
|
||||
|
||||
test('should reset scm subform values when scm type changes', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ProjectForm
|
||||
handleSubmit={jest.fn()}
|
||||
handleCancel={jest.fn()}
|
||||
project={{ scm_type: 'insights' }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
|
||||
const scmTypeSelect = wrapper.find(
|
||||
'FormGroup[label="SCM Type"] FormSelect'
|
||||
);
|
||||
const formik = wrapper.find('Formik').instance();
|
||||
expect(formik.state.values.scm_url).toEqual('');
|
||||
await act(async () => {
|
||||
scmTypeSelect.props().onChange('hg', { target: { name: 'Mercurial' } });
|
||||
});
|
||||
wrapper.update();
|
||||
await act(async () => {
|
||||
wrapper.find('FormGroup[label="SCM URL"] input').simulate('change', {
|
||||
target: { value: 'baz', name: 'scm_url' },
|
||||
});
|
||||
});
|
||||
expect(formik.state.values.scm_url).toEqual('baz');
|
||||
await act(async () => {
|
||||
scmTypeSelect
|
||||
.props()
|
||||
.onChange('insights', { target: { name: 'insights' } });
|
||||
});
|
||||
wrapper.update();
|
||||
await act(async () => {
|
||||
scmTypeSelect.props().onChange('svn', { target: { name: 'Subversion' } });
|
||||
});
|
||||
wrapper.update();
|
||||
expect(formik.state.values.scm_url).toEqual('');
|
||||
});
|
||||
|
||||
test('should call handleSubmit when Submit button is clicked', async () => {
|
||||
const handleSubmit = jest.fn();
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ProjectForm
|
||||
project={mockData}
|
||||
handleSubmit={handleSubmit}
|
||||
handleCancel={jest.fn()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(handleSubmit).not.toHaveBeenCalled();
|
||||
wrapper.find('button[aria-label="Save"]').simulate('click');
|
||||
await sleep(1);
|
||||
expect(handleSubmit).toBeCalled();
|
||||
});
|
||||
|
||||
test('should call handleCancel when Cancel button is clicked', async () => {
|
||||
const handleCancel = jest.fn();
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ProjectForm
|
||||
project={mockData}
|
||||
handleSubmit={jest.fn()}
|
||||
handleCancel={handleCancel}
|
||||
/>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(handleCancel).not.toHaveBeenCalled();
|
||||
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
|
||||
expect(handleCancel).toBeCalled();
|
||||
});
|
||||
|
||||
test('should display ContentError on throw', async () => {
|
||||
CredentialTypesAPI.read = () => Promise.reject(new Error());
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ProjectForm handleSubmit={jest.fn()} handleCancel={jest.fn()} />
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('ContentError').length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import FormField from '@components/FormField';
|
||||
import {
|
||||
UrlFormField,
|
||||
BranchFormField,
|
||||
ScmCredentialFormField,
|
||||
ScmTypeOptions,
|
||||
} from './SharedFields';
|
||||
|
||||
const GitSubForm = ({
|
||||
i18n,
|
||||
scmCredential,
|
||||
setScmCredential,
|
||||
scmUpdateOnLaunch,
|
||||
}) => (
|
||||
<>
|
||||
<UrlFormField
|
||||
i18n={i18n}
|
||||
tooltip={
|
||||
<span>
|
||||
{i18n._(t`Example URLs for GIT SCM include:`)}
|
||||
<ul css="margin: 10px 0 10px 20px">
|
||||
<li>https://github.com/ansible/ansible.git</li>
|
||||
<li>git@github.com:ansible/ansible.git</li>
|
||||
<li>git://servername.example.com/ansible.git</li>
|
||||
</ul>
|
||||
{i18n._(t`Note: When using SSH protocol for GitHub or
|
||||
Bitbucket, enter an SSH key only, do not enter a username
|
||||
(other than git). Additionally, GitHub and Bitbucket do
|
||||
not support password authentication when using SSH. GIT
|
||||
read only protocol (git://) does not use username or
|
||||
password information.`)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<BranchFormField i18n={i18n} label={i18n._(t`SCM Branch/Tag/Commit`)} />
|
||||
<FormField
|
||||
id="project-scm-refspec"
|
||||
label={i18n._(t`SCM Refspec`)}
|
||||
name="scm_refspec"
|
||||
type="text"
|
||||
tooltipMaxWidth="400px"
|
||||
tooltip={
|
||||
<span>
|
||||
{i18n._(t`A refspec to fetch (passed to the Ansible git
|
||||
module). This parameter allows access to references via
|
||||
the branch field not otherwise available.`)}
|
||||
<br />
|
||||
<br />
|
||||
{i18n._(t`Note: This field assumes the remote name is "origin".`)}
|
||||
<br />
|
||||
<br />
|
||||
{i18n._(t`Examples include:`)}
|
||||
<ul css={{ margin: '10px 0 10px 20px' }}>
|
||||
<li>refs/*:refs/remotes/origin/*</li>
|
||||
<li>refs/pull/62/head:refs/remotes/origin/pull/62/head</li>
|
||||
</ul>
|
||||
{i18n._(t`The first fetches all references. The second
|
||||
fetches the Github pull request number 62, in this example
|
||||
the branch needs to be "pull/62/head".`)}
|
||||
<br />
|
||||
<br />
|
||||
{i18n._(t`For more information, refer to the`)}{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://docs.ansible.com/ansible-tower/latest/html/userguide/projects.html#manage-playbooks-using-source-control"
|
||||
>
|
||||
{i18n._(t`Ansible Tower Documentation.`)}
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<ScmCredentialFormField
|
||||
setScmCredential={setScmCredential}
|
||||
scmCredential={scmCredential}
|
||||
/>
|
||||
<ScmTypeOptions scmUpdateOnLaunch={scmUpdateOnLaunch} />
|
||||
</>
|
||||
);
|
||||
|
||||
export default withI18n()(GitSubForm);
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
UrlFormField,
|
||||
BranchFormField,
|
||||
ScmCredentialFormField,
|
||||
ScmTypeOptions,
|
||||
} from './SharedFields';
|
||||
|
||||
const HgSubForm = ({
|
||||
i18n,
|
||||
scmCredential,
|
||||
setScmCredential,
|
||||
scmUpdateOnLaunch,
|
||||
}) => (
|
||||
<>
|
||||
<UrlFormField
|
||||
i18n={i18n}
|
||||
tooltip={
|
||||
<span>
|
||||
{i18n._(t`Example URLs for Mercurial SCM include:`)}
|
||||
<ul css={{ margin: '10px 0 10px 20px' }}>
|
||||
<li>https://bitbucket.org/username/project</li>
|
||||
<li>ssh://hg@bitbucket.org/username/project</li>
|
||||
<li>ssh://server.example.com/path</li>
|
||||
</ul>
|
||||
{i18n._(t`Note: Mercurial does not support password authentication
|
||||
for SSH. Do not put the username and key in the URL. If using
|
||||
Bitbucket and SSH, do not supply your Bitbucket username.
|
||||
`)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<BranchFormField i18n={i18n} label={i18n._(t`SCM Branch/Tag/Revision`)} />
|
||||
<ScmCredentialFormField
|
||||
setScmCredential={setScmCredential}
|
||||
scmCredential={scmCredential}
|
||||
/>
|
||||
<ScmTypeOptions scmUpdateOnLaunch={scmUpdateOnLaunch} />
|
||||
</>
|
||||
);
|
||||
|
||||
export default withI18n()(HgSubForm);
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Field } from 'formik';
|
||||
import CredentialLookup from '@components/Lookup/CredentialLookup';
|
||||
import { required } from '@util/validators';
|
||||
import { ScmTypeOptions } from './SharedFields';
|
||||
|
||||
const InsightsSubForm = ({
|
||||
i18n,
|
||||
setInsightsCredential,
|
||||
insightsCredential,
|
||||
scmUpdateOnLaunch,
|
||||
}) => (
|
||||
<>
|
||||
<Field
|
||||
name="credential"
|
||||
validate={required(i18n._(t`Select a value for this field`), i18n)}
|
||||
render={({ form }) => (
|
||||
<CredentialLookup
|
||||
credentialTypeId={insightsCredential.typeId}
|
||||
label={i18n._(t`Insights Credential`)}
|
||||
helperTextInvalid={form.errors.credential}
|
||||
isValid={!form.touched.credential || !form.errors.credential}
|
||||
onBlur={() => form.setFieldTouched('credential')}
|
||||
onChange={credential => {
|
||||
form.setFieldValue('credential', credential.id);
|
||||
setInsightsCredential({
|
||||
...insightsCredential,
|
||||
value: credential,
|
||||
});
|
||||
}}
|
||||
value={insightsCredential.value}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<ScmTypeOptions hideAllowOverride scmUpdateOnLaunch={scmUpdateOnLaunch} />
|
||||
</>
|
||||
);
|
||||
|
||||
export default withI18n()(InsightsSubForm);
|
||||
@@ -0,0 +1,135 @@
|
||||
import React from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Field } from 'formik';
|
||||
import CredentialLookup from '@components/Lookup/CredentialLookup';
|
||||
import FormField, { CheckboxField } from '@components/FormField';
|
||||
import { required } from '@util/validators';
|
||||
import FormRow from '@components/FormRow';
|
||||
import { FormGroup, Title } from '@patternfly/react-core';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const SubFormTitle = styled(Title)`
|
||||
--pf-c-title--m-md--FontWeight: 700;
|
||||
grid-column: 1 / -1;
|
||||
`;
|
||||
|
||||
export const UrlFormField = withI18n()(({ i18n, tooltip }) => (
|
||||
<FormField
|
||||
id="project-scm-url"
|
||||
isRequired
|
||||
label={i18n._(t`SCM URL`)}
|
||||
name="scm_url"
|
||||
tooltip={tooltip}
|
||||
tooltipMaxWidth="350px"
|
||||
type="text"
|
||||
validate={required(null, i18n)}
|
||||
/>
|
||||
));
|
||||
|
||||
export const BranchFormField = withI18n()(({ i18n, label }) => (
|
||||
<FormField
|
||||
id="project-scm-branch"
|
||||
name="scm_branch"
|
||||
type="text"
|
||||
label={label}
|
||||
tooltip={i18n._(t`Branch to checkout. In addition to branches,
|
||||
you can input tags, commit hashes, and arbitrary refs. Some
|
||||
commit hashes and refs may not be availble unless you also
|
||||
provide a custom refspec.`)}
|
||||
/>
|
||||
));
|
||||
|
||||
export const ScmCredentialFormField = withI18n()(
|
||||
({ i18n, setScmCredential, scmCredential }) => (
|
||||
<Field
|
||||
name="credential"
|
||||
render={({ form }) => (
|
||||
<CredentialLookup
|
||||
credentialTypeId={scmCredential.typeId}
|
||||
label={i18n._(t`SCM Credential`)}
|
||||
value={scmCredential.value}
|
||||
onChange={credential => {
|
||||
form.setFieldValue('credential', credential.id);
|
||||
setScmCredential({
|
||||
...scmCredential,
|
||||
value: credential,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
export const ScmTypeOptions = withI18n()(
|
||||
({ i18n, scmUpdateOnLaunch, hideAllowOverride }) => (
|
||||
<>
|
||||
<FormGroup
|
||||
css="grid-column: 1/-1"
|
||||
fieldId="project-option-checkboxes"
|
||||
label={i18n._(t`Options`)}
|
||||
>
|
||||
<FormRow>
|
||||
<CheckboxField
|
||||
id="option-scm-clean"
|
||||
name="scm_clean"
|
||||
label={i18n._(t`Clean`)}
|
||||
tooltip={i18n._(
|
||||
t`Remove any local modifications prior to performing an update.`
|
||||
)}
|
||||
/>
|
||||
<CheckboxField
|
||||
id="option-scm-delete-on-update"
|
||||
name="scm_delete_on_update"
|
||||
label={i18n._(t`Delete`)}
|
||||
tooltip={i18n._(
|
||||
t`Delete the local repository in its entirety prior to
|
||||
performing an update. Depending on the size of the
|
||||
repository this may significantly increase the amount
|
||||
of time required to complete an update.`
|
||||
)}
|
||||
/>
|
||||
<CheckboxField
|
||||
id="option-scm-update-on-launch"
|
||||
name="scm_update_on_launch"
|
||||
label={i18n._(t`Update Revision on Launch`)}
|
||||
tooltip={i18n._(
|
||||
t`Each time a job runs using this project, update the
|
||||
revision of the project prior to starting the job.`
|
||||
)}
|
||||
/>
|
||||
{!hideAllowOverride && (
|
||||
<CheckboxField
|
||||
id="option-allow-override"
|
||||
name="allow_override"
|
||||
label={i18n._(t`Allow Branch Override`)}
|
||||
tooltip={i18n._(
|
||||
t`Allow changing the SCM branch or revision in a job
|
||||
template that uses this project.`
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</FormRow>
|
||||
</FormGroup>
|
||||
{scmUpdateOnLaunch && (
|
||||
<>
|
||||
<SubFormTitle size="md">{i18n._(t`Option Details`)}</SubFormTitle>
|
||||
<FormField
|
||||
id="project-cache-timeout"
|
||||
name="scm_update_cache_timeout"
|
||||
type="number"
|
||||
min="0"
|
||||
label={i18n._(t`Cache Timeout`)}
|
||||
tooltip={i18n._(t`Time in seconds to consider a project
|
||||
to be current. During job runs and callbacks the task
|
||||
system will evaluate the timestamp of the latest project
|
||||
update. If it is older than Cache Timeout, it is not
|
||||
considered current, and a new project update will be
|
||||
performed.`)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
UrlFormField,
|
||||
BranchFormField,
|
||||
ScmCredentialFormField,
|
||||
ScmTypeOptions,
|
||||
} from './SharedFields';
|
||||
|
||||
const SvnSubForm = ({
|
||||
i18n,
|
||||
scmCredential,
|
||||
setScmCredential,
|
||||
scmUpdateOnLaunch,
|
||||
}) => (
|
||||
<>
|
||||
<UrlFormField
|
||||
i18n={i18n}
|
||||
tooltip={
|
||||
<span>
|
||||
{i18n._(t`Example URLs for Subversion SCM include:`)}
|
||||
<ul css={{ margin: '10px 0 10px 20px' }}>
|
||||
<li>https://github.com/ansible/ansible</li>
|
||||
<li>svn://servername.example.com/path</li>
|
||||
<li>svn+ssh://servername.example.com/path</li>
|
||||
</ul>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<BranchFormField i18n={i18n} label={i18n._(t`Revision #`)} />
|
||||
<ScmCredentialFormField
|
||||
setScmCredential={setScmCredential}
|
||||
scmCredential={scmCredential}
|
||||
/>
|
||||
<ScmTypeOptions scmUpdateOnLaunch={scmUpdateOnLaunch} />
|
||||
</>
|
||||
);
|
||||
|
||||
export default withI18n()(SvnSubForm);
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default as GitSubForm } from './GitSubForm';
|
||||
export { default as HgSubForm } from './HgSubForm';
|
||||
export { default as SvnSubForm } from './SvnSubForm';
|
||||
export { default as InsightsSubForm } from './InsightsSubForm';
|
||||
export { SubFormTitle } from './SharedFields';
|
||||
@@ -0,0 +1,54 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Link, withRouter } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Dropdown, DropdownPosition } from '@patternfly/react-core';
|
||||
import { ToolbarAddButton } from '@components/PaginatedDataList';
|
||||
|
||||
function TemplateAddButton({ match, i18n }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const element = useRef(null);
|
||||
|
||||
const toggle = e => {
|
||||
if (!element || !element.current.contains(e.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('click', toggle, false);
|
||||
return () => {
|
||||
document.removeEventListener('click', toggle);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={element} key="add">
|
||||
<Dropdown
|
||||
isPlain
|
||||
isOpen={isOpen}
|
||||
position={DropdownPosition.right}
|
||||
toggle={<ToolbarAddButton onClick={() => setIsOpen(!isOpen)} />}
|
||||
dropdownItems={[
|
||||
<Link
|
||||
key="job"
|
||||
className="pf-c-dropdown__menu-item"
|
||||
to={`${match.url}/job_template/add/`}
|
||||
>
|
||||
{i18n._(t`Job Template`)}
|
||||
</Link>,
|
||||
<Link
|
||||
key="workflow"
|
||||
className="pf-c-dropdown__menu-item"
|
||||
to={`${match.url}_workflow/add/`}
|
||||
>
|
||||
{i18n._(t`Workflow Template`)}
|
||||
</Link>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { TemplateAddButton as _TemplateAddButton };
|
||||
export default withI18n()(withRouter(TemplateAddButton));
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import TemplateAddButton from './TemplateAddButton';
|
||||
|
||||
describe('<TemplateAddButton />', () => {
|
||||
test('should be closed initially', () => {
|
||||
const wrapper = mountWithContexts(<TemplateAddButton />);
|
||||
expect(wrapper.find('Dropdown').prop('isOpen')).toEqual(false);
|
||||
});
|
||||
|
||||
test('should render two links', () => {
|
||||
const wrapper = mountWithContexts(<TemplateAddButton />);
|
||||
wrapper.find('button').simulate('click');
|
||||
expect(wrapper.find('Dropdown').prop('isOpen')).toEqual(true);
|
||||
expect(wrapper.find('Link')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('should close when button re-clicked', () => {
|
||||
const wrapper = mountWithContexts(<TemplateAddButton />);
|
||||
wrapper.find('button').simulate('click');
|
||||
expect(wrapper.find('Dropdown').prop('isOpen')).toEqual(true);
|
||||
wrapper.find('button').simulate('click');
|
||||
expect(wrapper.find('Dropdown').prop('isOpen')).toEqual(false);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,8 @@
|
||||
import React, { Component } from 'react';
|
||||
import { withRouter, Link } from 'react-router-dom';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Card,
|
||||
PageSection,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownPosition,
|
||||
} from '@patternfly/react-core';
|
||||
import { Card, PageSection } from '@patternfly/react-core';
|
||||
|
||||
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '@api';
|
||||
import AlertModal from '@components/AlertModal';
|
||||
@@ -17,11 +10,11 @@ import DatalistToolbar from '@components/DataListToolbar';
|
||||
import ErrorDetail from '@components/ErrorDetail';
|
||||
import PaginatedDataList, {
|
||||
ToolbarDeleteButton,
|
||||
ToolbarAddButton,
|
||||
} from '@components/PaginatedDataList';
|
||||
import { getQSConfig, parseQueryString } from '@util/qs';
|
||||
|
||||
import TemplateListItem from './TemplateListItem';
|
||||
import TemplateAddButton from './TemplateAddButton';
|
||||
|
||||
// The type value in const QS_CONFIG below does not have a space between job_template and
|
||||
// workflow_job_template so the params sent to the API match what the api expects.
|
||||
@@ -43,7 +36,6 @@ class TemplatesList extends Component {
|
||||
selected: [],
|
||||
templates: [],
|
||||
itemCount: 0,
|
||||
isAddOpen: false,
|
||||
};
|
||||
|
||||
this.loadTemplates = this.loadTemplates.bind(this);
|
||||
@@ -51,7 +43,6 @@ class TemplatesList extends Component {
|
||||
this.handleSelect = this.handleSelect.bind(this);
|
||||
this.handleTemplateDelete = this.handleTemplateDelete.bind(this);
|
||||
this.handleDeleteErrorClose = this.handleDeleteErrorClose.bind(this);
|
||||
this.handleAddToggle = this.handleAddToggle.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -89,21 +80,6 @@ class TemplatesList extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
handleAddToggle(e) {
|
||||
const { isAddOpen } = this.state;
|
||||
document.addEventListener('click', this.handleAddToggle, false);
|
||||
|
||||
if (this.node && this.node.contains(e.target) && isAddOpen) {
|
||||
document.removeEventListener('click', this.handleAddToggle, false);
|
||||
this.setState({ isAddOpen: false });
|
||||
} else if (this.node && this.node.contains(e.target) && !isAddOpen) {
|
||||
this.setState({ isAddOpen: true });
|
||||
} else {
|
||||
this.setState({ isAddOpen: false });
|
||||
document.removeEventListener('click', this.handleAddToggle, false);
|
||||
}
|
||||
}
|
||||
|
||||
async handleTemplateDelete() {
|
||||
const { selected, itemCount } = this.state;
|
||||
|
||||
@@ -178,7 +154,6 @@ class TemplatesList extends Component {
|
||||
templates,
|
||||
itemCount,
|
||||
selected,
|
||||
isAddOpen,
|
||||
actions,
|
||||
} = this.state;
|
||||
const { match, i18n } = this.props;
|
||||
@@ -231,35 +206,7 @@ class TemplatesList extends Component {
|
||||
itemsToDelete={selected}
|
||||
pluralizedItemName="Templates"
|
||||
/>,
|
||||
canAdd && (
|
||||
<div
|
||||
ref={node => {
|
||||
this.node = node;
|
||||
}}
|
||||
key="add"
|
||||
>
|
||||
<Dropdown
|
||||
isPlain
|
||||
isOpen={isAddOpen}
|
||||
position={DropdownPosition.right}
|
||||
toggle={
|
||||
<ToolbarAddButton onClick={this.handleAddToggle} />
|
||||
}
|
||||
dropdownItems={[
|
||||
<DropdownItem key="job">
|
||||
<Link to={`${match.url}/job_template/add/`}>
|
||||
{i18n._(t`Job Template`)}
|
||||
</Link>
|
||||
</DropdownItem>,
|
||||
<DropdownItem key="workflow">
|
||||
<Link to={`${match.url}_workflow/add/`}>
|
||||
{i18n._(t`Workflow Template`)}
|
||||
</Link>
|
||||
</DropdownItem>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
canAdd && <TemplateAddButton key="add" />,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
@@ -273,35 +220,7 @@ class TemplatesList extends Component {
|
||||
isSelected={selected.some(row => row.id === template.id)}
|
||||
/>
|
||||
)}
|
||||
emptyStateControls={
|
||||
canAdd && (
|
||||
<div
|
||||
ref={node => {
|
||||
this.node = node;
|
||||
}}
|
||||
key="add"
|
||||
>
|
||||
<Dropdown
|
||||
isPlain
|
||||
isOpen={isAddOpen}
|
||||
position={DropdownPosition.right}
|
||||
toggle={<ToolbarAddButton onClick={this.handleAddToggle} />}
|
||||
dropdownItems={[
|
||||
<DropdownItem key="job">
|
||||
<Link to={`${match.url}/job_template/add/`}>
|
||||
{i18n._(t`Job Template`)}
|
||||
</Link>
|
||||
</DropdownItem>,
|
||||
<DropdownItem key="workflow">
|
||||
<Link to={`${match.url}_workflow/add/`}>
|
||||
{i18n._(t`Workflow Template`)}
|
||||
</Link>
|
||||
</DropdownItem>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
emptyStateControls={canAdd && <TemplateAddButton />}
|
||||
/>
|
||||
</Card>
|
||||
<AlertModal
|
||||
|
||||
@@ -136,6 +136,12 @@ def main():
|
||||
json_output['timeout'] = True
|
||||
except exc.NotFound as excinfo:
|
||||
fail_json = dict(msg='Unable to wait, no job_id {0} found: {1}'.format(job_id, excinfo), changed=False)
|
||||
except exc.JobFailure as excinfo:
|
||||
fail_json = dict(msg='Job with id={} failed, error: {}'.format(job_id, excinfo))
|
||||
fail_json['success'] = False
|
||||
result = job.get(job_id)
|
||||
for k in ('id', 'status', 'elapsed', 'started', 'finished'):
|
||||
fail_json[k] = result.get(k)
|
||||
except (exc.ConnectionError, exc.BadRequest, exc.AuthError) as excinfo:
|
||||
fail_json = dict(msg='Unable to wait for job: {0}'.format(excinfo), changed=False)
|
||||
|
||||
|
||||
53
awx_collection/test/awx/test_job.py
Normal file
53
awx_collection/test/awx/test_job.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import pytest
|
||||
from django.utils.timezone import now
|
||||
|
||||
from awx.main.models import Job
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_job_wait_successful(run_module, admin_user):
|
||||
job = Job.objects.create(status='successful', started=now(), finished=now())
|
||||
result = run_module('tower_job_wait', dict(
|
||||
job_id=job.id
|
||||
), admin_user)
|
||||
result.pop('invocation', None)
|
||||
assert result.pop('finished', '')[:10] == str(job.finished)[:10]
|
||||
assert result.pop('started', '')[:10] == str(job.started)[:10]
|
||||
assert result == {
|
||||
"status": "successful",
|
||||
"success": True,
|
||||
"elapsed": str(job.elapsed),
|
||||
"id": job.id
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_job_wait_failed(run_module, admin_user):
|
||||
job = Job.objects.create(status='failed', started=now(), finished=now())
|
||||
result = run_module('tower_job_wait', dict(
|
||||
job_id=job.id
|
||||
), admin_user)
|
||||
result.pop('invocation', None)
|
||||
assert result.pop('finished', '')[:10] == str(job.finished)[:10]
|
||||
assert result.pop('started', '')[:10] == str(job.started)[:10]
|
||||
assert result == {
|
||||
"status": "failed",
|
||||
"failed": True,
|
||||
"success": False,
|
||||
"elapsed": str(job.elapsed),
|
||||
"id": job.id,
|
||||
"msg": "Job with id=1 failed, error: Job failed."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_job_wait_not_found(run_module, admin_user):
|
||||
result = run_module('tower_job_wait', dict(
|
||||
job_id=42
|
||||
), admin_user)
|
||||
result.pop('invocation', None)
|
||||
assert result == {
|
||||
"changed": False,
|
||||
"failed": True,
|
||||
"msg": "Unable to wait, no job_id 42 found: The requested object could not be found."
|
||||
}
|
||||
@@ -14,6 +14,7 @@ RUN dnf -y update && \
|
||||
gcc \
|
||||
gcc-c++ \
|
||||
git-core \
|
||||
glibc-langpack-en \
|
||||
krb5-workstation \
|
||||
libcurl-devel \
|
||||
libffi-devel \
|
||||
@@ -33,6 +34,7 @@ RUN dnf -y update && \
|
||||
python3-devel \
|
||||
python3-libselinux \
|
||||
python3-pip \
|
||||
python3-psycopg2 \
|
||||
python3-setuptools \
|
||||
rsync \
|
||||
subversion \
|
||||
@@ -114,6 +116,9 @@ RUN for dir in /home/awx /var/log/tower /var/log/nginx /var/lib/nginx; \
|
||||
RUN for file in /etc/passwd /var/run/nginx.pid; \
|
||||
do touch $file; chmod -R g+rwx $file; chgrp -R root $file; done
|
||||
|
||||
# https://github.com/ansible/awx/issues/5224
|
||||
RUN chmod u+s /usr/bin/bwrap
|
||||
|
||||
VOLUME /var/lib/nginx
|
||||
RUN ln -sf /dev/stdout /var/log/nginx/access.log \
|
||||
&& ln -sf /dev/stderr /var/log/nginx/error.log
|
||||
|
||||
@@ -55,3 +55,4 @@ custom_venvs_python: "python2"
|
||||
ca_trust_bundle: "/etc/pki/tls/certs/ca-bundle.crt"
|
||||
rabbitmq_use_ssl: False
|
||||
|
||||
container_groups_image: "ansible/ansible-runner"
|
||||
|
||||
@@ -113,58 +113,61 @@
|
||||
seconds: "{{ postgress_activate_wait }}"
|
||||
when: openshift_pg_activate.changed or kubernetes_pg_activate.changed
|
||||
|
||||
- name: Check if Postgres 9.6 is being used
|
||||
shell: |
|
||||
POD=$({{ kubectl_or_oc }} -n {{ kubernetes_namespace }} \
|
||||
get pods -l=name=postgresql --field-selector status.phase=Running -o jsonpath="{.items[0].metadata.name}")
|
||||
{{ kubectl_or_oc }} exec -ti $POD -n {{ kubernetes_namespace }} -- bash -c "psql -U {{ pg_username }} -tAc 'select version()'"
|
||||
register: pg_version
|
||||
|
||||
- name: Upgrade Postgres if necessary
|
||||
- name: Check postgres version and upgrade Postgres if necessary
|
||||
block:
|
||||
- name: Set new pg image
|
||||
shell: |
|
||||
IMAGE=registry.access.redhat.com/rhscl/postgresql-10-rhel7
|
||||
{{ kubectl_or_oc }} -n {{ kubernetes_namespace }} set image dc/postgresql postgresql=$IMAGE
|
||||
|
||||
- name: Wait for change to take affect
|
||||
pause:
|
||||
seconds: 5
|
||||
|
||||
- name: Set env var for pg upgrade
|
||||
shell: |
|
||||
{{ kubectl_or_oc }} -n {{ kubernetes_namespace }} set env dc/postgresql POSTGRESQL_UPGRADE=copy
|
||||
|
||||
- name: Wait for change to take affect
|
||||
pause:
|
||||
seconds: 5
|
||||
|
||||
- name: Set env var for new pg version
|
||||
shell: |
|
||||
{{ kubectl_or_oc }} -n {{ kubernetes_namespace }} set env dc/postgresql POSTGRESQL_VERSION=10
|
||||
|
||||
- name: Wait for Postgres to redeploy
|
||||
pause:
|
||||
seconds: "{{ postgress_activate_wait }}"
|
||||
|
||||
- name: Wait for Postgres to finish upgrading
|
||||
- name: Check if Postgres 9.6 is being used
|
||||
shell: |
|
||||
POD=$({{ kubectl_or_oc }} -n {{ kubernetes_namespace }} \
|
||||
get pods -l=name=postgresql -o jsonpath="{.items[0].metadata.name}")
|
||||
{{ kubectl_or_oc }} -n {{ kubernetes_namespace }} logs $POD | grep 'Upgrade DONE'
|
||||
register: pg_upgrade_logs
|
||||
retries: 360
|
||||
delay: 10
|
||||
until: pg_upgrade_logs is success
|
||||
get pods -l=name=postgresql --field-selector status.phase=Running -o jsonpath="{.items[0].metadata.name}")
|
||||
oc exec $POD -n {{ kubernetes_namespace }} -- bash -c "psql -tAc 'select version()'"
|
||||
register: pg_version
|
||||
- name: Upgrade postgres if necessary
|
||||
block:
|
||||
- name: Set new pg image
|
||||
shell: |
|
||||
IMAGE=registry.access.redhat.com/rhscl/postgresql-10-rhel7
|
||||
{{ kubectl_or_oc }} -n {{ kubernetes_namespace }} set image dc/postgresql postgresql=$IMAGE
|
||||
|
||||
- name: Unset upgrade env var
|
||||
shell: |
|
||||
{{ kubectl_or_oc }} -n {{ kubernetes_namespace }} set env dc/postgresql POSTGRESQL_UPGRADE-
|
||||
- name: Wait for change to take affect
|
||||
pause:
|
||||
seconds: 5
|
||||
|
||||
- name: Wait for Postgres to redeploy
|
||||
pause:
|
||||
seconds: "{{ postgress_activate_wait }}"
|
||||
when: "pg_version is success and '9.6' in pg_version.stdout"
|
||||
- name: Set env var for pg upgrade
|
||||
shell: |
|
||||
{{ kubectl_or_oc }} -n {{ kubernetes_namespace }} set env dc/postgresql POSTGRESQL_UPGRADE=copy
|
||||
|
||||
- name: Wait for change to take affect
|
||||
pause:
|
||||
seconds: 5
|
||||
|
||||
- name: Set env var for new pg version
|
||||
shell: |
|
||||
{{ kubectl_or_oc }} -n {{ kubernetes_namespace }} set env dc/postgresql POSTGRESQL_VERSION=10
|
||||
|
||||
- name: Wait for Postgres to redeploy
|
||||
pause:
|
||||
seconds: "{{ postgress_activate_wait }}"
|
||||
|
||||
- name: Wait for Postgres to finish upgrading
|
||||
shell: |
|
||||
POD=$({{ kubectl_or_oc }} -n {{ kubernetes_namespace }} \
|
||||
get pods -l=name=postgresql -o jsonpath="{.items[0].metadata.name}")
|
||||
{{ kubectl_or_oc }} -n {{ kubernetes_namespace }} logs $POD | grep 'Upgrade DONE'
|
||||
register: pg_upgrade_logs
|
||||
retries: 360
|
||||
delay: 10
|
||||
until: pg_upgrade_logs is success
|
||||
|
||||
- name: Unset upgrade env var
|
||||
shell: |
|
||||
{{ kubectl_or_oc }} -n {{ kubernetes_namespace }} set env dc/postgresql POSTGRESQL_UPGRADE-
|
||||
|
||||
- name: Wait for Postgres to redeploy
|
||||
pause:
|
||||
seconds: "{{ postgress_activate_wait }}"
|
||||
when: "pg_version is success and '9.6' in pg_version.stdout"
|
||||
when:
|
||||
- pg_hostname is not defined or pg_hostname == ''
|
||||
|
||||
- name: Set image names if using custom registry
|
||||
block:
|
||||
|
||||
@@ -4,6 +4,126 @@ metadata:
|
||||
name: {{ kubernetes_deployment_name }}-config
|
||||
namespace: {{ kubernetes_namespace }}
|
||||
data:
|
||||
{{ kubernetes_deployment_name }}_nginx_conf: |
|
||||
#user awx;
|
||||
|
||||
worker_processes 1;
|
||||
|
||||
pid /tmp/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
server_tokens off;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /dev/stdout main;
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
sendfile on;
|
||||
#tcp_nopush on;
|
||||
#gzip on;
|
||||
|
||||
upstream uwsgi {
|
||||
server 127.0.0.1:8050;
|
||||
}
|
||||
|
||||
upstream daphne {
|
||||
server 127.0.0.1:8051;
|
||||
}
|
||||
|
||||
{% if ssl_certificate is defined %}
|
||||
server {
|
||||
listen 8052 default_server;
|
||||
server_name _;
|
||||
|
||||
# Redirect all HTTP links to the matching HTTPS page
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
{%endif %}
|
||||
|
||||
server {
|
||||
{% if ssl_certificate is defined %}
|
||||
listen 8053 ssl;
|
||||
|
||||
ssl_certificate /etc/nginx/awxweb.pem;
|
||||
ssl_certificate_key /etc/nginx/awxweb.pem;
|
||||
{% else %}
|
||||
listen 8052 default_server;
|
||||
{% endif %}
|
||||
|
||||
# If you have a domain name, this is where to add it
|
||||
server_name _;
|
||||
keepalive_timeout 65;
|
||||
|
||||
# HSTS (ngx_http_headers_module is required) (15768000 seconds = 6 months)
|
||||
add_header Strict-Transport-Security max-age=15768000;
|
||||
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' *.pendo.io; img-src 'self' *.pendo.io data:; report-uri /csp-violation/";
|
||||
add_header X-Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' *.pendo.io; img-src 'self' *.pendo.io data:; report-uri /csp-violation/";
|
||||
|
||||
# Protect against click-jacking https://www.owasp.org/index.php/Testing_for_Clickjacking_(OTG-CLIENT-009)
|
||||
add_header X-Frame-Options "DENY";
|
||||
|
||||
location /nginx_status {
|
||||
stub_status on;
|
||||
access_log off;
|
||||
allow 127.0.0.1;
|
||||
deny all;
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
alias /var/lib/awx/public/static/;
|
||||
}
|
||||
|
||||
location /favicon.ico { alias /var/lib/awx/public/static/favicon.ico; }
|
||||
|
||||
location /websocket {
|
||||
# Pass request to the upstream alias
|
||||
proxy_pass http://daphne;
|
||||
# Require http version 1.1 to allow for upgrade requests
|
||||
proxy_http_version 1.1;
|
||||
# We want proxy_buffering off for proxying to websockets.
|
||||
proxy_buffering off;
|
||||
# http://en.wikipedia.org/wiki/X-Forwarded-For
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# enable this if you use HTTPS:
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
# pass the Host: header from the client for the sake of redirects
|
||||
proxy_set_header Host $http_host;
|
||||
# We've set the Host header, so we don't need Nginx to muddle
|
||||
# about with redirects
|
||||
proxy_redirect off;
|
||||
# Depending on the request value, set the Upgrade and
|
||||
# connection headers
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
|
||||
location / {
|
||||
# Add trailing / if missing
|
||||
rewrite ^(.*)$http_host(.*[^/])$ $1$http_host$2/ permanent;
|
||||
uwsgi_read_timeout 120s;
|
||||
uwsgi_pass uwsgi;
|
||||
include /etc/nginx/uwsgi_params;
|
||||
{%- if extra_nginx_include is defined %}
|
||||
include {{ extra_nginx_include }};
|
||||
{%- endif %}
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{{ kubernetes_deployment_name }}_settings: |
|
||||
import os
|
||||
import socket
|
||||
@@ -82,3 +202,5 @@ data:
|
||||
}
|
||||
|
||||
USE_X_FORWARDED_PORT = True
|
||||
|
||||
AWX_CONTAINER_GROUP_DEFAULT_IMAGE = "{{ container_groups_image }}"
|
||||
|
||||
@@ -203,6 +203,11 @@ spec:
|
||||
subPath: settings.py
|
||||
readOnly: true
|
||||
|
||||
- name: {{ kubernetes_deployment_name }}-nginx-config
|
||||
mountPath: /etc/nginx/nginx.conf
|
||||
subPath: nginx.conf
|
||||
readOnly: true
|
||||
|
||||
- name: "{{ kubernetes_deployment_name }}-application-credentials"
|
||||
mountPath: "/etc/tower/conf.d/"
|
||||
readOnly: true
|
||||
@@ -390,6 +395,13 @@ spec:
|
||||
- key: {{ kubernetes_deployment_name }}_settings
|
||||
path: settings.py
|
||||
|
||||
- name: {{ kubernetes_deployment_name }}-nginx-config
|
||||
configMap:
|
||||
name: {{ kubernetes_deployment_name }}-config
|
||||
items:
|
||||
- key: {{ kubernetes_deployment_name }}_nginx_conf
|
||||
path: nginx.conf
|
||||
|
||||
- name: "{{ kubernetes_deployment_name }}-application-credentials"
|
||||
secret:
|
||||
secretName: "{{ kubernetes_deployment_name }}-secrets"
|
||||
|
||||
@@ -17,6 +17,7 @@ RUN dnf -y update && \
|
||||
gcc \
|
||||
gcc-c++ \
|
||||
git-core \
|
||||
glibc-langpack-en \
|
||||
krb5-workstation \
|
||||
libcurl-devel \
|
||||
libffi-devel \
|
||||
@@ -49,10 +50,10 @@ RUN dnf -y update && \
|
||||
xmlsec1-devel \
|
||||
xmlsec1-openssl \
|
||||
xmlsec1-openssl-devel \
|
||||
dnf-utils && \
|
||||
dnf-utils
|
||||
|
||||
# UI tests only, do not put in installer/roles/image_build/templates/Dockerfile.j2
|
||||
dnf -y install \
|
||||
RUN dnf -y install \
|
||||
gtk3 \
|
||||
alsa-lib \
|
||||
libX11-xcb \
|
||||
@@ -71,6 +72,7 @@ ADD tools/docker-compose/awx.egg-info /tmp/awx.egg-info
|
||||
|
||||
RUN openssl req -nodes -newkey rsa:2048 -keyout /etc/nginx/nginx.key -out /etc/nginx/nginx.csr -subj "/C=US/ST=North Carolina/L=Durham/O=Ansible/OU=AWX Development/CN=awx.localhost"
|
||||
RUN openssl x509 -req -days 365 -in /etc/nginx/nginx.csr -signkey /etc/nginx/nginx.key -out /etc/nginx/nginx.crt
|
||||
RUN chmod 640 /etc/nginx/nginx.{csr,key,crt}
|
||||
|
||||
RUN python3 -m ensurepip && pip3 install virtualenv flake8
|
||||
RUN pip3 install supervisor
|
||||
|
||||
Reference in New Issue
Block a user