Merge pull request #10532 from shanemcd/downstream-fixes

Downstream fixes

Reviewed-by: Elijah DeLee <kdelee@redhat.com>
Reviewed-by: Alan Rominger <arominge@redhat.com>
This commit is contained in:
softwarefactory-project-zuul[bot]
2021-06-28 15:31:31 +00:00
committed by GitHub
88 changed files with 2720 additions and 2066 deletions

View File

@@ -710,8 +710,12 @@ class ExecutionEnvironmentDetail(RetrieveUpdateDestroyAPIView):
fields_to_check = ['name', 'description', 'organization', 'image', 'credential'] fields_to_check = ['name', 'description', 'organization', 'image', 'credential']
if instance.managed and request.user.can_access(models.ExecutionEnvironment, 'change', instance): if instance.managed and request.user.can_access(models.ExecutionEnvironment, 'change', instance):
for field in fields_to_check: for field in fields_to_check:
if kwargs.get('partial') and field not in request.data:
continue
left = getattr(instance, field, None) left = getattr(instance, field, None)
right = request.data.get(field, None) if hasattr(left, 'id'):
left = left.id
right = request.data.get(field)
if left != right: if left != right:
raise PermissionDenied(_("Only the 'pull' field can be edited for managed execution environments.")) raise PermissionDenied(_("Only the 'pull' field can be edited for managed execution environments."))
return super().update(request, *args, **kwargs) return super().update(request, *args, **kwargs)

View File

@@ -692,6 +692,15 @@ register(
unit=_('seconds'), unit=_('seconds'),
) )
register(
'IS_K8S',
field_class=fields.BooleanField,
read_only=True,
category=_('System'),
category_slug='system',
help_text=_('Indicates whether the instance is part of a kubernetes-based deployment.'),
)
def logging_validate(serializer, attrs): def logging_validate(serializer, attrs):
if not serializer.instance or not hasattr(serializer.instance, 'LOG_AGGREGATOR_HOST') or not hasattr(serializer.instance, 'LOG_AGGREGATOR_TYPE'): if not serializer.instance or not hasattr(serializer.instance, 'LOG_AGGREGATOR_HOST') or not hasattr(serializer.instance, 'LOG_AGGREGATOR_TYPE'):

View File

@@ -63,7 +63,15 @@ base_inputs = {
'id': 'secret_path', 'id': 'secret_path',
'label': _('Path to Secret'), 'label': _('Path to Secret'),
'type': 'string', 'type': 'string',
'help_text': _('The path to the secret stored in the secret backend e.g, /some/secret/'), 'help_text': _(
(
'The path to the secret stored in the secret backend e.g, /some/secret/. It is recommended'
' that you use the secret backend field to identify the storage backend and to use this field'
' for locating a specific secret within that store. However, if you prefer to fully identify'
' both the secret backend and one of its secrets using only this field, join their locations'
' into a single path without any additional separators, e.g, /location/of/backend/some/secret.'
)
),
}, },
{ {
'id': 'auth_path', 'id': 'auth_path',

View File

@@ -1246,7 +1246,7 @@ class SystemJob(UnifiedJob, SystemJobOptions, JobNotificationMixin):
@property @property
def preferred_instance_groups(self): def preferred_instance_groups(self):
return self.global_instance_groups return self.control_plane_instance_group
''' '''
JobNotificationMixin JobNotificationMixin

View File

@@ -1437,7 +1437,12 @@ class UnifiedJob(
def global_instance_groups(self): def global_instance_groups(self):
from awx.main.models.ha import InstanceGroup from awx.main.models.ha import InstanceGroup
default_instance_groups = InstanceGroup.objects.filter(name__in=[settings.DEFAULT_EXECUTION_QUEUE_NAME, settings.DEFAULT_CONTROL_PLANE_QUEUE_NAME]) default_instance_group_names = [settings.DEFAULT_EXECUTION_QUEUE_NAME]
if not settings.IS_K8S:
default_instance_group_names.append(settings.DEFAULT_CONTROL_PLANE_QUEUE_NAME)
default_instance_groups = InstanceGroup.objects.filter(name__in=default_instance_group_names)
return list(default_instance_groups) return list(default_instance_groups)

View File

@@ -87,7 +87,7 @@ from awx.main.exceptions import AwxTaskError, PostRunError
from awx.main.queue import CallbackQueueDispatcher from awx.main.queue import CallbackQueueDispatcher
from awx.main.dispatch.publish import task from awx.main.dispatch.publish import task
from awx.main.dispatch import get_local_queuename, reaper from awx.main.dispatch import get_local_queuename, reaper
from awx.main.utils import ( from awx.main.utils.common import (
update_scm_url, update_scm_url,
ignore_inventory_computed_fields, ignore_inventory_computed_fields,
ignore_inventory_group_removal, ignore_inventory_group_removal,
@@ -97,6 +97,7 @@ from awx.main.utils import (
deepmerge, deepmerge,
parse_yaml_or_json, parse_yaml_or_json,
cleanup_new_process, cleanup_new_process,
create_partition,
) )
from awx.main.utils.execution_environments import get_default_pod_spec, CONTAINER_ROOT, to_container_path from awx.main.utils.execution_environments import get_default_pod_spec, CONTAINER_ROOT, to_container_path
from awx.main.utils.ansible import read_ansible_config from awx.main.utils.ansible import read_ansible_config
@@ -1267,11 +1268,17 @@ class BaseTask(object):
for k, v in self.safe_env.items(): for k, v in self.safe_env.items():
if k in job_env: if k in job_env:
job_env[k] = v job_env[k] = v
self.instance = self.update_model(self.instance.pk, job_args=json.dumps(runner_config.command), job_cwd=runner_config.cwd, job_env=job_env) from awx.main.signals import disable_activity_stream # Circular import
with disable_activity_stream():
self.instance = self.update_model(self.instance.pk, job_args=json.dumps(runner_config.command), job_cwd=runner_config.cwd, job_env=job_env)
elif status_data['status'] == 'error': elif status_data['status'] == 'error':
result_traceback = status_data.get('result_traceback', None) result_traceback = status_data.get('result_traceback', None)
if result_traceback: if result_traceback:
self.instance = self.update_model(self.instance.pk, result_traceback=result_traceback) from awx.main.signals import disable_activity_stream # Circular import
with disable_activity_stream():
self.instance = self.update_model(self.instance.pk, result_traceback=result_traceback)
@with_path_cleanup @with_path_cleanup
def run(self, pk, **kwargs): def run(self, pk, **kwargs):
@@ -1791,6 +1798,7 @@ class RunJob(BaseTask):
if 'update_' not in sync_metafields['job_tags']: if 'update_' not in sync_metafields['job_tags']:
sync_metafields['scm_revision'] = job_revision sync_metafields['scm_revision'] = job_revision
local_project_sync = job.project.create_project_update(_eager_fields=sync_metafields) local_project_sync = job.project.create_project_update(_eager_fields=sync_metafields)
create_partition(local_project_sync.event_class._meta.db_table, start=local_project_sync.created)
# save the associated job before calling run() so that a # save the associated job before calling run() so that a
# cancel() call on the job can cancel the project update # cancel() call on the job can cancel the project update
job = self.update_model(job.pk, project_update=local_project_sync) job = self.update_model(job.pk, project_update=local_project_sync)
@@ -2070,17 +2078,24 @@ class RunProjectUpdate(BaseTask):
if InventoryUpdate.objects.filter(inventory_source=inv_src, status__in=ACTIVE_STATES).exists(): if InventoryUpdate.objects.filter(inventory_source=inv_src, status__in=ACTIVE_STATES).exists():
logger.debug('Skipping SCM inventory update for `{}` because ' 'another update is already active.'.format(inv_src.name)) logger.debug('Skipping SCM inventory update for `{}` because ' 'another update is already active.'.format(inv_src.name))
continue continue
if settings.IS_K8S:
instance_group = InventoryUpdate(inventory_source=inv_src).preferred_instance_groups[0]
else:
instance_group = project_update.instance_group
local_inv_update = inv_src.create_inventory_update( local_inv_update = inv_src.create_inventory_update(
_eager_fields=dict( _eager_fields=dict(
launch_type='scm', launch_type='scm',
status='running', status='running',
instance_group=project_update.instance_group, instance_group=instance_group,
execution_node=project_update.execution_node, execution_node=project_update.execution_node,
source_project_update=project_update, source_project_update=project_update,
celery_task_id=project_update.celery_task_id, celery_task_id=project_update.celery_task_id,
) )
) )
try: try:
create_partition(local_inv_update.event_class._meta.db_table, start=local_inv_update.created)
inv_update_class().run(local_inv_update.id) inv_update_class().run(local_inv_update.id)
except Exception: except Exception:
logger.exception('{} Unhandled exception updating dependent SCM inventory sources.'.format(project_update.log_format)) logger.exception('{} Unhandled exception updating dependent SCM inventory sources.'.format(project_update.log_format))
@@ -2161,8 +2176,6 @@ class RunProjectUpdate(BaseTask):
if not os.path.exists(settings.PROJECTS_ROOT): if not os.path.exists(settings.PROJECTS_ROOT):
os.mkdir(settings.PROJECTS_ROOT) os.mkdir(settings.PROJECTS_ROOT)
project_path = instance.project.get_project_path(check_if_exists=False) project_path = instance.project.get_project_path(check_if_exists=False)
if not os.path.exists(project_path):
os.makedirs(project_path) # used as container mount
self.acquire_lock(instance) self.acquire_lock(instance)
@@ -2175,6 +2188,9 @@ class RunProjectUpdate(BaseTask):
else: else:
self.original_branch = git_repo.active_branch self.original_branch = git_repo.active_branch
if not os.path.exists(project_path):
os.makedirs(project_path) # used as container mount
stage_path = os.path.join(instance.get_cache_path(), 'stage') stage_path = os.path.join(instance.get_cache_path(), 'stage')
if os.path.exists(stage_path): if os.path.exists(stage_path):
logger.warning('{0} unexpectedly existed before update'.format(stage_path)) logger.warning('{0} unexpectedly existed before update'.format(stage_path))
@@ -2988,7 +3004,8 @@ class AWXReceptorJob:
if state_name == 'Succeeded': if state_name == 'Succeeded':
return res return res
raise RuntimeError(detail) if self.task.instance.result_traceback is None:
raise RuntimeError(detail)
return res return res

View File

@@ -36,7 +36,7 @@ def mk_instance(persisted=True, hostname='instance.example.org'):
return Instance.objects.get_or_create(uuid=settings.SYSTEM_UUID, hostname=hostname)[0] return Instance.objects.get_or_create(uuid=settings.SYSTEM_UUID, hostname=hostname)[0]
def mk_instance_group(name='tower', instance=None, minimum=0, percentage=0): def mk_instance_group(name='default', instance=None, minimum=0, percentage=0):
ig, status = InstanceGroup.objects.get_or_create(name=name, policy_instance_minimum=minimum, policy_instance_percentage=percentage) ig, status = InstanceGroup.objects.get_or_create(name=name, policy_instance_minimum=minimum, policy_instance_percentage=percentage)
if instance is not None: if instance is not None:
if type(instance) == list: if type(instance) == list:

View File

@@ -35,18 +35,19 @@ class TestDependentInventoryUpdate:
task.post_run_hook(proj_update, 'successful') task.post_run_hook(proj_update, 'successful')
assert not inv_update_mck.called assert not inv_update_mck.called
def test_dependent_inventory_updates(self, scm_inventory_source): def test_dependent_inventory_updates(self, scm_inventory_source, default_instance_group):
task = RunProjectUpdate() task = RunProjectUpdate()
scm_inventory_source.scm_last_revision = '' scm_inventory_source.scm_last_revision = ''
proj_update = ProjectUpdate.objects.create(project=scm_inventory_source.source_project) proj_update = ProjectUpdate.objects.create(project=scm_inventory_source.source_project)
with mock.patch.object(RunInventoryUpdate, 'run') as iu_run_mock: with mock.patch.object(RunInventoryUpdate, 'run') as iu_run_mock:
task._update_dependent_inventories(proj_update, [scm_inventory_source]) with mock.patch('awx.main.tasks.create_partition'):
assert InventoryUpdate.objects.count() == 1 task._update_dependent_inventories(proj_update, [scm_inventory_source])
inv_update = InventoryUpdate.objects.first() assert InventoryUpdate.objects.count() == 1
iu_run_mock.assert_called_once_with(inv_update.id) inv_update = InventoryUpdate.objects.first()
assert inv_update.source_project_update_id == proj_update.pk iu_run_mock.assert_called_once_with(inv_update.id)
assert inv_update.source_project_update_id == proj_update.pk
def test_dependent_inventory_project_cancel(self, project, inventory): def test_dependent_inventory_project_cancel(self, project, inventory, default_instance_group):
""" """
Test that dependent inventory updates exhibit good behavior on cancel Test that dependent inventory updates exhibit good behavior on cancel
of the source project update of the source project update
@@ -63,8 +64,9 @@ class TestDependentInventoryUpdate:
ProjectUpdate.objects.all().update(cancel_flag=True) ProjectUpdate.objects.all().update(cancel_flag=True)
with mock.patch.object(RunInventoryUpdate, 'run') as iu_run_mock: with mock.patch.object(RunInventoryUpdate, 'run') as iu_run_mock:
iu_run_mock.side_effect = user_cancels_project with mock.patch('awx.main.tasks.create_partition'):
task._update_dependent_inventories(proj_update, [is1, is2]) iu_run_mock.side_effect = user_cancels_project
# Verify that it bails after 1st update, detecting a cancel task._update_dependent_inventories(proj_update, [is1, is2])
assert is2.inventory_updates.count() == 0 # Verify that it bails after 1st update, detecting a cancel
iu_run_mock.assert_called_once() assert is2.inventory_updates.count() == 0
iu_run_mock.assert_called_once()

View File

@@ -63,6 +63,7 @@
"aria-labelledby", "aria-labelledby",
"aria-hidden", "aria-hidden",
"aria-controls", "aria-controls",
"aria-pressed",
"sortKey", "sortKey",
"ouiaId", "ouiaId",
"credentialTypeNamespace", "credentialTypeNamespace",

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,12 @@
function isEqual(array1, array2) {
return (
array1.length === array2.length &&
array1.every((element, index) => {
return element.id === array2[index].id;
})
);
}
const InstanceGroupsMixin = parent => const InstanceGroupsMixin = parent =>
class extends parent { class extends parent {
readInstanceGroups(resourceId, params) { readInstanceGroups(resourceId, params) {
@@ -18,6 +27,20 @@ const InstanceGroupsMixin = parent =>
disassociate: true, disassociate: true,
}); });
} }
async orderInstanceGroups(resourceId, current, original) {
/* eslint-disable no-await-in-loop, no-restricted-syntax */
// Resolve Promises sequentially to maintain order and avoid race condition
if (!isEqual(current, original)) {
for (const group of original) {
await this.disassociateInstanceGroup(resourceId, group.id);
}
for (const group of current) {
await this.associateInstanceGroup(resourceId, group.id);
}
}
}
/* eslint-enable no-await-in-loop, no-restricted-syntax */
}; };
export default InstanceGroupsMixin; export default InstanceGroupsMixin;

View File

@@ -14,6 +14,10 @@ class Settings extends Base {
return this.http.patch(`${this.baseUrl}all/`, data); return this.http.patch(`${this.baseUrl}all/`, data);
} }
readAll() {
return this.http.get(`${this.baseUrl}all/`);
}
updateCategory(category, data) { updateCategory(category, data) {
return this.http.patch(`${this.baseUrl}${category}/`, data); return this.http.patch(`${this.baseUrl}${category}/`, data);
} }

View File

@@ -0,0 +1,7 @@
.pf-c-select__toggle:before {
border-top: var(--pf-c-select__toggle--before--BorderTopWidth) solid var(--pf-c-select__toggle--before--BorderTopColor);
border-right: var(--pf-c-select__toggle--before--BorderRightWidth) solid var(--pf-c-select__toggle--before--BorderRightColor);
border-bottom: var(--pf-c-select__toggle--before--BorderBottomWidth) solid var(--pf-c-select__toggle--before--BorderBottomColor);
border-left: var(--pf-c-select__toggle--before--BorderLeftWidth) solid var(--pf-c-select__toggle--before--BorderLeftColor);
}
/* https://github.com/patternfly/patternfly-react/issues/5650 */

View File

@@ -6,7 +6,7 @@ import useRequest from '../../util/useRequest';
import { SearchColumns, SortColumns } from '../../types'; import { SearchColumns, SortColumns } from '../../types';
import DataListToolbar from '../DataListToolbar'; import DataListToolbar from '../DataListToolbar';
import CheckboxListItem from '../CheckboxListItem'; import CheckboxListItem from '../CheckboxListItem';
import SelectedList from '../SelectedList'; import { SelectedList } from '../SelectedList';
import { getQSConfig, parseQueryString } from '../../util/qs'; import { getQSConfig, parseQueryString } from '../../util/qs';
import PaginatedTable, { HeaderCell, HeaderRow } from '../PaginatedTable'; import PaginatedTable, { HeaderCell, HeaderRow } from '../PaginatedTable';

