Add several changes to Instance Groups

Add several changes to API and UI related to Instance Groups.

* Update summary_fields for DEFAULT_CONTROL_PLANE_QUEUE_NAME, and
  DEFAULT_EXECUTION_QUEUE_NAME. Rely on API validation for those fields.

* Fix Instance Group list RBAC

* Add validation for a couple of fields on the Instance Groups endpoint
	1. is_container_group
	2. policy_instance_percentage
	3. policy_instance_list

See: https://github.com/ansible/awx/issues/11130
Also: https://github.com/ansible/awx/issues/11718
This commit is contained in:
nixocio
2022-02-25 12:30:22 -05:00
parent 2e4d866f69
commit ce8b9750c9
20 changed files with 80 additions and 314 deletions

View File

@@ -4,8 +4,6 @@
# Python # Python
import logging import logging
from django.conf import settings
# Django REST Framework # Django REST Framework
from rest_framework.exceptions import MethodNotAllowed, PermissionDenied from rest_framework.exceptions import MethodNotAllowed, PermissionDenied
from rest_framework import permissions from rest_framework import permissions
@@ -250,13 +248,6 @@ class IsSystemAdminOrAuditor(permissions.BasePermission):
return request.user.is_superuser return request.user.is_superuser
class InstanceGroupTowerPermission(ModelAccessPermission):
def has_object_permission(self, request, view, obj):
if request.method == 'DELETE' and obj.name in [settings.DEFAULT_EXECUTION_QUEUE_NAME, settings.DEFAULT_CONTROL_PLANE_QUEUE_NAME]:
return False
return super(InstanceGroupTowerPermission, self).has_object_permission(request, view, obj)
class WebhookKeyPermission(permissions.BasePermission): class WebhookKeyPermission(permissions.BasePermission):
def has_object_permission(self, request, view, obj): def has_object_permission(self, request, view, obj):
return request.user.can_access(view.model, 'admin', obj, request.data) return request.user.can_access(view.model, 'admin', obj, request.data)

View File

@@ -4947,6 +4947,9 @@ class InstanceGroupSerializer(BaseSerializer):
return res return res
def validate_policy_instance_list(self, value): def validate_policy_instance_list(self, value):
if self.instance and self.instance.name in [settings.DEFAULT_EXECUTION_QUEUE_NAME, settings.DEFAULT_CONTROL_PLANE_QUEUE_NAME]:
if self.instance.policy_instance_list != value:
raise serializers.ValidationError(_('%s instance group policy_instance_list may not be changed.' % self.instance.name))
for instance_name in value: for instance_name in value:
if value.count(instance_name) > 1: if value.count(instance_name) > 1:
raise serializers.ValidationError(_('Duplicate entry {}.').format(instance_name)) raise serializers.ValidationError(_('Duplicate entry {}.').format(instance_name))
@@ -4957,6 +4960,11 @@ class InstanceGroupSerializer(BaseSerializer):
return value return value
def validate_policy_instance_percentage(self, value): def validate_policy_instance_percentage(self, value):
if self.instance and self.instance.name in [settings.DEFAULT_EXECUTION_QUEUE_NAME, settings.DEFAULT_CONTROL_PLANE_QUEUE_NAME]:
if value != self.instance.policy_instance_percentage:
raise serializers.ValidationError(
_('%s instance group policy_instance_percentage may not be changed from the initial value set by the installer.' % self.instance.name)
)
if value and self.instance and self.instance.is_container_group: if value and self.instance and self.instance.is_container_group:
raise serializers.ValidationError(_('Containerized instances may not be managed via the API')) raise serializers.ValidationError(_('Containerized instances may not be managed via the API'))
return value return value
@@ -4975,6 +4983,13 @@ class InstanceGroupSerializer(BaseSerializer):
return value return value
def validate_is_container_group(self, value):
if self.instance and self.instance.name in [settings.DEFAULT_EXECUTION_QUEUE_NAME, settings.DEFAULT_CONTROL_PLANE_QUEUE_NAME]:
if value != self.instance.is_container_group:
raise serializers.ValidationError(_('%s instance group is_container_group may not be changed.' % self.instance.name))
return value
def validate_credential(self, value): def validate_credential(self, value):
if value and not value.kubernetes: if value and not value.kubernetes:
raise serializers.ValidationError(_('Only Kubernetes credentials can be associated with an Instance Group')) raise serializers.ValidationError(_('Only Kubernetes credentials can be associated with an Instance Group'))

