From a17a4bc15fdaf1c1a3d85e84d51842bb9f3ffb8c Mon Sep 17 00:00:00 2001 From: Quanah Gibson-Mount Date: Wed, 7 Oct 2026 20:14:18 +0000 Subject: [PATCH] Keep inventory sources in their inventory's organization An inventory source copies its inventory's organization only when it is created, and the API can't change it afterwards, so when an inventory moves to another organization its sources stay behind. Inventory updates copy the source's organization, so they are filed under the old organization too, and their execution environment can fall back to the old organization's default. Have Inventory.save bring its sources' organization in line whenever it is saved with organization, which also repairs sources that are already stale the next time their inventory is saved. related #16702 Assisted-by: Claude Opus 5.5 Signed-off-by: Quanah Gibson-Mount --- awx/main/models/inventory.py | 5 +++ .../tests/functional/models/test_inventory.py | 39 ++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index 4157d96921..ca34aab684 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -481,6 +481,11 @@ class Inventory(CommonModelNameNotUnique, RelatedJobsMixin, OpaQueryPathMixin): def save(self, *args, **kwargs): self._update_host_smart_inventory_memeberships() super(Inventory, self).save(*args, **kwargs) + update_fields = kwargs.get('update_fields') + if update_fields is None or {'organization', 'organization_id'} & set(update_fields): + # An inventory source takes its organization from its inventory and can't set its own, so keep it in step + # when the inventory moves. + self.inventory_sources.exclude(organization_id=self.organization_id).update(organization_id=self.organization_id) if self.kind == 'smart' and 'host_filter' in kwargs.get('update_fields', ['host_filter']) and connection.vendor != 'sqlite': # Minimal update of host_count for smart inventory host filter changes self.update_computed_fields() diff --git a/awx/main/tests/functional/models/test_inventory.py b/awx/main/tests/functional/models/test_inventory.py index 8811b92258..404f1bb99b 100644 --- a/awx/main/tests/functional/models/test_inventory.py +++ b/awx/main/tests/functional/models/test_inventory.py @@ -4,7 +4,7 @@ import pytest from unittest import mock # AWX -from awx.main.models import Host, Inventory, InventorySource, InventoryUpdate, CredentialType, Credential, Job +from awx.main.models import Host, Inventory, InventorySource, InventoryUpdate, CredentialType, Credential, Job, Organization from awx.main.utils.filters import SmartFilter from awx.main.utils.plugins import discover_available_cloud_provider_plugin_names @@ -385,3 +385,40 @@ class TestHostManager: # 2 organizations with host of same name only has 1 entry in smart inventory # smart inventory in 1 organization does not include host from another # smart inventory correctly returns hosts in filter in same organization + + +@pytest.mark.django_db +class TestInventorySourceOrganization: + """An inventory source can't set its organization; it follows its inventory's.""" + + def test_new_source_takes_inventory_organization(self, inventory): + source = inventory.inventory_sources.create(name='source', source='ec2') + assert source.organization_id == inventory.organization_id + + def test_moving_inventory_moves_its_sources(self, inventory, inventory_source): + other = Organization.objects.create(name='other-org') + inventory.organization = other + inventory.save() + inventory_source.refresh_from_db() + assert inventory_source.organization_id == other.id + + def test_inventory_updates_carry_the_new_organization(self, inventory, inventory_source): + other = Organization.objects.create(name='other-org') + inventory.organization = other + inventory.save() + inventory_source.refresh_from_db() + assert inventory_source.create_unified_job().organization_id == other.id + + def test_save_repairs_a_source_left_behind(self, inventory, inventory_source): + other = Organization.objects.create(name='other-org') + InventorySource.objects.filter(pk=inventory_source.pk).update(organization=other) + inventory.save() + inventory_source.refresh_from_db() + assert inventory_source.organization_id == inventory.organization_id + + def test_partial_save_without_organization_leaves_sources_alone(self, inventory, inventory_source): + other = Organization.objects.create(name='other-org') + InventorySource.objects.filter(pk=inventory_source.pk).update(organization=other) + inventory.save(update_fields=['description']) + inventory_source.refresh_from_db() + assert inventory_source.organization_id == other.id