View File

@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import CheckboxCard from './CheckboxCard'; import CheckboxCard from './CheckboxCard';
import SelectedList from '../SelectedList'; import { SelectedList } from '../SelectedList';
function RolesStep({ function RolesStep({
onRolesClick, onRolesClick,

View File

@@ -1,15 +1,17 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import styled from 'styled-components';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { import {
Checkbox, Checkbox,
Toolbar, Toolbar,
ToolbarContent, ToolbarContent as PFToolbarContent,
ToolbarGroup, ToolbarGroup,
ToolbarItem, ToolbarItem,
ToolbarToggleGroup, ToolbarToggleGroup,
Dropdown, Dropdown,
DropdownPosition,
KebabToggle, KebabToggle,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { SearchIcon } from '@patternfly/react-icons'; import { SearchIcon } from '@patternfly/react-icons';
@@ -19,6 +21,12 @@ import Sort from '../Sort';
import { SearchColumns, SortColumns, QSConfig } from '../../types'; import { SearchColumns, SortColumns, QSConfig } from '../../types';
import { KebabifiedProvider } from '../../contexts/Kebabified'; import { KebabifiedProvider } from '../../contexts/Kebabified';
const ToolbarContent = styled(PFToolbarContent)`
& > .pf-c-toolbar__content-section {
flex-wrap: nowrap;
}
`;
function DataListToolbar({ function DataListToolbar({
itemCount, itemCount,
clearAllFilters, clearAllFilters,
@@ -47,6 +55,11 @@ function DataListToolbar({
const [isKebabModalOpen, setIsKebabModalOpen] = useState(false); const [isKebabModalOpen, setIsKebabModalOpen] = useState(false);
const [isAdvancedSearchShown, setIsAdvancedSearchShown] = useState(false); const [isAdvancedSearchShown, setIsAdvancedSearchShown] = useState(false);
const viewportWidth =
window.innerWidth || document.documentElement.clientWidth;
const dropdownPosition =
viewportWidth >= 992 ? DropdownPosition.right : DropdownPosition.left;
const onShowAdvancedSearch = shown => { const onShowAdvancedSearch = shown => {
setIsAdvancedSearchShown(shown); setIsAdvancedSearchShown(shown);
setIsKebabOpen(false); setIsKebabOpen(false);
@@ -135,6 +148,7 @@ function DataListToolbar({
/> />
} }
isOpen={isKebabOpen} isOpen={isKebabOpen}
position={dropdownPosition}
isPlain isPlain
dropdownItems={additionalControls} dropdownItems={additionalControls}
/> />

View File

@@ -12,7 +12,15 @@ import {
import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons'; import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons';
function PasswordInput(props) { function PasswordInput(props) {
const { autocomplete, id, name, validate, isRequired, isDisabled } = props; const {
autocomplete,
id,
name,
validate,
isFieldGroupValid,
isRequired,
isDisabled,
} = props;
const [inputType, setInputType] = useState('password'); const [inputType, setInputType] = useState('password');
const [field, meta] = useField({ name, validate }); const [field, meta] = useField({ name, validate });
@@ -44,7 +52,7 @@ function PasswordInput(props) {
value={field.value === '$encrypted$' ? '' : field.value} value={field.value === '$encrypted$' ? '' : field.value}
isDisabled={isDisabled} isDisabled={isDisabled}
isRequired={isRequired} isRequired={isRequired}
validated={isValid ? 'default' : 'error'} validated={isValid || isFieldGroupValid ? 'default' : 'error'}
type={inputType} type={inputType}
onChange={(_, event) => { onChange={(_, event) => {
field.onChange(event); field.onChange(event);

View File

@@ -39,6 +39,7 @@ const InventoryLookupField = ({ isDisabled }) => {
error={inventoryMeta.error} error={inventoryMeta.error}
validate={required(t`Select a value for this field`)} validate={required(t`Select a value for this field`)}
isDisabled={isDisabled} isDisabled={isDisabled}
hideSmartInventories
/> />
); );

View File

@@ -29,22 +29,24 @@ const QS_CONFIG = getQSConfig('credentials', {
}); });
function CredentialLookup({ function CredentialLookup({
helperTextInvalid, autoPopulate,
label,
isValid,
onBlur,
onChange,
required,
credentialTypeId, credentialTypeId,
credentialTypeKind, credentialTypeKind,
credentialTypeNamespace, credentialTypeNamespace,
value,
tooltip,
isDisabled,
autoPopulate,
multiple,
validate,
fieldName, fieldName,
helperTextInvalid,
isDisabled,
isSelectedDraggable,
isValid,
label,
modalDescription,
multiple,
onBlur,
onChange,
required,
tooltip,
validate,
value,
}) { }) {
const history = useHistory(); const history = useHistory();
const autoPopulateLookup = useAutoPopulateLookup(onChange); const autoPopulateLookup = useAutoPopulateLookup(onChange);
@@ -174,6 +176,7 @@ function CredentialLookup({
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
isDisabled={isDisabled} isDisabled={isDisabled}
multiple={multiple} multiple={multiple}
modalDescription={modalDescription}
renderOptionsList={({ state, dispatch, canDelete }) => ( renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList <OptionsList
value={state.selectedItems} value={state.selectedItems}
@@ -208,7 +211,11 @@ function CredentialLookup({
name="credential" name="credential"
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })} selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
sortSelectedItems={selectedItems =>
dispatch({ type: 'SET_SELECTED_ITEMS', selectedItems })
}
multiple={multiple} multiple={multiple}
isSelectedDraggable={isSelectedDraggable}
/> />
)} )}
/> />

View File

@@ -41,7 +41,7 @@ function ExecutionEnvironmentLookup({
const { const {
request: fetchProject, request: fetchProject,
error: fetchProjectError, error: fetchProjectError,
isLoading: fetchProjectLoading, isLoading: isProjectLoading,
result: project, result: project,
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
@@ -53,6 +53,7 @@ function ExecutionEnvironmentLookup({
}, [projectId]), }, [projectId]),
{ {
project: null, project: null,
isLoading: true,
} }
); );
@@ -72,6 +73,12 @@ function ExecutionEnvironmentLookup({
isLoading, isLoading,
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
if (isProjectLoading) {
return {
executionEnvironments: [],
count: 0,
};
}
const params = parseQueryString(QS_CONFIG, location.search); const params = parseQueryString(QS_CONFIG, location.search);
const globallyAvailableParams = globallyAvailable const globallyAvailableParams = globallyAvailable
? { or__organization__isnull: 'True' } ? { or__organization__isnull: 'True' }
@@ -105,7 +112,14 @@ function ExecutionEnvironmentLookup({
actionsResponse.data.actions?.GET || {} actionsResponse.data.actions?.GET || {}
).filter(key => actionsResponse.data.actions?.GET[key].filterable), ).filter(key => actionsResponse.data.actions?.GET[key].filterable),
}; };
}, [location, globallyAvailable, organizationId, projectId, project]), }, [
location,
globallyAvailable,
organizationId,
projectId,
project,
isProjectLoading,
]),
{ {
executionEnvironments: [], executionEnvironments: [],
count: 0, count: 0,
@@ -149,7 +163,7 @@ function ExecutionEnvironmentLookup({
fieldName={fieldName} fieldName={fieldName}
validate={validate} validate={validate}
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
isLoading={isLoading || fetchProjectLoading} isLoading={isLoading || isProjectLoading}
isDisabled={isDisabled} isDisabled={isDisabled}
renderOptionsList={({ state, dispatch, canDelete }) => ( renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList <OptionsList

View File

@@ -30,9 +30,9 @@ describe('ExecutionEnvironmentLookup', () => {
let wrapper; let wrapper;
beforeEach(() => { beforeEach(() => {
ExecutionEnvironmentsAPI.read.mockResolvedValue( ExecutionEnvironmentsAPI.read.mockResolvedValue({
mockedExecutionEnvironments data: mockedExecutionEnvironments,
); });
ProjectsAPI.readDetail.mockResolvedValue({ data: { organization: 39 } }); ProjectsAPI.readDetail.mockResolvedValue({ data: { organization: 39 } });
}); });
@@ -63,7 +63,7 @@ describe('ExecutionEnvironmentLookup', () => {
); );
}); });
wrapper.update(); wrapper.update();
expect(ExecutionEnvironmentsAPI.read).toHaveBeenCalledTimes(2); expect(ExecutionEnvironmentsAPI.read).toHaveBeenCalledTimes(1);
expect(wrapper.find('ExecutionEnvironmentLookup')).toHaveLength(1); expect(wrapper.find('ExecutionEnvironmentLookup')).toHaveLength(1);
expect( expect(
wrapper.find('FormGroup[label="Default Execution Environment"]').length wrapper.find('FormGroup[label="Default Execution Environment"]').length
@@ -84,7 +84,7 @@ describe('ExecutionEnvironmentLookup', () => {
</Formik> </Formik>
); );
}); });
expect(ExecutionEnvironmentsAPI.read).toHaveBeenCalledTimes(2); expect(ExecutionEnvironmentsAPI.read).toHaveBeenCalledTimes(1);
expect( expect(
wrapper.find('FormGroup[label="Default Execution Environment"]').length wrapper.find('FormGroup[label="Default Execution Environment"]').length
).toBe(0); ).toBe(0);

View File

@@ -2,7 +2,7 @@ import React, { useCallback, useEffect } from 'react';
import { arrayOf, string, func, bool } from 'prop-types'; import { arrayOf, string, func, bool } from 'prop-types';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { t } from '@lingui/macro'; import { t, Trans } from '@lingui/macro';
import { FormGroup } from '@patternfly/react-core'; import { FormGroup } from '@patternfly/react-core';
import { InstanceGroupsAPI } from '../../api'; import { InstanceGroupsAPI } from '../../api';
import { InstanceGroup } from '../../types'; import { InstanceGroup } from '../../types';
@@ -82,6 +82,18 @@ function InstanceGroupsLookup({
multiple multiple
required={required} required={required}
isLoading={isLoading} isLoading={isLoading}
modalDescription={
<>
<b>
<Trans>Selected</Trans>
</b>
<br />
<Trans>
Note: The order in which these are selected sets the execution
precedence.
</Trans>
</>
}
renderOptionsList={({ state, dispatch, canDelete }) => ( renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList <OptionsList
value={state.selectedItems} value={state.selectedItems}
@@ -113,6 +125,10 @@ function InstanceGroupsLookup({
readOnly={!canDelete} readOnly={!canDelete}
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })} selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
sortSelectedItems={selectedItems =>
dispatch({ type: 'SET_SELECTED_ITEMS', selectedItems })
}
isSelectedDraggable
/> />
)} )}
/> />

View File

@@ -7,7 +7,7 @@ import { Inventory } from '../../types';
import Lookup from './Lookup'; import Lookup from './Lookup';
import OptionsList from '../OptionsList'; import OptionsList from '../OptionsList';
import useRequest from '../../util/useRequest'; import useRequest from '../../util/useRequest';
import { getQSConfig, parseQueryString } from '../../util/qs'; import { getQSConfig, parseQueryString, mergeParams } from '../../util/qs';
import LookupErrorMessage from './shared/LookupErrorMessage'; import LookupErrorMessage from './shared/LookupErrorMessage';
import FieldWithPrompt from '../FieldWithPrompt'; import FieldWithPrompt from '../FieldWithPrompt';
@@ -32,6 +32,7 @@ function InventoryLookup({
validate, validate,
fieldName, fieldName,
isDisabled, isDisabled,
hideSmartInventories,
}) { }) {
const { const {
result: { result: {
@@ -47,8 +48,15 @@ function InventoryLookup({
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const params = parseQueryString(QS_CONFIG, history.location.search); const params = parseQueryString(QS_CONFIG, history.location.search);
const inventoryKindParams = hideSmartInventories
? { not__kind: 'smart' }
: {};
const [{ data }, actionsResponse] = await Promise.all([ const [{ data }, actionsResponse] = await Promise.all([
InventoriesAPI.read(params), InventoriesAPI.read(
mergeParams(params, {
...inventoryKindParams,
})
),
InventoriesAPI.readOptions(), InventoriesAPI.readOptions(),
]); ]);
@@ -60,7 +68,12 @@ function InventoryLookup({
).map(val => val.slice(0, -8)), ).map(val => val.slice(0, -8)),
searchableKeys: Object.keys( searchableKeys: Object.keys(
actionsResponse.data.actions?.GET || {} actionsResponse.data.actions?.GET || {}
).filter(key => actionsResponse.data.actions?.GET[key].filterable), ).filter(key => {
if (['kind', 'host_filter'].includes(key) && hideSmartInventories) {
return false;
}
return actionsResponse.data.actions?.GET[key].filterable;
}),
canEdit: canEdit:
Boolean(actionsResponse.data.actions.POST) || isOverrideDisabled, Boolean(actionsResponse.data.actions.POST) || isOverrideDisabled,
}; };
@@ -230,6 +243,7 @@ InventoryLookup.propTypes = {
validate: func, validate: func,
fieldName: string, fieldName: string,
isDisabled: bool, isDisabled: bool,
hideSmartInventories: bool,
}; };
InventoryLookup.defaultProps = { InventoryLookup.defaultProps = {
@@ -239,6 +253,7 @@ InventoryLookup.defaultProps = {
validate: () => {}, validate: () => {},
fieldName: 'inventory', fieldName: 'inventory',
isDisabled: false, isDisabled: false,
hideSmartInventories: false,
}; };
export default withRouter(InventoryLookup); export default withRouter(InventoryLookup);

View File

@@ -48,6 +48,42 @@ describe('InventoryLookup', () => {
}); });
wrapper.update(); wrapper.update();
expect(InventoriesAPI.read).toHaveBeenCalledTimes(1); expect(InventoriesAPI.read).toHaveBeenCalledTimes(1);
expect(InventoriesAPI.read).toHaveBeenCalledWith({
order_by: 'name',
page: 1,
page_size: 5,
role_level: 'use_role',
});
expect(wrapper.find('InventoryLookup')).toHaveLength(1);
expect(wrapper.find('Lookup').prop('isDisabled')).toBe(false);
});
test('should fetch only regular inventories when hideSmartInventories is true', async () => {
InventoriesAPI.readOptions.mockReturnValue({
data: {
actions: {
GET: {},
POST: {},
},
related_search_fields: [],
},
});
await act(async () => {
wrapper = mountWithContexts(
<Formik>
<InventoryLookup onChange={() => {}} hideSmartInventories />
</Formik>
);
});
wrapper.update();
expect(InventoriesAPI.read).toHaveBeenCalledTimes(1);
expect(InventoriesAPI.read).toHaveBeenCalledWith({
not__kind: 'smart',
order_by: 'name',
page: 1,
page_size: 5,
role_level: 'use_role',
});
expect(wrapper.find('InventoryLookup')).toHaveLength(1); expect(wrapper.find('InventoryLookup')).toHaveLength(1);
expect(wrapper.find('Lookup').prop('isDisabled')).toBe(false); expect(wrapper.find('Lookup').prop('isDisabled')).toBe(false);
}); });

View File

@@ -49,6 +49,7 @@ function Lookup(props) {
onDebounce, onDebounce,
fieldName, fieldName,
validate, validate,
modalDescription,
} = props; } = props;
const [typedText, setTypedText] = useState(''); const [typedText, setTypedText] = useState('');
const debounceRequest = useDebounce(onDebounce, 1000); const debounceRequest = useDebounce(onDebounce, 1000);
@@ -166,6 +167,7 @@ function Lookup(props) {
aria-label={t`Lookup modal`} aria-label={t`Lookup modal`}
isOpen={isModalOpen} isOpen={isModalOpen}
onClose={closeModal} onClose={closeModal}
description={state?.selectedItems?.length > 0 && modalDescription}
actions={[ actions={[
<Button <Button
ouiaId="modal-select-button" ouiaId="modal-select-button"
@@ -204,6 +206,7 @@ const Item = shape({
Lookup.propTypes = { Lookup.propTypes = {
id: string, id: string,
header: string, header: string,
modalDescription: string,
onChange: func.isRequired, onChange: func.isRequired,
value: oneOfType([Item, arrayOf(Item)]), value: oneOfType([Item, arrayOf(Item)]),
multiple: bool, multiple: bool,
@@ -224,6 +227,7 @@ Lookup.defaultProps = {
value: null, value: null,
multiple: false, multiple: false,
required: false, required: false,
modalDescription: '',
onBlur: () => {}, onBlur: () => {},
renderItemChip: ({ item, removeItem, canDelete }) => ( renderItemChip: ({ item, removeItem, canDelete }) => (
<Chip <Chip

View File

@@ -10,7 +10,7 @@ import {
} from 'prop-types'; } from 'prop-types';
import styled from 'styled-components'; import styled from 'styled-components';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import SelectedList from '../SelectedList'; import { SelectedList, DraggableSelectedList } from '../SelectedList';
import CheckboxListItem from '../CheckboxListItem'; import CheckboxListItem from '../CheckboxListItem';
import DataListToolbar from '../DataListToolbar'; import DataListToolbar from '../DataListToolbar';
import { QSConfig, SearchColumns, SortColumns } from '../../types'; import { QSConfig, SearchColumns, SortColumns } from '../../types';
@@ -23,28 +23,39 @@ const ModalList = styled.div`
`; `;
function OptionsList({ function OptionsList({
value,
contentError, contentError,
options, deselectItem,
optionCount, displayKey,
searchColumns,
sortColumns,
searchableKeys,
relatedSearchableKeys,
multiple,
header, header,
isLoading,
isSelectedDraggable,
multiple,
name, name,
optionCount,
options,
qsConfig, qsConfig,
readOnly, readOnly,
selectItem, relatedSearchableKeys,
deselectItem,
renderItemChip, renderItemChip,
isLoading, searchColumns,
displayKey, searchableKeys,
selectItem,
sortColumns,
sortSelectedItems,
value,
}) { }) {
return ( let selectionPreview = null;
<ModalList> if (value.length > 0) {
{value.length > 0 && ( if (isSelectedDraggable) {
selectionPreview = (
<DraggableSelectedList
onRemove={deselectItem}
onRowDrag={sortSelectedItems}
selected={value}
/>
);
} else {
selectionPreview = (
<SelectedList <SelectedList
label={t`Selected`} label={t`Selected`}
selected={value} selected={value}
@@ -53,7 +64,13 @@ function OptionsList({
renderItemChip={renderItemChip} renderItemChip={renderItemChip}
displayKey={displayKey} displayKey={displayKey}
/> />
)} );
}
}
return (
<ModalList>
{selectionPreview}
<PaginatedTable <PaginatedTable
contentError={contentError} contentError={contentError}
items={options} items={options}
@@ -99,6 +116,7 @@ const Item = shape({
OptionsList.propTypes = { OptionsList.propTypes = {
deselectItem: func.isRequired, deselectItem: func.isRequired,
displayKey: string, displayKey: string,
isSelectedDraggable: bool,
multiple: bool, multiple: bool,
optionCount: number.isRequired, optionCount: number.isRequired,
options: arrayOf(Item).isRequired, options: arrayOf(Item).isRequired,
@@ -110,6 +128,7 @@ OptionsList.propTypes = {
value: arrayOf(Item).isRequired, value: arrayOf(Item).isRequired,
}; };
OptionsList.defaultProps = { OptionsList.defaultProps = {
isSelectedDraggable: false,
multiple: false, multiple: false,
renderItemChip: null, renderItemChip: null,
searchColumns: [], searchColumns: [],

View File

@@ -24,6 +24,10 @@ const AdvancedGroup = styled.div`
display: grid; display: grid;
grid-gap: var(--pf-c-toolbar__expandable-content--m-expanded--GridRowGap); grid-gap: var(--pf-c-toolbar__expandable-content--m-expanded--GridRowGap);
} }
& .pf-c-select {
min-width: 150px;
}
`; `;
function AdvancedSearch({ function AdvancedSearch({

View File

@@ -0,0 +1,132 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import {
Button,
DataList,
DataListAction,
DataListItem,
DataListCell,
DataListItemRow,
DataListControl,
DataListDragButton,
DataListItemCells,
} from '@patternfly/react-core';
import { TimesIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
import { t } from '@lingui/macro';
const RemoveActionSection = styled(DataListAction)`
&& {
align-items: center;
padding: 0;
}
`;
function DraggableSelectedList({ selected, onRemove, onRowDrag }) {
const [liveText, setLiveText] = useState('');
const [id, setId] = useState('');
const onDragStart = newId => {
setId(newId);
setLiveText(t`Dragging started for item id: ${newId}.`);
};
const onDragMove = (oldIndex, newIndex) => {
setLiveText(
t`Dragging item ${id}. Item with index ${oldIndex} in now ${newIndex}.`
);
};
const onDragCancel = () => {
setLiveText(t`Dragging cancelled. List is unchanged.`);
};
const onDragFinish = newItemOrder => {
const selectedItems = newItemOrder.map(item =>
selected.find(i => i.name === item)
);
onRowDrag(selectedItems);
};
const removeItem = item => {
onRemove(selected.find(i => i.name === item));
};
if (selected.length <= 0) {
return null;
}
const orderedList = selected.map(item => item.name);
return (
<>
<DataList
aria-label={t`Draggable list to reorder and remove selected items.`}
data-cy="draggable-list"
itemOrder={orderedList}
onDragCancel={onDragCancel}
onDragFinish={onDragFinish}
onDragMove={onDragMove}
onDragStart={onDragStart}
>
{orderedList.map((label, index) => {
const rowPosition = index + 1;
return (
<DataListItem id={label} key={rowPosition}>
<DataListItemRow>
<DataListControl>
<DataListDragButton
aria-label={t`Reorder`}
aria-labelledby={rowPosition}
aria-describedby={t`Press space or enter to begin dragging,
and use the arrow keys to navigate up or down.
Press enter to confirm the drag, or any other key to
cancel the drag operation.`}
aria-pressed="false"
data-cy={`reorder-${label}`}
/>
</DataListControl>
<DataListItemCells
dataListCells={[
<DataListCell key={label}>
<span id={rowPosition}>{`${rowPosition}. ${label}`}</span>
</DataListCell>,
]}
/>
<RemoveActionSection aria-label={t`Actions`} id={rowPosition}>
<Button
onClick={() => removeItem(label)}
variant="plain"
aria-label={t`Remove`}
ouiaId={`draggable-list-remove-${label}`}
>
<TimesIcon />
</Button>
</RemoveActionSection>
</DataListItemRow>
</DataListItem>
);
})}
</DataList>
<div className="pf-screen-reader" aria-live="assertive">
{liveText}
</div>
</>
);
}
const ListItem = PropTypes.shape({
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
});
DraggableSelectedList.propTypes = {
onRemove: PropTypes.func,
onRowDrag: PropTypes.func,
selected: PropTypes.arrayOf(ListItem),
};
DraggableSelectedList.defaultProps = {
onRemove: () => null,
onRowDrag: () => null,
selected: [],
};
export default DraggableSelectedList;

View File

@@ -0,0 +1,75 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import DraggableSelectedList from './DraggableSelectedList';
describe('<DraggableSelectedList />', () => {
let wrapper;
afterEach(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('should render expected rows', () => {
const mockSelected = [
{
id: 1,
name: 'foo',
},
{
id: 2,
name: 'bar',
},
];
wrapper = mountWithContexts(
<DraggableSelectedList
selected={mockSelected}
onRemove={() => {}}
onRowDrag={() => {}}
/>
);
expect(wrapper.find('DraggableSelectedList').length).toBe(1);
expect(wrapper.find('DataListItem').length).toBe(2);
expect(
wrapper
.find('DataListItem DataListCell')
.first()
.containsMatchingElement(<span>1. foo</span>)
).toEqual(true);
expect(
wrapper
.find('DataListItem DataListCell')
.last()
.containsMatchingElement(<span>2. bar</span>)
).toEqual(true);
});
test('should not render when selected list is empty', () => {
wrapper = mountWithContexts(
<DraggableSelectedList
selected={[]}
onRemove={() => {}}
onRowDrag={() => {}}
/>
);
expect(wrapper.find('DataList').length).toBe(0);
});
test('should call onRemove callback prop on remove button click', () => {
const onRemove = jest.fn();
const mockSelected = [
{
id: 1,
name: 'foo',
},
];
wrapper = mountWithContexts(
<DraggableSelectedList selected={mockSelected} onRemove={onRemove} />
);
wrapper
.find('DataListItem[id="foo"] Button[aria-label="Remove"]')
.simulate('click');
expect(onRemove).toBeCalledWith({
id: 1,
name: 'foo',
});
});
});

View File

@@ -1 +1,2 @@
export { default } from './SelectedList'; export { default as SelectedList } from './SelectedList';
export { default as DraggableSelectedList } from './DraggableSelectedList';

View File

@@ -2,6 +2,7 @@ import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import './setupCSP'; import './setupCSP';
import '@patternfly/react-core/dist/styles/base.css'; import '@patternfly/react-core/dist/styles/base.css';
import './border.css';
import App from './App'; import App from './App';
ReactDOM.render( ReactDOM.render(

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -26,7 +26,12 @@ const FileUpload = styled(PFFileUpload)`
flex-grow: 1; flex-grow: 1;
`; `;
function CredentialInput({ fieldOptions, credentialKind, ...rest }) { function CredentialInput({
fieldOptions,
isFieldGroupValid,
credentialKind,
...rest
}) {
const [fileName, setFileName] = useState(''); const [fileName, setFileName] = useState('');
const [fileIsUploading, setFileIsUploading] = useState(false); const [fileIsUploading, setFileIsUploading] = useState(false);
const [subFormField, meta, helpers] = useField(`inputs.${fieldOptions.id}`); const [subFormField, meta, helpers] = useField(`inputs.${fieldOptions.id}`);
@@ -116,6 +121,7 @@ function CredentialInput({ fieldOptions, credentialKind, ...rest }) {
<> <>
{RevertReplaceButton} {RevertReplaceButton}
<PasswordInput <PasswordInput
isFieldGroupValid={isFieldGroupValid}
{...subFormField} {...subFormField}
id={`credential-${fieldOptions.id}`} id={`credential-${fieldOptions.id}`}
{...rest} {...rest}
@@ -169,7 +175,9 @@ function CredentialField({ credentialType, fieldOptions }) {
name: `inputs.${fieldOptions.id}`, name: `inputs.${fieldOptions.id}`,
validate: validateField(), validate: validateField(),
}); });
const isValid = !(meta.touched && meta.error); const isValid =
!(meta.touched && meta.error) ||
formikValues.passwordPrompts[fieldOptions.id];
if (fieldOptions.choices) { if (fieldOptions.choices) {
const selectOptions = fieldOptions.choices.map(choice => { const selectOptions = fieldOptions.choices.map(choice => {
@@ -235,7 +243,10 @@ function CredentialField({ credentialType, fieldOptions }) {
isRequired={isRequired} isRequired={isRequired}
validated={isValid ? 'default' : 'error'} validated={isValid ? 'default' : 'error'}
> >
<CredentialInput fieldOptions={fieldOptions} /> <CredentialInput
isFieldGroupValid={isValid}
fieldOptions={fieldOptions}
/>
</CredentialPluginField> </CredentialPluginField>
); );
} }

View File

@@ -98,15 +98,23 @@ function CredentialPluginField(props) {
const [, meta, helpers] = useField(`inputs.${fieldOptions.id}`); const [, meta, helpers] = useField(`inputs.${fieldOptions.id}`);
const [passwordPromptField] = useField(`passwordPrompts.${fieldOptions.id}`); const [passwordPromptField] = useField(`passwordPrompts.${fieldOptions.id}`);
const invalidHelperTextToDisplay = meta.error && meta.touched && ( let invalidHelperTextToDisplay;
<div
className={css(styles.formHelperText, styles.modifiers.error)} if (meta.error && meta.touched) {
id={`${fieldOptions.id}-helper`} invalidHelperTextToDisplay = (
aria-live="polite" <div
> className={css(styles.formHelperText, styles.modifiers.error)}
{meta.error} id={`${fieldOptions.id}-helper`}
</div> aria-live="polite"
); >
{meta.error}
</div>
);
}
if (fieldOptions.id === 'vault_password' && passwordPromptField.value) {
invalidHelperTextToDisplay = null;
}
useEffect(() => { useEffect(() => {
if (passwordPromptField.value) { if (passwordPromptField.value) {
@@ -119,8 +127,6 @@ function CredentialPluginField(props) {
<> <>
{fieldOptions.ask_at_runtime ? ( {fieldOptions.ask_at_runtime ? (
<FieldWithPrompt <FieldWithPrompt
fieldId={`credential-${fieldOptions.id}`}
helperTextInvalid={meta.error}
isRequired={isRequired} isRequired={isRequired}
label={fieldOptions.label} label={fieldOptions.label}
promptId={`credential-prompt-${fieldOptions.id}`} promptId={`credential-prompt-${fieldOptions.id}`}

View File

@@ -74,7 +74,10 @@ function ExecutionEnvironmentFormFields({
: null : null
} }
autoPopulate={!me?.is_superuser ? !executionEnvironment?.id : null} autoPopulate={!me?.is_superuser ? !executionEnvironment?.id : null}
isDisabled={!!isOrgLookupDisabled && isGloballyAvailable.current} isDisabled={
(!!isOrgLookupDisabled && isGloballyAvailable.current) ||
executionEnvironment?.managed
}
validate={ validate={
!me?.is_superuser !me?.is_superuser
? required(t`Select a value for this field`) ? required(t`Select a value for this field`)
@@ -93,6 +96,7 @@ function ExecutionEnvironmentFormFields({
type="text" type="text"
validate={required(null)} validate={required(null)}
isRequired isRequired
isDisabled={executionEnvironment?.managed || false}
/> />
<FormField <FormField
id="execution-environment-image" id="execution-environment-image"
@@ -101,6 +105,7 @@ function ExecutionEnvironmentFormFields({
type="text" type="text"
validate={required(null)} validate={required(null)}
isRequired isRequired
isDisabled={executionEnvironment?.managed || false}
tooltip={ tooltip={
<span> <span>
{t`The full image location, including the container registry, image name, and version tag.`} {t`The full image location, including the container registry, image name, and version tag.`}
@@ -142,6 +147,7 @@ function ExecutionEnvironmentFormFields({
label={t`Description`} label={t`Description`}
name="description" name="description"
type="text" type="text"
isDisabled={executionEnvironment?.managed || false}
/> />
{isOrgLookupDisabled && isGloballyAvailable.current ? ( {isOrgLookupDisabled && isGloballyAvailable.current ? (
<Tooltip <Tooltip
@@ -162,6 +168,7 @@ function ExecutionEnvironmentFormFields({
onChange={onCredentialChange} onChange={onCredentialChange}
value={credentialField.value} value={credentialField.value}
tooltip={t`Credential to authenticate with a protected container registry.`} tooltip={t`Credential to authenticate with a protected container registry.`}
isDisabled={executionEnvironment?.managed || false}
/> />
</> </>
); );

View File

@@ -271,4 +271,46 @@ describe('<ExecutionEnvironmentForm/>', () => {
newWrapper.update(); newWrapper.update();
expect(newWrapper.find('OrganizationLookup').prop('value')).toEqual(null); expect(newWrapper.find('OrganizationLookup').prop('value')).toEqual(null);
}); });
test('should disable edition for managed EEs, except pull option', async () => {
let newWrapper;
await act(async () => {
newWrapper = mountWithContexts(
<ExecutionEnvironmentForm
onCancel={onCancel}
onSubmit={onSubmit}
executionEnvironment={{ ...executionEnvironment, managed: true }}
options={mockOptions}
me={mockMe}
/>
);
});
await waitForElement(newWrapper, 'ContentLoading', el => el.length === 0);
expect(newWrapper.find('OrganizationLookup').prop('isDisabled')).toEqual(
true
);
expect(newWrapper.find('CredentialLookup').prop('isDisabled')).toEqual(
true
);
expect(
newWrapper
.find('TextInputBase[id="execution-environment-name"]')
.prop('isDisabled')
).toEqual(true);
expect(
newWrapper
.find('TextInputBase[id="execution-environment-description"]')
.prop('isDisabled')
).toEqual(true);
expect(
newWrapper
.find('TextInputBase[id="execution-environment-image"]')
.prop('isDisabled')
).toEqual(true);
expect(
newWrapper
.find('FormSelect[id="container-pull-options"]')
.prop('isDisabled')
).toEqual(false);
});
}); });

View File

@@ -13,7 +13,7 @@ import { CaretLeftIcon } from '@patternfly/react-icons';
import { Card, PageSection } from '@patternfly/react-core'; import { Card, PageSection } from '@patternfly/react-core';
import useRequest from '../../util/useRequest'; import useRequest from '../../util/useRequest';
import { InstanceGroupsAPI } from '../../api'; import { InstanceGroupsAPI, SettingsAPI } from '../../api';
import RoutedTabs from '../../components/RoutedTabs'; import RoutedTabs from '../../components/RoutedTabs';
import ContentError from '../../components/ContentError'; import ContentError from '../../components/ContentError';
import ContentLoading from '../../components/ContentLoading'; import ContentLoading from '../../components/ContentLoading';
@@ -30,12 +30,24 @@ function ContainerGroup({ setBreadcrumb }) {
isLoading, isLoading,
error: contentError, error: contentError,
request: fetchInstanceGroups, request: fetchInstanceGroups,
result: instanceGroup, result: { instanceGroup, defaultExecution },
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const { data } = await InstanceGroupsAPI.readDetail(id); const [
return data; { data },
}, [id]) {
data: { DEFAULT_EXECUTION_QUEUE_NAME },
},
] = await Promise.all([
InstanceGroupsAPI.readDetail(id),
SettingsAPI.readAll(),
]);
return {
instanceGroup: data,
defaultExecution: DEFAULT_EXECUTION_QUEUE_NAME,
};
}, [id]),
{ instanceGroup: null, defaultExecution: '' }
); );
useEffect(() => { useEffect(() => {
@@ -109,10 +121,16 @@ function ContainerGroup({ setBreadcrumb }) {
{instanceGroup && ( {instanceGroup && (
<> <>
<Route path="/instance_groups/container_group/:id/edit"> <Route path="/instance_groups/container_group/:id/edit">
<ContainerGroupEdit instanceGroup={instanceGroup} /> <ContainerGroupEdit
instanceGroup={instanceGroup}
defaultExecution={defaultExecution}
/>
</Route> </Route>
<Route path="/instance_groups/container_group/:id/details"> <Route path="/instance_groups/container_group/:id/details">
<ContainerGroupDetails instanceGroup={instanceGroup} /> <ContainerGroupDetails
instanceGroup={instanceGroup}
defaultExecution={defaultExecution}
/>
</Route> </Route>
<Route path="/instance_groups/container_group/:id/jobs"> <Route path="/instance_groups/container_group/:id/jobs">
<JobList <JobList

View File

@@ -6,6 +6,7 @@ import { Button, Label } from '@patternfly/react-core';
import { VariablesDetail } from '../../../components/CodeEditor'; import { VariablesDetail } from '../../../components/CodeEditor';
import AlertModal from '../../../components/AlertModal'; import AlertModal from '../../../components/AlertModal';
import ErrorDetail from '../../../components/ErrorDetail';
import { CardBody, CardActionsRow } from '../../../components/Card'; import { CardBody, CardActionsRow } from '../../../components/Card';
import DeleteButton from '../../../components/DeleteButton'; import DeleteButton from '../../../components/DeleteButton';
import { import {
@@ -18,7 +19,7 @@ import { jsonToYaml, isJsonString } from '../../../util/yaml';
import { InstanceGroupsAPI } from '../../../api'; import { InstanceGroupsAPI } from '../../../api';
import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails'; import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails';
function ContainerGroupDetails({ instanceGroup }) { function ContainerGroupDetails({ instanceGroup, defaultExecution }) {
const { id, name } = instanceGroup; const { id, name } = instanceGroup;
const history = useHistory(); const history = useHistory();
@@ -102,7 +103,8 @@ function ContainerGroupDetails({ instanceGroup }) {
{t`Edit`} {t`Edit`}
</Button> </Button>
)} )}
{instanceGroup.summary_fields.user_capabilities && {name !== defaultExecution &&
instanceGroup.summary_fields.user_capabilities &&
instanceGroup.summary_fields.user_capabilities.delete && ( instanceGroup.summary_fields.user_capabilities.delete && (
<DeleteButton <DeleteButton
ouiaId="container-group-detail-delete-button" ouiaId="container-group-detail-delete-button"
@@ -123,7 +125,9 @@ function ContainerGroupDetails({ instanceGroup }) {
onClose={dismissError} onClose={dismissError}
title={t`Error`} title={t`Error`}
variant="error" variant="error"
/> >
<ErrorDetail error={error} />
</AlertModal>
)} )}
</CardBody> </CardBody>
); );

View File

@@ -13,7 +13,7 @@ import { CaretLeftIcon } from '@patternfly/react-icons';
import { Card, PageSection } from '@patternfly/react-core'; import { Card, PageSection } from '@patternfly/react-core';
import useRequest from '../../util/useRequest'; import useRequest from '../../util/useRequest';
import { InstanceGroupsAPI } from '../../api'; import { InstanceGroupsAPI, SettingsAPI } from '../../api';
import RoutedTabs from '../../components/RoutedTabs'; import RoutedTabs from '../../components/RoutedTabs';
import ContentError from '../../components/ContentError'; import ContentError from '../../components/ContentError';
import ContentLoading from '../../components/ContentLoading'; import ContentLoading from '../../components/ContentLoading';
@@ -31,12 +31,24 @@ function InstanceGroup({ setBreadcrumb }) {
isLoading, isLoading,
error: contentError, error: contentError,
request: fetchInstanceGroups, request: fetchInstanceGroups,
result: instanceGroup, result: { instanceGroup, defaultControlPlane },
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const { data } = await InstanceGroupsAPI.readDetail(id); const [
return data; { data },
}, [id]) {
data: { DEFAULT_CONTROL_PLANE_QUEUE_NAME },
},
] = await Promise.all([
InstanceGroupsAPI.readDetail(id),
SettingsAPI.readAll(),
]);
return {
instanceGroup: data,
defaultControlPlane: DEFAULT_CONTROL_PLANE_QUEUE_NAME,
};
}, [id]),
{ instanceGroup: null, defaultControlPlane: '' }
); );
useEffect(() => { useEffect(() => {
@@ -115,10 +127,16 @@ function InstanceGroup({ setBreadcrumb }) {
{instanceGroup && ( {instanceGroup && (
<> <>
<Route path="/instance_groups/:id/edit"> <Route path="/instance_groups/:id/edit">
<InstanceGroupEdit instanceGroup={instanceGroup} /> <InstanceGroupEdit
instanceGroup={instanceGroup}
defaultControlPlane={defaultControlPlane}
/>
</Route> </Route>
<Route path="/instance_groups/:id/details"> <Route path="/instance_groups/:id/details">
<InstanceGroupDetails instanceGroup={instanceGroup} /> <InstanceGroupDetails
defaultControlPlane={defaultControlPlane}
instanceGroup={instanceGroup}
/>
</Route> </Route>
<Route path="/instance_groups/:id/instances"> <Route path="/instance_groups/:id/instances">
<InstanceList /> <InstanceList />

View File

@@ -7,6 +7,7 @@ import { Button } from '@patternfly/react-core';
import AlertModal from '../../../components/AlertModal'; import AlertModal from '../../../components/AlertModal';
import { CardBody, CardActionsRow } from '../../../components/Card'; import { CardBody, CardActionsRow } from '../../../components/Card';
import ErrorDetail from '../../../components/ErrorDetail';
import DeleteButton from '../../../components/DeleteButton'; import DeleteButton from '../../../components/DeleteButton';
import { import {
Detail, Detail,
@@ -22,7 +23,7 @@ const Unavailable = styled.span`
color: var(--pf-global--danger-color--200); color: var(--pf-global--danger-color--200);
`; `;
function InstanceGroupDetails({ instanceGroup }) { function InstanceGroupDetails({ instanceGroup, defaultControlPlane }) {
const { id, name } = instanceGroup; const { id, name } = instanceGroup;
const history = useHistory(); const history = useHistory();
@@ -110,7 +111,7 @@ function InstanceGroupDetails({ instanceGroup }) {
{t`Edit`} {t`Edit`}
</Button> </Button>
)} )}
{name !== 'tower' && {name !== defaultControlPlane &&
instanceGroup.summary_fields.user_capabilities && instanceGroup.summary_fields.user_capabilities &&
instanceGroup.summary_fields.user_capabilities.delete && ( instanceGroup.summary_fields.user_capabilities.delete && (
<DeleteButton <DeleteButton
@@ -132,7 +133,9 @@ function InstanceGroupDetails({ instanceGroup }) {
onClose={dismissError} onClose={dismissError}
title={t`Error`} title={t`Error`}
variant="error" variant="error"
/> >
<ErrorDetail error={error} />
</AlertModal>
)} )}
</CardBody> </CardBody>
); );

View File

@@ -5,7 +5,7 @@ import { CardBody } from '../../../components/Card';
import { InstanceGroupsAPI } from '../../../api'; import { InstanceGroupsAPI } from '../../../api';
import InstanceGroupForm from '../shared/InstanceGroupForm'; import InstanceGroupForm from '../shared/InstanceGroupForm';
function InstanceGroupEdit({ instanceGroup }) { function InstanceGroupEdit({ instanceGroup, defaultControlPlane }) {
const history = useHistory(); const history = useHistory();
const [submitError, setSubmitError] = useState(null); const [submitError, setSubmitError] = useState(null);
const detailsUrl = `/instance_groups/${instanceGroup.id}/details`; const detailsUrl = `/instance_groups/${instanceGroup.id}/details`;
@@ -27,6 +27,7 @@ function InstanceGroupEdit({ instanceGroup }) {
<CardBody> <CardBody>
<InstanceGroupForm <InstanceGroupForm
instanceGroup={instanceGroup} instanceGroup={instanceGroup}
defaultControlPlane={defaultControlPlane}
onSubmit={handleSubmit} onSubmit={handleSubmit}
submitError={submitError} submitError={submitError}
onCancel={handleCancel} onCancel={handleCancel}

View File

@@ -55,7 +55,10 @@ describe('<InstanceGroupEdit>', () => {
history = createMemoryHistory(); history = createMemoryHistory();
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<InstanceGroupEdit instanceGroup={instanceGroupData} />, <InstanceGroupEdit
defaultControlPlane="controlplane"
instanceGroup={instanceGroupData}
/>,
{ {
context: { router: { history } }, context: { router: { history } },
} }
@@ -68,12 +71,13 @@ describe('<InstanceGroupEdit>', () => {
wrapper.unmount(); wrapper.unmount();
}); });
test('tower instance group name can not be updated', async () => { test('controlplane instance group name can not be updated', async () => {
let towerWrapper; let towerWrapper;
await act(async () => { await act(async () => {
towerWrapper = mountWithContexts( towerWrapper = mountWithContexts(
<InstanceGroupEdit <InstanceGroupEdit
instanceGroup={{ ...instanceGroupData, name: 'tower' }} defaultControlPlane="controlplane"
instanceGroup={{ ...instanceGroupData, name: 'controlplane' }}
/>, />,
{ {
context: { router: { history } }, context: { router: { history } },
@@ -85,7 +89,7 @@ describe('<InstanceGroupEdit>', () => {
).toBeTruthy(); ).toBeTruthy();
expect( expect(
towerWrapper.find('input#instance-group-name').prop('value') towerWrapper.find('input#instance-group-name').prop('value')
).toEqual('tower'); ).toEqual('controlplane');
}); });
test('handleSubmit should call the api and redirect to details page', async () => { test('handleSubmit should call the api and redirect to details page', async () => {

View File

@@ -4,13 +4,14 @@ import { useLocation, useRouteMatch, Link } from 'react-router-dom';
import { t, Plural } from '@lingui/macro'; import { t, Plural } from '@lingui/macro';
import { Card, PageSection, DropdownItem } from '@patternfly/react-core'; import { Card, PageSection, DropdownItem } from '@patternfly/react-core';
import { InstanceGroupsAPI } from '../../../api'; import { InstanceGroupsAPI, SettingsAPI } from '../../../api';
import { getQSConfig, parseQueryString } from '../../../util/qs'; import { getQSConfig, parseQueryString } from '../../../util/qs';
import useRequest, { useDeleteItems } from '../../../util/useRequest'; import useRequest, { useDeleteItems } from '../../../util/useRequest';
import useSelected from '../../../util/useSelected'; import useSelected from '../../../util/useSelected';
import PaginatedTable, { import PaginatedTable, {
HeaderRow, HeaderRow,
HeaderCell, HeaderCell,
ToolbarAddButton,
ToolbarDeleteButton, ToolbarDeleteButton,
} from '../../../components/PaginatedTable'; } from '../../../components/PaginatedTable';
import ErrorDetail from '../../../components/ErrorDetail'; import ErrorDetail from '../../../components/ErrorDetail';
@@ -25,7 +26,11 @@ const QS_CONFIG = getQSConfig('instance-group', {
page_size: 20, page_size: 20,
}); });
function modifyInstanceGroups(items = []) { function modifyInstanceGroups(
items = [],
defaultControlPlane,
defaultExecution
) {
return items.map(item => { return items.map(item => {
const clonedItem = { const clonedItem = {
...item, ...item,
@@ -36,16 +41,44 @@ function modifyInstanceGroups(items = []) {
}, },
}, },
}; };
if (clonedItem.name === 'tower') { if (clonedItem.name === (defaultControlPlane || defaultExecution)) {
clonedItem.summary_fields.user_capabilities.delete = false; clonedItem.summary_fields.user_capabilities.delete = false;
} }
return clonedItem; return clonedItem;
}); });
} }
function InstanceGroupList() { function InstanceGroupList({
isKubernetes,
isSettingsRequestLoading,
settingsRequestError,
}) {
const location = useLocation(); const location = useLocation();
const match = useRouteMatch(); const match = useRouteMatch();
const {
error: protectedItemsError,
isloading: isLoadingProtectedItems,
request: fetchProtectedItems,
result: { defaultControlPlane, defaultExecution },
} = useRequest(
useCallback(async () => {
const {
data: {
DEFAULT_CONTROL_PLANE_QUEUE_NAME,
DEFAULT_EXECUTION_QUEUE_NAME,
},
} = await SettingsAPI.readAll();
return {
defaultControlPlane: DEFAULT_CONTROL_PLANE_QUEUE_NAME,
defaultExecution: DEFAULT_EXECUTION_QUEUE_NAME,
};
}, []),
{ defaultControlPlane: '', defaultExecution: '' }
);
useEffect(() => {
fetchProtectedItems();
}, [fetchProtectedItems]);
const { const {
error: contentError, error: contentError,
@@ -100,7 +133,11 @@ function InstanceGroupList() {
selectAll, selectAll,
} = useSelected(instanceGroups); } = useSelected(instanceGroups);
const modifiedSelected = modifyInstanceGroups(selected); const modifiedSelected = modifyInstanceGroups(
selected,
defaultControlPlane,
defaultExecution
);
const { const {
isLoading: deleteLoading, isLoading: deleteLoading,
@@ -128,63 +165,66 @@ function InstanceGroupList() {
const canAdd = actions && actions.POST; const canAdd = actions && actions.POST;
function cannotDelete(item) { function cannotDelete(item) {
return !item.summary_fields.user_capabilities.delete; return (
!item.summary_fields.user_capabilities.delete ||
item.name === defaultExecution ||
item.name === defaultControlPlane
);
} }
const pluralizedItemName = t`Instance Groups`; const pluralizedItemName = t`Instance Groups`;
let errorMessageDelete = ''; let errorMessageDelete = '';
const notdeletedable = selected.filter(
i => i.name === defaultControlPlane || i.name === defaultExecution
);
if (modifiedSelected.some(item => item.name === 'tower')) { if (notdeletedable.length) {
const itemsUnableToDelete = modifiedSelected errorMessageDelete = (
.filter(cannotDelete) <Plural
.filter(item => item.name !== 'tower') value={notdeletedable.length}
.map(item => item.name) one="The following Instance Group cannot be deleted"
.join(', '); other="The following Instance Groups cannot be deleted"
/>
if (itemsUnableToDelete) {
if (modifiedSelected.some(cannotDelete)) {
errorMessageDelete = t`You do not have permission to delete ${pluralizedItemName}: ${itemsUnableToDelete}. `;
}
}
if (errorMessageDelete.length > 0) {
errorMessageDelete = errorMessageDelete.concat('\n');
}
errorMessageDelete = errorMessageDelete.concat(
t`The tower instance group cannot be deleted.`
); );
} }
const addContainerGroup = t`Add container group`; const addContainerGroup = t`Add container group`;
const addInstanceGroup = t`Add instance group`; const addInstanceGroup = t`Add instance group`;
const addButton = ( const addButton =
<AddDropDownButton !isSettingsRequestLoading && !isKubernetes ? (
ouiaId="add-instance-group-button" <AddDropDownButton
key="add" ouiaId="add-instance-group-button"
dropdownItems={[ key="add"
<DropdownItem dropdownItems={[
ouiaId="add-container-group-item" <DropdownItem
to="/instance_groups/container_group/add" ouiaId="add-container-group-item"
component={Link} to="/instance_groups/container_group/add"
key={addContainerGroup} component={Link}
aria-label={addContainerGroup} key={addContainerGroup}
> aria-label={addContainerGroup}
{addContainerGroup} >
</DropdownItem>, {addContainerGroup}
<DropdownItem </DropdownItem>,
ouiaId="add-instance-group-item" <DropdownItem
to="/instance_groups/add" ouiaId="add-instance-group-item"
component={Link} to="/instance_groups/add"
key={addInstanceGroup} component={Link}
aria-label={addInstanceGroup} key={addInstanceGroup}
> aria-label={addInstanceGroup}
{addInstanceGroup} >
</DropdownItem>, {addInstanceGroup}
]} </DropdownItem>,
/> ]}
); />
) : (
<ToolbarAddButton
key="add"
ouiaId="add-container-group-button"
linkTo={`${match.url}/container_group/add`}
/>
);
const getDetailUrl = item => { const getDetailUrl = item => {
return item.is_container_group return item.is_container_group
@@ -199,8 +239,15 @@ function InstanceGroupList() {
<PageSection> <PageSection>
<Card> <Card>
<PaginatedTable <PaginatedTable
contentError={contentError} contentError={
hasContentLoading={isLoading || deleteLoading} contentError || settingsRequestError || protectedItemsError
}
hasContentLoading={
isLoading ||
deleteLoading ||
isSettingsRequestLoading ||
isLoadingProtectedItems
}
items={instanceGroups} items={instanceGroups}
itemCount={instanceGroupsCount} itemCount={instanceGroupsCount}
pluralizedItemName={pluralizedItemName} pluralizedItemName={pluralizedItemName}
@@ -220,6 +267,7 @@ function InstanceGroupList() {
<ToolbarDeleteButton <ToolbarDeleteButton
key="delete" key="delete"
onDelete={handleDelete} onDelete={handleDelete}
cannotDelete={cannotDelete}
itemsToDelete={modifiedSelected} itemsToDelete={modifiedSelected}
pluralizedItemName={t`Instance Groups`} pluralizedItemName={t`Instance Groups`}
errorMessage={errorMessageDelete} errorMessage={errorMessageDelete}

View File

@@ -11,6 +11,7 @@ import {
OrganizationsAPI, OrganizationsAPI,
InventoriesAPI, InventoriesAPI,
UnifiedJobTemplatesAPI, UnifiedJobTemplatesAPI,
SettingsAPI,
} from '../../../api'; } from '../../../api';
import InstanceGroupList from './InstanceGroupList'; import InstanceGroupList from './InstanceGroupList';
@@ -18,6 +19,7 @@ jest.mock('../../../api/models/InstanceGroups');
jest.mock('../../../api/models/Organizations'); jest.mock('../../../api/models/Organizations');
jest.mock('../../../api/models/Inventories'); jest.mock('../../../api/models/Inventories');
jest.mock('../../../api/models/UnifiedJobTemplates'); jest.mock('../../../api/models/UnifiedJobTemplates');
jest.mock('../../../api/models/Settings');
const instanceGroups = { const instanceGroups = {
data: { data: {
@@ -32,7 +34,7 @@ const instanceGroups = {
}, },
{ {
id: 2, id: 2,
name: 'tower', name: 'controlplan',
type: 'instance_group', type: 'instance_group',
url: '/api/v2/instance_groups/2', url: '/api/v2/instance_groups/2',
consumed_capacity: 42, consumed_capacity: 42,
@@ -40,6 +42,14 @@ const instanceGroups = {
}, },
{ {
id: 3, id: 3,
name: 'default',
type: 'instance_group',
url: '/api/v2/instance_groups/2',
consumed_capacity: 42,
summary_fields: { user_capabilities: { edit: true, delete: true } },
},
{
id: 4,
name: 'Bar', name: 'Bar',
type: 'instance_group', type: 'instance_group',
url: '/api/v2/instance_groups/3', url: '/api/v2/instance_groups/3',
@@ -47,11 +57,17 @@ const instanceGroups = {
summary_fields: { user_capabilities: { edit: true, delete: false } }, summary_fields: { user_capabilities: { edit: true, delete: false } },
}, },
], ],
count: 3, count: 4,
}, },
}; };
const options = { data: { actions: { POST: true } } }; const options = { data: { actions: { POST: true } } };
const settings = {
data: {
DEFAULT_CONTROL_PLANE_QUEUE_NAME: 'controlplan',
DEFAULT_EXECUTION_QUEUE_NAME: 'default',
},
};
describe('<InstanceGroupList />', () => { describe('<InstanceGroupList />', () => {
let wrapper; let wrapper;
@@ -62,6 +78,7 @@ describe('<InstanceGroupList />', () => {
UnifiedJobTemplatesAPI.read.mockResolvedValue({ data: { count: 0 } }); UnifiedJobTemplatesAPI.read.mockResolvedValue({ data: { count: 0 } });
InstanceGroupsAPI.read.mockResolvedValue(instanceGroups); InstanceGroupsAPI.read.mockResolvedValue(instanceGroups);
InstanceGroupsAPI.readOptions.mockResolvedValue(options); InstanceGroupsAPI.readOptions.mockResolvedValue(options);
SettingsAPI.readAll.mockResolvedValue(settings);
}); });
test('should have data fetched and render 3 rows', async () => { test('should have data fetched and render 3 rows', async () => {
@@ -69,7 +86,7 @@ describe('<InstanceGroupList />', () => {
wrapper = mountWithContexts(<InstanceGroupList />); wrapper = mountWithContexts(<InstanceGroupList />);
}); });
await waitForElement(wrapper, 'InstanceGroupList', el => el.length > 0); await waitForElement(wrapper, 'InstanceGroupList', el => el.length > 0);
expect(wrapper.find('InstanceGroupListItem').length).toBe(3); expect(wrapper.find('InstanceGroupListItem').length).toBe(4);
expect(InstanceGroupsAPI.read).toBeCalled(); expect(InstanceGroupsAPI.read).toBeCalled();
expect(InstanceGroupsAPI.readOptions).toBeCalled(); expect(InstanceGroupsAPI.readOptions).toBeCalled();
}); });
@@ -109,13 +126,13 @@ describe('<InstanceGroupList />', () => {
); );
}); });
test('should not be able to delete tower instance group', async () => { test('should not be able to delete controlplan or default instance group', async () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<InstanceGroupList />); wrapper = mountWithContexts(<InstanceGroupList />);
}); });
await waitForElement(wrapper, 'InstanceGroupList', el => el.length > 0); await waitForElement(wrapper, 'InstanceGroupList', el => el.length > 0);
const instanceGroupIndex = [0, 1, 2]; const instanceGroupIndex = [0, 1, 2, 3];
instanceGroupIndex.forEach(element => { instanceGroupIndex.forEach(element => {
wrapper wrapper

View File

@@ -1,17 +1,37 @@
import React, { useState, useCallback } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Route, Switch } from 'react-router-dom'; import { Route, Switch } from 'react-router-dom';
import useRequest from '../../util/useRequest';
import { SettingsAPI } from '../../api';
import InstanceGroupAdd from './InstanceGroupAdd'; import InstanceGroupAdd from './InstanceGroupAdd';
import InstanceGroupList from './InstanceGroupList'; import InstanceGroupList from './InstanceGroupList';
import InstanceGroup from './InstanceGroup'; import InstanceGroup from './InstanceGroup';
import ContainerGroupAdd from './ContainerGroupAdd'; import ContainerGroupAdd from './ContainerGroupAdd';
import ContainerGroup from './ContainerGroup'; import ContainerGroup from './ContainerGroup';
import ScreenHeader from '../../components/ScreenHeader'; import ScreenHeader from '../../components/ScreenHeader';
function InstanceGroups() { function InstanceGroups() {
const {
request: settingsRequest,
isLoading: isSettingsRequestLoading,
error: settingsRequestError,
result: isKubernetes,
} = useRequest(
useCallback(async () => {
const {
data: { IS_K8S },
} = await SettingsAPI.readCategory('all');
return IS_K8S;
}, []),
{ isLoading: true }
);
useEffect(() => {
settingsRequest();
}, [settingsRequest]);
const [breadcrumbConfig, setBreadcrumbConfig] = useState({ const [breadcrumbConfig, setBreadcrumbConfig] = useState({
'/instance_groups': t`Instance Groups`, '/instance_groups': t`Instance Groups`,
'/instance_groups/add': t`Create new instance group`, '/instance_groups/add': t`Create new instance group`,
@@ -39,6 +59,7 @@ function InstanceGroups() {
[`/instance_groups/container_group/${instanceGroups.id}`]: `${instanceGroups.name}`, [`/instance_groups/container_group/${instanceGroups.id}`]: `${instanceGroups.name}`,
}); });
}, []); }, []);
return ( return (
<> <>
<ScreenHeader <ScreenHeader
@@ -52,14 +73,20 @@ function InstanceGroups() {
<Route path="/instance_groups/container_group/:id"> <Route path="/instance_groups/container_group/:id">
<ContainerGroup setBreadcrumb={buildBreadcrumbConfig} /> <ContainerGroup setBreadcrumb={buildBreadcrumbConfig} />
</Route> </Route>
<Route path="/instance_groups/add"> {!isSettingsRequestLoading && !isKubernetes ? (
<InstanceGroupAdd /> <Route path="/instance_groups/add">
</Route> <InstanceGroupAdd />
</Route>
) : null}
<Route path="/instance_groups/:id"> <Route path="/instance_groups/:id">
<InstanceGroup setBreadcrumb={buildBreadcrumbConfig} /> <InstanceGroup setBreadcrumb={buildBreadcrumbConfig} />
</Route> </Route>
<Route path="/instance_groups"> <Route path="/instance_groups">
<InstanceGroupList /> <InstanceGroupList
isKubernetes={isKubernetes}
isSettingsRequestLoading={isSettingsRequestLoading}
settingsRequestError={settingsRequestError}
/>
</Route> </Route>
</Switch> </Switch>
</> </>

View File

@@ -27,6 +27,8 @@ function ContainerGroupFormFields({ instanceGroup }) {
'credential' 'credential'
); );
const [nameField] = useField('name');
const [overrideField] = useField('override'); const [overrideField] = useField('override');
const handleCredentialUpdate = useCallback( const handleCredentialUpdate = useCallback(
@@ -45,6 +47,7 @@ function ContainerGroupFormFields({ instanceGroup }) {
label={t`Name`} label={t`Name`}
type="text" type="text"
validate={required(null)} validate={required(null)}
isDisabled={nameField.value === 'default'}
isRequired isRequired
/> />
<CredentialLookup <CredentialLookup

View File

@@ -10,8 +10,9 @@ import FormActionGroup from '../../../components/FormActionGroup';
import { required, minMaxValue } from '../../../util/validators'; import { required, minMaxValue } from '../../../util/validators';
import { FormColumnLayout } from '../../../components/FormLayout'; import { FormColumnLayout } from '../../../components/FormLayout';
function InstanceGroupFormFields() { function InstanceGroupFormFields({ defaultControlPlane }) {
const [instanceGroupNameField, ,] = useField('name'); const [instanceGroupNameField, ,] = useField('name');
return ( return (
<> <>
<FormField <FormField
@@ -21,7 +22,7 @@ function InstanceGroupFormFields() {
type="text" type="text"
validate={required(null)} validate={required(null)}
isRequired isRequired
isDisabled={instanceGroupNameField.value === 'tower'} isDisabled={instanceGroupNameField.value === defaultControlPlane}
/> />
<FormField <FormField
id="instance-group-policy-instance-minimum" id="instance-group-policy-instance-minimum"
@@ -50,6 +51,7 @@ function InstanceGroupFormFields() {
function InstanceGroupForm({ function InstanceGroupForm({
instanceGroup = {}, instanceGroup = {},
onSubmit, onSubmit,
onCancel, onCancel,
submitError, submitError,

View File

@@ -23,12 +23,12 @@ function InventoryAdd() {
organization: organization.id, organization: organization.id,
...remainingValues, ...remainingValues,
}); });
if (instanceGroups) { /* eslint-disable no-await-in-loop, no-restricted-syntax */
const associatePromises = instanceGroups.map(async ig => // Resolve Promises sequentially to maintain order and avoid race condition
InventoriesAPI.associateInstanceGroup(inventoryId, ig.id) for (const group of instanceGroups) {
); await InventoriesAPI.associateInstanceGroup(inventoryId, group.id);
await Promise.all(associatePromises);
} }
/* eslint-enable no-await-in-loop, no-restricted-syntax */
const url = history.location.pathname.startsWith( const url = history.location.pathname.startsWith(
'/inventories/smart_inventory' '/inventories/smart_inventory'
) )

View File

@@ -6,7 +6,6 @@ import { CardBody } from '../../../components/Card';
import { InventoriesAPI } from '../../../api'; import { InventoriesAPI } from '../../../api';
import ContentLoading from '../../../components/ContentLoading'; import ContentLoading from '../../../components/ContentLoading';
import InventoryForm from '../shared/InventoryForm'; import InventoryForm from '../shared/InventoryForm';
import { getAddedAndRemoved } from '../../../util/lists';
import useIsMounted from '../../../util/useIsMounted'; import useIsMounted from '../../../util/useIsMounted';
function InventoryEdit({ inventory }) { function InventoryEdit({ inventory }) {
@@ -54,20 +53,12 @@ function InventoryEdit({ inventory }) {
organization: organization.id, organization: organization.id,
...remainingValues, ...remainingValues,
}); });
if (instanceGroups) { await InventoriesAPI.orderInstanceGroups(
const { added, removed } = getAddedAndRemoved( inventory.id,
associatedInstanceGroups, instanceGroups,
instanceGroups associatedInstanceGroups
); );
const associatePromises = added.map(async ig =>
InventoriesAPI.associateInstanceGroup(inventory.id, ig.id)
);
const disassociatePromises = removed.map(async ig =>
InventoriesAPI.disassociateInstanceGroup(inventory.id, ig.id)
);
await Promise.all([...associatePromises, ...disassociatePromises]);
}
const url = const url =
history.location.pathname.search('smart') > -1 history.location.pathname.search('smart') > -1
? `/inventories/smart_inventory/${inventory.id}/details` ? `/inventories/smart_inventory/${inventory.id}/details`

View File

@@ -106,17 +106,10 @@ describe('<InventoryEdit />', () => {
}); });
}); });
await sleep(0); await sleep(0);
instanceGroups.map(IG => expect(InventoriesAPI.orderInstanceGroups).toHaveBeenCalledWith(
expect(InventoriesAPI.associateInstanceGroup).toHaveBeenCalledWith( mockInventory.id,
1, instanceGroups,
IG.id associatedInstanceGroups
)
);
associatedInstanceGroups.map(async aIG =>
expect(InventoriesAPI.disassociateInstanceGroup).toHaveBeenCalledWith(
1,
aIG.id
)
); );
}); });
}); });

View File

@@ -168,6 +168,18 @@ function InventoryList() {
key: 'name__icontains', key: 'name__icontains',
isDefault: true, isDefault: true,
}, },
{
name: t`Inventory Type`,
key: 'or__kind',
options: [
['', t`Inventory`],
['smart', t`Smart Inventory`],
],
},
{
name: t`Organization`,
key: 'organization__name',
},
{ {
name: t`Description`, name: t`Description`,
key: 'description__icontains', key: 'description__icontains',

View File

@@ -19,11 +19,12 @@ function SmartInventoryAdd() {
data: { id: invId }, data: { id: invId },
} = await InventoriesAPI.create(values); } = await InventoriesAPI.create(values);
await Promise.all( /* eslint-disable no-await-in-loop, no-restricted-syntax */
groupsToAssociate.map(({ id }) => // Resolve Promises sequentially to maintain order and avoid race condition
InventoriesAPI.associateInstanceGroup(invId, id) for (const group of groupsToAssociate) {
) await InventoriesAPI.associateInstanceGroup(invId, group.id);
); }
/* eslint-enable no-await-in-loop, no-restricted-syntax */
return invId; return invId;
}, []) }, [])
); );

View File

@@ -1,7 +1,6 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { Inventory } from '../../../types'; import { Inventory } from '../../../types';
import { getAddedAndRemoved } from '../../../util/lists';
import useRequest from '../../../util/useRequest'; import useRequest from '../../../util/useRequest';
import { InventoriesAPI } from '../../../api'; import { InventoriesAPI } from '../../../api';
import { CardBody } from '../../../components/Card'; import { CardBody } from '../../../components/Card';
@@ -17,7 +16,7 @@ function SmartInventoryEdit({ inventory }) {
error: contentError, error: contentError,
isLoading: hasContentLoading, isLoading: hasContentLoading,
request: fetchInstanceGroups, request: fetchInstanceGroups,
result: instanceGroups, result: initialInstanceGroups,
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const { const {
@@ -40,15 +39,10 @@ function SmartInventoryEdit({ inventory }) {
useCallback( useCallback(
async (values, groupsToAssociate, groupsToDisassociate) => { async (values, groupsToAssociate, groupsToDisassociate) => {
const { data } = await InventoriesAPI.update(inventory.id, values); const { data } = await InventoriesAPI.update(inventory.id, values);
await Promise.all( await InventoriesAPI.orderInstanceGroups(
groupsToAssociate.map(id => inventory.id,
InventoriesAPI.associateInstanceGroup(inventory.id, id) groupsToAssociate,
) groupsToDisassociate
);
await Promise.all(
groupsToDisassociate.map(id =>
InventoriesAPI.disassociateInstanceGroup(inventory.id, id)
)
); );
return data; return data;
}, },
@@ -68,20 +62,13 @@ function SmartInventoryEdit({ inventory }) {
const handleSubmit = async form => { const handleSubmit = async form => {
const { instance_groups, organization, ...remainingForm } = form; const { instance_groups, organization, ...remainingForm } = form;
const { added, removed } = getAddedAndRemoved(
instanceGroups,
instance_groups
);
const addedIds = added.map(({ id }) => id);
const removedIds = removed.map(({ id }) => id);
await submitRequest( await submitRequest(
{ {
organization: organization?.id, organization: organization?.id,
...remainingForm, ...remainingForm,
}, },
addedIds, instance_groups,
removedIds initialInstanceGroups
); );
}; };
@@ -104,7 +91,7 @@ function SmartInventoryEdit({ inventory }) {
<CardBody> <CardBody>
<SmartInventoryForm <SmartInventoryForm
inventory={inventory} inventory={inventory}
instanceGroups={instanceGroups} instanceGroups={initialInstanceGroups}
onCancel={handleCancel} onCancel={handleCancel}
onSubmit={handleSubmit} onSubmit={handleSubmit}
submitError={submitError} submitError={submitError}

View File

@@ -104,8 +104,7 @@ describe('<SmartInventoryEdit />', () => {
}); });
}); });
expect(InventoriesAPI.update).toHaveBeenCalledTimes(1); expect(InventoriesAPI.update).toHaveBeenCalledTimes(1);
expect(InventoriesAPI.associateInstanceGroup).toHaveBeenCalledTimes(1); expect(InventoriesAPI.orderInstanceGroups).toHaveBeenCalledTimes(1);
expect(InventoriesAPI.disassociateInstanceGroup).toHaveBeenCalledTimes(1);
}); });
test('successful form submission should trigger redirect to details', async () => { test('successful form submission should trigger redirect to details', async () => {

View File

@@ -67,6 +67,7 @@ function JobDetail({ job }) {
workflow_job_template: workflowJobTemplate, workflow_job_template: workflowJobTemplate,
labels, labels,
project, project,
project_update: projectUpdate,
source_workflow_job, source_workflow_job,
execution_environment: executionEnvironment, execution_environment: executionEnvironment,
} = job.summary_fields; } = job.summary_fields;
@@ -104,6 +105,25 @@ function JobDetail({ job }) {
); );
}; };
const buildProjectDetailValue = () => {
if (projectUpdate) {
return (
<StatusDetailValue>
<Link to={`/jobs/project/${projectUpdate.id}`}>
<StatusIcon status={project.status} />
</Link>
<Link to={`/projects/${project.id}`}>{project.name}</Link>
</StatusDetailValue>
);
}
return (
<StatusDetailValue>
<StatusIcon status={project.status} />
<Link to={`/projects/${project.id}`}>{project.name}</Link>
</StatusDetailValue>
);
};
return ( return (
<CardBody> <CardBody>
<DetailList> <DetailList>
@@ -199,15 +219,7 @@ function JobDetail({ job }) {
/> />
)} )}
{project && ( {project && (
<Detail <Detail label={t`Project`} value={buildProjectDetailValue()} />
label={t`Project`}
value={
<StatusDetailValue>
{project.status && <StatusIcon status={project.status} />}
<Link to={`/projects/${project.id}`}>{project.name}</Link>
</StatusDetailValue>
}
/>
)} )}
{scmBranch && ( {scmBranch && (
<Detail <Detail

View File

@@ -434,28 +434,42 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
); );
} }
const eventPromise = getJobModel(job.type).readEvents(job.id, {
...params,
...parseQueryString(QS_CONFIG, location.search),
});
let countRequest;
if (isJobRunning(job?.status)) {
// If the job is running, it means we're using limit-offset pagination. Requests
// with limit-offset pagination won't return a total event count for performance
// reasons. In this situation, we derive the remote row count by using the highest
// counter available in the database.
countRequest = async () => {
const {
data: { results: lastEvents = [] },
} = await getJobModel(job.type).readEvents(job.id, {
order_by: '-counter',
limit: 1,
});
return lastEvents.length >= 1 ? lastEvents[0].counter : 0;
};
} else {
countRequest = async () => {
const {
data: { count: eventCount },
} = await eventPromise;
return eventCount;
};
}
try { try {
const [ const [
{ {
data: { results: fetchedEvents = [] }, data: { results: fetchedEvents = [] },
}, },
{ count,
data: { results: lastEvents = [] }, ] = await Promise.all([eventPromise, countRequest()]);
},
] = await Promise.all([
getJobModel(job.type).readEvents(job.id, {
...params,
...parseQueryString(QS_CONFIG, location.search),
}),
getJobModel(job.type).readEvents(job.id, {
order_by: '-counter',
limit: 1,
}),
]);
let count = 0;
if (lastEvents.length >= 1 && lastEvents[0]?.counter) {
count = lastEvents[0]?.counter;
}
if (isMounted.current) { if (isMounted.current) {
let countOffset = 0; let countOffset = 0;

View File

@@ -13,6 +13,22 @@ import mockFilteredJobEventsData from './data.filtered_job_events.json';
jest.mock('../../../api'); jest.mock('../../../api');
const applyJobEventMock = mockJobEvents => {
const mockReadEvents = async (jobId, params) => {
const [...results] = mockJobEvents.results;
if (params.order_by && params.order_by.includes('-')) {
results.reverse();
}
return {
data: {
results,
count: mockJobEvents.count,
},
};
};
JobsAPI.readEvents = jest.fn().mockImplementation(mockReadEvents);
};
const generateChattyRows = () => { const generateChattyRows = () => {
const rows = [ const rows = [
'', '',
@@ -82,24 +98,13 @@ const originalOffsetWidth = Object.getOwnPropertyDescriptor(
describe('<JobOutput />', () => { describe('<JobOutput />', () => {
let wrapper; let wrapper;
const mockJob = mockJobData; const mockJob = mockJobData;
const mockJobEvents = mockJobEventsData;
beforeEach(() => { beforeEach(() => {
JobsAPI.readEvents = (jobId, params) => { applyJobEventMock(mockJobEventsData);
const [...results] = mockJobEvents.results;
if (params.order_by && params.order_by.includes('-')) {
results.reverse();
}
return {
data: {
results,
},
};
};
}); });
afterEach(() => { afterEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
wrapper.unmount();
}); });
test('initially renders successfully', async () => { test('initially renders successfully', async () => {
@@ -141,7 +146,7 @@ describe('<JobOutput />', () => {
}); });
wrapper.update(); wrapper.update();
jobEvents = wrapper.find('JobEvent'); jobEvents = wrapper.find('JobEvent');
expect(jobEvents.at(jobEvents.length - 1).prop('stdout')).toBe( expect(jobEvents.at(jobEvents.length - 2).prop('stdout')).toBe(
'\r\nPLAY RECAP *********************************************************************\r\n\u001b[0;32mlocalhost\u001b[0m : \u001b[0;32mok=1 \u001b[0m changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 \r\n' '\r\nPLAY RECAP *********************************************************************\r\n\u001b[0;32mlocalhost\u001b[0m : \u001b[0;32mok=1 \u001b[0m changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 \r\n'
); );
await act(async () => { await act(async () => {
@@ -149,10 +154,10 @@ describe('<JobOutput />', () => {
}); });
wrapper.update(); wrapper.update();
jobEvents = wrapper.find('JobEvent'); jobEvents = wrapper.find('JobEvent');
expect(jobEvents.at(1).prop('stdout')).toBe( expect(jobEvents.at(0).prop('stdout')).toBe(
'\u001b[0;32mok: [localhost] => (item=76) => {\u001b[0m\r\n\u001b[0;32m "msg": "This is a debug message: 76"\u001b[0m\r\n\u001b[0;32m}\u001b[0m' '\u001b[0;32mok: [localhost] => (item=76) => {\u001b[0m\r\n\u001b[0;32m "msg": "This is a debug message: 76"\u001b[0m\r\n\u001b[0;32m}\u001b[0m'
); );
expect(jobEvents.at(2).prop('stdout')).toBe( expect(jobEvents.at(1).prop('stdout')).toBe(
'\u001b[0;32mok: [localhost] => (item=77) => {\u001b[0m\r\n\u001b[0;32m "msg": "This is a debug message: 77"\u001b[0m\r\n\u001b[0;32m}\u001b[0m' '\u001b[0;32mok: [localhost] => (item=77) => {\u001b[0m\r\n\u001b[0;32m "msg": "This is a debug message: 77"\u001b[0m\r\n\u001b[0;32m}\u001b[0m'
); );
await act(async () => { await act(async () => {
@@ -169,7 +174,7 @@ describe('<JobOutput />', () => {
}); });
wrapper.update(); wrapper.update();
jobEvents = wrapper.find('JobEvent'); jobEvents = wrapper.find('JobEvent');
expect(jobEvents.at(jobEvents.length - 1).prop('stdout')).toBe( expect(jobEvents.at(jobEvents.length - 2).prop('stdout')).toBe(
'\r\nPLAY RECAP *********************************************************************\r\n\u001b[0;32mlocalhost\u001b[0m : \u001b[0;32mok=1 \u001b[0m changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 \r\n' '\r\nPLAY RECAP *********************************************************************\r\n\u001b[0;32mlocalhost\u001b[0m : \u001b[0;32mok=1 \u001b[0m changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 \r\n'
); );
Object.defineProperty( Object.defineProperty(
@@ -266,11 +271,7 @@ describe('<JobOutput />', () => {
wrapper = mountWithContexts(<JobOutput job={mockJob} />); wrapper = mountWithContexts(<JobOutput job={mockJob} />);
}); });
await waitForElement(wrapper, 'JobEvent', el => el.length > 0); await waitForElement(wrapper, 'JobEvent', el => el.length > 0);
JobsAPI.readEvents = jest.fn(); applyJobEventMock(mockFilteredJobEventsData);
JobsAPI.readEvents.mockClear();
JobsAPI.readEvents.mockResolvedValueOnce({
data: mockFilteredJobEventsData,
});
await act(async () => { await act(async () => {
wrapper.find(searchTextInput).instance().value = '99'; wrapper.find(searchTextInput).instance().value = '99';
wrapper.find(searchTextInput).simulate('change'); wrapper.find(searchTextInput).simulate('change');
@@ -281,14 +282,13 @@ describe('<JobOutput />', () => {
}); });
wrapper.update(); wrapper.update();
expect(JobsAPI.readEvents).toHaveBeenCalled(); expect(JobsAPI.readEvents).toHaveBeenCalled();
// TODO: Fix these assertions const jobEvents = wrapper.find('JobEvent');
// const jobEvents = wrapper.find('JobEvent'); expect(jobEvents.at(0).prop('stdout')).toBe(
// expect(jobEvents.at(0).prop('stdout')).toBe( '\u001b[0;32mok: [localhost] => (item=99) => {\u001b[0m\r\n\u001b[0;32m "msg": "This is a debug message: 99"\u001b[0m\r\n\u001b[0;32m}\u001b[0m'
// '\u001b[0;32mok: [localhost] => (item=99) => {\u001b[0m\r\n\u001b[0;32m "msg": "This is a debug message: 99"\u001b[0m\r\n\u001b[0;32m}\u001b[0m' );
// ); expect(jobEvents.at(1).prop('stdout')).toBe(
// expect(jobEvents.at(1).prop('stdout')).toBe( '\u001b[0;32mok: [localhost] => (item=199) => {\u001b[0m\r\n\u001b[0;32m "msg": "This is a debug message: 199"\u001b[0m\r\n\u001b[0;32m}\u001b[0m'
// '\u001b[0;32mok: [localhost] => (item=199) => {\u001b[0m\r\n\u001b[0;32m "msg": "This is a debug message: 199"\u001b[0m\r\n\u001b[0;32m}\u001b[0m' );
// );
}); });
test('should throw error', async () => { test('should throw error', async () => {

View File

@@ -4,7 +4,6 @@ import React from 'react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Button as PFButton } from '@patternfly/react-core'; import { Button as PFButton } from '@patternfly/react-core';
import { import {
PlusIcon,
AngleDoubleUpIcon, AngleDoubleUpIcon,
AngleDoubleDownIcon, AngleDoubleDownIcon,
AngleUpIcon, AngleUpIcon,
@@ -17,6 +16,7 @@ const Wrapper = styled.div`
height: 35px; height: 35px;
outline: 1px solid #d7d7d7; outline: 1px solid #d7d7d7;
width: 100%; width: 100%;
justify-content: flex-end;
`; `;
const Button = styled(PFButton)` const Button = styled(PFButton)`
@@ -31,14 +31,6 @@ const PageControls = ({
onScrollPrevious, onScrollPrevious,
}) => ( }) => (
<Wrapper> <Wrapper>
<Button
ouiaId="job-output-expand-collapse-lines-button"
aria-label={t`Toggle expand/collapse event lines`}
variant="plain"
css="margin-right: auto"
>
<PlusIcon />
</Button>
<Button <Button
ouiaId="job-output-scroll-previous-button" ouiaId="job-output-scroll-previous-button"
aria-label={t`Scroll previous`} aria-label={t`Scroll previous`}

View File

@@ -3,14 +3,12 @@ import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import PageControls from './PageControls'; import PageControls from './PageControls';
let wrapper; let wrapper;
let PlusIcon;
let AngleDoubleUpIcon; let AngleDoubleUpIcon;
let AngleDoubleDownIcon; let AngleDoubleDownIcon;
let AngleUpIcon; let AngleUpIcon;
let AngleDownIcon; let AngleDownIcon;
const findChildren = () => { const findChildren = () => {
PlusIcon = wrapper.find('PlusIcon');
AngleDoubleUpIcon = wrapper.find('AngleDoubleUpIcon'); AngleDoubleUpIcon = wrapper.find('AngleDoubleUpIcon');
AngleDoubleDownIcon = wrapper.find('AngleDoubleDownIcon'); AngleDoubleDownIcon = wrapper.find('AngleDoubleDownIcon');
AngleUpIcon = wrapper.find('AngleUpIcon'); AngleUpIcon = wrapper.find('AngleUpIcon');
@@ -26,7 +24,6 @@ describe('PageControls', () => {
test('should render menu control icons', () => { test('should render menu control icons', () => {
wrapper = mountWithContexts(<PageControls />); wrapper = mountWithContexts(<PageControls />);
findChildren(); findChildren();
expect(PlusIcon.length).toBe(1);
expect(AngleDoubleUpIcon.length).toBe(1); expect(AngleDoubleUpIcon.length).toBe(1);
expect(AngleDoubleDownIcon.length).toBe(1); expect(AngleDoubleDownIcon.length).toBe(1);
expect(AngleUpIcon.length).toBe(1); expect(AngleUpIcon.length).toBe(1);

View File

@@ -11,10 +11,7 @@ import { secondsToHHMMSS } from '../../../util/dates';
import { constants as wfConstants } from '../../../components/Workflow/WorkflowUtils'; import { constants as wfConstants } from '../../../components/Workflow/WorkflowUtils';
const NodeG = styled.g` const NodeG = styled.g`
cursor: ${props => cursor: ${props => (props.job ? 'pointer' : 'default')};
props.job && props.job.type !== 'workflow_approval'
? 'pointer'
: 'default'};
`; `;
const JobTopLine = styled.div` const JobTopLine = styled.div`
@@ -90,8 +87,10 @@ function WorkflowOutputNode({ mouseEnter, mouseLeave, node }) {
} }
const handleNodeClick = () => { const handleNodeClick = () => {
if (job && job.type !== 'workflow_aproval') { if (job) {
history.push(`/jobs/${job.id}/details`); const basePath =
job.type !== 'workflow_approval' ? 'jobs' : 'workflow_approvals';
history.push(`/${basePath}/${job.id}/details`);
} }
}; };

View File

@@ -41,15 +41,19 @@ function OrganizationAdd() {
...values, ...values,
default_environment: values.default_environment?.id, default_environment: values.default_environment?.id,
}); });
await Promise.all( /* eslint-disable no-await-in-loop, no-restricted-syntax */
groupsToAssociate // Resolve Promises sequentially to maintain order and avoid race condition
.map(id => OrganizationsAPI.associateInstanceGroup(response.id, id)) for (const group of groupsToAssociate) {
.concat( await OrganizationsAPI.associateInstanceGroup(response.id, group.id);
values.galaxy_credentials.map(({ id: credId }) => }
OrganizationsAPI.associateGalaxyCredential(response.id, credId) for (const credential of values.galaxy_credentials) {
) await OrganizationsAPI.associateGalaxyCredential(
) response.id,
); credential.id
);
}
/* eslint-enable no-await-in-loop, no-restricted-syntax */
history.push(`/organizations/${response.id}`); history.push(`/organizations/${response.id}`);
} catch (error) { } catch (error) {
setFormError(error); setFormError(error);

View File

@@ -95,6 +95,12 @@ describe('<OrganizationAdd />', () => {
description: 'new description', description: 'new description',
galaxy_credentials: [], galaxy_credentials: [],
}; };
const mockInstanceGroups = [
{
name: 'mock ig',
id: 3,
},
];
OrganizationsAPI.create.mockResolvedValueOnce({ OrganizationsAPI.create.mockResolvedValueOnce({
data: { data: {
id: 5, id: 5,
@@ -109,7 +115,10 @@ describe('<OrganizationAdd />', () => {
wrapper = mountWithContexts(<OrganizationAdd />); wrapper = mountWithContexts(<OrganizationAdd />);
}); });
await waitForElement(wrapper, 'button[aria-label="Save"]'); await waitForElement(wrapper, 'button[aria-label="Save"]');
await wrapper.find('OrganizationForm').prop('onSubmit')(orgData, [3]); await wrapper.find('OrganizationForm').prop('onSubmit')(
orgData,
mockInstanceGroups
);
expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(5, 3); expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(5, 3);
}); });

View File

@@ -3,9 +3,14 @@ import PropTypes from 'prop-types';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { CardBody } from '../../../components/Card'; import { CardBody } from '../../../components/Card';
import { OrganizationsAPI } from '../../../api'; import { OrganizationsAPI } from '../../../api';
import { getAddedAndRemoved } from '../../../util/lists';
import OrganizationForm from '../shared/OrganizationForm'; import OrganizationForm from '../shared/OrganizationForm';
const isEqual = (array1, array2) =>
array1.length === array2.length &&
array1.every((element, index) => {
return element.id === array2[index].id;
});
function OrganizationEdit({ organization }) { function OrganizationEdit({ organization }) {
const detailsUrl = `/organizations/${organization.id}/details`; const detailsUrl = `/organizations/${organization.id}/details`;
const history = useHistory(); const history = useHistory();
@@ -17,43 +22,35 @@ function OrganizationEdit({ organization }) {
groupsToDisassociate groupsToDisassociate
) => { ) => {
try { try {
const {
added: addedCredentials,
removed: removedCredentials,
} = getAddedAndRemoved(
organization.galaxy_credentials,
values.galaxy_credentials
);
const addedCredentialIds = addedCredentials.map(({ id }) => id);
const removedCredentialIds = removedCredentials.map(({ id }) => id);
await OrganizationsAPI.update(organization.id, { await OrganizationsAPI.update(organization.id, {
...values, ...values,
default_environment: values.default_environment?.id || null, default_environment: values.default_environment?.id || null,
}); });
await Promise.all( await OrganizationsAPI.orderInstanceGroups(
groupsToAssociate organization.id,
.map(id => groupsToAssociate,
OrganizationsAPI.associateInstanceGroup(organization.id, id)
)
.concat(
addedCredentialIds.map(id =>
OrganizationsAPI.associateGalaxyCredential(organization.id, id)
)
)
);
await Promise.all(
groupsToDisassociate groupsToDisassociate
.map(id =>
OrganizationsAPI.disassociateInstanceGroup(organization.id, id)
)
.concat(
removedCredentialIds.map(id =>
OrganizationsAPI.disassociateGalaxyCredential(organization.id, id)
)
)
); );
/* eslint-disable no-await-in-loop, no-restricted-syntax */
// Resolve Promises sequentially to avoid race condition
if (
!isEqual(organization.galaxy_credentials, values.galaxy_credentials)
) {
for (const credential of organization.galaxy_credentials) {
await OrganizationsAPI.disassociateGalaxyCredential(
organization.id,
credential.id
);
}
for (const credential of values.galaxy_credentials) {
await OrganizationsAPI.associateGalaxyCredential(
organization.id,
credential.id
);
}
}
/* eslint-enable no-await-in-loop, no-restricted-syntax */
history.push(detailsUrl); history.push(detailsUrl);
} catch (error) { } catch (error) {
setFormError(error); setFormError(error);

View File

@@ -54,18 +54,34 @@ describe('<OrganizationEdit />', () => {
name: 'new name', name: 'new name',
description: 'new description', description: 'new description',
}; };
const newInstanceGroups = [
{
name: 'mock three',
id: 3,
},
{
name: 'mock four',
id: 4,
},
];
const oldInstanceGroups = [
{
name: 'mock two',
id: 2,
},
];
await act(async () => { await act(async () => {
wrapper.find('OrganizationForm').invoke('onSubmit')( wrapper.find('OrganizationForm').invoke('onSubmit')(
updatedOrgData, updatedOrgData,
[3, 4], newInstanceGroups,
[2] oldInstanceGroups
); );
}); });
expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 3); expect(OrganizationsAPI.orderInstanceGroups).toHaveBeenCalledWith(
expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 4); mockData.id,
expect(OrganizationsAPI.disassociateInstanceGroup).toHaveBeenCalledWith( newInstanceGroups,
1, oldInstanceGroups
2
); );
}); });

View File

@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Formik, useField, useFormikContext } from 'formik'; import { Formik, useField, useFormikContext } from 'formik';
import { t } from '@lingui/macro'; import { t, Trans } from '@lingui/macro';
import { Form } from '@patternfly/react-core'; import { Form } from '@patternfly/react-core';
import { OrganizationsAPI } from '../../../api'; import { OrganizationsAPI } from '../../../api';
@@ -15,7 +15,6 @@ import {
InstanceGroupsLookup, InstanceGroupsLookup,
ExecutionEnvironmentLookup, ExecutionEnvironmentLookup,
} from '../../../components/Lookup'; } from '../../../components/Lookup';
import { getAddedAndRemoved } from '../../../util/lists';
import { required, minMaxValue } from '../../../util/validators'; import { required, minMaxValue } from '../../../util/validators';
import { FormColumnLayout } from '../../../components/FormLayout'; import { FormColumnLayout } from '../../../components/FormLayout';
import CredentialLookup from '../../../components/Lookup/CredentialLookup'; import CredentialLookup from '../../../components/Lookup/CredentialLookup';
@@ -106,7 +105,20 @@ function OrganizationFormFields({
onChange={handleCredentialUpdate} onChange={handleCredentialUpdate}
value={galaxyCredentialsField.value} value={galaxyCredentialsField.value}
multiple multiple
isSelectedDraggable
fieldName="galaxy_credentials" fieldName="galaxy_credentials"
modalDescription={
<>
<b>
<Trans>Selected</Trans>
</b>
<br />
<Trans>
Note: The order of these credentials sets precedence for the sync
and lookup of the content.
</Trans>
</>
}
/> />
</> </>
); );
@@ -130,19 +142,13 @@ function OrganizationForm({
}; };
const handleSubmit = values => { const handleSubmit = values => {
const { added, removed } = getAddedAndRemoved(
initialInstanceGroups,
instanceGroups
);
const addedIds = added.map(({ id }) => id);
const removedIds = removed.map(({ id }) => id);
if ( if (
typeof values.max_hosts !== 'number' || typeof values.max_hosts !== 'number' ||
values.max_hosts === 'undefined' values.max_hosts === 'undefined'
) { ) {
values.max_hosts = 0; values.max_hosts = 0;
} }
onSubmit(values, addedIds, removedIds); onSubmit(values, instanceGroups, initialInstanceGroups);
}; };
useEffect(() => { useEffect(() => {

View File

@@ -258,7 +258,14 @@ describe('<OrganizationForm />', () => {
await act(async () => { await act(async () => {
wrapper.find('button[aria-label="Save"]').simulate('click'); wrapper.find('button[aria-label="Save"]').simulate('click');
}); });
expect(onSubmit).toHaveBeenCalledWith(mockDataForm, [3], [2]); expect(onSubmit).toHaveBeenCalledWith(
mockDataForm,
[
{ name: 'One', id: 1 },
{ name: 'Three', id: 3 },
],
mockInstanceGroups
);
}); });
test('onSubmit does not get called if max_hosts value is out of range', async () => { test('onSubmit does not get called if max_hosts value is out of range', async () => {
@@ -332,8 +339,8 @@ describe('<OrganizationForm />', () => {
max_hosts: 0, max_hosts: 0,
default_environment: null, default_environment: null,
}, },
[], mockInstanceGroups,
[] mockInstanceGroups
); );
}); });

View File

@@ -63,11 +63,13 @@ function JobTemplateAdd() {
return Promise.all([...associationPromises]); return Promise.all([...associationPromises]);
} }
function submitInstanceGroups(templateId, addedGroups = []) { async function submitInstanceGroups(templateId, addedGroups = []) {
const associatePromises = addedGroups.map(group => /* eslint-disable no-await-in-loop, no-restricted-syntax */
JobTemplatesAPI.associateInstanceGroup(templateId, group.id) // Resolve Promises sequentially to maintain order and avoid race condition
); for (const group of addedGroups) {
return Promise.all(associatePromises); await JobTemplatesAPI.associateInstanceGroup(templateId, group.id);
}
/* eslint-enable no-await-in-loop, no-restricted-syntax */
} }
function submitCredentials(templateId, credentials = []) { function submitCredentials(templateId, credentials = []) {

View File

@@ -109,8 +109,9 @@ describe('<JobTemplateAdd />', () => {
let wrapper; let wrapper;
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<JobTemplateAdd />); wrapper = mountWithContexts(<JobTemplateAdd />);
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
}); });
wrapper.update();
expect(wrapper.find('input#template-description').text()).toBe( expect(wrapper.find('input#template-description').text()).toBe(
defaultProps.description defaultProps.description
); );

View File

@@ -10,7 +10,7 @@ import JobTemplateForm from '../shared/JobTemplateForm';
import ContentLoading from '../../../components/ContentLoading'; import ContentLoading from '../../../components/ContentLoading';
import { CardBody } from '../../../components/Card'; import { CardBody } from '../../../components/Card';
function JobTemplateEdit({ template }) { function JobTemplateEdit({ template, reloadTemplate }) {
const history = useHistory(); const history = useHistory();
const [formSubmitError, setFormSubmitError] = useState(null); const [formSubmitError, setFormSubmitError] = useState(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -18,11 +18,7 @@ function JobTemplateEdit({ template }) {
const detailsUrl = `/templates/${template.type}/${template.id}/details`; const detailsUrl = `/templates/${template.type}/${template.id}/details`;
const { const { request: fetchProject, error: fetchProjectError } = useRequest(
request: fetchProject,
error: fetchProjectError,
isLoading: projectLoading,
} = useRequest(
useCallback(async () => { useCallback(async () => {
await ProjectsAPI.readDetail(template.project); await ProjectsAPI.readDetail(template.project);
}, [template.project]) }, [template.project])
@@ -65,9 +61,14 @@ function JobTemplateEdit({ template }) {
await JobTemplatesAPI.update(template.id, remainingValues); await JobTemplatesAPI.update(template.id, remainingValues);
await Promise.all([ await Promise.all([
submitLabels(labels, template?.organization), submitLabels(labels, template?.organization),
submitInstanceGroups(instanceGroups, initialInstanceGroups),
submitCredentials(credentials), submitCredentials(credentials),
JobTemplatesAPI.orderInstanceGroups(
template.id,
instanceGroups,
initialInstanceGroups
),
]); ]);
reloadTemplate();
history.push(detailsUrl); history.push(detailsUrl);
} catch (error) { } catch (error) {
setFormSubmitError(error); setFormSubmitError(error);
@@ -96,17 +97,6 @@ function JobTemplateEdit({ template }) {
return results; return results;
}; };
const submitInstanceGroups = async (groups, initialGroups) => {
const { added, removed } = getAddedAndRemoved(initialGroups, groups);
const disassociatePromises = await removed.map(group =>
JobTemplatesAPI.disassociateInstanceGroup(template.id, group.id)
);
const associatePromises = await added.map(group =>
JobTemplatesAPI.associateInstanceGroup(template.id, group.id)
);
return Promise.all([...disassociatePromises, ...associatePromises]);
};
const submitCredentials = async newCredentials => { const submitCredentials = async newCredentials => {
const { added, removed } = getAddedAndRemoved( const { added, removed } = getAddedAndRemoved(
template.summary_fields.credentials, template.summary_fields.credentials,
@@ -130,7 +120,7 @@ function JobTemplateEdit({ template }) {
if (!canEdit) { if (!canEdit) {
return <Redirect to={detailsUrl} />; return <Redirect to={detailsUrl} />;
} }
if (isLoading || projectLoading) { if (isLoading) {
return <ContentLoading />; return <ContentLoading />;
} }

View File

@@ -287,8 +287,8 @@ describe('<JobTemplateEdit />', () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<JobTemplateEdit template={mockJobTemplate} /> <JobTemplateEdit template={mockJobTemplate} />
); );
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
}); });
wrapper.update();
expect(wrapper.find('FormGroup[label="Host Config Key"]').length).toBe(1); expect(wrapper.find('FormGroup[label="Host Config Key"]').length).toBe(1);
expect( expect(
wrapper.find('FormGroup[label="Host Config Key"]').prop('isRequired') wrapper.find('FormGroup[label="Host Config Key"]').prop('isRequired')
@@ -301,8 +301,9 @@ describe('<JobTemplateEdit />', () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<JobTemplateEdit template={mockJobTemplate} /> <JobTemplateEdit template={mockJobTemplate} />
); );
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
}); });
wrapper.update();
const updatedTemplateData = { const updatedTemplateData = {
job_type: 'check', job_type: 'check',
name: 'new name', name: 'new name',

View File

@@ -72,16 +72,7 @@ function Template({ setBreadcrumb }) {
surveyConfiguration = survey; surveyConfiguration = survey;
} }
if (data.summary_fields.credentials) { if (data.summary_fields.credentials) {
const params = { data.summary_fields.credentials = defaultCredentials;
page: 1,
page_size: 200,
order_by: 'name',
};
const {
data: { results },
} = await JobTemplatesAPI.readCredentials(data.id, params);
data.summary_fields.credentials = results;
} }
if (actions.data.actions.PUT) { if (actions.data.actions.PUT) {
@@ -106,7 +97,7 @@ function Template({ setBreadcrumb }) {
useEffect(() => { useEffect(() => {
loadTemplateAndRoles(); loadTemplateAndRoles();
}, [loadTemplateAndRoles, location.pathname]); }, [loadTemplateAndRoles]);
useEffect(() => { useEffect(() => {
if (template) { if (template) {
@@ -214,7 +205,10 @@ function Template({ setBreadcrumb }) {
/> />
</Route> </Route>
<Route key="edit" path="/templates/:templateType/:id/edit"> <Route key="edit" path="/templates/:templateType/:id/edit">
<JobTemplateEdit template={template} /> <JobTemplateEdit
template={template}
reloadTemplate={loadTemplateAndRoles}
/>
</Route> </Route>
<Route key="access" path="/templates/:templateType/:id/access"> <Route key="access" path="/templates/:templateType/:id/access">
<ResourceAccessList <ResourceAccessList

View File

@@ -56,7 +56,7 @@ function JobTemplateForm({
setFieldTouched, setFieldTouched,
submitError, submitError,
validateField, validateField,
isOverrideDisabledLookup, isOverrideDisabledLookup, // TODO: this is a confusing variable name
}) { }) {
const [contentError, setContentError] = useState(false); const [contentError, setContentError] = useState(false);
const [allowCallbacks, setAllowCallbacks] = useState( const [allowCallbacks, setAllowCallbacks] = useState(
@@ -123,7 +123,10 @@ function JobTemplateForm({
setFieldValue('instanceGroups', [...data.results]); setFieldValue('instanceGroups', [...data.results]);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [setFieldValue, template]) }, [setFieldValue, template]),
{
isLoading: true,
}
); );
useEffect(() => { useEffect(() => {

View File

@@ -185,7 +185,7 @@ describe('<JobTemplateForm />', () => {
test('should update form values on input changes', async () => { test('should update form values on input changes', async () => {
let wrapper; let wrapper;
await act(async () => { await act(() => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<JobTemplateForm <JobTemplateForm
template={mockData} template={mockData}
@@ -193,8 +193,9 @@ describe('<JobTemplateForm />', () => {
handleCancel={jest.fn()} handleCancel={jest.fn()}
/> />
); );
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
}); });
wrapper.update();
await act(async () => { await act(async () => {
wrapper.find('input#template-name').simulate('change', { wrapper.find('input#template-name').simulate('change', {
target: { value: 'new foo', name: 'name' }, target: { value: 'new foo', name: 'name' },
@@ -308,6 +309,8 @@ describe('<JobTemplateForm />', () => {
} }
); );
}); });
wrapper.update();
act(() => { act(() => {
wrapper.find('Checkbox[aria-label="Enable Webhook"]').invoke('onChange')( wrapper.find('Checkbox[aria-label="Enable Webhook"]').invoke('onChange')(
true, true,
@@ -392,6 +395,8 @@ describe('<JobTemplateForm />', () => {
} }
); );
}); });
wrapper.update();
expect( expect(
wrapper.find('TextInputBase#template-webhook_key').prop('value') wrapper.find('TextInputBase#template-webhook_key').prop('value')
).toBe('A NEW WEBHOOK KEY WILL BE GENERATED ON SAVE.'); ).toBe('A NEW WEBHOOK KEY WILL BE GENERATED ON SAVE.');
@@ -399,6 +404,7 @@ describe('<JobTemplateForm />', () => {
wrapper.find('Button[aria-label="Update webhook key"]').prop('isDisabled') wrapper.find('Button[aria-label="Update webhook key"]').prop('isDisabled')
).toBe(true); ).toBe(true);
}); });
test('should call handleSubmit when Submit button is clicked', async () => { test('should call handleSubmit when Submit button is clicked', async () => {
const handleSubmit = jest.fn(); const handleSubmit = jest.fn();
let wrapper; let wrapper;

View File

@@ -18,7 +18,7 @@ import useIsMounted from './useIsMounted';
export default function useRequest(makeRequest, initialValue) { export default function useRequest(makeRequest, initialValue) {
const [result, setResult] = useState(initialValue); const [result, setResult] = useState(initialValue);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(initialValue?.isLoading || false);
const isMounted = useIsMounted(); const isMounted = useIsMounted();
return { return {

View File

@@ -47,11 +47,16 @@ class HasStatus(object):
return self.wait_until_status(self.started_statuses, interval=interval, timeout=timeout) return self.wait_until_status(self.started_statuses, interval=interval, timeout=timeout)
def failure_output_details(self): def failure_output_details(self):
msg = ''
if getattr(self, 'result_stdout', ''): if getattr(self, 'result_stdout', ''):
output = bytes_to_str(self.result_stdout) output = bytes_to_str(self.result_stdout)
if output: if output:
return '\nstdout:\n{}'.format(output) msg = '\nstdout:\n{}'.format(output)
return '' if getattr(self, 'job_explanation', ''):
msg += '\njob_explanation: {}'.format(bytes_to_str(self.job_explanation))
if getattr(self, 'result_traceback', ''):
msg += '\nresult_traceback:\n{}'.format(bytes_to_str(self.result_traceback))
return msg
def assert_status(self, status_list, msg=None): def assert_status(self, status_list, msg=None):
if isinstance(status_list, str): if isinstance(status_list, str):
@@ -65,8 +70,6 @@ class HasStatus(object):
else: else:
msg += '\n' msg += '\n'
msg += '{0}-{1} has status of {2}, which is not in {3}.'.format(self.type.title(), self.id, self.status, status_list) msg += '{0}-{1} has status of {2}, which is not in {3}.'.format(self.type.title(), self.id, self.status, status_list)
if getattr(self, 'job_explanation', ''):
msg += '\njob_explanation: {}'.format(bytes_to_str(self.job_explanation))
if getattr(self, 'execution_environment', ''): if getattr(self, 'execution_environment', ''):
msg += '\nexecution_environment: {}'.format(bytes_to_str(self.execution_environment)) msg += '\nexecution_environment: {}'.format(bytes_to_str(self.execution_environment))
if getattr(self, 'related', False): if getattr(self, 'related', False):
@@ -75,19 +78,17 @@ class HasStatus(object):
msg += f'\nee_credential: {ee.credential}' msg += f'\nee_credential: {ee.credential}'
msg += f'\nee_pull_option: {ee.pull}' msg += f'\nee_pull_option: {ee.pull}'
msg += f'\nee_summary_fields: {ee.summary_fields}' msg += f'\nee_summary_fields: {ee.summary_fields}'
if getattr(self, 'result_traceback', ''):
msg += '\nresult_traceback:\n{}'.format(bytes_to_str(self.result_traceback))
msg += self.failure_output_details() msg += self.failure_output_details()
if getattr(self, 'job_explanation', '').startswith('Previous Task Failed'): if getattr(self, 'job_explanation', '').startswith('Previous Task Failed'):
try: try:
data = json.loads(self.job_explanation.replace('Previous Task Failed: ', '')) data = json.loads(self.job_explanation.replace('Previous Task Failed: ', ''))
dep_output = self.connection.get( dependency = self.walk('/api/v2/{0}s/{1}/'.format(data['job_type'], data['job_id']))
'{0}/api/v2/{1}s/{2}/stdout/'.format(self.endpoint.split('/api')[0], data['job_type'], data['job_id']), if hasattr(dependency, 'failure_output_details'):
query_parameters=dict(format='txt_download'), msg += '\nDependency output:\n{}'.format(dependency.failure_output_details())
).content else:
msg += '\nDependency output:\n{}'.format(bytes_to_str(dep_output)) msg += '\nDependency info:\n{}'.format(dependency)
except Exception as e: except Exception as e:
msg += '\nFailed to obtain dependency stdout: {}'.format(e) msg += '\nFailed to obtain dependency stdout: {}'.format(e)

View File

@@ -66,10 +66,11 @@ In the root of awx-operator:
``` ```
$ ansible-playbook ansible/instantiate-awx-deployment.yml \ $ ansible-playbook ansible/instantiate-awx-deployment.yml \
-e development_mode=yes \ -e development_mode=yes \
-e tower_image=quay.io/awx/awx_kube_devel \ -e image=quay.io/awx/awx_kube_devel \
-e tower_image_version=devel \ -e image_version=devel \
-e tower_image_pull_policy=Always \ -e image_pull_policy=Always \
-e tower_ingress_type=ingress -e service_type=nodeport \
-e namespace=default
``` ```
### Custom AWX Development Image for Kubernetes ### Custom AWX Development Image for Kubernetes

View File

@@ -1,5 +1,5 @@
aiohttp aiohttp
ansible-runner==2.0.0.0b1 ansible-runner==2.0.0.0rc3
ansiconv==1.0.0 # UPGRADE BLOCKER: from 2013, consider replacing instead of upgrading ansiconv==1.0.0 # UPGRADE BLOCKER: from 2013, consider replacing instead of upgrading
asciichartpy asciichartpy
autobahn>=20.12.3 # CVE-2020-35678 autobahn>=20.12.3 # CVE-2020-35678
@@ -45,7 +45,7 @@ python3-saml
python-dsv-sdk python-dsv-sdk
python-ldap>=3.3.1 # https://github.com/python-ldap/python-ldap/issues/270 python-ldap>=3.3.1 # https://github.com/python-ldap/python-ldap/issues/270
pyyaml>=5.4.1 # minimum to fix https://github.com/yaml/pyyaml/issues/478 pyyaml>=5.4.1 # minimum to fix https://github.com/yaml/pyyaml/issues/478
receptorctl receptorctl==1.0.0.0rc1
schedule==0.6.0 schedule==0.6.0
social-auth-core==3.3.1 # see UPGRADE BLOCKERs social-auth-core==3.3.1 # see UPGRADE BLOCKERs
social-auth-app-django==3.1.0 # see UPGRADE BLOCKERs social-auth-app-django==3.1.0 # see UPGRADE BLOCKERs

View File

@@ -4,7 +4,7 @@ aiohttp==3.6.2
# via -r /awx_devel/requirements/requirements.in # via -r /awx_devel/requirements/requirements.in
aioredis==1.3.1 aioredis==1.3.1
# via channels-redis # via channels-redis
ansible-runner==2.0.0.0b1 ansible-runner==2.0.0.0rc3
# via -r /awx_devel/requirements/requirements.in # via -r /awx_devel/requirements/requirements.in
ansiconv==1.0.0 ansiconv==1.0.0
# via -r /awx_devel/requirements/requirements.in # via -r /awx_devel/requirements/requirements.in
@@ -47,12 +47,12 @@ cachetools==4.0.0
# requests # requests
cffi==1.14.0 cffi==1.14.0
# via cryptography # via cryptography
channels-redis==3.1.0
# via -r /awx_devel/requirements/requirements.in
channels==2.4.0 channels==2.4.0
# via # via
# -r /awx_devel/requirements/requirements.in # -r /awx_devel/requirements/requirements.in
# channels-redis # channels-redis
channels-redis==3.1.0
# via -r /awx_devel/requirements/requirements.in
chardet==3.0.4 chardet==3.0.4
# via # via
# aiohttp # aiohttp
@@ -85,6 +85,19 @@ dictdiffer==0.8.1
# via openshift # via openshift
distro==1.5.0 distro==1.5.0
# via -r /awx_devel/requirements/requirements.in # via -r /awx_devel/requirements/requirements.in
django==2.2.16
# via
# -r /awx_devel/requirements/requirements.in
# channels
# django-auth-ldap
# django-cors-headers
# django-crum
# django-guid
# django-jsonfield
# django-oauth-toolkit
# django-polymorphic
# django-taggit
# djangorestframework
django-auth-ldap==2.1.0 django-auth-ldap==2.1.0
# via -r /awx_devel/requirements/requirements.in # via -r /awx_devel/requirements/requirements.in
django-cors-headers==3.7.0 django-cors-headers==3.7.0
@@ -115,23 +128,10 @@ django-split-settings==1.0.0
# via -r /awx_devel/requirements/requirements.in # via -r /awx_devel/requirements/requirements.in
django-taggit==1.2.0 django-taggit==1.2.0
# via -r /awx_devel/requirements/requirements.in # via -r /awx_devel/requirements/requirements.in
django==2.2.16
# via
# -r /awx_devel/requirements/requirements.in
# channels
# django-auth-ldap
# django-cors-headers
# django-crum
# django-guid
# django-jsonfield
# django-oauth-toolkit
# django-polymorphic
# django-taggit
# djangorestframework
djangorestframework-yaml==1.0.3
# via -r /awx_devel/requirements/requirements.in
djangorestframework==3.12.1 djangorestframework==3.12.1
# via -r /awx_devel/requirements/requirements.in # via -r /awx_devel/requirements/requirements.in
djangorestframework-yaml==1.0.3
# via -r /awx_devel/requirements/requirements.in
docutils==0.16 docutils==0.16
# via python-daemon # via python-daemon
future==0.16.0 future==0.16.0
@@ -237,17 +237,17 @@ psycopg2==2.8.4
# via -r /awx_devel/requirements/requirements.in # via -r /awx_devel/requirements/requirements.in
ptyprocess==0.6.0 ptyprocess==0.6.0
# via pexpect # via pexpect
pyasn1-modules==0.2.8
# via
# google-auth
# python-ldap
# service-identity
pyasn1==0.4.8 pyasn1==0.4.8
# via # via
# pyasn1-modules # pyasn1-modules
# python-ldap # python-ldap
# rsa # rsa
# service-identity # service-identity
pyasn1-modules==0.2.8
# via
# google-auth
# python-ldap
# service-identity
pycparser==2.20 pycparser==2.20
# via cffi # via cffi
pygerduty==0.38.2 pygerduty==0.38.2
@@ -299,17 +299,12 @@ pyyaml==5.4.1
# djangorestframework-yaml # djangorestframework-yaml
# kubernetes # kubernetes
# receptorctl # receptorctl
receptorctl==1.0.0.0a2 receptorctl==1.0.0.0rc1
# via -r /awx_devel/requirements/requirements.in # via -r /awx_devel/requirements/requirements.in
redis==3.4.1 redis==3.4.1
# via # via
# -r /awx_devel/requirements/requirements.in # -r /awx_devel/requirements/requirements.in
# django-redis # django-redis
requests-oauthlib==1.3.0
# via
# kubernetes
# msrest
# social-auth-core
requests==2.23.0 requests==2.23.0
# via # via
# -r /awx_devel/requirements/requirements.in # -r /awx_devel/requirements/requirements.in
@@ -323,12 +318,17 @@ requests==2.23.0
# slackclient # slackclient
# social-auth-core # social-auth-core
# twilio # twilio
requests-oauthlib==1.3.0
# via
# kubernetes
# msrest
# social-auth-core
rsa==4.0 rsa==4.0
# via google-auth # via google-auth
ruamel.yaml.clib==0.2.0
# via ruamel.yaml
ruamel.yaml==0.16.10 ruamel.yaml==0.16.10
# via openshift # via openshift
ruamel.yaml.clib==0.2.0
# via ruamel.yaml
schedule==0.6.0 schedule==0.6.0
# via -r /awx_devel/requirements/requirements.in # via -r /awx_devel/requirements/requirements.in
service-identity==18.1.0 service-identity==18.1.0

View File

@@ -154,7 +154,7 @@ setup(
"tools/scripts/ansible-tower-setup", "tools/scripts/ansible-tower-setup",
], ],
), ),
("%s" % sosconfig, ["tools/sosreport/tower.py"]), ("%s" % sosconfig, ["tools/sosreport/controller.py"]),
] ]
), ),
options={ options={

View File

@@ -7,7 +7,7 @@ readonly CMDNAME=$(basename "$0")
readonly MIN_SLEEP=0.5 readonly MIN_SLEEP=0.5
readonly MAX_SLEEP=30 readonly MAX_SLEEP=30
readonly ATTEMPTS=10 readonly ATTEMPTS=30
readonly TIMEOUT=60 readonly TIMEOUT=60
log_message() { echo "[${CMDNAME}]" "$@" >&2; } log_message() { echo "[${CMDNAME}]" "$@" >&2; }

View File

@@ -0,0 +1,68 @@
# Copyright (c) 2016 Ansible, Inc.
# All Rights Reserved.
try:
from sos.plugins import Plugin, RedHatPlugin
except ImportError:
from sos.report.plugins import Plugin, RedHatPlugin
SOSREPORT_CONTROLLER_COMMANDS = [
"awx-manage --version", # controller version
"awx-manage list_instances", # controller cluster configuration
"awx-manage run_dispatcher --status", # controller dispatch worker status
"awx-manage run_callback_receiver --status", # controller callback worker status
"awx-manage check_license --data", # controller license status
"awx-manage run_wsbroadcast --status", # controller broadcast websocket status
"supervisorctl status", # controller process status
"/var/lib/awx/venv/awx/bin/pip freeze", # pip package list
"/var/lib/awx/venv/awx/bin/pip freeze -l", # pip package list without globally-installed packages
"/var/lib/awx/venv/ansible/bin/pip freeze", # pip package list
"/var/lib/awx/venv/ansible/bin/pip freeze -l", # pip package list without globally-installed packages
"tree -d /var/lib/awx", # show me the dirs
"ls -ll /var/lib/awx", # check permissions
"ls -ll /var/lib/awx/venv", # list all venvs
"ls -ll /etc/tower",
"umask -p", # check current umask
]
SOSREPORT_CONTROLLER_DIRS = [
"/etc/tower/",
"/etc/supervisord.d/",
"/etc/nginx/",
"/var/log/tower",
"/var/log/nginx",
"/var/log/supervisor",
"/var/log/redis",
"/etc/opt/rh/rh-redis5/redis.conf",
"/etc/redis.conf",
"/var/opt/rh/rh-redis5/log/redis/redis.log",
"/var/log/dist-upgrade",
"/var/log/installer",
"/var/log/unattended-upgrades",
"/var/log/apport.log",
]
SOSREPORT_FORBIDDEN_PATHS = [
"/etc/tower/SECRET_KEY",
"/etc/tower/tower.key",
"/etc/tower/awx.key",
"/etc/tower/tower.cert",
"/etc/tower/awx.cert",
"/var/log/tower/profile",
]
class Controller(Plugin, RedHatPlugin):
'''Collect Ansible Automation Platform controller information'''
plugin_name = "controller"
short_desc = "Ansible Automation Platform controller information"
def setup(self):
for path in SOSREPORT_CONTROLLER_DIRS:
self.add_copy_spec(path)
self.add_forbidden_path(SOSREPORT_FORBIDDEN_PATHS)
self.add_cmd_output(SOSREPORT_CONTROLLER_COMMANDS)

View File

@@ -1,67 +0,0 @@
# Copyright (c) 2016 Ansible, Inc.
# All Rights Reserved.
try:
from sos.plugins import Plugin, RedHatPlugin, UbuntuPlugin
except ImportError:
from sos.report.plugins import Plugin, RedHatPlugin, UbuntuPlugin
SOSREPORT_TOWER_COMMANDS = [
"awx-manage --version", # tower version
"awx-manage list_instances", # tower cluster configuration
"awx-manage run_dispatcher --status", # tower dispatch worker status
"awx-manage run_callback_receiver --status", # tower callback worker status
"awx-manage check_license --data", # tower license status
"awx-manage run_wsbroadcast --status", # tower broadcast websocket status
"supervisorctl status", # tower process status
"/var/lib/awx/venv/awx/bin/pip freeze", # pip package list
"/var/lib/awx/venv/awx/bin/pip freeze -l", # pip package list without globally-installed packages
"/var/lib/awx/venv/ansible/bin/pip freeze", # pip package list
"/var/lib/awx/venv/ansible/bin/pip freeze -l", # pip package list without globally-installed packages
"tree -d /var/lib/awx", # show me the dirs
"ls -ll /var/lib/awx", # check permissions
"ls -ll /var/lib/awx/venv", # list all venvs
"ls -ll /etc/tower",
"umask -p" # check current umask
]
SOSREPORT_TOWER_DIRS = [
"/etc/tower/",
"/etc/supervisord.d/",
"/etc/nginx/",
"/var/log/tower",
"/var/log/nginx",
"/var/log/supervisor",
"/var/log/redis",
"/etc/opt/rh/rh-redis5/redis.conf",
"/etc/redis.conf",
"/var/opt/rh/rh-redis5/log/redis/redis.log",
"/var/log/dist-upgrade",
"/var/log/installer",
"/var/log/unattended-upgrades",
"/var/log/apport.log"
]
SOSREPORT_FORBIDDEN_PATHS = [
"/etc/tower/SECRET_KEY",
"/etc/tower/tower.key",
"/etc/tower/awx.key",
"/etc/tower/tower.cert",
"/etc/tower/awx.cert",
"/var/log/tower/profile"
]
class Tower(Plugin, RedHatPlugin, UbuntuPlugin):
'''Collect Ansible Tower related information'''
plugin_name = "tower"
def setup(self):
for path in SOSREPORT_TOWER_DIRS:
self.add_copy_spec(path)
self.add_forbidden_path(SOSREPORT_FORBIDDEN_PATHS)
self.add_cmd_output(SOSREPORT_TOWER_COMMANDS)