View File

@@ -105,7 +105,6 @@ from awx.api.permissions import (
ProjectUpdatePermission, ProjectUpdatePermission,
InventoryInventorySourcesUpdatePermission, InventoryInventorySourcesUpdatePermission,
UserPermission, UserPermission,
InstanceGroupTowerPermission,
VariableDataPermission, VariableDataPermission,
WorkflowApprovalPermission, WorkflowApprovalPermission,
IsSystemAdminOrAuditor, IsSystemAdminOrAuditor,
@@ -480,7 +479,6 @@ class InstanceGroupDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAP
name = _("Instance Group Detail") name = _("Instance Group Detail")
model = models.InstanceGroup model = models.InstanceGroup
serializer_class = serializers.InstanceGroupSerializer serializer_class = serializers.InstanceGroupSerializer
permission_classes = (InstanceGroupTowerPermission,)
def update_raw_data(self, data): def update_raw_data(self, data):
if self.get_object().is_container_group: if self.get_object().is_container_group:

View File

@@ -465,7 +465,7 @@ class BaseAccess(object):
if display_method == 'schedule': if display_method == 'schedule':
user_capabilities['schedule'] = user_capabilities['start'] user_capabilities['schedule'] = user_capabilities['start']
continue continue
elif display_method == 'delete' and not isinstance(obj, (User, UnifiedJob, CredentialInputSource, ExecutionEnvironment)): elif display_method == 'delete' and not isinstance(obj, (User, UnifiedJob, CredentialInputSource, ExecutionEnvironment, InstanceGroup)):
user_capabilities['delete'] = user_capabilities['edit'] user_capabilities['delete'] = user_capabilities['edit']
continue continue
elif display_method == 'copy' and isinstance(obj, (Group, Host)): elif display_method == 'copy' and isinstance(obj, (Group, Host)):
@@ -575,6 +575,11 @@ class InstanceGroupAccess(BaseAccess):
def can_change(self, obj, data): def can_change(self, obj, data):
return self.user.is_superuser return self.user.is_superuser
def can_delete(self, obj):
if obj.name in [settings.DEFAULT_EXECUTION_QUEUE_NAME, settings.DEFAULT_CONTROL_PLANE_QUEUE_NAME]:
return False
return self.user.is_superuser
class UserAccess(BaseAccess): class UserAccess(BaseAccess):
""" """

View File

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

View File

@@ -11,7 +11,7 @@ import { jsonToYaml, isJsonString } from 'util/yaml';
import ContainerGroupForm from '../shared/ContainerGroupForm'; import ContainerGroupForm from '../shared/ContainerGroupForm';
function ContainerGroupAdd({ defaultExecution, defaultControlPlane }) { function ContainerGroupAdd() {
const history = useHistory(); const history = useHistory();
const [submitError, setSubmitError] = useState(null); const [submitError, setSubmitError] = useState(null);
@@ -93,8 +93,6 @@ function ContainerGroupAdd({ defaultExecution, defaultControlPlane }) {
<Card> <Card>
<CardBody> <CardBody>
<ContainerGroupForm <ContainerGroupForm
defaultControlPlane={defaultControlPlane}
defaultExecution={defaultExecution}
initialPodSpec={initialPodSpec} initialPodSpec={initialPodSpec}
onSubmit={handleSubmit} onSubmit={handleSubmit}
submitError={submitError} submitError={submitError}

View File

@@ -15,7 +15,7 @@ import { jsonToYaml, isJsonString } from 'util/yaml';
import { InstanceGroupsAPI } from 'api'; import { InstanceGroupsAPI } from 'api';
import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails'; import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails';
function ContainerGroupDetails({ instanceGroup, defaultExecution }) { function ContainerGroupDetails({ instanceGroup }) {
const { id, name } = instanceGroup; const { id, name } = instanceGroup;
const history = useHistory(); const history = useHistory();
@@ -99,8 +99,7 @@ function ContainerGroupDetails({ instanceGroup, defaultExecution }) {
{t`Edit`} {t`Edit`}
</Button> </Button>
)} )}
{name !== defaultExecution && {instanceGroup.summary_fields.user_capabilities &&
instanceGroup.summary_fields.user_capabilities &&
instanceGroup.summary_fields.user_capabilities.delete && ( instanceGroup.summary_fields.user_capabilities.delete && (
<DeleteButton <DeleteButton
ouiaId="container-group-detail-delete-button" ouiaId="container-group-detail-delete-button"

View File

@@ -9,11 +9,7 @@ import ContentError from 'components/ContentError';
import ContentLoading from 'components/ContentLoading'; import ContentLoading from 'components/ContentLoading';
import ContainerGroupForm from '../shared/ContainerGroupForm'; import ContainerGroupForm from '../shared/ContainerGroupForm';
function ContainerGroupEdit({ function ContainerGroupEdit({ instanceGroup }) {
instanceGroup,
defaultControlPlane,
defaultExecution,
}) {
const history = useHistory(); const history = useHistory();
const [submitError, setSubmitError] = useState(null); const [submitError, setSubmitError] = useState(null);
const detailsIUrl = `/instance_groups/container_group/${instanceGroup.id}/details`; const detailsIUrl = `/instance_groups/container_group/${instanceGroup.id}/details`;
@@ -77,8 +73,6 @@ function ContainerGroupEdit({
return ( return (
<CardBody> <CardBody>
<ContainerGroupForm <ContainerGroupForm
defaultControlPlane={defaultControlPlane}
defaultExecution={defaultExecution}
instanceGroup={instanceGroup} instanceGroup={instanceGroup}
initialPodSpec={initialPodSpec} initialPodSpec={initialPodSpec}
onSubmit={handleSubmit} onSubmit={handleSubmit}

View File

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

View File

@@ -6,7 +6,7 @@ import { CardBody } from 'components/Card';
import { InstanceGroupsAPI } from 'api'; import { InstanceGroupsAPI } from 'api';
import InstanceGroupForm from '../shared/InstanceGroupForm'; import InstanceGroupForm from '../shared/InstanceGroupForm';
function InstanceGroupAdd({ defaultExecution, defaultControlPlane }) { function InstanceGroupAdd() {
const history = useHistory(); const history = useHistory();
const [submitError, setSubmitError] = useState(null); const [submitError, setSubmitError] = useState(null);
@@ -28,8 +28,6 @@ function InstanceGroupAdd({ defaultExecution, defaultControlPlane }) {
<Card> <Card>
<CardBody> <CardBody>
<InstanceGroupForm <InstanceGroupForm
defaultControlPlane={defaultControlPlane}
defaultExecution={defaultExecution}
onSubmit={handleSubmit} onSubmit={handleSubmit}
submitError={submitError} submitError={submitError}
onCancel={handleCancel} onCancel={handleCancel}

View File

@@ -23,11 +23,7 @@ const Unavailable = styled.span`
color: var(--pf-global--danger-color--200); color: var(--pf-global--danger-color--200);
`; `;
function InstanceGroupDetails({ function InstanceGroupDetails({ instanceGroup }) {
instanceGroup,
defaultControlPlane,
defaultExecution,
}) {
const { id, name } = instanceGroup; const { id, name } = instanceGroup;
const history = useHistory(); const history = useHistory();
@@ -46,8 +42,6 @@ function InstanceGroupDetails({
const { error, dismissError } = useDismissableError(deleteError); const { error, dismissError } = useDismissableError(deleteError);
const deleteDetailsRequests = const deleteDetailsRequests =
relatedResourceDeleteRequests.instanceGroup(instanceGroup); relatedResourceDeleteRequests.instanceGroup(instanceGroup);
const isDefaultInstanceGroup =
name === defaultControlPlane || name === defaultExecution;
return ( return (
<CardBody> <CardBody>
<DetailList> <DetailList>
@@ -115,8 +109,7 @@ function InstanceGroupDetails({
{t`Edit`} {t`Edit`}
</Button> </Button>
)} )}
{!isDefaultInstanceGroup && {instanceGroup.summary_fields.user_capabilities &&
instanceGroup.summary_fields.user_capabilities &&
instanceGroup.summary_fields.user_capabilities.delete && ( instanceGroup.summary_fields.user_capabilities.delete && (
<DeleteButton <DeleteButton
ouiaId="instance-group-detail-delete-button" ouiaId="instance-group-detail-delete-button"

View File

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

View File

@@ -55,10 +55,7 @@ describe('<InstanceGroupEdit>', () => {
history = createMemoryHistory(); history = createMemoryHistory();
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<InstanceGroupEdit <InstanceGroupEdit instanceGroup={instanceGroupData} />,
defaultControlPlane="controlplane"
instanceGroup={instanceGroupData}
/>,
{ {
context: { router: { history } }, context: { router: { history } },
} }
@@ -70,27 +67,6 @@ describe('<InstanceGroupEdit>', () => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
test('controlplane instance group name can not be updated', async () => {
let towerWrapper;
await act(async () => {
towerWrapper = mountWithContexts(
<InstanceGroupEdit
defaultControlPlane="controlplane"
instanceGroup={{ ...instanceGroupData, name: 'controlplane' }}
/>,
{
context: { router: { history } },
}
);
});
expect(
towerWrapper.find('input#instance-group-name').prop('disabled')
).toBeTruthy();
expect(
towerWrapper.find('input#instance-group-name').prop('value')
).toEqual('controlplane');
});
test('handleSubmit should call the api and redirect to details page', async () => { test('handleSubmit should call the api and redirect to details page', async () => {
await act(async () => { await act(async () => {
wrapper.find('InstanceGroupForm').invoke('onSubmit')( wrapper.find('InstanceGroupForm').invoke('onSubmit')(

View File

@@ -4,7 +4,7 @@ import { useLocation, useRouteMatch, Link } from 'react-router-dom';
import { t, Plural } from '@lingui/macro'; import { t, Plural } from '@lingui/macro';
import { Card, PageSection, DropdownItem } from '@patternfly/react-core'; import { Card, PageSection, DropdownItem } from '@patternfly/react-core';
import { InstanceGroupsAPI, SettingsAPI } from 'api'; import { InstanceGroupsAPI } from 'api';
import { getQSConfig, parseQueryString } from 'util/qs'; import { getQSConfig, parseQueryString } from 'util/qs';
import useRequest, { useDeleteItems } from 'hooks/useRequest'; import useRequest, { useDeleteItems } from 'hooks/useRequest';
import useSelected from 'hooks/useSelected'; import useSelected from 'hooks/useSelected';
@@ -27,28 +27,6 @@ const QS_CONFIG = getQSConfig('instance-group', {
page_size: 20, page_size: 20,
}); });
function modifyInstanceGroups(
defaultControlPlane,
defaultExecution,
items = []
) {
return items.map((item) => {
const clonedItem = {
...item,
summary_fields: {
...item.summary_fields,
user_capabilities: {
...item.summary_fields.user_capabilities,
},
},
};
if (clonedItem.name === (defaultControlPlane || defaultExecution)) {
clonedItem.summary_fields.user_capabilities.delete = false;
}
return clonedItem;
});
}
function InstanceGroupList({ function InstanceGroupList({
isKubernetes, isKubernetes,
isSettingsRequestLoading, isSettingsRequestLoading,
@@ -56,30 +34,6 @@ function InstanceGroupList({
}) { }) {
const location = useLocation(); const location = useLocation();
const match = useRouteMatch(); const match = useRouteMatch();
const {
error: protectedItemsError,
isLoading: isLoadingProtectedItems,
request: fetchProtectedItems,
result: { defaultControlPlane, defaultExecution },
} = useRequest(
useCallback(async () => {
const {
data: {
DEFAULT_CONTROL_PLANE_QUEUE_NAME,
DEFAULT_EXECUTION_QUEUE_NAME,
},
} = await SettingsAPI.readAll();
return {
defaultControlPlane: DEFAULT_CONTROL_PLANE_QUEUE_NAME,
defaultExecution: DEFAULT_EXECUTION_QUEUE_NAME,
};
}, []),
{ defaultControlPlane: '', defaultExecution: '' }
);
useEffect(() => {
fetchProtectedItems();
}, [fetchProtectedItems]);
const { const {
error: contentError, error: contentError,
@@ -127,12 +81,6 @@ function InstanceGroupList({
const { selected, isAllSelected, handleSelect, clearSelected, selectAll } = const { selected, isAllSelected, handleSelect, clearSelected, selectAll } =
useSelected(instanceGroups); useSelected(instanceGroups);
const modifiedSelected = modifyInstanceGroups(
defaultControlPlane,
defaultExecution,
selected
);
const { const {
isLoading: deleteLoading, isLoading: deleteLoading,
deletionError, deletionError,
@@ -158,28 +106,10 @@ function InstanceGroupList({
const canAdd = actions && actions.POST; const canAdd = actions && actions.POST;
const cannotDelete = (item) => const cannotDelete = (item) => !item.summary_fields.user_capabilities.delete;
!item.summary_fields.user_capabilities.delete ||
item.name === defaultExecution ||
item.name === defaultControlPlane;
const pluralizedItemName = t`Instance Groups`; const pluralizedItemName = t`Instance Groups`;
let errorMessageDelete = '';
const notdeletedable = selected.filter(
(i) => i.name === defaultControlPlane || i.name === defaultExecution
);
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 addContainerGroup = t`Add container group`;
const addInstanceGroup = t`Add instance group`; const addInstanceGroup = t`Add instance group`;
@@ -229,14 +159,9 @@ function InstanceGroupList({
<PageSection> <PageSection>
<Card> <Card>
<PaginatedTable <PaginatedTable
contentError={ contentError={contentError || settingsRequestError}
contentError || settingsRequestError || protectedItemsError
}
hasContentLoading={ hasContentLoading={
isLoading || isLoading || deleteLoading || isSettingsRequestLoading
deleteLoading ||
isSettingsRequestLoading ||
isLoadingProtectedItems
} }
items={instanceGroups} items={instanceGroups}
itemCount={instanceGroupsCount} itemCount={instanceGroupsCount}
@@ -264,9 +189,8 @@ function InstanceGroupList({
key="delete" key="delete"
onDelete={handleDelete} onDelete={handleDelete}
cannotDelete={cannotDelete} cannotDelete={cannotDelete}
itemsToDelete={modifiedSelected} itemsToDelete={selected}
pluralizedItemName={t`Instance Groups`} pluralizedItemName={t`Instance Groups`}
errorMessage={errorMessageDelete}
deleteDetailsRequests={deleteDetailsRequests} deleteDetailsRequests={deleteDetailsRequests}
deleteMessage={ deleteMessage={
<Plural <Plural

View File

@@ -4,6 +4,7 @@ import { t } from '@lingui/macro';
import { Route, Switch, useLocation } from 'react-router-dom'; import { Route, Switch, useLocation } from 'react-router-dom';
import { Card, PageSection } from '@patternfly/react-core'; import { Card, PageSection } from '@patternfly/react-core';
import { useUserProfile } from 'contexts/Config';
import useRequest from 'hooks/useRequest'; import useRequest from 'hooks/useRequest';
import { SettingsAPI } from 'api'; import { SettingsAPI } from 'api';
import ScreenHeader from 'components/ScreenHeader'; import ScreenHeader from 'components/ScreenHeader';
@@ -16,31 +17,28 @@ import ContainerGroup from './ContainerGroup';
function InstanceGroups() { function InstanceGroups() {
const { pathname } = useLocation(); const { pathname } = useLocation();
const { isSuperUser, isSystemAuditor } = useUserProfile();
const userCanReadSettings = isSuperUser || isSystemAuditor;
const { const {
request: settingsRequest, request: settingsRequest,
isLoading: isSettingsRequestLoading, isLoading: isSettingsRequestLoading,
error: settingsRequestError, error: settingsRequestError,
result: { isKubernetes, defaultControlPlane, defaultExecution }, result: { isKubernetes },
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const { const {
data: { data: { IS_K8S },
IS_K8S,
DEFAULT_CONTROL_PLANE_QUEUE_NAME,
DEFAULT_EXECUTION_QUEUE_NAME,
},
} = await SettingsAPI.readCategory('all'); } = await SettingsAPI.readCategory('all');
return { return {
isKubernetes: IS_K8S, isKubernetes: IS_K8S,
defaultControlPlane: DEFAULT_CONTROL_PLANE_QUEUE_NAME,
defaultExecution: DEFAULT_EXECUTION_QUEUE_NAME,
}; };
}, []), }, []),
{ isLoading: true } { isKubernetes: false }
); );
useEffect(() => { useEffect(() => {
settingsRequest(); userCanReadSettings && settingsRequest();
}, [settingsRequest]); }, [settingsRequest, userCanReadSettings]);
const [breadcrumbConfig, setBreadcrumbConfig] = useState({ const [breadcrumbConfig, setBreadcrumbConfig] = useState({
'/instance_groups': t`Instance Groups`, '/instance_groups': t`Instance Groups`,
@@ -91,20 +89,14 @@ function InstanceGroups() {
) : ( ) : (
<Switch> <Switch>
<Route path="/instance_groups/container_group/add"> <Route path="/instance_groups/container_group/add">
<ContainerGroupAdd <ContainerGroupAdd />
defaultControlPlane={defaultControlPlane}
defaultExecution={defaultExecution}
/>
</Route> </Route>
<Route path="/instance_groups/container_group/:id"> <Route path="/instance_groups/container_group/:id">
<ContainerGroup setBreadcrumb={buildBreadcrumbConfig} /> <ContainerGroup setBreadcrumb={buildBreadcrumbConfig} />
</Route> </Route>
{!isKubernetes && ( {!isKubernetes && (
<Route path="/instance_groups/add"> <Route path="/instance_groups/add">
<InstanceGroupAdd <InstanceGroupAdd />
defaultControlPlane={defaultControlPlane}
defaultExecution={defaultExecution}
/>
</Route> </Route>
)} )}
<Route path="/instance_groups/:id"> <Route path="/instance_groups/:id">

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { InstanceGroupsAPI } from 'api'; import { InstanceGroupsAPI } from 'api';
import InstanceGroups from './InstanceGroups'; import InstanceGroups from './InstanceGroups';
import { useUserProfile } from 'contexts/Config';
const mockUseLocationValue = { const mockUseLocationValue = {
pathname: '', pathname: '',
@@ -11,6 +12,19 @@ jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), ...jest.requireActual('react-router-dom'),
useLocation: () => mockUseLocationValue, useLocation: () => mockUseLocationValue,
})); }));
beforeEach(() => {
useUserProfile.mockImplementation(() => {
return {
isSuperUser: true,
isSystemAuditor: false,
isOrgAdmin: false,
isNotificationAdmin: false,
isExecEnvAdmin: false,
};
});
});
describe('<InstanceGroups/>', () => { describe('<InstanceGroups/>', () => {
test('should set breadcrumbs', () => { test('should set breadcrumbs', () => {
mockUseLocationValue.pathname = '/instance_groups'; mockUseLocationValue.pathname = '/instance_groups';

View File

@@ -11,7 +11,7 @@ import FormField, {
CheckboxField, CheckboxField,
} from 'components/FormField'; } from 'components/FormField';
import FormActionGroup from 'components/FormActionGroup'; import FormActionGroup from 'components/FormActionGroup';
import { combine, required, protectedResourceName } from 'util/validators'; import { required } from 'util/validators';
import { import {
FormColumnLayout, FormColumnLayout,
FormFullWidthLayout, FormFullWidthLayout,
@@ -21,21 +21,11 @@ import {
import CredentialLookup from 'components/Lookup/CredentialLookup'; import CredentialLookup from 'components/Lookup/CredentialLookup';
import { VariablesField } from 'components/CodeEditor'; import { VariablesField } from 'components/CodeEditor';
function ContainerGroupFormFields({ function ContainerGroupFormFields({ instanceGroup }) {
instanceGroup,
defaultControlPlane,
defaultExecution,
}) {
const { setFieldValue, setFieldTouched } = useFormikContext(); const { setFieldValue, setFieldTouched } = useFormikContext();
const [credentialField, credentialMeta, credentialHelpers] = const [credentialField, credentialMeta, credentialHelpers] =
useField('credential'); useField('credential');
const [, { initialValue }] = useField('name');
const isProtected =
initialValue === `${defaultControlPlane}` ||
initialValue === `${defaultExecution}`;
const [overrideField] = useField('override'); const [overrideField] = useField('override');
const handleCredentialUpdate = useCallback( const handleCredentialUpdate = useCallback(
@@ -50,21 +40,10 @@ function ContainerGroupFormFields({
<> <>
<FormField <FormField
name="name" name="name"
helperText={
isProtected
? t`This is a protected Instance Group. The name cannot be changed.`
: ''
}
id="container-group-name" id="container-group-name"
label={t`Name`} label={t`Name`}
type="text" type="text"
validate={combine([ validate={required(null)}
required(null),
protectedResourceName(
t`This is a protected name for Container Groups. Please use a different name.`,
[defaultControlPlane, defaultExecution]
),
])}
isRequired isRequired
/> />
<CredentialLookup <CredentialLookup

View File

@@ -1,49 +1,25 @@
import React from 'react'; import React from 'react';
import { func, shape } from 'prop-types'; import { func, shape } from 'prop-types';
import { Formik, useField } from 'formik'; import { Formik } from 'formik';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Form } from '@patternfly/react-core'; import { Form } from '@patternfly/react-core';
import FormField, { FormSubmitError } from 'components/FormField'; import FormField, { FormSubmitError } from 'components/FormField';
import FormActionGroup from 'components/FormActionGroup'; import FormActionGroup from 'components/FormActionGroup';
import { import { required, minMaxValue } from 'util/validators';
combine,
required,
protectedResourceName,
minMaxValue,
} from 'util/validators';
import { FormColumnLayout } from 'components/FormLayout'; import { FormColumnLayout } from 'components/FormLayout';
function InstanceGroupFormFields({ defaultControlPlane, defaultExecution }) { function InstanceGroupFormFields() {
const [, { initialValue }] = useField('name');
const isProtected =
initialValue === `${defaultControlPlane}` ||
initialValue === `${defaultExecution}`;
const validators = combine([
required(null),
protectedResourceName(
t`This is a protected name for Instance Groups. Please use a different name.`,
[defaultControlPlane, defaultExecution]
),
]);
return ( return (
<> <>
<FormField <FormField
name="name" name="name"
helperText={
isProtected
? t`This is a protected Instance Group. The name cannot be changed.`
: ''
}
id="instance-group-name" id="instance-group-name"
label={t`Name`} label={t`Name`}
type="text" type="text"
validate={validators} validate={required(null)}
isRequired isRequired
isDisabled={isProtected}
/> />
<FormField <FormField
id="instance-group-policy-instance-minimum" id="instance-group-policy-instance-minimum"

View File

@@ -117,44 +117,4 @@ describe('<InstanceGroupForm/>', () => {
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
expect(onCancel).toBeCalled(); expect(onCancel).toBeCalled();
}); });
test('Name field should be disabled, default', async () => {
let defaultInstanceGroupWrapper;
await act(async () => {
defaultInstanceGroupWrapper = mountWithContexts(
<InstanceGroupForm
onCancel={onCancel}
onSubmit={onSubmit}
defaultControlPlane="controlplane"
defaultExecution="default"
instanceGroup={{ ...instanceGroup, name: 'default' }}
/>
);
});
expect(
defaultInstanceGroupWrapper
.find('TextInput[name="name"]')
.prop('isDisabled')
).toBe(true);
});
test('Name field should be disabled, controlplane', async () => {
let defaultInstanceGroupWrapper;
await act(async () => {
defaultInstanceGroupWrapper = mountWithContexts(
<InstanceGroupForm
onCancel={onCancel}
onSubmit={onSubmit}
defaultControlPlane="controlplane"
defaultExecution="default"
instanceGroup={{ ...instanceGroup, name: 'controlplane' }}
/>
);
});
expect(
defaultInstanceGroupWrapper
.find('TextInput[name="name"]')
.prop('isDisabled')
).toBe(true);
});
}); });

View File

@@ -93,6 +93,7 @@ jest.doMock('./contexts/Config', () => ({
Config: MockConfigContext.Consumer, Config: MockConfigContext.Consumer,
useConfig: () => React.useContext(MockConfigContext), useConfig: () => React.useContext(MockConfigContext),
useAuthorizedPath: jest.fn(), useAuthorizedPath: jest.fn(),
useUserProfile: jest.fn(),
})); }));
// ? // ?