Merge pull request #4 from ansible/devel

Rebase from Dev
This commit is contained in:
Sean Sullivan 2020-08-31 13:45:49 -05:00 committed by GitHub
commit d0ab307787
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
58 changed files with 1818 additions and 226 deletions

View File

@ -180,7 +180,8 @@ def ship(path):
auth=(rh_user, rh_password),
headers=s.headers,
timeout=(31, 31))
if response.status_code != 202:
# Accept 2XX status_codes
if response.status_code >= 300:
return logger.exception('Upload failed with status {}, {}'.format(response.status_code,
response.text))
run_now = now()

View File

@ -152,7 +152,7 @@ def kv_backend(**kwargs):
sess = requests.Session()
sess.headers['Authorization'] = 'Bearer {}'.format(token)
# Compatability header for older installs of Hashicorp Vault
# Compatibility header for older installs of Hashicorp Vault
sess.headers['X-Vault-Token'] = token
if api_version == 'v2':

View File

@ -13,7 +13,7 @@ from django.core.management.base import BaseCommand, CommandError
class Command(BaseCommand):
"""
Internal tower command.
Regsiter this instance with the database for HA tracking.
Register this instance with the database for HA tracking.
"""
help = (

View File

@ -2169,7 +2169,7 @@ class RunProjectUpdate(BaseTask):
self._write_extra_vars_file(private_data_dir, extra_vars)
def build_cwd(self, project_update, private_data_dir):
return self.get_path_to('..', 'playbooks')
return os.path.join(private_data_dir, 'project')
def build_playbook_path_relative_to_cwd(self, project_update, private_data_dir):
return os.path.join('project_update.yml')
@ -2310,6 +2310,12 @@ class RunProjectUpdate(BaseTask):
shutil.rmtree(stage_path)
os.makedirs(stage_path) # presence of empty cache indicates lack of roles or collections
# the project update playbook is not in a git repo, but uses a vendoring directory
# to be consistent with the ansible-runner model,
# that is moved into the runner projecct folder here
awx_playbooks = self.get_path_to('..', 'playbooks')
copy_tree(awx_playbooks, os.path.join(private_data_dir, 'project'))
@staticmethod
def clear_project_cache(cache_dir, keep_value):
if os.path.isdir(cache_dir):

View File

@ -7,6 +7,7 @@ import Credentials from './models/Credentials';
import Groups from './models/Groups';
import Hosts from './models/Hosts';
import InstanceGroups from './models/InstanceGroups';
import Instances from './models/Instances';
import Inventories from './models/Inventories';
import InventoryScripts from './models/InventoryScripts';
import InventorySources from './models/InventorySources';
@ -19,8 +20,8 @@ import NotificationTemplates from './models/NotificationTemplates';
import Organizations from './models/Organizations';
import ProjectUpdates from './models/ProjectUpdates';
import Projects from './models/Projects';
import Root from './models/Root';
import Roles from './models/Roles';
import Root from './models/Root';
import Schedules from './models/Schedules';
import SystemJobs from './models/SystemJobs';
import Teams from './models/Teams';
@ -42,6 +43,7 @@ const CredentialsAPI = new Credentials();
const GroupsAPI = new Groups();
const HostsAPI = new Hosts();
const InstanceGroupsAPI = new InstanceGroups();
const InstancesAPI = new Instances();
const InventoriesAPI = new Inventories();
const InventoryScriptsAPI = new InventoryScripts();
const InventorySourcesAPI = new InventorySources();
@ -54,8 +56,8 @@ const NotificationTemplatesAPI = new NotificationTemplates();
const OrganizationsAPI = new Organizations();
const ProjectUpdatesAPI = new ProjectUpdates();
const ProjectsAPI = new Projects();
const RootAPI = new Root();
const RolesAPI = new Roles();
const RootAPI = new Root();
const SchedulesAPI = new Schedules();
const SystemJobsAPI = new SystemJobs();
const TeamsAPI = new Teams();
@ -78,6 +80,7 @@ export {
GroupsAPI,
HostsAPI,
InstanceGroupsAPI,
InstancesAPI,
InventoriesAPI,
InventoryScriptsAPI,
InventorySourcesAPI,
@ -90,8 +93,8 @@ export {
OrganizationsAPI,
ProjectUpdatesAPI,
ProjectsAPI,
RootAPI,
RolesAPI,
RootAPI,
SchedulesAPI,
SystemJobsAPI,
TeamsAPI,

View File

@ -27,6 +27,10 @@ class CredentialTypes extends Base {
.concat(nextResults)
.filter(type => acceptableKinds.includes(type.kind));
}
test(id, data) {
return this.http.post(`${this.baseUrl}${id}/test/`, data);
}
}
export default CredentialTypes;

View File

@ -25,6 +25,10 @@ class Credentials extends Base {
params,
});
}
test(id, data) {
return this.http.post(`${this.baseUrl}${id}/test/`, data);
}
}
export default Credentials;

View File

@ -4,6 +4,37 @@ class InstanceGroups extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/instance_groups/';
this.associateInstance = this.associateInstance.bind(this);
this.disassociateInstance = this.disassociateInstance.bind(this);
this.readInstanceOptions = this.readInstanceOptions.bind(this);
this.readInstances = this.readInstances.bind(this);
this.readJobs = this.readJobs.bind(this);
}
associateInstance(instanceGroupId, instanceId) {
return this.http.post(`${this.baseUrl}${instanceGroupId}/instances/`, {
id: instanceId,
});
}
disassociateInstance(instanceGroupId, instanceId) {
return this.http.post(`${this.baseUrl}${instanceGroupId}/instances/`, {
id: instanceId,
disassociate: true,
});
}
readInstances(id, params) {
return this.http.get(`${this.baseUrl}${id}/instances/`, { params });
}
readInstanceOptions(id) {
return this.http.options(`${this.baseUrl}${id}/instances/`);
}
readJobs(id) {
return this.http.get(`${this.baseUrl}${id}/jobs/`);
}
}

View File

@ -0,0 +1,10 @@
import Base from '../Base';
class Instances extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/instances/';
}
}
export default Instances;

View File

