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
commit dc7bd73431
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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']
if instance.managed and request.user.can_access(models.ExecutionEnvironment, 'change', instance):
for field in fields_to_check:
if kwargs.get('partial') and field not in request.data:
continue
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:
raise PermissionDenied(_("Only the 'pull' field can be edited for managed execution environments."))
return super().update(request, *args, **kwargs)

View File

@ -692,6 +692,15 @@ register(
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):
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',
'label': _('Path to Secret'),
'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',

View File

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

View File

@ -1437,7 +1437,12 @@ class UnifiedJob(
def global_instance_groups(self):
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)

View File

@ -87,7 +87,7 @@ from awx.main.exceptions import AwxTaskError, PostRunError
from awx.main.queue import CallbackQueueDispatcher
from awx.main.dispatch.publish import task
from awx.main.dispatch import get_local_queuename, reaper
from awx.main.utils import (
from awx.main.utils.common import (
update_scm_url,
ignore_inventory_computed_fields,
ignore_inventory_group_removal,
@ -97,6 +97,7 @@ from awx.main.utils import (
deepmerge,
parse_yaml_or_json,
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.ansible import read_ansible_config
@ -1267,11 +1268,17 @@ class BaseTask(object):
for k, v in self.safe_env.items():
if k in job_env:
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':
result_traceback = status_data.get('result_traceback', None)
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
def run(self, pk, **kwargs):
@ -1791,6 +1798,7 @@ class RunJob(BaseTask):
if 'update_' not in sync_metafields['job_tags']:
sync_metafields['scm_revision'] = job_revision
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
# cancel() call on the job can cancel the project update
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():
logger.debug('Skipping SCM inventory update for `{}` because ' 'another update is already active.'.format(inv_src.name))
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(
_eager_fields=dict(
launch_type='scm',
status='running',
instance_group=project_update.instance_group,
instance_group=instance_group,
execution_node=project_update.execution_node,
source_project_update=project_update,
celery_task_id=project_update.celery_task_id,
)
)
try:
create_partition(local_inv_update.event_class._meta.db_table, start=local_inv_update.created)
inv_update_class().run(local_inv_update.id)
except Exception:
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):
os.mkdir(settings.PROJECTS_ROOT)
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)
@ -2175,6 +2188,9 @@ class RunProjectUpdate(BaseTask):
else:
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')
if os.path.exists(stage_path):
logger.warning('{0} unexpectedly existed before update'.format(stage_path))
@ -2988,7 +3004,8 @@ class AWXReceptorJob:
if state_name == 'Succeeded':
return res
raise RuntimeError(detail)
if self.task.instance.result_traceback is None:
raise RuntimeError(detail)
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]
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)
if instance is not None:
if type(instance) == list:

View File

@ -35,18 +35,19 @@ class TestDependentInventoryUpdate:
task.post_run_hook(proj_update, 'successful')
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()
scm_inventory_source.scm_last_revision = ''
proj_update = ProjectUpdate.objects.create(project=scm_inventory_source.source_project)
with mock.patch.object(RunInventoryUpdate, 'run') as iu_run_mock:
task._update_dependent_inventories(proj_update, [scm_inventory_source])
assert InventoryUpdate.objects.count() == 1
inv_update = InventoryUpdate.objects.first()
iu_run_mock.assert_called_once_with(inv_update.id)
assert inv_update.source_project_update_id == proj_update.pk
with mock.patch('awx.main.tasks.create_partition'):
task._update_dependent_inventories(proj_update, [scm_inventory_source])
assert InventoryUpdate.objects.count() == 1
inv_update = InventoryUpdate.objects.first()
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
of the source project update
@ -63,8 +64,9 @@ class TestDependentInventoryUpdate:
ProjectUpdate.objects.all().update(cancel_flag=True)
with mock.patch.object(RunInventoryUpdate, 'run') as iu_run_mock:
iu_run_mock.side_effect = user_cancels_project
task._update_dependent_inventories(proj_update, [is1, is2])
# Verify that it bails after 1st update, detecting a cancel
assert is2.inventory_updates.count() == 0
iu_run_mock.assert_called_once()
with mock.patch('awx.main.tasks.create_partition'):
iu_run_mock.side_effect = user_cancels_project
task._update_dependent_inventories(proj_update, [is1, is2])
# Verify that it bails after 1st update, detecting a cancel
assert is2.inventory_updates.count() == 0
iu_run_mock.assert_called_once()

View File

