Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions awx/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2516,6 +2516,7 @@ def get_related(self, obj):
activity_stream=self.reverse('api:inventory_source_activity_stream_list', kwargs={'pk': obj.pk}),
hosts=self.reverse('api:inventory_source_hosts_list', kwargs={'pk': obj.pk}),
groups=self.reverse('api:inventory_source_groups_list', kwargs={'pk': obj.pk}),
instance_groups=self.reverse('api:inventory_source_instance_groups_list', kwargs={'pk': obj.pk}),
notification_templates_started=self.reverse('api:inventory_source_notification_templates_started_list', kwargs={'pk': obj.pk}),
notification_templates_success=self.reverse('api:inventory_source_notification_templates_success_list', kwargs={'pk': obj.pk}),
notification_templates_error=self.reverse('api:inventory_source_notification_templates_error_list', kwargs={'pk': obj.pk}),
Expand Down
2 changes: 2 additions & 0 deletions awx/api/urls/inventory_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
InventorySourceSchedulesList,
InventorySourceCredentialsList,
InventorySourceGroupsList,
InventorySourceInstanceGroupsList,
InventorySourceHostsList,
InventorySourceNotificationTemplatesErrorList,
InventorySourceNotificationTemplatesStartedList,
Expand All @@ -26,6 +27,7 @@
path('<int:pk>/activity_stream/', InventorySourceActivityStreamList.as_view(), name='inventory_source_activity_stream_list'),
path('<int:pk>/schedules/', InventorySourceSchedulesList.as_view(), name='inventory_source_schedules_list'),
path('<int:pk>/credentials/', InventorySourceCredentialsList.as_view(), name='inventory_source_credentials_list'),
path('<int:pk>/instance_groups/', InventorySourceInstanceGroupsList.as_view(), name='inventory_source_instance_groups_list'),
path('<int:pk>/groups/', InventorySourceGroupsList.as_view(), name='inventory_source_groups_list'),
path('<int:pk>/hosts/', InventorySourceHostsList.as_view(), name='inventory_source_hosts_list'),
path(
Expand Down
8 changes: 8 additions & 0 deletions awx/api/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2294,6 +2294,14 @@ def is_valid_relation(self, parent, sub, created=False):
return None


class InventorySourceInstanceGroupsList(SubListAttachDetachAPIView):
model = models.InstanceGroup
serializer_class = serializers.InstanceGroupSerializer
parent_model = models.InventorySource
relationship = 'instance_groups'
filter_read_permission = False


class InventorySourceUpdateView(RetrieveAPIView):
model = models.InventorySource
obj_permission_type = 'start'
Expand Down
14 changes: 14 additions & 0 deletions awx/main/access.py
Original file line number Diff line number Diff line change
Expand Up @@ -1117,6 +1117,20 @@ def can_start(self, obj, validate_license=True):
return self.user in obj.inventory.update_role
return False

@check_superuser
def can_attach(self, obj, sub_obj, relationship, data, skip_sub_obj_read_check=False):
if relationship == 'instance_groups':
if not obj.inventory:
return False
return self.user in sub_obj.use_role and self.user in obj.inventory.admin_role
return super(InventorySourceAccess, self).can_attach(obj, sub_obj, relationship, data, skip_sub_obj_read_check=skip_sub_obj_read_check)

@check_superuser
def can_unattach(self, obj, sub_obj, relationship, data=None, skip_sub_obj_read_check=False):
if relationship == 'instance_groups':
return self.can_attach(obj, sub_obj, relationship, data, skip_sub_obj_read_check=skip_sub_obj_read_check)
return super(InventorySourceAccess, self).can_unattach(obj, sub_obj, relationship, data)


class InventoryUpdateAccess(BaseAccess):
"""
Expand Down
5 changes: 4 additions & 1 deletion awx/main/models/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -1424,8 +1424,11 @@ def get_notification_friendly_name(self):
@property
def preferred_instance_groups(self):
selected_groups = []
# Instance groups set directly on the inventory source take precedence over the inventory's
for instance_group in self.inventory_source.instance_groups.all():
selected_groups.append(instance_group)
if self.inventory_source.inventory is not None:
# Add the inventory sources IG to the selected IGs first
# Add the inventory's IGs to the selected IGs next
for instance_group in self.inventory_source.inventory.instance_groups.all():
selected_groups.append(instance_group)
# If the inventory allows for fallback and we have an organization then also append the orgs IGs to the end of the list
Expand Down
16 changes: 15 additions & 1 deletion awx/main/tests/functional/api/test_instance_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ def test_delete_rename_tower_instance_group_prevented(


@pytest.mark.django_db
@pytest.mark.parametrize('source_model', ['job_template', 'inventory', 'organization'], indirect=True)
@pytest.mark.parametrize('source_model', ['job_template', 'inventory', 'organization', 'inventory_source'], indirect=True)
def test_instance_group_order_persistence(get, post, admin, source_model):
# create several instance groups in random order
total = 5
Expand Down Expand Up @@ -167,6 +167,20 @@ def test_instance_group_order_persistence(get, post, admin, source_model):
assert [ig['name'] for ig in resp.data['results']] == [ig.name for ig in before]


@pytest.mark.django_db
def test_inventory_source_instance_group_attach_permissions(get, post, rando, inventory_source, instance_group):
url = reverse('api:inventory_source_instance_groups_list', kwargs={'pk': inventory_source.pk})
inventory_source.inventory.admin_role.members.add(rando)
# inventory admin without use permission on the instance group cannot attach it
post(url, {'associate': True, 'id': instance_group.id}, rando, expect=403)
assert list(inventory_source.instance_groups.all()) == []
instance_group.use_role.members.add(rando)
post(url, {'associate': True, 'id': instance_group.id}, rando, expect=204)
assert list(inventory_source.instance_groups.all()) == [instance_group]
post(url, {'disassociate': True, 'id': instance_group.id}, rando, expect=204)
assert list(inventory_source.instance_groups.all()) == []


@pytest.mark.django_db
def test_instance_group_update_fields(patch, instance, instance_group, admin, containerized_instance_group):
# policy_instance_ variables can only be updated in instance groups that are NOT containerized
Expand Down
6 changes: 4 additions & 2 deletions awx/main/tests/functional/test_instances.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,9 +423,11 @@ def test_inventory_update_instance_groups(self, instance_group_factory, inventor
inventory_source.inventory.instance_groups.add(ig_inv)
assert iu.preferred_instance_groups == [ig_inv, ig_org]
inventory_source.instance_groups.add(ig_tmp)
# API does not allow setting IGs on inventory source, so ignore those
assert iu.preferred_instance_groups == [ig_inv, ig_org]
# Instance groups on the inventory source take precedence over the inventory's
assert iu.preferred_instance_groups == [ig_tmp, ig_inv, ig_org]
inventory_source.inventory.prevent_instance_group_fallback = True
assert iu.preferred_instance_groups == [ig_tmp, ig_inv]
inventory_source.instance_groups.remove(ig_tmp)
assert iu.preferred_instance_groups == [ig_inv]

def test_job_instance_groups(self, instance_group_factory, inventory, project, default_instance_group):
Expand Down
18 changes: 18 additions & 0 deletions awx/main/tests/functional/test_rbac_instance_groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
InstanceGroupAccess,
OrganizationAccess,
InventoryAccess,
InventorySourceAccess,
JobTemplateAccess,
)

Expand Down Expand Up @@ -41,6 +42,23 @@ def test_ig_role_based_associability(default_instance_group, rando, organization
assert allowed == OrganizationAccess(rando).can_attach(objects.organization, default_instance_group, 'instance_groups', None)


@pytest.mark.django_db
@pytest.mark.parametrize(
"obj_perm,subobj_perm,allowed",
[('admin_role', 'use_role', True), ('admin_role', 'read_role', False), ('use_role', 'use_role', False), ('admin_role', 'admin_role', True)],
)
def test_ig_inventory_source_associability(default_instance_group, rando, inventory_source, obj_perm, subobj_perm, allowed):
if obj_perm:
getattr(inventory_source.inventory, obj_perm).members.add(rando)
if subobj_perm:
getattr(default_instance_group, subobj_perm).members.add(rando)

assert allowed == InventorySourceAccess(rando).can_attach(inventory_source, default_instance_group, 'instance_groups', None)
assert allowed == InventorySourceAccess(rando).can_unattach(inventory_source, default_instance_group, 'instance_groups', None)
# data is optional for unattach checks, callers are allowed to omit it
assert allowed == InventorySourceAccess(rando).can_unattach(inventory_source, default_instance_group, 'instance_groups')


@pytest.mark.django_db
def test_ig_use_with_org_admin(default_instance_group, rando, org_admin):
default_instance_group.use_role.members.add(rando)
Expand Down
5 changes: 3 additions & 2 deletions awx/ui/src/api/models/InventorySources.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import Base from '../Base';
import NotificationsMixin from '../mixins/Notifications.mixin';
import LaunchUpdateMixin from '../mixins/LaunchUpdate.mixin';
import SchedulesMixin from '../mixins/Schedules.mixin';
import InstanceGroupsMixin from '../mixins/InstanceGroups.mixin';

class InventorySources extends LaunchUpdateMixin(
NotificationsMixin(SchedulesMixin(Base))
class InventorySources extends InstanceGroupsMixin(
LaunchUpdateMixin(NotificationsMixin(SchedulesMixin(Base)))
) {
constructor(http) {
super(http);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,14 @@ function InventorySourceAdd({ inventory }) {
const { id, organization } = inventory;

const { error, request, result } = useRequest(
useCallback(async (values) => {
useCallback(async ({ instanceGroups, ...values }) => {
const { data } = await InventorySourcesAPI.create(values);
/* 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 InventorySourcesAPI.associateInstanceGroup(data.id, group.id);
}
/* eslint-enable no-await-in-loop, no-restricted-syntax */
return data;
}, [])
);
Expand All @@ -34,6 +40,7 @@ function InventorySourceAdd({ inventory }) {
source_project,
source_script,
execution_environment,
instanceGroups,
...remainingForm
} = form;

Expand All @@ -50,6 +57,7 @@ function InventorySourceAdd({ inventory }) {
inventory: id,
source_script: source_script?.id || null,
execution_environment: execution_environment?.id || null,
instanceGroups,
...sourcePath,
...sourceProject,
...remainingForm,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const invSourceData = {
update_cache_timeout: 0,
update_on_launch: false,
verbosity: 1,
instanceGroups: [{ id: 100 }, { id: 200 }],
};

const mockInventory = {
Expand Down Expand Up @@ -96,6 +97,30 @@ describe('<InventorySourceAdd />', () => {
});
});

test('should associate instance groups after creation', async () => {
InventorySourcesAPI.create.mockResolvedValue({ data: { id: 55 } });
const { user } = renderWithContexts(
<InventorySourceAdd inventory={mockInventory} />
);
await user.click(await screen.findByRole('button', { name: 'mock-submit' }));

await waitFor(() =>
expect(InventorySourcesAPI.associateInstanceGroup).toHaveBeenCalledTimes(
2
)
);
expect(InventorySourcesAPI.associateInstanceGroup).toHaveBeenNthCalledWith(
1,
55,
100
);
expect(InventorySourcesAPI.associateInstanceGroup).toHaveBeenNthCalledWith(
2,
55,
200
);
});

test('successful form submission should trigger redirect', async () => {
const history = createMemoryHistory({});
InventorySourcesAPI.create.mockResolvedValue({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import CredentialChip from 'components/CredentialChip';
import DeleteButton from 'components/DeleteButton';
import ErrorDetail from 'components/ErrorDetail';
import ExecutionEnvironmentDetail from 'components/ExecutionEnvironmentDetail';
import InstanceGroupLabels from 'components/InstanceGroupLabels';
import JobCancelButton from 'components/JobCancelButton';
import StatusLabel from 'components/StatusLabel';
import { CardBody, CardActionsRow } from 'components/Card';
Expand Down Expand Up @@ -52,6 +53,20 @@ function InventorySourceDetail({ inventorySource }) {
}, [])
);

const { result: instanceGroups, request: fetchInstanceGroups } = useRequest(
useCallback(async () => {
const { data } = await InventorySourcesAPI.readInstanceGroups(
inventorySource.id
);
return data.results;
}, [inventorySource.id]),
[]
);

useEffect(() => {
fetchInstanceGroups();
}, [fetchInstanceGroups]);

const {
created,
description,
Expand Down Expand Up @@ -218,6 +233,13 @@ function InventorySourceDetail({ inventorySource }) {
<ExecutionEnvironmentDetail
executionEnvironment={execution_environment}
/>
{instanceGroups && instanceGroups.length > 0 && (
<Detail
fullWidth
label={t`Instance Groups`}
value={<InstanceGroupLabels labels={instanceGroups} isLinkable />}
/>
)}
{source_project && (
<Detail
label={t`Project`}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import React, { useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router';
import { Card } from '@patternfly/react-core';
import { CardBody } from 'components/Card';
import ContentError from 'components/ContentError';
import ContentLoading from 'components/ContentLoading';
import useRequest from 'hooks/useRequest';
import { InventorySourcesAPI } from 'api';
import InventorySourceForm from '../shared/InventorySourceForm';
Expand All @@ -11,13 +13,35 @@ function InventorySourceEdit({ source, inventory }) {
const { id, organization } = inventory;
const detailsUrl = `/inventories/inventory/${id}/sources/${source.id}/details`;

const {
isLoading: isInstanceGroupsLoading,
error: instanceGroupsError,
request: fetchInstanceGroups,
result: associatedInstanceGroups,
} = useRequest(
useCallback(async () => {
const { data } = await InventorySourcesAPI.readInstanceGroups(source.id);
return data.results;
}, [source.id]),
null
);

useEffect(() => {
fetchInstanceGroups();
}, [fetchInstanceGroups]);

const { error, request, result } = useRequest(
useCallback(
async (values) => {
async ({ instanceGroups, ...values }) => {
const { data } = await InventorySourcesAPI.replace(source.id, values);
await InventorySourcesAPI.orderInstanceGroups(
source.id,
instanceGroups,
associatedInstanceGroups
);
return data;
},
[source.id]
[source.id, associatedInstanceGroups]
),
null
);
Expand All @@ -37,6 +61,7 @@ function InventorySourceEdit({ source, inventory }) {
source_project,
source_script,
execution_environment,
instanceGroups,
...remainingForm
} = form;

Expand All @@ -53,6 +78,7 @@ function InventorySourceEdit({ source, inventory }) {
inventory: id,
source_script: source_script?.id || null,
execution_environment: execution_environment?.id || null,
instanceGroups,
...sourcePath,
...sourceProject,
...remainingForm,
Expand All @@ -63,11 +89,32 @@ function InventorySourceEdit({ source, inventory }) {
navigate(detailsUrl);
};

if (instanceGroupsError) {
return (
<Card>
<CardBody>
<ContentError error={instanceGroupsError} />
</CardBody>
</Card>
);
}

if (isInstanceGroupsLoading || !associatedInstanceGroups) {
return (
<Card>
<CardBody>
<ContentLoading />
</CardBody>
</Card>
);
}

return (
<Card>
<CardBody>
<InventorySourceForm
source={source}
instanceGroups={associatedInstanceGroups}
onCancel={handleCancel}
onSubmit={handleSubmit}
submitError={error}
Expand Down
Loading