@ -8,11 +8,13 @@ import useRequest from '../../util/useRequest';
import { getQSConfig, parseQueryString } from '../../util/qs';
import useSelected from '../../util/useSelected';
const QS_CONFIG = getQSConfig('associate', {
page: 1,
page_size: 5,
order_by: 'name',
});
const QS_CONFIG = (order_by = 'name') => {
return getQSConfig('associate', {
page: 1,
page_size: 5,
order_by,
});
};
function AssociateModal({
i18n,
@ -23,6 +25,7 @@ function AssociateModal({
fetchRequest,
optionsRequest,
isModalOpen = false,
displayKey = 'name',
}) {
const history = useHistory();
const { selected, handleSelect } = useSelected([]);
@ -34,7 +37,10 @@ function AssociateModal({
isLoading,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, history.location.search);
const params = parseQueryString(
QS_CONFIG(displayKey),
history.location.search
);
const [
{
data: { count, results },
@ -52,7 +58,7 @@ function AssociateModal({
actionsResponse.data.actions?.GET || {}
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
};
}, [fetchRequest, optionsRequest, history.location.search]),
}, [fetchRequest, optionsRequest, history.location.search, displayKey]),
{
items: [],
itemCount: 0,
@ -112,6 +118,7 @@ function AssociateModal({
]}
>
<OptionsList
displayKey={displayKey}
contentError={contentError}
deselectItem={handleSelect}
header={header}
@ -119,14 +126,14 @@ function AssociateModal({
multiple
optionCount={itemCount}
options={items}
qsConfig={QS_CONFIG}
qsConfig={QS_CONFIG(displayKey)}
readOnly={false}
selectItem={handleSelect}
value={selected}
searchColumns={[
{
name: i18n._(t`Name`),
key: 'name__icontains',
key: `${displayKey}__icontains`,
isDefault: true,
},
{
@ -141,7 +148,7 @@ function AssociateModal({
sortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
key: `${displayKey}`,
},
]}
searchableKeys={searchableKeys}

View File

@ -16,6 +16,7 @@ function DisassociateButton({
modalNote = '',
modalTitle = i18n._(t`Disassociate?`),
onDisassociate,
verifyCannotDisassociate = true,
}) {
const [isOpen, setIsOpen] = useState(false);
@ -25,33 +26,41 @@ function DisassociateButton({
}
function cannotDisassociate(item) {
return !item.summary_fields.user_capabilities.delete;
return !item.summary_fields?.user_capabilities?.delete;
}
function renderTooltip() {
const itemsUnableToDisassociate = itemsToDisassociate
.filter(cannotDisassociate)
.map(item => item.name)
.join(', ');
if (verifyCannotDisassociate) {
const itemsUnableToDisassociate = itemsToDisassociate
.filter(cannotDisassociate)
.map(item => item.name)
.join(', ');
if (itemsToDisassociate.some(cannotDisassociate)) {
return (
<div>
{i18n._(
t`You do not have permission to disassociate the following: ${itemsUnableToDisassociate}`
)}
</div>
);
if (itemsToDisassociate.some(cannotDisassociate)) {
return (
<div>
{i18n._(
t`You do not have permission to disassociate the following: ${itemsUnableToDisassociate}`
)}
</div>
);
}
}
if (itemsToDisassociate.length) {
return i18n._(t`Disassociate`);
}
return i18n._(t`Select a row to disassociate`);
}
const isDisabled =
itemsToDisassociate.length === 0 ||
itemsToDisassociate.some(cannotDisassociate);
let isDisabled = false;
if (verifyCannotDisassociate) {
isDisabled =
itemsToDisassociate.length === 0 ||
itemsToDisassociate.some(cannotDisassociate);
} else {
isDisabled = itemsToDisassociate.length === 0;
}
// NOTE: Once PF supports tooltips on disabled elements,
// we can delete the extra <div> around the <DeleteButton> below.
@ -102,7 +111,7 @@ function DisassociateButton({
{itemsToDisassociate.map(item => (
<span key={item.id}>
<strong>{item.name}</strong>
<strong>{item.hostname ? item.hostname : item.name}</strong>
<br />
</span>
))}

View File

@ -0,0 +1,81 @@
import React, { useState, useEffect, useCallback } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Switch, Tooltip } from '@patternfly/react-core';
import AlertModal from '../AlertModal';
import ErrorDetail from '../ErrorDetail';
import useRequest from '../../util/useRequest';
import { InstancesAPI } from '../../api';
import { useConfig } from '../../contexts/Config';
function InstanceToggle({
className,
fetchInstances,
instance,
onToggle,
i18n,
}) {
const { me } = useConfig();
const [isEnabled, setIsEnabled] = useState(instance.enabled);
const [showError, setShowError] = useState(false);
const { result, isLoading, error, request: toggleInstance } = useRequest(
useCallback(async () => {
await InstancesAPI.update(instance.id, { enabled: !isEnabled });
await fetchInstances();
return !isEnabled;
}, [instance, isEnabled, fetchInstances]),
instance.enabled
);
useEffect(() => {
if (result !== isEnabled) {
setIsEnabled(result);
if (onToggle) {
onToggle(result);
}
}
}, [result, isEnabled, onToggle]);
useEffect(() => {
if (error) {
setShowError(true);
}
}, [error]);
return (
<>
<Tooltip
content={i18n._(
t`Set the instance online or offline. If offline, jobs will not be assigned to this instance.`
)}
position="top"
>
<Switch
className={className}
css="display: inline-flex;"
id={`host-${instance.id}-toggle`}
label={i18n._(t`On`)}
labelOff={i18n._(t`Off`)}
isChecked={isEnabled}
isDisabled={isLoading || !me.is_superuser}
onChange={toggleInstance}
aria-label={i18n._(t`Toggle instance`)}
/>
</Tooltip>
{showError && error && !isLoading && (
<AlertModal
variant="error"
title={i18n._(t`Error!`)}
isOpen={error && !isLoading}
onClose={() => setShowError(false)}
>
{i18n._(t`Failed to toggle instance.`)}
<ErrorDetail error={error} />
</AlertModal>
)}
</>
);
}
export default withI18n()(InstanceToggle);

View File

@ -0,0 +1,114 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { InstancesAPI } from '../../api';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import InstanceToggle from './InstanceToggle';
jest.mock('../../api');
const mockInstance = {
id: 1,
type: 'instance',
url: '/api/v2/instances/1/',
related: {
jobs: '/api/v2/instances/1/jobs/',
instance_groups: '/api/v2/instances/1/instance_groups/',
},
uuid: '00000000-0000-0000-0000-000000000000',
hostname: 'awx',
created: '2020-07-14T19:03:49.000054Z',
modified: '2020-08-05T19:17:18.080033Z',
capacity_adjustment: '0.40',
version: '13.0.0',
capacity: 10,
consumed_capacity: 0,
percent_capacity_remaining: 100.0,
jobs_running: 0,
jobs_total: 67,
cpu: 6,
memory: 2087469056,
cpu_capacity: 24,
mem_capacity: 1,
enabled: true,
managed_by_policy: true,
};
describe('<InstanceToggle>', () => {
const onToggle = jest.fn();
const fetchInstances = jest.fn();
afterEach(() => {
jest.clearAllMocks();
});
test('should show toggle off', async () => {
const wrapper = mountWithContexts(
<InstanceToggle
instance={mockInstance}
fetchInstances={fetchInstances}
onToggle={onToggle}
/>
);
expect(wrapper.find('Switch').prop('isChecked')).toEqual(true);
await act(async () => {
wrapper.find('Switch').invoke('onChange')();
});
expect(InstancesAPI.update).toHaveBeenCalledWith(1, {
enabled: false,
});
wrapper.update();
expect(wrapper.find('Switch').prop('isChecked')).toEqual(false);
expect(onToggle).toHaveBeenCalledWith(false);
expect(fetchInstances).toHaveBeenCalledTimes(1);
});
test('should show toggle on', async () => {
const wrapper = mountWithContexts(
<InstanceToggle
instance={{
...mockInstance,
enabled: false,
}}
onToggle={onToggle}
fetchInstances={fetchInstances}
/>
);
expect(wrapper.find('Switch').prop('isChecked')).toEqual(false);
await act(async () => {
wrapper.find('Switch').invoke('onChange')();
});
expect(InstancesAPI.update).toHaveBeenCalledWith(1, {
enabled: true,
});
wrapper.update();
expect(wrapper.find('Switch').prop('isChecked')).toEqual(true);
expect(onToggle).toHaveBeenCalledWith(true);
expect(fetchInstances).toHaveBeenCalledTimes(1);
});
test('should show error modal', async () => {
InstancesAPI.update.mockImplementation(() => {
throw new Error('nope');
});
const wrapper = mountWithContexts(
<InstanceToggle instance={mockInstance} />
);
expect(wrapper.find('Switch').prop('isChecked')).toEqual(true);
await act(async () => {
wrapper.find('Switch').invoke('onChange')();
});
wrapper.update();
const modal = wrapper.find('AlertModal');
expect(modal).toHaveLength(1);
expect(modal.prop('isOpen')).toEqual(true);
act(() => {
modal.invoke('onClose')();
});
wrapper.update();
expect(wrapper.find('AlertModal')).toHaveLength(0);
});
});

View File

@ -0,0 +1 @@
export { default } from './InstanceToggle';

View File

@ -42,6 +42,7 @@ function OptionsList({
renderItemChip,
isLoading,
i18n,
displayKey,
}) {
return (
<ModalList>
@ -52,6 +53,7 @@ function OptionsList({
onRemove={item => deselectItem(item)}
isReadOnly={readOnly}
renderItemChip={renderItemChip}
displayKey={displayKey}
/>
)}
<PaginatedDataList
@ -70,8 +72,8 @@ function OptionsList({
<CheckboxListItem
key={item.id}
itemId={item.id}
name={multiple ? item.name : name}
label={item.name}
name={multiple ? item[displayKey] : name}
label={item[displayKey]}
isSelected={value.some(i => i.id === item.id)}
onSelect={() => selectItem(item)}
onDeselect={() => deselectItem(item)}
@ -91,22 +93,24 @@ const Item = shape({
url: string,
});
OptionsList.propTypes = {
value: arrayOf(Item).isRequired,
options: arrayOf(Item).isRequired,
optionCount: number.isRequired,
searchColumns: SearchColumns,
sortColumns: SortColumns,
multiple: bool,
qsConfig: QSConfig.isRequired,
selectItem: func.isRequired,
deselectItem: func.isRequired,
displayKey: string,
multiple: bool,
optionCount: number.isRequired,
options: arrayOf(Item).isRequired,
qsConfig: QSConfig.isRequired,
renderItemChip: func,
searchColumns: SearchColumns,
selectItem: func.isRequired,
sortColumns: SortColumns,
value: arrayOf(Item).isRequired,
};
OptionsList.defaultProps = {
multiple: false,
renderItemChip: null,
searchColumns: [],
sortColumns: [],
displayKey: 'name',
};
export default withI18n()(OptionsList);

View File

@ -6,7 +6,13 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { useKebabifiedMenu } from '../../contexts/Kebabified';
function ToolbarAddButton({ linkTo, onClick, i18n, isDisabled }) {
function ToolbarAddButton({
linkTo,
onClick,
i18n,
isDisabled,
defaultLabel = i18n._(t`Add`),
}) {
const { isKebabified } = useKebabifiedMenu();
if (!linkTo && !onClick) {
@ -14,6 +20,7 @@ function ToolbarAddButton({ linkTo, onClick, i18n, isDisabled }) {
'ToolbarAddButton requires either `linkTo` or `onClick` prop'
);
}
if (isKebabified) {
return (
<DropdownItem
@ -23,28 +30,28 @@ function ToolbarAddButton({ linkTo, onClick, i18n, isDisabled }) {
to={linkTo}
onClick={!onClick ? undefined : onClick}
>
{i18n._(t`Add`)}
{defaultLabel}
</DropdownItem>
);
}
if (linkTo) {
return (
<Tooltip content={i18n._(t`Add`)} position="top">
<Tooltip content={defaultLabel} position="top">
<Button
isDisabled={isDisabled}
component={Link}
to={linkTo}
variant="primary"
aria-label={i18n._(t`Add`)}
aria-label={defaultLabel}
>
{i18n._(t`Add`)}
{defaultLabel}
</Button>
</Tooltip>
);
}
return (
<Button variant="primary" aria-label={i18n._(t`Add`)} onClick={onClick}>
{i18n._(t`Add`)}
<Button variant="primary" aria-label={defaultLabel} onClick={onClick}>
{defaultLabel}
</Button>
);
}

View File

@ -1,16 +1,19 @@
import React from 'react';
import React, { useState } from 'react';
import { Formik, useField } from 'formik';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { arrayOf, func, object, shape } from 'prop-types';
import { Form, FormGroup } from '@patternfly/react-core';
import { ActionGroup, Button, Form, FormGroup } from '@patternfly/react-core';
import FormField, { FormSubmitError } from '../../../components/FormField';
import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup';
import {
FormColumnLayout,
FormFullWidthLayout,
} from '../../../components/FormLayout';
import AnsibleSelect from '../../../components/AnsibleSelect';
import { required } from '../../../util/validators';
import OrganizationLookup from '../../../components/Lookup/OrganizationLookup';
import { FormColumnLayout } from '../../../components/FormLayout';
import TypeInputsSubForm from './TypeInputsSubForm';
import ExternalTestModal from './ExternalTestModal';
function CredentialFormFields({
i18n,
@ -139,6 +142,7 @@ function CredentialFormFields({
}
function CredentialForm({
i18n,
credential = {},
credentialTypes,
inputSources,
@ -147,6 +151,7 @@ function CredentialForm({
submitError,
...rest
}) {
const [showExternalTestModal, setShowExternalTestModal] = useState(false);
const initialValues = {
name: credential.name || '',
description: credential.description || '',
@ -205,21 +210,61 @@ function CredentialForm({
}}
>
{formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<CredentialFormFields
formik={formik}
initialValues={initialValues}
credentialTypes={credentialTypes}
{...rest}
<>
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<CredentialFormFields
formik={formik}
initialValues={initialValues}
credentialTypes={credentialTypes}
i18n={i18n}
{...rest}
/>
<FormSubmitError error={submitError} />
<FormFullWidthLayout>
<ActionGroup>
<Button
aria-label={i18n._(t`Save`)}
variant="primary"
type="button"
onClick={formik.handleSubmit}
>
{i18n._(t`Save`)}
</Button>
{formik?.values?.credential_type &&
credentialTypes[formik.values.credential_type]?.kind ===
'external' && (
<Button
aria-label={i18n._(t`Test`)}
variant="secondary"
type="button"
onClick={() => setShowExternalTestModal(true)}
isDisabled={!formik.isValid}
>
{i18n._(t`Test`)}
</Button>
)}
<Button
aria-label={i18n._(t`Cancel`)}
variant="secondary"
type="button"
onClick={onCancel}
>
{i18n._(t`Cancel`)}
</Button>
</ActionGroup>
</FormFullWidthLayout>
</FormColumnLayout>
</Form>
{showExternalTestModal && (
<ExternalTestModal
credential={credential}
credentialType={credentialTypes[formik.values.credential_type]}
credentialFormValues={formik.values}
onClose={() => setShowExternalTestModal(false)}
/>
<FormSubmitError error={submitError} />
<FormActionGroup
onCancel={onCancel}
onSubmit={formik.handleSubmit}
/>
</FormColumnLayout>
</Form>
)}
</>
)}
</Formik>
);

View File

@ -99,6 +99,9 @@ describe('<CredentialForm />', () => {
test('should display form fields on add properly', async () => {
addFieldExpects();
});
test('should hide Test button initially', () => {
expect(wrapper.find('Button[children="Test"]').length).toBe(0);
});
test('should update form values', async () => {
// name and description change
await act(async () => {
@ -221,6 +224,18 @@ describe('<CredentialForm />', () => {
'There was an error parsing the file. Please check the file formatting and try again.'
);
});
test('should show Test button when external credential type is selected', async () => {
await act(async () => {
await wrapper
.find('AnsibleSelect[id="credential_type"]')
.invoke('onChange')(null, 21);
});
wrapper.update();
expect(wrapper.find('Button[children="Test"]').length).toBe(1);
expect(wrapper.find('Button[children="Test"]').props().isDisabled).toBe(
true
);
});
test('should call handleCancel when Cancel button is clicked', async () => {
expect(onCancel).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();

View File

@ -110,7 +110,7 @@ function CredentialField({ credentialType, fieldOptions, i18n }) {
>
<AnsibleSelect
{...subFormField}
id="credential_type"
id={`credential-${fieldOptions.id}`}
data={selectOptions}
onChange={(event, value) => {
helpers.setValue(value);

View File

@ -0,0 +1,91 @@
import React, { useEffect, useState } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { string, shape } from 'prop-types';
import {
Alert,
AlertActionCloseButton,
AlertGroup,
} from '@patternfly/react-core';
function CredentialPluginTestAlert({
i18n,
credentialName,
successResponse,
errorResponse,
}) {
const [testMessage, setTestMessage] = useState('');
const [testVariant, setTestVariant] = useState(false);
useEffect(() => {
if (errorResponse) {
if (errorResponse?.response?.data?.inputs) {
if (errorResponse.response.data.inputs.startsWith('HTTP')) {
const [
errorCode,
errorStr,
] = errorResponse.response.data.inputs.split('\n');
try {
const errorJSON = JSON.parse(errorStr);
setTestMessage(
`${errorCode}${
errorJSON?.errors[0] ? `: ${errorJSON.errors[0]}` : ''
}`
);
} catch {
setTestMessage(errorResponse.response.data.inputs);
}
} else {
setTestMessage(errorResponse.response.data.inputs);
}
} else {
setTestMessage(
i18n._(
t`Something went wrong with the request to test this credential and metadata.`
)
);
}
setTestVariant('danger');
} else if (successResponse) {
setTestMessage(i18n._(t`Test passed`));
setTestVariant('success');
}
}, [i18n, successResponse, errorResponse]);
return (
<AlertGroup isToast>
{testMessage && testVariant && (
<Alert
actionClose={
<AlertActionCloseButton
onClose={() => {
setTestMessage(null);
setTestVariant(null);
}}
/>
}
title={
<>
<b id="credential-plugin-test-name">{credentialName}</b>
<p id="credential-plugin-test-message">{testMessage}</p>
</>
}
variant={testVariant}
/>
)}
</AlertGroup>
);
}
CredentialPluginTestAlert.propTypes = {
credentialName: string.isRequired,
successResponse: shape({}),
errorResponse: shape({}),
};
CredentialPluginTestAlert.defaultProps = {
successResponse: null,
errorResponse: null,
};
export default withI18n()(CredentialPluginTestAlert);

View File

@ -1,2 +1,3 @@
export { default as CredentialPluginSelected } from './CredentialPluginSelected';
export { default as CredentialPluginField } from './CredentialPluginField';
export { default as CredentialPluginTestAlert } from './CredentialPluginTestAlert';

View File

@ -0,0 +1,198 @@
import React, { useCallback } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import { func, shape } from 'prop-types';
import { Formik } from 'formik';
import {
Button,
Form,
FormGroup,
Modal,
Tooltip,
} from '@patternfly/react-core';
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
import { CredentialsAPI, CredentialTypesAPI } from '../../../api';
import AnsibleSelect from '../../../components/AnsibleSelect';
import FormField from '../../../components/FormField';
import { FormFullWidthLayout } from '../../../components/FormLayout';
import { required } from '../../../util/validators';
import useRequest from '../../../util/useRequest';
import { CredentialPluginTestAlert } from './CredentialFormFields/CredentialPlugins';
const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
margin-left: 10px;
`;
function ExternalTestModal({
i18n,
credential,
credentialType,
credentialFormValues,
onClose,
}) {
const {
result: testPluginSuccess,
error: testPluginError,
request: testPluginMetadata,
} = useRequest(
useCallback(
async values => {
const payload = {
inputs: credentialType.inputs.fields.reduce(
(filteredInputs, field) => {
filteredInputs[field.id] = credentialFormValues.inputs[field.id];
return filteredInputs;
},
{}
),
metadata: values,
};
if (credential && credential.credential_type === credentialType.id) {
return CredentialsAPI.test(credential.id, payload);
}
return CredentialTypesAPI.test(credentialType.id, payload);
},
[
credential,
credentialType.id,
credentialType.inputs.fields,
credentialFormValues.inputs,
]
),
null
);
const handleTest = async values => {
await testPluginMetadata(values);
};
return (
<>
<Formik
initialValues={credentialType.inputs.metadata.reduce(
(initialValues, field) => {
if (field.type === 'string' && field.choices) {
initialValues[field.id] = field.default || field.choices[0];
} else {
initialValues[field.id] = '';
}
return initialValues;
},
{}
)}
onSubmit={values => handleTest(values)}
>
{({ handleSubmit, setFieldValue }) => (
<Modal
title={i18n._(t`Test External Credential`)}
isOpen
onClose={() => onClose()}
variant="small"
actions={[
<Button
id="run-external-credential-test"
key="confirm"
variant="primary"
onClick={() => handleSubmit()}
>
{i18n._(t`Run`)}
</Button>,
<Button
id="cancel-external-credential-test"
key="cancel"
variant="link"
onClick={() => onClose()}
>
{i18n._(t`Cancel`)}
</Button>,
]}
>
<Form>
<FormFullWidthLayout>
{credentialType.inputs.metadata.map(field => {
const isRequired = credentialType.inputs?.required.includes(
field.id
);
if (field.type === 'string') {
if (field.choices) {
return (
<FormGroup
key={field.id}
fieldId={`credential-${field.id}`}
label={field.label}
labelIcon={
field.help_text && (
<Tooltip
content={field.help_text}
position="right"
>
<QuestionCircleIcon />
</Tooltip>
)
}
isRequired={isRequired}
>
<AnsibleSelect
name={field.id}
value={field.default}
id={`credential-${field.id}`}
data={field.choices.map(choice => {
return {
value: choice,
key: choice,
label: choice,
};
})}
onChange={(event, value) => {
setFieldValue(field.id, value);
}}
validate={isRequired ? required(null, i18n) : null}
/>
</FormGroup>
);
}
return (
<FormField
key={field.id}
id={`credential-${field.id}`}
label={field.label}
tooltip={field.help_text}
name={field.id}
type={field.multiline ? 'textarea' : 'text'}
isRequired={isRequired}
validate={isRequired ? required(null, i18n) : null}
/>
);
}
return null;
})}
</FormFullWidthLayout>
</Form>
</Modal>
)}
</Formik>
<CredentialPluginTestAlert
credentialName={credentialFormValues.name}
successResponse={testPluginSuccess}
errorResponse={testPluginError}
/>
</>
);
}
ExternalTestModal.proptype = {
credential: shape({}),
credentialType: shape({}).isRequired,
credentialFormValues: shape({}).isRequired,
onClose: func.isRequired,
};
ExternalTestModal.defaultProps = {
credential: null,
};
export default withI18n()(ExternalTestModal);

View File

@ -0,0 +1,180 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { CredentialsAPI, CredentialTypesAPI } from '../../../api';
import ExternalTestModal from './ExternalTestModal';
import credentialTypesArr from './data.credentialTypes.json';
jest.mock('../../../api/models/Credentials');
jest.mock('../../../api/models/CredentialTypes');
const credentialType = credentialTypesArr.find(
credType => credType.namespace === 'hashivault_kv'
);
const credentialFormValues = {
name: 'Foobar',
credential_type: credentialType.id,
inputs: {
api_version: 'v2',
token: '$encrypted$',
url: 'http://hashivault:8200',
},
};
const credential = {
id: 1,
name: 'A credential',
credential_type: credentialType.id,
};
describe('<ExternalTestModal />', () => {
let wrapper;
afterEach(() => wrapper.unmount());
test('should display metadata fields correctly', async () => {
wrapper = mountWithContexts(
<ExternalTestModal
credentialType={credentialType}
credentialFormValues={credentialFormValues}
onClose={jest.fn()}
/>
);
expect(wrapper.find('FormField').length).toBe(5);
expect(wrapper.find('input#credential-secret_backend').length).toBe(1);
expect(wrapper.find('input#credential-secret_path').length).toBe(1);
expect(wrapper.find('input#credential-auth_path').length).toBe(1);
expect(wrapper.find('input#credential-secret_key').length).toBe(1);
expect(wrapper.find('input#credential-secret_version').length).toBe(1);
});
test('should make the test request correctly when testing an existing credential', async () => {
wrapper = mountWithContexts(
<ExternalTestModal
credential={credential}
credentialType={credentialType}
credentialFormValues={credentialFormValues}
onClose={jest.fn()}
/>
);
await act(async () => {
wrapper.find('input#credential-secret_path').simulate('change', {
target: { value: '/secret/foo/bar/baz', name: 'secret_path' },
});
wrapper.find('input#credential-secret_key').simulate('change', {
target: { value: 'password', name: 'secret_key' },
});
});
wrapper.update();
await act(async () => {
wrapper.find('Button[children="Run"]').simulate('click');
});
expect(CredentialsAPI.test).toHaveBeenCalledWith(1, {
inputs: {
api_version: 'v2',
cacert: undefined,
role_id: undefined,
secret_id: undefined,
token: '$encrypted$',
url: 'http://hashivault:8200',
},
metadata: {
auth_path: '',
secret_backend: '',
secret_key: 'password',
secret_path: '/secret/foo/bar/baz',
secret_version: '',
},
});
});
test('should make the test request correctly when testing a new credential', async () => {
wrapper = mountWithContexts(
<ExternalTestModal
credentialType={credentialType}
credentialFormValues={credentialFormValues}
onClose={jest.fn()}
/>
);
await act(async () => {
wrapper.find('input#credential-secret_path').simulate('change', {
target: { value: '/secret/foo/bar/baz', name: 'secret_path' },
});
wrapper.find('input#credential-secret_key').simulate('change', {
target: { value: 'password', name: 'secret_key' },
});
});
wrapper.update();
await act(async () => {
wrapper.find('Button[children="Run"]').simulate('click');
});
expect(CredentialTypesAPI.test).toHaveBeenCalledWith(21, {
inputs: {
api_version: 'v2',
cacert: undefined,
role_id: undefined,
secret_id: undefined,
token: '$encrypted$',
url: 'http://hashivault:8200',
},
metadata: {
auth_path: '',
secret_backend: '',
secret_key: 'password',
secret_path: '/secret/foo/bar/baz',
secret_version: '',
},
});
});
test('should display the alert after a successful test', async () => {
CredentialTypesAPI.test.mockResolvedValue({});
wrapper = mountWithContexts(
<ExternalTestModal
credentialType={credentialType}
credentialFormValues={credentialFormValues}
onClose={jest.fn()}
/>
);
await act(async () => {
wrapper.find('input#credential-secret_path').simulate('change', {
target: { value: '/secret/foo/bar/baz', name: 'secret_path' },
});
wrapper.find('input#credential-secret_key').simulate('change', {
target: { value: 'password', name: 'secret_key' },
});
});
wrapper.update();
await act(async () => {
wrapper.find('Button[children="Run"]').simulate('click');
});
wrapper.update();
expect(wrapper.find('Alert').length).toBe(1);
expect(wrapper.find('Alert').props().variant).toBe('success');
});
test('should display the alert after a failed test', async () => {
CredentialTypesAPI.test.mockRejectedValue({
inputs: `HTTP 404
{"errors":["no handler for route '/secret/foo/bar/baz'"]}
`,
});
wrapper = mountWithContexts(
<ExternalTestModal
credentialType={credentialType}
credentialFormValues={credentialFormValues}
onClose={jest.fn()}
/>
);
await act(async () => {
wrapper.find('input#credential-secret_path').simulate('change', {
target: { value: '/secret/foo/bar/baz', name: 'secret_path' },
});
wrapper.find('input#credential-secret_key').simulate('change', {
target: { value: 'password', name: 'secret_key' },
});
});
wrapper.update();
await act(async () => {
wrapper.find('Button[children="Run"]').simulate('click');
});
wrapper.update();
expect(wrapper.find('Alert').length).toBe(1);
expect(wrapper.find('Alert').props().variant).toBe('danger');
});
});

View File

@ -1,2 +1,3 @@
export { default as mockCredentials } from './data.credentials.json';
export { default as mockCredentialType } from './data.credential_type.json';
export { default as ExternalTestModal } from './ExternalTestModal';

View File

@ -32,7 +32,13 @@ function CredentialTypeList({ i18n }) {
error: contentError,
isLoading,
request: fetchCredentialTypes,
result: { credentialTypes, credentialTypesCount, actions },
result: {
credentialTypes,
credentialTypesCount,
actions,
relatedSearchableKeys,
searchableKeys,
},
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
@ -46,12 +52,20 @@ function CredentialTypeList({ i18n }) {
credentialTypes: response.data.results,
credentialTypesCount: response.data.count,
actions: responseActions.data.actions,
relatedSearchableKeys: (
responseActions?.data?.related_search_fields || []
).map(val => val.slice(0, -8)),
searchableKeys: Object.keys(
responseActions.data.actions?.GET || {}
).filter(key => responseActions.data.actions?.GET[key].filterable),
};
}, [location]),
{
credentialTypes: [],
credentialTypesCount: 0,
actions: {},
relatedSearchableKeys: [],
searchableKeys: [],
}
);
@ -100,6 +114,8 @@ function CredentialTypeList({ i18n }) {
pluralizedItemName={i18n._(t`Credential Types`)}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
renderToolbar={props => (
<DatalistToolbar
{...props}

View File

@ -21,7 +21,7 @@ import JobList from '../../components/JobList';
import InstanceGroupDetails from './InstanceGroupDetails';
import InstanceGroupEdit from './InstanceGroupEdit';
import Instances from './Instances';
import InstanceList from './Instances/InstanceList';
function InstanceGroup({ i18n, setBreadcrumb }) {
const { id } = useParams();
@ -123,7 +123,7 @@ function InstanceGroup({ i18n, setBreadcrumb }) {
<InstanceGroupDetails instanceGroup={instanceGroup} />
</Route>
<Route path="/instance_groups/:id/instances">
<Instances />
<InstanceList />
</Route>
<Route path="/instance_groups/:id/jobs">
<JobList

View File

@ -39,13 +39,6 @@ function InstanceGroupDetails({ instanceGroup, i18n }) {
const { error, dismissError } = useDismissableError(deleteError);
const isAvailable = item => {
return (
(item.policy_instance_minimum || item.policy_instance_percentage) &&
item.capacity
);
};
const verifyIsIsolated = item => {
if (item.is_isolated) {
return (
@ -89,10 +82,12 @@ function InstanceGroupDetails({ instanceGroup, i18n }) {
dataCy="instance-group-policy-instance-percentage"
content={`${instanceGroup.policy_instance_percentage} %`}
/>
{isAvailable(instanceGroup) ? (
{instanceGroup.capacity ? (
<DetailBadge
label={i18n._(t`Used capacity`)}
content={`${100 - instanceGroup.percent_capacity_remaining} %`}
content={`${Math.round(
100 - instanceGroup.percent_capacity_remaining
)} %`}
dataCy="instance-group-used-capacity"
/>
) : (

View File

@ -49,7 +49,13 @@ function InstanceGroupList({ i18n }) {
error: contentError,
isLoading,
request: fetchInstanceGroups,
result: { instanceGroups, instanceGroupsCount, actions },
result: {
instanceGroups,
instanceGroupsCount,
actions,
relatedSearchableKeys,
searchableKeys,
},
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
@ -63,12 +69,20 @@ function InstanceGroupList({ i18n }) {
instanceGroups: response.data.results,
instanceGroupsCount: response.data.count,
actions: responseActions.data.actions,
relatedSearchableKeys: (
responseActions?.data?.related_search_fields || []
).map(val => val.slice(0, -8)),
searchableKeys: Object.keys(
responseActions.data.actions?.GET || {}
).filter(key => responseActions.data.actions?.GET[key].filterable),
};
}, [location]),
{
instanceGroups: [],
instanceGroupsCount: 0,
actions: {},
relatedSearchableKeys: [],
searchableKeys: [],
}
);
@ -171,6 +185,8 @@ function InstanceGroupList({ i18n }) {
pluralizedItemName={pluralizedItemName}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
renderToolbar={props => (
<DatalistToolbar
{...props}

View File

@ -45,7 +45,7 @@ const instanceGroups = {
const options = { data: { actions: { POST: true } } };
describe('<InstanceGroupList', () => {
describe('<InstanceGroupList />', () => {
let wrapper;
test('should have data fetched and render 3 rows', async () => {

View File

@ -60,23 +60,16 @@ function InstanceGroupListItem({
}) {
const labelId = `check-action-${instanceGroup.id}`;
const isAvailable = item => {
return (
(item.policy_instance_minimum || item.policy_instance_percentage) &&
item.capacity
);
};
const isContainerGroup = item => {
return item.is_containerized;
};
function usedCapacity(item) {
if (!isContainerGroup(item)) {
if (isAvailable(item)) {
if (item.capacity) {
return (
<Progress
value={100 - item.percent_capacity_remaining}
value={Math.round(100 - item.percent_capacity_remaining)}
measureLocation={ProgressMeasureLocation.top}
size={ProgressSize.sm}
title={i18n._(t`Used capacity`)}

View File

@ -0,0 +1,245 @@
import React, { useCallback, useEffect, useState } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { useLocation, useParams } from 'react-router-dom';
import 'styled-components/macro';
import DataListToolbar from '../../../components/DataListToolbar';
import PaginatedDataList, {
ToolbarAddButton,
} from '../../../components/PaginatedDataList';
import DisassociateButton from '../../../components/DisassociateButton';
import AssociateModal from '../../../components/AssociateModal';
import AlertModal from '../../../components/AlertModal';
import ErrorDetail from '../../../components/ErrorDetail';
import useRequest, {
useDeleteItems,
useDismissableError,
} from '../../../util/useRequest';
import useSelected from '../../../util/useSelected';
import { InstanceGroupsAPI, InstancesAPI } from '../../../api';
import { getQSConfig, parseQueryString, mergeParams } from '../../../util/qs';
import InstanceListItem from './InstanceListItem';
const QS_CONFIG = getQSConfig('instance', {
page: 1,
page_size: 20,
order_by: 'hostname',
});
function InstanceList({ i18n }) {
const [isModalOpen, setIsModalOpen] = useState(false);
const location = useLocation();
const { id: instanceGroupId } = useParams();
const {
result: {
instances,
count,
actions,
relatedSearchableKeys,
searchableKeys,
},
error: contentError,
isLoading,
request: fetchInstances,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
const [response, responseActions] = await Promise.all([
InstanceGroupsAPI.readInstances(instanceGroupId, params),
InstanceGroupsAPI.readInstanceOptions(instanceGroupId),
]);
return {
instances: response.data.results,
count: response.data.count,
actions: responseActions.data.actions,
relatedSearchableKeys: (
responseActions?.data?.related_search_fields || []
).map(val => val.slice(0, -8)),
searchableKeys: Object.keys(
responseActions.data.actions?.GET || {}
).filter(key => responseActions.data.actions?.GET[key].filterable),
};
}, [location.search, instanceGroupId]),
{
instances: [],
count: 0,
actions: {},
relatedSearchableKeys: [],
searchableKeys: [],
}
);
const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
instances
);
useEffect(() => {
fetchInstances();
}, [fetchInstances]);
const {
isLoading: isDisassociateLoading,
deleteItems: disassociateInstances,
deletionError: disassociateError,
} = useDeleteItems(
useCallback(async () => {
return Promise.all(
selected.map(instance =>
InstanceGroupsAPI.disassociateInstance(instanceGroupId, instance.id)
)
);
}, [instanceGroupId, selected]),
{
qsConfig: QS_CONFIG,
allItemsSelected: isAllSelected,
fetchItems: fetchInstances,
}
);
const { request: handleAssociate, error: associateError } = useRequest(
useCallback(
async instancesToAssociate => {
await Promise.all(
instancesToAssociate.map(instance =>
InstanceGroupsAPI.associateInstance(instanceGroupId, instance.id)
)
);
fetchInstances();
},
[instanceGroupId, fetchInstances]
)
);
const handleDisassociate = async () => {
await disassociateInstances();
setSelected([]);
};
const { error, dismissError } = useDismissableError(
associateError || disassociateError
);
const canAdd =
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
const fetchInstancesToAssociate = useCallback(
params => {
return InstancesAPI.read(
mergeParams(params, { not__rampart_groups__id: instanceGroupId })
);
},
[instanceGroupId]
);
const readInstancesOptions = () =>
InstanceGroupsAPI.readInstanceOptions(instanceGroupId);
return (
<>
<PaginatedDataList
contentError={contentError}
hasContentLoading={isLoading || isDisassociateLoading}
items={instances}
itemCount={count}
pluralizedItemName={i18n._(t`Instances`)}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'hostname',
isDefault: true,
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Name`),
key: 'hostname',
},
]}
renderToolbar={props => (
<DataListToolbar
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={isSelected =>
setSelected(isSelected ? [...instances] : [])
}
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd
? [
<ToolbarAddButton
key="associate"
onClick={() => setIsModalOpen(true)}
defaultLabel={i18n._(t`Associate`)}
/>,
]
: []),
<DisassociateButton
verifyCannotDisassociate={false}
key="disassociate"
onDisassociate={handleDisassociate}
itemsToDisassociate={selected}
modalTitle={i18n._(
t`Disassociate instance from instance group?`
)}
/>,
]}
emptyStateControls={
canAdd ? (
<ToolbarAddButton
key="add"
onClick={() => setIsModalOpen(true)}
/>
) : null
}
/>
)}
renderItem={instance => (
<InstanceListItem
key={instance.id}
value={instance.hostname}
instance={instance}
onSelect={() => handleSelect(instance)}
isSelected={selected.some(row => row.id === instance.id)}
fetchInstances={fetchInstances}
/>
)}
/>
{isModalOpen && (
<AssociateModal
header={i18n._(t`Instances`)}
fetchRequest={fetchInstancesToAssociate}
isModalOpen={isModalOpen}
onAssociate={handleAssociate}
onClose={() => setIsModalOpen(false)}
title={i18n._(t`Select Instances`)}
optionsRequest={readInstancesOptions}
displayKey="hostname"
/>
)}
{error && (
<AlertModal
isOpen={error}
onClose={dismissError}
title={i18n._(t`Error!`)}
variant="error"
>
{associateError
? i18n._(t`Failed to associate.`)
: i18n._(t`Failed to disassociate one or more instances.`)}
<ErrorDetail error={error} />
</AlertModal>
)}
</>
);
}
export default withI18n()(InstanceList);

View File

@ -0,0 +1,156 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { Route } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import { InstanceGroupsAPI } from '../../../api';
import InstanceList from './InstanceList';
jest.mock('../../../api');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
id: 1,
instanceGroupId: 2,
}),
}));
const instances = [
{
id: 1,
type: 'instance',
url: '/api/v2/instances/1/',
related: {
jobs: '/api/v2/instances/1/jobs/',
instance_groups: '/api/v2/instances/1/instance_groups/',
},
uuid: '00000000-0000-0000-0000-000000000000',
hostname: 'awx',
created: '2020-07-14T19:03:49.000054Z',
modified: '2020-08-12T20:08:02.836748Z',
capacity_adjustment: '0.40',
version: '13.0.0',
capacity: 10,
consumed_capacity: 0,
percent_capacity_remaining: 60.0,
jobs_running: 0,
jobs_total: 68,
cpu: 6,
memory: 2087469056,
cpu_capacity: 24,
mem_capacity: 1,
enabled: true,
managed_by_policy: true,
},
{
id: 2,
type: 'instance',
url: '/api/v2/instances/2/',
related: {
jobs: '/api/v2/instances/2/jobs/',
instance_groups: '/api/v2/instances/2/instance_groups/',
},
uuid: '00000000-0000-0000-0000-000000000000',
hostname: 'foo',
created: '2020-07-14T19:03:49.000054Z',
modified: '2020-08-12T20:08:02.836748Z',
capacity_adjustment: '0.40',
version: '13.0.0',
capacity: 10,
consumed_capacity: 0,
percent_capacity_remaining: 60.0,
jobs_running: 0,
jobs_total: 68,
cpu: 6,
memory: 2087469056,
cpu_capacity: 24,
mem_capacity: 1,
enabled: true,
managed_by_policy: false,
},
{
id: 3,
type: 'instance',
url: '/api/v2/instances/3/',
related: {
jobs: '/api/v2/instances/3/jobs/',
instance_groups: '/api/v2/instances/3/instance_groups/',
},
uuid: '00000000-0000-0000-0000-000000000000',
hostname: 'bar',
created: '2020-07-14T19:03:49.000054Z',
modified: '2020-08-12T20:08:02.836748Z',
capacity_adjustment: '0.40',
version: '13.0.0',
capacity: 10,
consumed_capacity: 0,
percent_capacity_remaining: 60.0,
jobs_running: 0,
jobs_total: 68,
cpu: 6,
memory: 2087469056,
cpu_capacity: 24,
mem_capacity: 1,
enabled: false,
managed_by_policy: true,
},
];
const options = { data: { actions: { POST: true } } };
describe('<InstanceList/>', () => {
let wrapper;
beforeEach(async () => {
InstanceGroupsAPI.readInstances.mockResolvedValue({
data: {
count: instances.length,
results: instances,
},
});
InstanceGroupsAPI.readInstanceOptions.mockResolvedValue(options);
const history = createMemoryHistory({
initialEntries: ['/instance_groups/1/instances'],
});
await act(async () => {
wrapper = mountWithContexts(
<Route path="/instance_groups/:id/instances">
<InstanceList />
</Route>,
{
context: {
router: { history, route: { location: history.location } },
},
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
afterEach(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('should have data fetched', () => {
expect(wrapper.find('InstanceList').length).toBe(1);
});
test('should fetch instances from the api and render them in the list', () => {
expect(InstanceGroupsAPI.readInstances).toHaveBeenCalled();
expect(InstanceGroupsAPI.readInstanceOptions).toHaveBeenCalled();
expect(wrapper.find('InstanceListItem').length).toBe(3);
});
test('should show associate group modal when adding an existing group', () => {
wrapper.find('ToolbarAddButton').simulate('click');
expect(wrapper.find('AssociateModal').length).toBe(1);
wrapper.find('ModalBoxCloseButton').simulate('click');
expect(wrapper.find('AssociateModal').length).toBe(0);
});
});

View File

@ -0,0 +1,136 @@
import React from 'react';
import { bool, func } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import 'styled-components/macro';
import {
Badge as PFBadge,
Progress,
ProgressMeasureLocation,
ProgressSize,
DataListAction,
DataListCheck,
DataListItem,
DataListItemRow,
DataListItemCells,
} from '@patternfly/react-core';
import _DataListCell from '../../../components/DataListCell';
import InstanceToggle from '../../../components/InstanceToggle';
import { Instance } from '../../../types';
const Unavailable = styled.span`
color: var(--pf-global--danger-color--200);
`;
const DataListCell = styled(_DataListCell)`
white-space: nowrap;
`;
const Badge = styled(PFBadge)`
margin-left: 8px;
`;
const ListGroup = styled.span`
margin-left: 12px;
&:first-of-type {
margin-left: 0;
}
`;
function InstanceListItem({
instance,
isSelected,
onSelect,
fetchInstances,
i18n,
}) {
const labelId = `check-action-${instance.id}`;
function usedCapacity(item) {
if (item.enabled) {
return (
<Progress
value={Math.round(100 - item.percent_capacity_remaining)}
measureLocation={ProgressMeasureLocation.top}
size={ProgressSize.sm}
title={i18n._(t`Used capacity`)}
/>
);
}
return <Unavailable>{i18n._(t`Unavailable`)}</Unavailable>;
}
return (
<DataListItem
aria-labelledby={labelId}
id={`${instance.id}`}
key={instance.id}
>
<DataListItemRow>
<DataListCheck
aria-labelledby={labelId}
checked={isSelected}
id={`instances-${instance.id}`}
onChange={onSelect}
/>
<DataListItemCells
dataListCells={[
<DataListCell key="name" aria-label={i18n._(t`instance host name`)}>
<b>{instance.hostname}</b>
</DataListCell>,
<DataListCell key="type" aria-label={i18n._(t`instance type`)}>
<b css="margin-right: 24px">{i18n._(t`Type`)}</b>
<span id={labelId}>
{instance.managed_by_policy
? i18n._(t`Auto`)
: i18n._(t`Manual`)}
</span>
</DataListCell>,
<DataListCell
key="related-field-counts"
aria-label={i18n._(t`instance counts`)}
width={2}
>
<ListGroup>
<b>{i18n._(t`Running jobs`)}</b>
<Badge isRead>{instance.jobs_running}</Badge>
</ListGroup>
<ListGroup>
<b>{i18n._(t`Total jobs`)}</b>
<Badge isRead>{instance.jobs_total}</Badge>
</ListGroup>
</DataListCell>,
<DataListCell
key="capacity"
aria-label={i18n._(t`instance group used capacity`)}
>
{usedCapacity(instance)}
</DataListCell>,
]}
/>
<DataListAction
aria-label="actions"
aria-labelledby={labelId}
id={labelId}
>
<InstanceToggle
css="display: inline-flex;"
fetchInstances={fetchInstances}
instance={instance}
/>
</DataListAction>
</DataListItemRow>
</DataListItem>
);
}
InstanceListItem.prototype = {
instance: Instance.isRequired,
isSelected: bool.isRequired,
onSelect: func.isRequired,
};
export default withI18n()(InstanceListItem);

View File

@ -0,0 +1,89 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import InstanceListItem from './InstanceListItem';
const instance = [
{
id: 1,
type: 'instance',
url: '/api/v2/instances/1/',
related: {
jobs: '/api/v2/instances/1/jobs/',
instance_groups: '/api/v2/instances/1/instance_groups/',
},
uuid: '00000000-0000-0000-0000-000000000000',
hostname: 'awx',
created: '2020-07-14T19:03:49.000054Z',
modified: '2020-08-12T20:08:02.836748Z',
capacity_adjustment: '0.40',
version: '13.0.0',
capacity: 10,
consumed_capacity: 0,
percent_capacity_remaining: 60.0,
jobs_running: 0,
jobs_total: 68,
cpu: 6,
memory: 2087469056,
cpu_capacity: 24,
mem_capacity: 1,
enabled: true,
managed_by_policy: true,
},
];
describe('<InstanceListItem/>', () => {
let wrapper;
test('should mount successfully', async () => {
await act(async () => {
wrapper = mountWithContexts(
<InstanceListItem
instance={instance[0]}
isSelected={false}
onSelect={() => {}}
/>
);
});
expect(wrapper.find('InstanceListItem').length).toBe(1);
});
test('should render the proper data instance', async () => {
await act(async () => {
wrapper = mountWithContexts(
<InstanceListItem
instance={instance[0]}
isSelected={false}
onSelect={() => {}}
/>
);
});
expect(
wrapper.find('PFDataListCell[aria-label="instance host name"]').text()
).toBe('awx');
expect(wrapper.find('Progress').prop('value')).toBe(40);
expect(
wrapper.find('PFDataListCell[aria-label="instance type"]').text()
).toBe('TypeAuto');
expect(wrapper.find('input#instances-1').prop('checked')).toBe(false);
});
test('should be checked', async () => {
await act(async () => {
wrapper = mountWithContexts(
<InstanceListItem
instance={instance[0]}
isSelected
onSelect={() => {}}
/>
);
});
expect(wrapper.find('input#instances-1').prop('checked')).toBe(true);
});
test('should display instance toggle', () => {
expect(wrapper.find('InstanceToggle').length).toBe(1);
});
});

View File

@ -1,14 +0,0 @@
import React from 'react';
import { Card, PageSection } from '@patternfly/react-core';
function Instances() {
return (
<PageSection>
<Card>
<div>Instances</div>
</Card>
</PageSection>
);
}
export default Instances;

View File

@ -1 +1,2 @@
export { default } from './Instances';
export { default as InstanceList } from './InstanceList';
export { default as InstanceListItem } from './InstanceListItem';

View File

@ -15,6 +15,7 @@ import { withI18n } from '@lingui/react';
import {
ExclamationTriangleIcon,
PencilAltIcon,
ProjectDiagramIcon,
RocketIcon,
} from '@patternfly/react-icons';
import styled from 'styled-components';
@ -32,7 +33,7 @@ const DataListAction = styled(_DataListAction)`
align-items: center;
display: grid;
grid-gap: 16px;
grid-template-columns: repeat(3, 40px);
grid-template-columns: repeat(4, 40px);
`;
function TemplateListItem({
@ -104,6 +105,20 @@ function TemplateListItem({
]}
/>
<DataListAction aria-label="actions" aria-labelledby={labelId}>
{template.type === 'workflow_job_template' && (
<Tooltip content={i18n._(t`Visualizer`)} position="top">
<Button
isDisabled={isDisabled}
aria-label={i18n._(t`Visualizer`)}
css="grid-column: 1"
variant="plain"
component={Link}
to={`/templates/workflow_job_template/${template.id}/visualizer`}
>
<ProjectDiagramIcon />
</Button>
</Tooltip>
)}
{template.summary_fields.user_capabilities.start && (
<Tooltip content={i18n._(t`Launch Template`)} position="top">
<LaunchButton resource={template}>
@ -111,7 +126,7 @@ function TemplateListItem({
<Button
isDisabled={isDisabled}
aria-label={i18n._(t`Launch template`)}
css="grid-column: 1"
css="grid-column: 2"
variant="plain"
onClick={handleLaunch}
>
@ -126,7 +141,7 @@ function TemplateListItem({
<Button
isDisabled={isDisabled}
aria-label={i18n._(t`Edit Template`)}
css="grid-column: 2"
css="grid-column: 3"
variant="plain"
component={Link}
to={`/templates/${template.type}/${template.id}/edit`}

View File

@ -239,4 +239,29 @@ describe('<TemplateListItem />', () => {
);
expect(wrapper.find('CopyButton').length).toBe(0);
});
test('should render visualizer button for workflow', async () => {
const wrapper = mountWithContexts(
<TemplateListItem
isSelected={false}
detailUrl="/templates/job_template/1/details"
template={{
...mockJobTemplateData,
type: 'workflow_job_template',
}}
/>
);
expect(wrapper.find('ProjectDiagramIcon').length).toBe(1);
});
test('should not render visualizer button for job template', async () => {
const wrapper = mountWithContexts(
<TemplateListItem
isSelected={false}
detailUrl="/templates/job_template/1/details"
template={mockJobTemplateData}
/>
);
expect(wrapper.find('ProjectDiagramIcon').length).toBe(0);
});
});

View File

@ -99,7 +99,7 @@ function WorkflowJobTemplateDetail({ template, i18n }) {
const canLaunch = summary_fields?.user_capabilities?.start;
const recentPlaybookJobs = summary_fields.recent_jobs.map(job => ({
...job,
type: 'job',
type: 'workflow_job',
}));
return (

View File

@ -91,6 +91,15 @@ function JobTemplateForm({
const [jobTagsField, , jobTagsHelpers] = useField('job_tags');
const [skipTagsField, , skipTagsHelpers] = useField('skip_tags');
const [, webhookServiceMeta, webhookServiceHelpers] = useField(
'webhook_service'
);
const [, webhookUrlMeta, webhookUrlHelpers] = useField('webhook_url');
const [, webhookKeyMeta, webhookKeyHelpers] = useField('webhook_key');
const [, webhookCredentialMeta, webhookCredentialHelpers] = useField(
'webhook_credential'
);
const {
request: fetchProject,
error: projectContentError,
@ -126,6 +135,21 @@ function JobTemplateForm({
loadRelatedInstanceGroups();
}, [loadRelatedInstanceGroups]);
useEffect(() => {
if (enableWebhooks) {
webhookServiceHelpers.setValue(webhookServiceMeta.initialValue);
webhookUrlHelpers.setValue(webhookUrlMeta.initialValue);
webhookKeyHelpers.setValue(webhookKeyMeta.initialValue);
webhookCredentialHelpers.setValue(webhookCredentialMeta.initialValue);
} else {
webhookServiceHelpers.setValue('');
webhookUrlHelpers.setValue('');
webhookKeyHelpers.setValue('');
webhookCredentialHelpers.setValue(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enableWebhooks]);
const handleProjectValidation = project => {
if (!project && projectMeta.touched) {
return i18n._(t`Select a value for this field`);

View File

@ -25,9 +25,7 @@ import {
function WebhookSubForm({ i18n, templateType }) {
const { id } = useParams();
const { pathname } = useLocation();
const { origin } = document.location;
const [
@ -35,11 +33,7 @@ function WebhookSubForm({ i18n, templateType }) {
webhookServiceMeta,
webhookServiceHelpers,
] = useField('webhook_service');
// eslint-disable-next-line no-unused-vars
const [webhookUrlField, webhookUrlMeta, webhookUrlHelpers] = useField(
'webhook_url'
);
const [webhookUrlField, , webhookUrlHelpers] = useField('webhook_url');
const [webhookKeyField, webhookKeyMeta, webhookKeyHelpers] = useField(
'webhook_key'
);

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { t } from '@lingui/macro';
import PropTypes, { shape } from 'prop-types';
@ -57,6 +57,29 @@ function WorkflowJobTemplateForm({
'organization'
);
const [scmField, , scmHelpers] = useField('scm_branch');
const [, webhookServiceMeta, webhookServiceHelpers] = useField(
'webhook_service'
);
const [, webhookUrlMeta, webhookUrlHelpers] = useField('webhook_url');
const [, webhookKeyMeta, webhookKeyHelpers] = useField('webhook_key');
const [, webhookCredentialMeta, webhookCredentialHelpers] = useField(
'webhook_credential'
);
useEffect(() => {
if (enableWebhooks) {
webhookServiceHelpers.setValue(webhookServiceMeta.initialValue);
webhookUrlHelpers.setValue(webhookUrlMeta.initialValue);
webhookKeyHelpers.setValue(webhookKeyMeta.initialValue);
webhookCredentialHelpers.setValue(webhookCredentialMeta.initialValue);
} else {
webhookServiceHelpers.setValue('');
webhookUrlHelpers.setValue('');
webhookKeyHelpers.setValue('');
webhookCredentialHelpers.setValue(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enableWebhooks]);
if (hasContentError) {
return <ContentError error={hasContentError} />;

View File

@ -118,6 +118,11 @@ export const InstanceGroup = shape({
name: string.isRequired,
});
export const Instance = shape({
id: number.isRequired,
name: string.isRequired,
});
export const Label = shape({
id: number.isRequired,
name: string.isRequired,

View File

@ -38,6 +38,9 @@ export default function useRequest(makeRequest, initialValue) {
request: useCallback(
async (...args) => {
setIsLoading(true);
if (isMounted.current) {
setError(null);
}
try {
const response = await makeRequest(...args);
if (isMounted.current) {

View File

@ -42,6 +42,7 @@ const defaultContexts = {
ansible_version: null,
custom_virtualenvs: [],
version: null,
me: { is_superuser: true },
toJSON: () => '/config/',
},
router: {

View File

@ -91,9 +91,9 @@ The following notes are changes that may require changes to playbooks:
- Specified `tower_config` file used to handle `k=v` pairs on a single line; this is no longer supported. Please use a file formatted as `yaml`, `json` or `ini` only.
- Some return values (e.g., `credential_type`) have been removed. Use of `id` is recommended.
- `tower_job_template` no longer supports the deprecated `extra_vars_path` parameter, please use `extra_vars` with the lookup plugin to replace this functionality.
- The `notification_configuration` parameter of `tower_notification` has changed from a string to a dict. Please use the `lookup` plugin to read an existing file into a dict.
- The `notification_configuration` parameter of `tower_notification_template` has changed from a string to a dict. Please use the `lookup` plugin to read an existing file into a dict.
- `tower_credential` no longer supports passing a file name to ssh_key_data.
- The HipChat `notification_type` has been removed and can no longer be created using the `tower_notification` module.
- The HipChat `notification_type` has been removed and can no longer be created using the `tower_notification_template` module.
## Running Unit Tests

View File

@ -13,5 +13,5 @@ plugin_routing:
deprecation:
removal_date: TBD
warning_text: see plugin documentation for details
tower_notifitcation:
tower_notification:
redirect: tower_notification_template

View File

@ -552,7 +552,7 @@ class TowerAPIModule(TowerModule):
return self.create_if_needed(existing_item, new_item, endpoint, on_create=on_create, item_type=item_type, associations=associations)
def logout(self):
if self.authenticated:
if self.authenticated and self.oauth_token_id:
# Attempt to delete our current token from /api/v2/tokens/
# Post to the tokens endpoint with baisc auth to try and get a token
api_token_url = (

View File

@ -48,7 +48,11 @@ options:
type: str
host_filter:
description:
- The host_filter field. Only useful when C(kind=smart).
- The host_filter field. Only useful when C(kind=smart).
type: str
insights_credential:
description:
- Credentials to be used by hosts belonging to this inventory when accessing Red Hat Insights API.
type: str
state:
description:
@ -84,6 +88,7 @@ def main():
variables=dict(type='dict'),
kind=dict(choices=['', 'smart'], default=''),
host_filter=dict(),
insights_credential=dict(),
state=dict(choices=['present', 'absent'], default='present'),
)
@ -98,6 +103,7 @@ def main():
state = module.params.get('state')
kind = module.params.get('kind')
host_filter = module.params.get('host_filter')
insights_credential = module.params.get('insights_credential')
# Attempt to look up the related items the user specified (these will fail the module if not found)
org_id = module.resolve_name_to_id('organizations', organization)
@ -125,6 +131,8 @@ def main():
inventory_fields['description'] = description
if variables is not None:
inventory_fields['variables'] = json.dumps(variables)
if insights_credential is not None:
inventory_fields['insights_credential'] = module.resolve_name_to_id('credentials', insights_credential)
# We need to perform a check to make sure you are not trying to convert a regular inventory into a smart one.
if inventory and inventory['kind'] == '' and inventory_fields['kind'] == 'smart':

View File

@ -17,6 +17,8 @@ import pytest
from awx.main.tests.functional.conftest import _request
from awx.main.models import Organization, Project, Inventory, JobTemplate, Credential, CredentialType
from django.db import transaction
try:
import tower_cli # noqa
HAS_TOWER_CLI = True
@ -107,8 +109,9 @@ def run_module(request, collection_import):
kwargs_copy['data'][k] = v
# make request
rf = _request(method.lower())
django_response = rf(url, user=request_user, expect=None, **kwargs_copy)
with transaction.atomic():
rf = _request(method.lower())
django_response = rf(url, user=request_user, expect=None, **kwargs_copy)
# requests library response object is different from the Django response, but they are the same concept
# this converts the Django response object into a requests response object for consumption

View File

@ -58,7 +58,6 @@ needs_development = [
]
needs_param_development = {
'tower_host': ['instance_id'],
'tower_inventory': ['insights_credential'],
}
# -----------------------------------------------------------------------------------------------------------

View File

@ -3,20 +3,26 @@ __metaclass__ = type
import pytest
from awx.main.models import Inventory
from awx.main.models import Inventory, Credential
from awx.main.tests.functional.conftest import insights_credential, credentialtype_insights
@pytest.mark.django_db
def test_inventory_create(run_module, admin_user, organization):
def test_inventory_create(run_module, admin_user, organization, insights_credential):
# Create an insights credential
result = run_module('tower_inventory', {
'name': 'foo-inventory',
'organization': organization.name,
'variables': {'foo': 'bar', 'another-foo': {'barz': 'bar2'}},
'insights_credential': insights_credential.name,
'state': 'present'
}, admin_user)
assert not result.get('failed', False), result.get('msg', result)
inv = Inventory.objects.get(name='foo-inventory')
assert inv.variables == '{"foo": "bar", "another-foo": {"barz": "bar2"}}'
assert inv.insights_credential.name == insights_credential.name
result.pop('module_args', None)
result.pop('invocation', None)

View File

@ -1,101 +1,140 @@
---
- name: Generate a test ID
set_fact:
test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
- name: Generate names
set_fact:
inv_name1: "AWX-Collection-tests-tower_inventory-inv1-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
inv_name2: "AWX-Collection-tests-tower_inventory-inv2-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
inv_name1: "AWX-Collection-tests-tower_inventory-inv1-{{ test_id }}"
inv_name2: "AWX-Collection-tests-tower_inventory-inv2-{{ test_id }}"
cred_name1: "AWX-Collection-tests-tower_inventory-cred1-{{ test_id }}"
- name: Create an Inventory
tower_inventory:
name: "{{ inv_name1 }}"
organization: Default
state: present
register: result
- block:
- name: Create an Insights Credential
tower_credential:
name: "{{ cred_name1 }}"
organization: Default
kind: insights
inputs:
username: joe
password: secret
state: present
register: result
- assert:
that:
- "result is changed"
- assert:
that:
- "result is changed"
- name: Test Inventory module idempotency
tower_inventory:
name: "{{ inv_name1 }}"
organization: Default
state: present
register: result
- name: Create an Inventory
tower_inventory:
name: "{{ inv_name1 }}"
organization: Default
insights_credential: "{{ cred_name1 }}"
state: present
register: result
- assert:
that:
- "result is not changed"
- assert:
that:
- "result is changed"
- name: Fail Change Regular to Smart
tower_inventory:
name: "{{ inv_name1 }}"
organization: Default
kind: smart
register: result
ignore_errors: true
- name: Test Inventory module idempotency
tower_inventory:
name: "{{ inv_name1 }}"
organization: Default
insights_credential: "{{ cred_name1 }}"
state: present
register: result
- assert:
that:
- "result is failed"
- assert:
that:
- "result is not changed"
- name: Create a smart inventory
tower_inventory:
name: "{{ inv_name2 }}"
organization: Default
kind: smart
host_filter: name=foo
register: result
- name: Fail Change Regular to Smart
tower_inventory:
name: "{{ inv_name1 }}"
organization: Default
kind: smart
register: result
ignore_errors: true
- assert:
that:
- "result is changed"
- assert:
that:
- "result is failed"
- name: Delete a smart inventory
tower_inventory:
name: "{{ inv_name2 }}"
organization: Default
kind: smart
host_filter: name=foo
state: absent
register: result
- name: Create a smart inventory
tower_inventory:
name: "{{ inv_name2 }}"
organization: Default
kind: smart
host_filter: name=foo
register: result
- assert:
that:
- "result is changed"
- assert:
that:
- "result is changed"
- name: Delete an Inventory
tower_inventory:
name: "{{ inv_name1 }}"
organization: Default
state: absent
register: result
- name: Delete a smart inventory
tower_inventory:
name: "{{ inv_name2 }}"
organization: Default
kind: smart
host_filter: name=foo
state: absent
register: result
- assert:
that:
- "result is changed"
- assert:
that:
- "result is changed"
- name: Delete a Non-Existent Inventory
tower_inventory:
name: "{{ inv_name1 }}"
organization: Default
state: absent
register: result
- name: Delete an Inventory
tower_inventory:
name: "{{ inv_name1 }}"
organization: Default
state: absent
register: result
- assert:
that:
- "result is not changed"
- assert:
that:
- "result is changed"
- name: Check module fails with correct msg
tower_inventory:
name: test-inventory
description: Inventory Description
organization: test-non-existing-org
state: present
register: result
ignore_errors: true
- name: Delete a Non-Existent Inventory
tower_inventory:
name: "{{ inv_name1 }}"
organization: Default
state: absent
register: result
- assert:
that:
- "result is not changed"
- "result.msg =='Failed to update inventory, organization not found: The requested object could not be found.'
or result.msg =='The organizations test-non-existing-org was not found on the Tower server'"
- assert:
that:
- "result is not changed"
- name: Check module fails with correct msg
tower_inventory:
name: test-inventory
description: Inventory Description
organization: test-non-existing-org
state: present
register: result
ignore_errors: true
- assert:
that:
- "result is not changed"
- "result.msg =='Failed to update inventory, organization not found: The requested object could not be found.'
or result.msg =='The organizations test-non-existing-org was not found on the Tower server'"
always:
- name: Delete Inventories
tower_inventory:
name: "{{ item }}"
organization: Default
state: absent
loop:
- "{{ inv_name1 }}"
- "{{ inv_name2 }}"
- name: Delete Insights Credential
tower_credential:
name: "{{ cred_name1 }}"
organization: "Default"
kind: insights
state: absent

View File

@ -13,7 +13,7 @@
jt2: "AWX-Collection-tests-tower_job_template-jt2-{{ test_id }}"
lab1: "AWX-Collection-tests-tower_job_template-lab1-{{ test_id }}"
email_not: "AWX-Collection-tests-tower_job_template-email-not-{{ test_id }}"
webhook_not: "AWX-Collection-tests-tower_notification-wehbook-not-{{ test_id }}"
webhook_not: "AWX-Collection-tests-tower_notification_template-wehbook-not-{{ test_id }}"
- name: Create a Demo Project
tower_project:
@ -49,7 +49,7 @@
organization: Default
- name: Add email notification
tower_notification:
tower_notification_template:
name: "{{ email_not }}"
organization: Default
notification_type: email
@ -65,7 +65,7 @@
state: present
- name: Add webhook notification
tower_notification:
tower_notification_template:
name: "{{ webhook_not }}"
organization: Default
notification_type: webhook
@ -366,13 +366,13 @@
# You can't delete a label directly so no cleanup needed
- name: Delete email notification
tower_notification:
tower_notification_template:
name: "{{ email_not }}"
organization: Default
state: absent
- name: Delete webhook notification
tower_notification:
tower_notification_template:
name: "{{ webhook_not }}"
organization: Default
state: absent

View File

@ -9,7 +9,7 @@
irc_not: "AWX-Collection-tests-tower_notification_template-irc-not-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
- name: Test deprecation warnings with legacy name
tower_notification:
tower_notification_template:
name: "{{ slack_not }}"
organization: Default
notification_type: slack

View File

@ -11,7 +11,7 @@
jt2_name: "AWX-Collection-tests-tower_workflow_job_template-jt2-{{ test_id }}"
wfjt_name: "AWX-Collection-tests-tower_workflow_job_template-wfjt-{{ test_id }}"
email_not: "AWX-Collection-tests-tower_job_template-email-not-{{ test_id }}"
webhook_not: "AWX-Collection-tests-tower_notification-wehbook-not-{{ test_id }}"
webhook_not: "AWX-Collection-tests-tower_notification_template-wehbook-not-{{ test_id }}"
- name: Create an SCM Credential
tower_credential:
@ -25,7 +25,7 @@
- "result is changed"
- name: Add email notification
tower_notification:
tower_notification_template:
name: "{{ email_not }}"
organization: Default
notification_type: email
@ -41,7 +41,7 @@
state: present
- name: Add webhook notification
tower_notification:
tower_notification_template:
name: "{{ webhook_not }}"
organization: Default
notification_type: webhook
@ -264,13 +264,13 @@
- "result is changed"
- name: Delete email notification
tower_notification:
tower_notification_template:
name: "{{ email_not }}"
organization: Default
state: absent
- name: Delete webhook notification
tower_notification:
tower_notification_template:
name: "{{ webhook_not }}"
organization: Default
state: absent

View File

@ -3,7 +3,7 @@ plugins/modules/tower_send.py validate-modules:deprecation-mismatch
plugins/modules/tower_workflow_template.py validate-modules:deprecation-mismatch
plugins/modules/tower_credential.py pylint:wrong-collection-deprecated-version-tag
plugins/modules/tower_job_wait.py pylint:wrong-collection-deprecated-version-tag
plugins/modules/tower_notification.py pylint:wrong-collection-deprecated-version-tag
plugins/modules/tower_notification_template.py pylint:wrong-collection-deprecated-version-tag
plugins/inventory/tower.py pylint:raise-missing-from
plugins/inventory/tower.py pylint:super-with-arguments
plugins/lookup/tower_schedule_rrule.py pylint:raise-missing-from

View File

@ -80,6 +80,7 @@ Notable releases of the `{{ collection_namespace }}.{{ collection_package }}` co
The following notes are changes that may require changes to playbooks:
- The module tower_notification was renamed tower_notification_template. In ansible >= 2.10 there is a seemless redirect. Ansible 2.9 does not respect the redirect.
- When a project is created, it will wait for the update/sync to finish by default; this can be turned off with the `wait` parameter, if desired.
- Creating a "scan" type job template is no longer supported.
- Specifying a custom certificate via the `TOWER_CERTIFICATE` environment variable no longer works.
@ -100,9 +101,9 @@ The following notes are changes that may require changes to playbooks:
- Specified `tower_config` file used to handle `k=v` pairs on a single line; this is no longer supported. Please use a file formatted as `yaml`, `json` or `ini` only.
- Some return values (e.g., `credential_type`) have been removed. Use of `id` is recommended.
- `tower_job_template` no longer supports the deprecated `extra_vars_path` parameter, please use `extra_vars` with the lookup plugin to replace this functionality.
- The `notification_configuration` parameter of `tower_notification` has changed from a string to a dict. Please use the `lookup` plugin to read an existing file into a dict.
- The `notification_configuration` parameter of `tower_notification_template` has changed from a string to a dict. Please use the `lookup` plugin to read an existing file into a dict.
- `tower_credential` no longer supports passing a file name to ssh_key_data.
- The HipChat `notification_type` has been removed and can no longer be created using the `tower_notification` module.
- The HipChat `notification_type` has been removed and can no longer be created using the `tower_notification_template` module.
{% if collection_package | lower() == "awx" %}
## Running Unit Tests