mirror of
https://github.com/ansible/awx.git
synced 2026-01-12 18:40:01 -03:30
commit
d0ab307787
@ -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()
|
||||
|
||||
@ -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':
|
||||
|
||||
@ -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 = (
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -25,6 +25,10 @@ class Credentials extends Base {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
test(id, data) {
|
||||
return this.http.post(`${this.baseUrl}${id}/test/`, data);
|
||||
}
|
||||
}
|
||||
|
||||
export default Credentials;
|
||||
|
||||
@ -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/`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
10
awx/ui_next/src/api/models/Instances.js
Normal file
10
awx/ui_next/src/api/models/Instances.js
Normal 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;
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
81
awx/ui_next/src/components/InstanceToggle/InstanceToggle.jsx
Normal file
81
awx/ui_next/src/components/InstanceToggle/InstanceToggle.jsx
Normal 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);
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
1
awx/ui_next/src/components/InstanceToggle/index.js
Normal file
1
awx/ui_next/src/components/InstanceToggle/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './InstanceToggle';
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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')();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
@ -1,2 +1,3 @@
|
||||
export { default as CredentialPluginSelected } from './CredentialPluginSelected';
|
||||
export { default as CredentialPluginField } from './CredentialPluginField';
|
||||
export { default as CredentialPluginTestAlert } from './CredentialPluginTestAlert';
|
||||
|
||||
198
awx/ui_next/src/screens/Credential/shared/ExternalTestModal.jsx
Normal file
198
awx/ui_next/src/screens/Credential/shared/ExternalTestModal.jsx
Normal 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);
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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';
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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`)}
|
||||
|
||||
245
awx/ui_next/src/screens/InstanceGroup/Instances/InstanceList.jsx
Normal file
245
awx/ui_next/src/screens/InstanceGroup/Instances/InstanceList.jsx
Normal 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);
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
@ -1 +1,2 @@
|
||||
export { default } from './Instances';
|
||||
export { default as InstanceList } from './InstanceList';
|
||||
export { default as InstanceListItem } from './InstanceListItem';
|
||||
|
||||
@ -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`}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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`);
|
||||
|
||||
@ -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'
|
||||
);
|
||||
|
||||
@ -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} />;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -42,6 +42,7 @@ const defaultContexts = {
|
||||
ansible_version: null,
|
||||
custom_virtualenvs: [],
|
||||
version: null,
|
||||
me: { is_superuser: true },
|
||||
toJSON: () => '/config/',
|
||||
},
|
||||
router: {
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = (
|
||||
|
||||
@ -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':
|
||||
|
||||
@ -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
|
||||
|
||||
@ -58,7 +58,6 @@ needs_development = [
|
||||
]
|
||||
needs_param_development = {
|
||||
'tower_host': ['instance_id'],
|
||||
'tower_inventory': ['insights_credential'],
|
||||
}
|
||||
# -----------------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user