diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 2223c590e9..42354c1500 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -402,6 +402,11 @@ class InstanceInstanceGroupsList(InstanceGroupMembershipMixin, SubListCreateAtta parent_model = models.Instance relationship = 'rampart_groups' + def is_valid_relation(self, parent, sub, created=False): + if parent.node_type == 'control': + return {'msg': _(f"Cannot change instance group membership of control-only node: {parent.hostname}.")} + return None + class InstanceGroupList(ListCreateAPIView): @@ -444,6 +449,11 @@ class InstanceGroupInstanceList(InstanceGroupMembershipMixin, SubListAttachDetac relationship = "instances" search_fields = ('hostname',) + def is_valid_relation(self, parent, sub, created=False): + if sub.node_type == 'control': + return {'msg': _(f"Cannot change instance group membership of control-only node: {sub.hostname}.")} + return None + class ScheduleList(ListCreateAPIView): diff --git a/awx/api/views/mixin.py b/awx/api/views/mixin.py index 61d19fa2ea..059e1120f7 100644 --- a/awx/api/views/mixin.py +++ b/awx/api/views/mixin.py @@ -68,13 +68,23 @@ class InstanceGroupMembershipMixin(object): membership. """ + def attach_validate(self, request): + parent = self.get_parent_object() + sub_id, res = super().attach_validate(request) + if res: # handle an error + return sub_id, res + sub = get_object_or_400(self.model, pk=sub_id) + attach_errors = self.is_valid_relation(parent, sub) + if attach_errors: + return sub_id, Response(attach_errors, status=status.HTTP_400_BAD_REQUEST) + return sub_id, res + def attach(self, request, *args, **kwargs): response = super(InstanceGroupMembershipMixin, self).attach(request, *args, **kwargs) sub_id, res = self.attach_validate(request) if status.is_success(response.status_code): if self.parent_model is Instance: - ig_obj = get_object_or_400(self.model, pk=sub_id) - inst_name = ig_obj.hostname + inst_name = self.get_parent_object().hostname else: inst_name = get_object_or_400(self.model, pk=sub_id).hostname with transaction.atomic(): @@ -91,11 +101,12 @@ class InstanceGroupMembershipMixin(object): return response def unattach_validate(self, request): + parent = self.get_parent_object() (sub_id, res) = super(InstanceGroupMembershipMixin, self).unattach_validate(request) if res: return (sub_id, res) sub = get_object_or_400(self.model, pk=sub_id) - attach_errors = self.is_valid_relation(None, sub) + attach_errors = self.is_valid_relation(parent, sub) if attach_errors: return (sub_id, Response(attach_errors, status=status.HTTP_400_BAD_REQUEST)) return (sub_id, res) diff --git a/awx/main/tests/functional/api/test_instance_group.py b/awx/main/tests/functional/api/test_instance_group.py index 8e4cc03844..5a787b6607 100644 --- a/awx/main/tests/functional/api/test_instance_group.py +++ b/awx/main/tests/functional/api/test_instance_group.py @@ -23,6 +23,14 @@ def instance(): return Instance.objects.create(hostname='instance') +@pytest.fixture +def node_type_instance(): + def fn(hostname, node_type): + return Instance.objects.create(hostname=hostname, node_type=node_type) + + return fn + + @pytest.fixture def instance_group(job_factory): ig = InstanceGroup(name="east") @@ -198,3 +206,41 @@ def test_containerized_group_default_fields(instance_group, kube_credential): assert ig.policy_instance_list == [] assert ig.policy_instance_minimum == 0 assert ig.policy_instance_percentage == 0 + + +@pytest.mark.django_db +@pytest.mark.parametrize('node_type', ['control', 'hybrid', 'execution']) +def test_instance_attach_to_instance_group(post, instance_group, node_type_instance, admin, node_type): + instance = node_type_instance(hostname=node_type, node_type=node_type) + + url = reverse(f'api:instance_group_instance_list', kwargs={'pk': instance_group.pk}) + post(url, {'associate': True, 'id': instance.id}, admin, expect=204 if node_type != 'control' else 400) + + +@pytest.mark.django_db +@pytest.mark.parametrize('node_type', ['control', 'hybrid', 'execution']) +def test_instance_unattach_from_instance_group(post, instance_group, node_type_instance, admin, node_type): + instance = node_type_instance(hostname=node_type, node_type=node_type) + instance_group.instances.add(instance) + + url = reverse(f'api:instance_group_instance_list', kwargs={'pk': instance_group.pk}) + post(url, {'disassociate': True, 'id': instance.id}, admin, expect=204 if node_type != 'control' else 400) + + +@pytest.mark.django_db +@pytest.mark.parametrize('node_type', ['control', 'hybrid', 'execution']) +def test_instance_group_attach_to_instance(post, instance_group, node_type_instance, admin, node_type): + instance = node_type_instance(hostname=node_type, node_type=node_type) + + url = reverse(f'api:instance_instance_groups_list', kwargs={'pk': instance.pk}) + post(url, {'associate': True, 'id': instance_group.id}, admin, expect=204 if node_type != 'control' else 400) + + +@pytest.mark.django_db +@pytest.mark.parametrize('node_type', ['control', 'hybrid', 'execution']) +def test_instance_group_unattach_from_instance(post, instance_group, node_type_instance, admin, node_type): + instance = node_type_instance(hostname=node_type, node_type=node_type) + instance_group.instances.add(instance) + + url = reverse(f'api:instance_instance_groups_list', kwargs={'pk': instance.pk}) + post(url, {'disassociate': True, 'id': instance_group.id}, admin, expect=204 if node_type != 'control' else 400)