@ -63,6 +63,7 @@
"aria-labelledby",
"aria-hidden",
"aria-controls",
"aria-pressed",
"sortKey",
"ouiaId",
"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 =>
class extends parent {
readInstanceGroups(resourceId, params) {
@ -18,6 +27,20 @@ const InstanceGroupsMixin = parent =>
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;

View File

@ -14,6 +14,10 @@ class Settings extends Base {
return this.http.patch(`${this.baseUrl}all/`, data);
}
readAll() {
return this.http.get(`${this.baseUrl}all/`);
}
updateCategory(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 DataListToolbar from '../DataListToolbar';
import CheckboxListItem from '../CheckboxListItem';
import SelectedList from '../SelectedList';
import { SelectedList } from '../SelectedList';
import { getQSConfig, parseQueryString } from '../../util/qs';
import PaginatedTable, { HeaderCell, HeaderRow } from '../PaginatedTable';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@ import React, { useCallback, useEffect } from 'react';
import { arrayOf, string, func, bool } from 'prop-types';
import { withRouter } from 'react-router-dom';
import { t } from '@lingui/macro';
import { t, Trans } from '@lingui/macro';
import { FormGroup } from '@patternfly/react-core';
import { InstanceGroupsAPI } from '../../api';
import { InstanceGroup } from '../../types';
@ -82,6 +82,18 @@ function InstanceGroupsLookup({
multiple
required={required}
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 }) => (
<OptionsList
value={state.selectedItems}
@ -113,6 +125,10 @@ function InstanceGroupsLookup({
readOnly={!canDelete}
selectItem={item => dispatch({ type: 'SELECT_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 OptionsList from '../OptionsList';
import useRequest from '../../util/useRequest';
import { getQSConfig, parseQueryString } from '../../util/qs';
import { getQSConfig, parseQueryString, mergeParams } from '../../util/qs';
import LookupErrorMessage from './shared/LookupErrorMessage';
import FieldWithPrompt from '../FieldWithPrompt';
@ -32,6 +32,7 @@ function InventoryLookup({
validate,
fieldName,
isDisabled,
hideSmartInventories,
}) {
const {
result: {
@ -47,8 +48,15 @@ function InventoryLookup({
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, history.location.search);
const inventoryKindParams = hideSmartInventories
? { not__kind: 'smart' }
: {};
const [{ data }, actionsResponse] = await Promise.all([
InventoriesAPI.read(params),
InventoriesAPI.read(
mergeParams(params, {
...inventoryKindParams,
})
),
InventoriesAPI.readOptions(),
]);
@ -60,7 +68,12 @@ function InventoryLookup({
).map(val => val.slice(0, -8)),
searchableKeys: Object.keys(
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:
Boolean(actionsResponse.data.actions.POST) || isOverrideDisabled,
};
@ -230,6 +243,7 @@ InventoryLookup.propTypes = {
validate: func,
fieldName: string,
isDisabled: bool,
hideSmartInventories: bool,
};
InventoryLookup.defaultProps = {
@ -239,6 +253,7 @@ InventoryLookup.defaultProps = {
validate: () => {},
fieldName: 'inventory',
isDisabled: false,
hideSmartInventories: false,
};
export default withRouter(InventoryLookup);

View File

@ -48,6 +48,42 @@ describe('InventoryLookup', () => {
});
wrapper.update();
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('Lookup').prop('isDisabled')).toBe(false);
});

View File

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

View File

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

View File

@ -24,6 +24,10 @@ const AdvancedGroup = styled.div`
display: grid;
grid-gap: var(--pf-c-toolbar__expandable-content--m-expanded--GridRowGap);
}
& .pf-c-select {
min-width: 150px;
}
`;
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 './setupCSP';
import '@patternfly/react-core/dist/styles/base.css';
import './border.css';
import App from './App';
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;
`;
function CredentialInput({ fieldOptions, credentialKind, ...rest }) {
function CredentialInput({
fieldOptions,
isFieldGroupValid,
credentialKind,
...rest
}) {
const [fileName, setFileName] = useState('');
const [fileIsUploading, setFileIsUploading] = useState(false);
const [subFormField, meta, helpers] = useField(`inputs.${fieldOptions.id}`);
@ -116,6 +121,7 @@ function CredentialInput({ fieldOptions, credentialKind, ...rest }) {
<>
{RevertReplaceButton}
<PasswordInput
isFieldGroupValid={isFieldGroupValid}
{...subFormField}
id={`credential-${fieldOptions.id}`}
{...rest}
@ -169,7 +175,9 @@ function CredentialField({ credentialType, fieldOptions }) {
name: `inputs.${fieldOptions.id}`,
validate: validateField(),
});
const isValid = !(meta.touched && meta.error);
const isValid =
!(meta.touched && meta.error) ||
formikValues.passwordPrompts[fieldOptions.id];
if (fieldOptions.choices) {
const selectOptions = fieldOptions.choices.map(choice => {
@ -235,7 +243,10 @@ function CredentialField({ credentialType, fieldOptions }) {
isRequired={isRequired}
validated={isValid ? 'default' : 'error'}
>
<CredentialInput fieldOptions={fieldOptions} />
<CredentialInput
isFieldGroupValid={isValid}
fieldOptions={fieldOptions}
/>
</CredentialPluginField>
);
}

View File

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

View File

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

View File

@ -271,4 +271,46 @@ describe('<ExecutionEnvironmentForm/>', () => {
newWrapper.update();
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 useRequest from '../../util/useRequest';
import { InstanceGroupsAPI } from '../../api';
import { InstanceGroupsAPI, SettingsAPI } from '../../api';
import RoutedTabs from '../../components/RoutedTabs';
import ContentError from '../../components/ContentError';
import ContentLoading from '../../components/ContentLoading';
@ -30,12 +30,24 @@ function ContainerGroup({ setBreadcrumb }) {
isLoading,
error: contentError,
request: fetchInstanceGroups,
result: instanceGroup,
result: { instanceGroup, defaultExecution },
} = useRequest(
useCallback(async () => {
const { data } = await InstanceGroupsAPI.readDetail(id);
return data;
}, [id])
const [
{ data },
{
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(() => {
@ -109,10 +121,16 @@ function ContainerGroup({ setBreadcrumb }) {
{instanceGroup && (
<>
<Route path="/instance_groups/container_group/:id/edit">
<ContainerGroupEdit instanceGroup={instanceGroup} />
<ContainerGroupEdit
instanceGroup={instanceGroup}
defaultExecution={defaultExecution}
/>
</Route>
<Route path="/instance_groups/container_group/:id/details">
<ContainerGroupDetails instanceGroup={instanceGroup} />
<ContainerGroupDetails
instanceGroup={instanceGroup}
defaultExecution={defaultExecution}
/>
</Route>
<Route path="/instance_groups/container_group/:id/jobs">
<JobList

View File

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

View File

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

View File

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

View File

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

View File

@ -55,7 +55,10 @@ describe('<InstanceGroupEdit>', () => {
history = createMemoryHistory();
await act(async () => {
wrapper = mountWithContexts(
<InstanceGroupEdit instanceGroup={instanceGroupData} />,
<InstanceGroupEdit
defaultControlPlane="controlplane"
instanceGroup={instanceGroupData}
/>,
{
context: { router: { history } },
}
@ -68,12 +71,13 @@ describe('<InstanceGroupEdit>', () => {
wrapper.unmount();
});
test('tower instance group name can not be updated', async () => {
test('controlplane instance group name can not be updated', async () => {
let towerWrapper;
await act(async () => {
towerWrapper = mountWithContexts(
<InstanceGroupEdit
instanceGroup={{ ...instanceGroupData, name: 'tower' }}
defaultControlPlane="controlplane"
instanceGroup={{ ...instanceGroupData, name: 'controlplane' }}
/>,
{
context: { router: { history } },
@ -85,7 +89,7 @@ describe('<InstanceGroupEdit>', () => {
).toBeTruthy();
expect(
towerWrapper.find('input#instance-group-name').prop('value')
).toEqual('tower');
).toEqual('controlplane');
});
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 { Card, PageSection, DropdownItem } from '@patternfly/react-core';
import { InstanceGroupsAPI } from '../../../api';
import { InstanceGroupsAPI, SettingsAPI } from '../../../api';
import { getQSConfig, parseQueryString } from '../../../util/qs';
import useRequest, { useDeleteItems } from '../../../util/useRequest';
import useSelected from '../../../util/useSelected';
import PaginatedTable, {
HeaderRow,
HeaderCell,
ToolbarAddButton,
ToolbarDeleteButton,
} from '../../../components/PaginatedTable';
import ErrorDetail from '../../../components/ErrorDetail';
@ -25,7 +26,11 @@ const QS_CONFIG = getQSConfig('instance-group', {
page_size: 20,
});
function modifyInstanceGroups(items = []) {
function modifyInstanceGroups(
items = [],
defaultControlPlane,
defaultExecution
) {
return items.map(item => {
const clonedItem = {
...item,
@ -36,16 +41,44 @@ function modifyInstanceGroups(items = []) {
},
},
};
if (clonedItem.name === 'tower') {
if (clonedItem.name === (defaultControlPlane || defaultExecution)) {
clonedItem.summary_fields.user_capabilities.delete = false;
}
return clonedItem;
});
}
function InstanceGroupList() {
function InstanceGroupList({
isKubernetes,
isSettingsRequestLoading,
settingsRequestError,
}) {
const location = useLocation();
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 {
error: contentError,
@ -100,7 +133,11 @@ function InstanceGroupList() {
selectAll,
} = useSelected(instanceGroups);
const modifiedSelected = modifyInstanceGroups(selected);
const modifiedSelected = modifyInstanceGroups(
selected,
defaultControlPlane,
defaultExecution
);
const {
isLoading: deleteLoading,
@ -128,63 +165,66 @@ function InstanceGroupList() {
const canAdd = actions && actions.POST;
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`;
let errorMessageDelete = '';
const notdeletedable = selected.filter(
i => i.name === defaultControlPlane || i.name === defaultExecution
);
if (modifiedSelected.some(item => item.name === 'tower')) {
const itemsUnableToDelete = modifiedSelected
.filter(cannotDelete)
.filter(item => item.name !== 'tower')
.map(item => item.name)
.join(', ');
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.`
if (notdeletedable.length) {
errorMessageDelete = (
<Plural
value={notdeletedable.length}
one="The following Instance Group cannot be deleted"
other="The following Instance Groups cannot be deleted"
/>
);
}
const addContainerGroup = t`Add container group`;
const addInstanceGroup = t`Add instance group`;
const addButton = (
<AddDropDownButton
ouiaId="add-instance-group-button"
key="add"
dropdownItems={[
<DropdownItem
ouiaId="add-container-group-item"
to="/instance_groups/container_group/add"
component={Link}
key={addContainerGroup}
aria-label={addContainerGroup}
>
{addContainerGroup}
</DropdownItem>,
<DropdownItem
ouiaId="add-instance-group-item"
to="/instance_groups/add"
component={Link}
key={addInstanceGroup}
aria-label={addInstanceGroup}
>
{addInstanceGroup}
</DropdownItem>,
]}
/>
);
const addButton =
!isSettingsRequestLoading && !isKubernetes ? (
<AddDropDownButton
ouiaId="add-instance-group-button"
key="add"
dropdownItems={[
<DropdownItem
ouiaId="add-container-group-item"
to="/instance_groups/container_group/add"
component={Link}
key={addContainerGroup}
aria-label={addContainerGroup}
>
{addContainerGroup}
</DropdownItem>,
<DropdownItem
ouiaId="add-instance-group-item"
to="/instance_groups/add"
component={Link}
key={addInstanceGroup}
aria-label={addInstanceGroup}
>
{addInstanceGroup}
</DropdownItem>,
]}
/>
) : (
<ToolbarAddButton
key="add"
ouiaId="add-container-group-button"
linkTo={`${match.url}/container_group/add`}
/>
);
const getDetailUrl = item => {
return item.is_container_group
@ -199,8 +239,15 @@ function InstanceGroupList() {
<PageSection>
<Card>
<PaginatedTable
contentError={contentError}
hasContentLoading={isLoading || deleteLoading}
contentError={
contentError || settingsRequestError || protectedItemsError
}
hasContentLoading={
isLoading ||
deleteLoading ||
isSettingsRequestLoading ||
isLoadingProtectedItems
}
items={instanceGroups}
itemCount={instanceGroupsCount}
pluralizedItemName={pluralizedItemName}
@ -220,6 +267,7 @@ function InstanceGroupList() {
<ToolbarDeleteButton
key="delete"
onDelete={handleDelete}
cannotDelete={cannotDelete}
itemsToDelete={modifiedSelected}
pluralizedItemName={t`Instance Groups`}
errorMessage={errorMessageDelete}

View File

@ -11,6 +11,7 @@ import {
OrganizationsAPI,
InventoriesAPI,
UnifiedJobTemplatesAPI,
SettingsAPI,
} from '../../../api';
import InstanceGroupList from './InstanceGroupList';
@ -18,6 +19,7 @@ jest.mock('../../../api/models/InstanceGroups');
jest.mock('../../../api/models/Organizations');
jest.mock('../../../api/models/Inventories');
jest.mock('../../../api/models/UnifiedJobTemplates');
jest.mock('../../../api/models/Settings');
const instanceGroups = {
data: {
@ -32,7 +34,7 @@ const instanceGroups = {
},
{
id: 2,
name: 'tower',
name: 'controlplan',
type: 'instance_group',
url: '/api/v2/instance_groups/2',
consumed_capacity: 42,
@ -40,6 +42,14 @@ const instanceGroups = {
},
{
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',
type: 'instance_group',
url: '/api/v2/instance_groups/3',
@ -47,11 +57,17 @@ const instanceGroups = {
summary_fields: { user_capabilities: { edit: true, delete: false } },
},
],
count: 3,
count: 4,
},
};
const options = { data: { actions: { POST: true } } };
const settings = {
data: {
DEFAULT_CONTROL_PLANE_QUEUE_NAME: 'controlplan',
DEFAULT_EXECUTION_QUEUE_NAME: 'default',
},
};
describe('<InstanceGroupList />', () => {
let wrapper;
@ -62,6 +78,7 @@ describe('<InstanceGroupList />', () => {
UnifiedJobTemplatesAPI.read.mockResolvedValue({ data: { count: 0 } });
InstanceGroupsAPI.read.mockResolvedValue(instanceGroups);
InstanceGroupsAPI.readOptions.mockResolvedValue(options);
SettingsAPI.readAll.mockResolvedValue(settings);
});
test('should have data fetched and render 3 rows', async () => {
@ -69,7 +86,7 @@ describe('<InstanceGroupList />', () => {
wrapper = mountWithContexts(<InstanceGroupList />);
});
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.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 () => {
wrapper = mountWithContexts(<InstanceGroupList />);
});
await waitForElement(wrapper, 'InstanceGroupList', el => el.length > 0);
const instanceGroupIndex = [0, 1, 2];
const instanceGroupIndex = [0, 1, 2, 3];
instanceGroupIndex.forEach(element => {
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 { Route, Switch } from 'react-router-dom';
import useRequest from '../../util/useRequest';
import { SettingsAPI } from '../../api';
import InstanceGroupAdd from './InstanceGroupAdd';
import InstanceGroupList from './InstanceGroupList';
import InstanceGroup from './InstanceGroup';
import ContainerGroupAdd from './ContainerGroupAdd';
import ContainerGroup from './ContainerGroup';
import ScreenHeader from '../../components/ScreenHeader';
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({
'/instance_groups': t`Instance Groups`,
'/instance_groups/add': t`Create new instance group`,
@ -39,6 +59,7 @@ function InstanceGroups() {
[`/instance_groups/container_group/${instanceGroups.id}`]: `${instanceGroups.name}`,
});
}, []);
return (
<>
<ScreenHeader
@ -52,14 +73,20 @@ function InstanceGroups() {
<Route path="/instance_groups/container_group/:id">
<ContainerGroup setBreadcrumb={buildBreadcrumbConfig} />
</Route>
<Route path="/instance_groups/add">
<InstanceGroupAdd />
</Route>
{!isSettingsRequestLoading && !isKubernetes ? (
<Route path="/instance_groups/add">
<InstanceGroupAdd />
</Route>
) : null}
<Route path="/instance_groups/:id">
<InstanceGroup setBreadcrumb={buildBreadcrumbConfig} />
</Route>
<Route path="/instance_groups">
<InstanceGroupList />
<InstanceGroupList
isKubernetes={isKubernetes}
isSettingsRequestLoading={isSettingsRequestLoading}
settingsRequestError={settingsRequestError}
/>
</Route>
</Switch>
</>

View File

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

View File

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

View File

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

View File

@ -6,7 +6,6 @@ import { CardBody } from '../../../components/Card';
import { InventoriesAPI } from '../../../api';
import ContentLoading from '../../../components/ContentLoading';
import InventoryForm from '../shared/InventoryForm';
import { getAddedAndRemoved } from '../../../util/lists';
import useIsMounted from '../../../util/useIsMounted';
function InventoryEdit({ inventory }) {
@ -54,20 +53,12 @@ function InventoryEdit({ inventory }) {
organization: organization.id,
...remainingValues,
});
if (instanceGroups) {
const { added, removed } = getAddedAndRemoved(
associatedInstanceGroups,
instanceGroups
);
await InventoriesAPI.orderInstanceGroups(
inventory.id,
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 =
history.location.pathname.search('smart') > -1
? `/inventories/smart_inventory/${inventory.id}/details`

View File

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

View File

@ -168,6 +168,18 @@ function InventoryList() {
key: 'name__icontains',
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`,
key: 'description__icontains',

View File

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

View File

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

View File

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

View File

@ -67,6 +67,7 @@ function JobDetail({ job }) {
workflow_job_template: workflowJobTemplate,
labels,
project,
project_update: projectUpdate,
source_workflow_job,
execution_environment: executionEnvironment,
} = 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 (
<CardBody>
<DetailList>
@ -199,15 +219,7 @@ function JobDetail({ job }) {
/>
)}
{project && (
<Detail
label={t`Project`}
value={
<StatusDetailValue>
{project.status && <StatusIcon status={project.status} />}
<Link to={`/projects/${project.id}`}>{project.name}</Link>
</StatusDetailValue>
}
/>
<Detail label={t`Project`} value={buildProjectDetailValue()} />
)}
{scmBranch && (
<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 {
const [
{
data: { results: fetchedEvents = [] },
},
{
data: { results: lastEvents = [] },
},
] = 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;
}
count,
] = await Promise.all([eventPromise, countRequest()]);
if (isMounted.current) {
let countOffset = 0;

View File

@ -13,6 +13,22 @@ import mockFilteredJobEventsData from './data.filtered_job_events.json';
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 rows = [
'',
@ -82,24 +98,13 @@ const originalOffsetWidth = Object.getOwnPropertyDescriptor(
describe('<JobOutput />', () => {
let wrapper;
const mockJob = mockJobData;
const mockJobEvents = mockJobEventsData;
beforeEach(() => {
JobsAPI.readEvents = (jobId, params) => {
const [...results] = mockJobEvents.results;
if (params.order_by && params.order_by.includes('-')) {
results.reverse();
}
return {
data: {
results,
},
};
};
applyJobEventMock(mockJobEventsData);
});
afterEach(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('initially renders successfully', async () => {
@ -141,7 +146,7 @@ describe('<JobOutput />', () => {
});
wrapper.update();
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'
);
await act(async () => {
@ -149,10 +154,10 @@ describe('<JobOutput />', () => {
});
wrapper.update();
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'
);
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'
);
await act(async () => {
@ -169,7 +174,7 @@ describe('<JobOutput />', () => {
});
wrapper.update();
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'
);
Object.defineProperty(
@ -266,11 +271,7 @@ describe('<JobOutput />', () => {
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
});
await waitForElement(wrapper, 'JobEvent', el => el.length > 0);
JobsAPI.readEvents = jest.fn();
JobsAPI.readEvents.mockClear();
JobsAPI.readEvents.mockResolvedValueOnce({
data: mockFilteredJobEventsData,
});
applyJobEventMock(mockFilteredJobEventsData);
await act(async () => {
wrapper.find(searchTextInput).instance().value = '99';
wrapper.find(searchTextInput).simulate('change');
@ -281,14 +282,13 @@ describe('<JobOutput />', () => {
});
wrapper.update();
expect(JobsAPI.readEvents).toHaveBeenCalled();
// TODO: Fix these assertions
// const jobEvents = wrapper.find('JobEvent');
// 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'
// );
// 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'
// );
const jobEvents = wrapper.find('JobEvent');
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'
);
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'
);
});
test('should throw error', async () => {

View File

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

View File

@ -3,14 +3,12 @@ import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import PageControls from './PageControls';
let wrapper;
let PlusIcon;
let AngleDoubleUpIcon;
let AngleDoubleDownIcon;
let AngleUpIcon;
let AngleDownIcon;
const findChildren = () => {
PlusIcon = wrapper.find('PlusIcon');
AngleDoubleUpIcon = wrapper.find('AngleDoubleUpIcon');
AngleDoubleDownIcon = wrapper.find('AngleDoubleDownIcon');
AngleUpIcon = wrapper.find('AngleUpIcon');
@ -26,7 +24,6 @@ describe('PageControls', () => {
test('should render menu control icons', () => {
wrapper = mountWithContexts(<PageControls />);
findChildren();
expect(PlusIcon.length).toBe(1);
expect(AngleDoubleUpIcon.length).toBe(1);
expect(AngleDoubleDownIcon.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';
const NodeG = styled.g`
cursor: ${props =>
props.job && props.job.type !== 'workflow_approval'
? 'pointer'
: 'default'};
cursor: ${props => (props.job ? 'pointer' : 'default')};
`;
const JobTopLine = styled.div`
@ -90,8 +87,10 @@ function WorkflowOutputNode({ mouseEnter, mouseLeave, node }) {
}
const handleNodeClick = () => {
if (job && job.type !== 'workflow_aproval') {
history.push(`/jobs/${job.id}/details`);
if (job) {
const basePath =
job.type !== 'workflow_approval' ? 'jobs' : 'workflow_approvals';
history.push(`/${basePath}/${job.id}/details`);
}
};

View File

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

View File

@ -95,6 +95,12 @@ describe('<OrganizationAdd />', () => {
description: 'new description',
galaxy_credentials: [],
};
const mockInstanceGroups = [
{
name: 'mock ig',
id: 3,
},
];
OrganizationsAPI.create.mockResolvedValueOnce({
data: {
id: 5,
@ -109,7 +115,10 @@ describe('<OrganizationAdd />', () => {
wrapper = mountWithContexts(<OrganizationAdd />);
});
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);
});

View File

@ -3,9 +3,14 @@ import PropTypes from 'prop-types';
import { useHistory } from 'react-router-dom';
import { CardBody } from '../../../components/Card';
import { OrganizationsAPI } from '../../../api';
import { getAddedAndRemoved } from '../../../util/lists';
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 }) {
const detailsUrl = `/organizations/${organization.id}/details`;
const history = useHistory();
@ -17,43 +22,35 @@ function OrganizationEdit({ organization }) {
groupsToDisassociate
) => {
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, {
...values,
default_environment: values.default_environment?.id || null,
});
await Promise.all(
groupsToAssociate
.map(id =>
OrganizationsAPI.associateInstanceGroup(organization.id, id)
)
.concat(
addedCredentialIds.map(id =>
OrganizationsAPI.associateGalaxyCredential(organization.id, id)
)
)
);
await Promise.all(
await OrganizationsAPI.orderInstanceGroups(
organization.id,
groupsToAssociate,
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);
} catch (error) {
setFormError(error);

View File

@ -54,18 +54,34 @@ describe('<OrganizationEdit />', () => {
name: 'new name',
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 () => {
wrapper.find('OrganizationForm').invoke('onSubmit')(
updatedOrgData,
[3, 4],
[2]
newInstanceGroups,
oldInstanceGroups
);
});
expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 3);
expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 4);
expect(OrganizationsAPI.disassociateInstanceGroup).toHaveBeenCalledWith(
1,
2
expect(OrganizationsAPI.orderInstanceGroups).toHaveBeenCalledWith(
mockData.id,
newInstanceGroups,
oldInstanceGroups
);
});

View File

@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { Formik, useField, useFormikContext } from 'formik';
import { t } from '@lingui/macro';
import { t, Trans } from '@lingui/macro';
import { Form } from '@patternfly/react-core';
import { OrganizationsAPI } from '../../../api';
@ -15,7 +15,6 @@ import {
InstanceGroupsLookup,
ExecutionEnvironmentLookup,
} from '../../../components/Lookup';
import { getAddedAndRemoved } from '../../../util/lists';
import { required, minMaxValue } from '../../../util/validators';
import { FormColumnLayout } from '../../../components/FormLayout';
import CredentialLookup from '../../../components/Lookup/CredentialLookup';
@ -106,7 +105,20 @@ function OrganizationFormFields({
onChange={handleCredentialUpdate}
value={galaxyCredentialsField.value}
multiple
isSelectedDraggable
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 { added, removed } = getAddedAndRemoved(
initialInstanceGroups,
instanceGroups
);
const addedIds = added.map(({ id }) => id);
const removedIds = removed.map(({ id }) => id);
if (
typeof values.max_hosts !== 'number' ||
values.max_hosts === 'undefined'
) {
values.max_hosts = 0;
}
onSubmit(values, addedIds, removedIds);
onSubmit(values, instanceGroups, initialInstanceGroups);
};
useEffect(() => {

View File

@ -258,7 +258,14 @@ describe('<OrganizationForm />', () => {
await act(async () => {
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 () => {
@ -332,8 +339,8 @@ describe('<OrganizationForm />', () => {
max_hosts: 0,
default_environment: null,
},
[],
[]
mockInstanceGroups,
mockInstanceGroups
);
});

View File

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

View File

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

View File

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

View File

@ -287,8 +287,8 @@ describe('<JobTemplateEdit />', () => {
wrapper = mountWithContexts(
<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"]').prop('isRequired')
@ -301,8 +301,9 @@ describe('<JobTemplateEdit />', () => {
wrapper = mountWithContexts(
<JobTemplateEdit template={mockJobTemplate} />
);
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
});
wrapper.update();
const updatedTemplateData = {
job_type: 'check',
name: 'new name',

View File

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

View File

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

View File

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

View File

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

View File

@ -47,11 +47,16 @@ class HasStatus(object):
return self.wait_until_status(self.started_statuses, interval=interval, timeout=timeout)
def failure_output_details(self):
msg = ''
if getattr(self, 'result_stdout', ''):
output = bytes_to_str(self.result_stdout)
if output:
return '\nstdout:\n{}'.format(output)
return ''
msg = '\nstdout:\n{}'.format(output)
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):
if isinstance(status_list, str):
@ -65,8 +70,6 @@ class HasStatus(object):
else:
msg += '\n'
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', ''):
msg += '\nexecution_environment: {}'.format(bytes_to_str(self.execution_environment))
if getattr(self, 'related', False):
@ -75,19 +78,17 @@ class HasStatus(object):
msg += f'\nee_credential: {ee.credential}'
msg += f'\nee_pull_option: {ee.pull}'
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()
if getattr(self, 'job_explanation', '').startswith('Previous Task Failed'):
try:
data = json.loads(self.job_explanation.replace('Previous Task Failed: ', ''))
dep_output = self.connection.get(
'{0}/api/v2/{1}s/{2}/stdout/'.format(self.endpoint.split('/api')[0], data['job_type'], data['job_id']),
query_parameters=dict(format='txt_download'),
).content
msg += '\nDependency output:\n{}'.format(bytes_to_str(dep_output))
dependency = self.walk('/api/v2/{0}s/{1}/'.format(data['job_type'], data['job_id']))
if hasattr(dependency, 'failure_output_details'):
msg += '\nDependency output:\n{}'.format(dependency.failure_output_details())
else:
msg += '\nDependency info:\n{}'.format(dependency)
except Exception as 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 \
-e development_mode=yes \
-e tower_image=quay.io/awx/awx_kube_devel \
-e tower_image_version=devel \
-e tower_image_pull_policy=Always \
-e tower_ingress_type=ingress
-e image=quay.io/awx/awx_kube_devel \
-e image_version=devel \
-e image_pull_policy=Always \
-e service_type=nodeport \
-e namespace=default
```
### Custom AWX Development Image for Kubernetes

View File

@ -1,5 +1,5 @@
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
asciichartpy
autobahn>=20.12.3 # CVE-2020-35678
@ -45,7 +45,7 @@ python3-saml
python-dsv-sdk
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
receptorctl
receptorctl==1.0.0.0rc1
schedule==0.6.0
social-auth-core==3.3.1 # 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
aioredis==1.3.1
# via channels-redis
ansible-runner==2.0.0.0b1
ansible-runner==2.0.0.0rc3
# via -r /awx_devel/requirements/requirements.in
ansiconv==1.0.0
# via -r /awx_devel/requirements/requirements.in
@ -47,12 +47,12 @@ cachetools==4.0.0
# requests
cffi==1.14.0
# via cryptography
channels-redis==3.1.0
# via -r /awx_devel/requirements/requirements.in
channels==2.4.0
# via
# -r /awx_devel/requirements/requirements.in
# channels-redis
channels-redis==3.1.0
# via -r /awx_devel/requirements/requirements.in
chardet==3.0.4
# via
# aiohttp
@ -85,6 +85,19 @@ dictdiffer==0.8.1
# via openshift
distro==1.5.0
# 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
# via -r /awx_devel/requirements/requirements.in
django-cors-headers==3.7.0
@ -115,23 +128,10 @@ django-split-settings==1.0.0
# via -r /awx_devel/requirements/requirements.in
django-taggit==1.2.0
# 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
# via -r /awx_devel/requirements/requirements.in
djangorestframework-yaml==1.0.3
# via -r /awx_devel/requirements/requirements.in
docutils==0.16
# via python-daemon
future==0.16.0
@ -237,17 +237,17 @@ psycopg2==2.8.4
# via -r /awx_devel/requirements/requirements.in
ptyprocess==0.6.0
# via pexpect
pyasn1-modules==0.2.8
# via
# google-auth
# python-ldap
# service-identity
pyasn1==0.4.8
# via
# pyasn1-modules
# python-ldap
# rsa
# service-identity
pyasn1-modules==0.2.8
# via
# google-auth
# python-ldap
# service-identity
pycparser==2.20
# via cffi
pygerduty==0.38.2
@ -299,17 +299,12 @@ pyyaml==5.4.1
# djangorestframework-yaml
# kubernetes
# receptorctl
receptorctl==1.0.0.0a2
receptorctl==1.0.0.0rc1
# via -r /awx_devel/requirements/requirements.in
redis==3.4.1
# via
# -r /awx_devel/requirements/requirements.in
# django-redis
requests-oauthlib==1.3.0
# via
# kubernetes
# msrest
# social-auth-core
requests==2.23.0
# via
# -r /awx_devel/requirements/requirements.in
@ -323,12 +318,17 @@ requests==2.23.0
# slackclient
# social-auth-core
# twilio
requests-oauthlib==1.3.0
# via
# kubernetes
# msrest
# social-auth-core
rsa==4.0
# via google-auth
ruamel.yaml.clib==0.2.0
# via ruamel.yaml
ruamel.yaml==0.16.10
# via openshift
ruamel.yaml.clib==0.2.0
# via ruamel.yaml
schedule==0.6.0
# via -r /awx_devel/requirements/requirements.in
service-identity==18.1.0

View File

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

View File

@ -7,7 +7,7 @@ readonly CMDNAME=$(basename "$0")
readonly MIN_SLEEP=0.5
readonly MAX_SLEEP=30
readonly ATTEMPTS=10
readonly ATTEMPTS=30
readonly TIMEOUT=60
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)