Compare commits

..

33 Commits
9.0.0 ... 9.0.1

Author SHA1 Message Date
Shane McDonald
8a4c85e473 Merge pull request #5231 from shanemcd/bump-version-9.0.1
Bump version to 9.0.1
2019-11-04 11:23:13 -05:00
Shane McDonald
09d883f94a Bump version to 9.0.1 2019-11-04 11:20:42 -05:00
Shane McDonald
9ef57ec510 Merge pull request #5227 from shanemcd/more-downstream-k8s-fallout-sorry-folks
More downstream k8s fallout, sorry folks
2019-11-04 11:19:15 -05:00
Shane McDonald
5be006f9d3 Merge pull request #5229 from shanemcd/setuid-bwrap
Set setuid bit on bwrap
2019-11-04 11:18:57 -05:00
Shane McDonald
089bafa5d4 Set setuid bit on bwrap
Related: https://github.com/ansible/awx/issues/5224
2019-11-04 11:10:09 -05:00
Shane McDonald
fa278f83ad Fix k8s installs using external db 2019-11-04 09:35:11 -05:00
Shane McDonald
0d68ca8f14 Allow for overriding container groups image from k8s installer 2019-11-04 09:35:03 -05:00
Shane McDonald
713079bd70 Merge pull request #5218 from shanemcd/fix-k8s-nginx
Fix broken k8s installs
2019-11-04 08:57:48 -05:00
Shane McDonald
d3b137fbc4 Fix broken k8s installs
Related: https://github.com/ansible/awx/issues/5205

The following commit introduced this bug:
712b07c136 (diff-a2ef2d6347894a92a6b882e3793fb06c)
2019-11-03 16:03:19 -05:00
softwarefactory-project-zuul[bot]
5246c842b2 Merge pull request #5203 from keithjgrant/warnings-cleanup
Cleanup console warnings/refactor

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2019-11-01 19:29:22 +00:00
softwarefactory-project-zuul[bot]
1dca4c9098 Merge pull request #5198 from AlanCoding/no_symlink
Remove venv symlink hack no longer needed

Reviewed-by: Alan Rominger <arominge@redhat.com>
             https://github.com/AlanCoding
2019-11-01 15:36:39 +00:00
softwarefactory-project-zuul[bot]
8cb32045f0 Merge pull request #5127 from marshmalien/project-add-form
Project add form

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2019-11-01 15:13:51 +00:00
softwarefactory-project-zuul[bot]
4962b729de Merge pull request #5199 from ryanpetrello/uuid-not-unique
make the callback receiver more robust to duplicate UUIDs from ansible

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2019-11-01 15:11:22 +00:00
softwarefactory-project-zuul[bot]
ed39a127e7 Merge pull request #5200 from ryanpetrello/quiet-celery
get rid of a loud celery error in the dev environment

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2019-11-01 15:06:00 +00:00
AlanCoding
c4b4a4c21a Remove venv symlink hack no longer needed 2019-11-01 09:46:27 -04:00
Ryan Petrello
bd81fda05c get rid of a loud celery error in the dev environment 2019-11-01 09:25:41 -04:00
Ryan Petrello
83550eeba0 make the callback receiver more robust to duplicate UUIDs from ansible 2019-11-01 09:24:52 -04:00
softwarefactory-project-zuul[bot]
4540cb653e Merge pull request #5208 from shanemcd/locale-fix
Install missing locales in dev container image.

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2019-11-01 13:24:11 +00:00
Shane McDonald
69597c5654 Sync Dockerfiles 2019-11-01 08:38:37 -04:00
Shane McDonald
fa61aef194 Install missing locales in dev container image. 2019-11-01 08:29:11 -04:00
softwarefactory-project-zuul[bot]
e35f6b2acb Merge pull request #5202 from shanemcd/fix-docker-cache
Fix docker cache

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2019-10-31 23:32:11 +00:00
Marliana Lara
a8140e86d7 Encapsulate each scm type subform in its own component 2019-10-31 19:00:55 -04:00
Shane McDonald
4d4ae84e32 Fix docker cache
This was causing the cache to miss on some docker versions, in addition to
throwing a warning that says it will break soon.
2019-10-31 16:57:17 -04:00
Marliana Lara
ae349addfe Resolve credential type id and retrieve scm_type choices from OPTIONS 2019-10-31 16:38:32 -04:00
Marliana Lara
31fdd5e85c Add scm refspec to project detail and remove org id from top level shared component 2019-10-31 16:38:32 -04:00
Marliana Lara
e4bde24f38 Add project add form and tests 2019-10-31 16:38:32 -04:00
Marliana Lara
9c019e1cc0 Add organization and credential lookups 2019-10-31 16:38:31 -04:00
softwarefactory-project-zuul[bot]
b3d298269b Merge pull request #5197 from wenottingham/tune-into-a-different-key
chmod the nginx cert/key/etc

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2019-10-31 18:53:40 +00:00
Keith Grant
21f7ca21e0 refactor dupe code to new TemplateAddButton component 2019-10-31 11:34:37 -07:00
softwarefactory-project-zuul[bot]
43bf370f8c Merge pull request #5195 from AlanCoding/job_fail_json
In tower_job_wait intentionally fail module for failure

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2019-10-31 17:53:53 +00:00
Bill Nottingham
6057921e34 chmod the nginx cert/key/etc
EL8 OpenSSL defaults to 0600 for the key, which will not work in the
dev environment.
2019-10-31 13:53:13 -04:00
AlanCoding
5095816762 In tower_job_wait intentionally fail module for failure 2019-10-31 12:41:53 -04:00
Keith Grant
c1da74cbc0 fix PF switch styling 2019-10-30 15:06:42 -07:00
37 changed files with 1794 additions and 171 deletions

View File

@@ -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

View File

@@ -1 +1 @@
9.0.0
9.0.1

View File

@@ -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):

View File

@@ -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)

View File

@@ -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;

View File

@@ -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>;
}

View 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);

View 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();
});
});

View File

@@ -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}

View 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 };

View File

@@ -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();
});
});

View File

@@ -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) {

View File

@@ -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

View File

@@ -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));

View 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');
});
});

View File

@@ -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`)}

View File

@@ -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}`

View File

@@ -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>

View 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);

View 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);
});
});

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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.`)}
/>
</>
)}
</>
)
);

View File

@@ -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);

View File

@@ -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';

View File

@@ -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));

View File

@@ -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);
});
});

View File

@@ -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

View File

@@ -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)

View 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."
}

View File

@@ -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

View File

@@ -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"

View File

@@ -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:

View File

@@ -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 }}"

View File

@@ -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"

View File

@@ -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