Skip to content

Commit 6cd4c2f

Browse files
Fix constructed inventory host job summaries and relink orphaned summaries (#577)
The job_host_summaries API endpoint for hosts and groups in constructed inventories was returning empty results because it queried the host FK (which points to the source inventory host) instead of the constructed_host FK. This matches the fix already present in the serializer for recent_jobs. Also adds a relink step after inventory sync that reconnects orphaned JobHostSummary records (where the host FK became NULL after the host was deleted by an overwrite sync) to the new host object by matching on host_name, scoped to the same inventory to prevent cross-inventory mis-linking.
1 parent 9635885 commit 6cd4c2f

5 files changed

Lines changed: 263 additions & 1 deletion

File tree

awx/api/views/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3711,10 +3711,20 @@ class BaseJobHostSummariesList(SubListAPIView):
37113711
class HostJobHostSummariesList(BaseJobHostSummariesList):
37123712
parent_model = models.Host
37133713

3714+
def get_sublist_queryset(self, parent):
3715+
if parent.inventory and parent.inventory.kind == 'constructed':
3716+
return parent.constructed_host_summaries
3717+
return super().get_sublist_queryset(parent)
3718+
37143719

37153720
class GroupJobHostSummariesList(BaseJobHostSummariesList):
37163721
parent_model = models.Group
37173722

3723+
def get_sublist_queryset(self, parent):
3724+
if parent.inventory and parent.inventory.kind == 'constructed':
3725+
return parent.constructed_host_summaries
3726+
return super().get_sublist_queryset(parent)
3727+
37183728

37193729
class JobJobHostSummariesList(BaseJobHostSummariesList):
37203730
parent_model = models.Job

awx/main/management/commands/inventory_import.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
# AWX inventory imports
2525
from awx.main.models.inventory import Inventory, InventorySource, InventoryUpdate, Host
26+
from awx.main.models.jobs import JobHostSummary
2627
from awx.main.utils.mem_inventory import MemInventory, dict_to_mem_data
2728
from awx.main.utils.safe_yaml import sanitize_jinja
2829

@@ -764,6 +765,26 @@ def _create_update_group_hosts(self):
764765
if settings.SQL_DEBUG:
765766
logger.warning('Group-host updates took %d queries for %d group-host relationships', len(connection.queries) - queries_before, group_host_count)
766767

768+
def _relink_orphaned_job_host_summaries(self):
769+
host_map = dict(self.inventory.hosts.values_list('name', 'pk'))
770+
if not host_map:
771+
return
772+
773+
host_names = list(host_map.keys())
774+
is_constructed = self.inventory.kind == 'constructed'
775+
fk_field = 'constructed_host_id' if is_constructed else 'host_id'
776+
null_filter = {'constructed_host__isnull': True} if is_constructed else {'host__isnull': True}
777+
778+
for offset in range(0, len(host_names), self._batch_size):
779+
batch_names = host_names[offset : offset + self._batch_size]
780+
orphaned = JobHostSummary.objects.filter(job__inventory=self.inventory, host_name__in=batch_names, **null_filter)
781+
for host_name in batch_names:
782+
host_pk = host_map.get(host_name)
783+
if host_pk:
784+
updated = orphaned.filter(host_name=host_name).update(**{fk_field: host_pk})
785+
if updated:
786+
logger.info('Re-linked %d orphaned job host summaries for host "%s"', updated, host_name)
787+
767788
def load_into_database(self):
768789
"""
769790
Load inventory from in-memory groups to the database, overwriting or
@@ -784,6 +805,7 @@ def load_into_database(self):
784805
self._create_update_hosts(pk_mem_host_map)
785806
self._create_update_group_children()
786807
self._create_update_group_hosts()
808+
self._relink_orphaned_job_host_summaries()
787809

788810
def remote_tower_license_compare(self, local_license_type):
789811
# this requires https://github.com/ansible/ansible/pull/52747

awx/main/models/inventory.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -876,6 +876,12 @@ def job_host_summaries(self):
876876

877877
return JobHostSummary.objects.filter(host__in=self.all_hosts)
878878

879+
@property
880+
def constructed_host_summaries(self):
881+
from awx.main.models.jobs import JobHostSummary
882+
883+
return JobHostSummary.objects.filter(constructed_host__in=self.all_hosts)
884+
879885
@property
880886
def job_events(self):
881887
from awx.main.models.jobs import JobEvent

awx/main/tests/functional/commands/test_inventory_import.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,3 +398,138 @@ def test_tower_version_compare():
398398
with pytest.raises(PermissionDenied):
399399
cmd.remote_tower_license_compare('very_supported')
400400
cmd.remote_tower_license_compare('open')
401+
402+
403+
@pytest.mark.django_db
404+
@mock.patch.object(inventory_import.Command, 'set_logging_level', mock_logging)
405+
class TestRelinkOrphanedJobHostSummaries:
406+
"""After an overwrite sync deletes and recreates a host, orphaned
407+
JobHostSummary records (host_id=NULL) should be re-linked to the
408+
new host object by matching on host_name."""
409+
410+
def test_relink_after_host_recreated(self, inventory):
411+
from awx.main.models import JobHostSummary, Job, Project, JobTemplate
412+
413+
inv_src = InventorySource.objects.create(inventory=inventory, source='ec2')
414+
project = Project.objects.create(name='test-proj')
415+
jt = JobTemplate.objects.create(name='test-jt', inventory=inventory, project=project)
416+
417+
data = {
418+
'_meta': {'hostvars': {'server1': {}, 'server2': {}}},
419+
'ungrouped': {'hosts': ['server1', 'server2']},
420+
}
421+
options = dict(overwrite=True)
422+
423+
inventory_import.Command().perform_update(options.copy(), data.copy(), inv_src.create_unified_job())
424+
host1 = inventory.hosts.get(name='server1')
425+
426+
job = Job.objects.create(inventory=inventory, job_template=jt, status='successful')
427+
JobHostSummary.objects.create(job=job, host=host1, host_name='server1', ok=1)
428+
429+
# Simulate host disappearing and reappearing (delete + reimport)
430+
host1.delete()
431+
inventory_import.Command().perform_update(options.copy(), data.copy(), inv_src.create_unified_job())
432+
433+
new_host = inventory.hosts.get(name='server1')
434+
assert new_host.pk != host1.pk
435+
436+
summary = JobHostSummary.objects.get(job=job, host_name='server1')
437+
assert summary.host_id == new_host.pk
438+
439+
def test_no_relink_when_host_still_linked(self, inventory):
440+
from awx.main.models import JobHostSummary, Job, Project, JobTemplate
441+
442+
inv_src = InventorySource.objects.create(inventory=inventory, source='ec2')
443+
project = Project.objects.create(name='test-proj')
444+
jt = JobTemplate.objects.create(name='test-jt', inventory=inventory, project=project)
445+
446+
data = {
447+
'_meta': {'hostvars': {'server1': {}}},
448+
'ungrouped': {'hosts': ['server1']},
449+
}
450+
options = dict(overwrite=True)
451+
452+
inventory_import.Command().perform_update(options.copy(), data.copy(), inv_src.create_unified_job())
453+
host1 = inventory.hosts.get(name='server1')
454+
455+
job = Job.objects.create(inventory=inventory, job_template=jt, status='successful')
456+
JobHostSummary.objects.create(job=job, host=host1, host_name='server1', ok=1)
457+
458+
# Sync again without host disappearing - PK should be preserved
459+
inventory_import.Command().perform_update(options.copy(), data.copy(), inv_src.create_unified_job())
460+
same_host = inventory.hosts.get(name='server1')
461+
assert same_host.pk == host1.pk
462+
463+
summary = JobHostSummary.objects.get(job=job, host_name='server1')
464+
assert summary.host_id == host1.pk
465+
466+
def test_relink_constructed_inventory(self, organization):
467+
from awx.main.models import JobHostSummary, Job, Project, JobTemplate
468+
469+
source_inv = Inventory.objects.create(name='source-inv', organization=organization)
470+
constructed_inv = Inventory.objects.create(name='constructed-inv', kind='constructed', organization=organization)
471+
project = Project.objects.create(name='test-proj')
472+
jt = JobTemplate.objects.create(name='test-jt', inventory=constructed_inv, project=project)
473+
474+
source_host = Host.objects.create(name='server1', inventory=source_inv)
475+
constructed_host = Host.objects.create(
476+
name='server1', inventory=constructed_inv, instance_id=str(source_host.pk)
477+
)
478+
479+
job = Job.objects.create(inventory=constructed_inv, job_template=jt, status='successful')
480+
JobHostSummary.objects.create(
481+
job=job, host=source_host, constructed_host=constructed_host,
482+
host_name='server1', ok=1
483+
)
484+
485+
old_constructed_pk = constructed_host.pk
486+
constructed_host.delete()
487+
488+
# Recreate constructed host (simulates constructed inventory re-sync)
489+
new_constructed_host = Host.objects.create(
490+
name='server1', inventory=constructed_inv, instance_id=str(source_host.pk)
491+
)
492+
493+
inv_src = InventorySource.objects.create(inventory=constructed_inv, source='constructed')
494+
data = {
495+
'_meta': {'hostvars': {'server1': {}}},
496+
'ungrouped': {'hosts': ['server1']},
497+
}
498+
inventory_import.Command().perform_update(dict(overwrite=True), data, inv_src.create_unified_job())
499+
500+
summary = JobHostSummary.objects.get(job=job, host_name='server1')
501+
assert summary.constructed_host_id is not None
502+
assert summary.constructed_host_id != old_constructed_pk
503+
504+
def test_relink_does_not_cross_inventories(self, organization):
505+
from awx.main.models import JobHostSummary, Job, Project, JobTemplate
506+
507+
inv_a = Inventory.objects.create(name='inv-a', organization=organization)
508+
inv_b = Inventory.objects.create(name='inv-b', organization=organization)
509+
inv_src_a = InventorySource.objects.create(inventory=inv_a, source='ec2')
510+
inv_src_b = InventorySource.objects.create(inventory=inv_b, source='ec2')
511+
project = Project.objects.create(name='test-proj')
512+
513+
data = {
514+
'_meta': {'hostvars': {'server1': {}}},
515+
'ungrouped': {'hosts': ['server1']},
516+
}
517+
options = dict(overwrite=True)
518+
519+
# Create host in both inventories
520+
inventory_import.Command().perform_update(options.copy(), data.copy(), inv_src_a.create_unified_job())
521+
inventory_import.Command().perform_update(options.copy(), data.copy(), inv_src_b.create_unified_job())
522+
523+
host_b = inv_b.hosts.get(name='server1')
524+
jt_b = JobTemplate.objects.create(name='test-jt-b', inventory=inv_b, project=project)
525+
job_b = Job.objects.create(inventory=inv_b, job_template=jt_b, status='successful')
526+
JobHostSummary.objects.create(job=job_b, host=host_b, host_name='server1', ok=1)
527+
528+
# Delete host from inv_b, orphaning the summary
529+
host_b.delete()
530+
531+
# Sync inv_a: should NOT re-link inv_b's orphaned summary
532+
inventory_import.Command().perform_update(options.copy(), data.copy(), inv_src_a.create_unified_job())
533+
534+
summary = JobHostSummary.objects.get(job=job_b, host_name='server1')
535+
assert summary.host_id is None, "Summary from inv_b should not be re-linked to inv_a's host"

awx/main/tests/functional/models/test_host_summary_fields.py

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
from django.utils.timezone import now
44

5-
from awx.main.models import Job, JobEvent, JobTemplate, Inventory, Host, JobHostSummary, Project
5+
from awx.main.models import Job, JobEvent, JobTemplate, Inventory, Host, Group, JobHostSummary, Project
66
from awx.api.serializers import HostSerializer
7+
from awx.api.versioning import reverse
78

89

910
@pytest.mark.django_db
@@ -109,3 +110,91 @@ def test_no_summary_fields_without_job(self):
109110

110111
assert 'last_job' not in d
111112
assert 'last_job_host_summary' not in d
113+
114+
115+
@pytest.mark.django_db
116+
class TestConstructedHostJobSummariesAPI:
117+
"""The job_host_summaries endpoint for hosts in a constructed inventory
118+
should use the constructed_host FK, not the regular host FK."""
119+
120+
def test_constructed_host_summaries_returned(self, get, admin, organization):
121+
source_inv = Inventory.objects.create(name='source', organization=organization)
122+
constructed_inv = Inventory.objects.create(name='constructed', kind='constructed', organization=organization)
123+
124+
source_host = Host.objects.create(name='server1', inventory=source_inv)
125+
constructed_host = Host.objects.create(name='server1', inventory=constructed_inv, instance_id=str(source_host.pk))
126+
127+
project = Project.objects.create(name='test-proj')
128+
jt = JobTemplate.objects.create(name='test-jt', inventory=constructed_inv, project=project)
129+
job = Job.objects.create(inventory=constructed_inv, job_template=jt, status='successful')
130+
131+
JobHostSummary.objects.create(
132+
job=job, host=source_host, constructed_host=constructed_host,
133+
host_name='server1', ok=1
134+
)
135+
136+
url = reverse('api:host_job_host_summaries_list', kwargs={'pk': constructed_host.pk})
137+
resp = get(url, user=admin, expect=200)
138+
assert resp.data['count'] == 1
139+
assert resp.data['results'][0]['host_name'] == 'server1'
140+
141+
def test_regular_host_summaries_still_work(self, get, admin, organization):
142+
inv = Inventory.objects.create(name='regular', organization=organization)
143+
host = Host.objects.create(name='server1', inventory=inv)
144+
145+
project = Project.objects.create(name='test-proj')
146+
jt = JobTemplate.objects.create(name='test-jt', inventory=inv, project=project)
147+
job = Job.objects.create(inventory=inv, job_template=jt, status='successful')
148+
149+
JobHostSummary.objects.create(job=job, host=host, host_name='server1', ok=1)
150+
151+
url = reverse('api:host_job_host_summaries_list', kwargs={'pk': host.pk})
152+
resp = get(url, user=admin, expect=200)
153+
assert resp.data['count'] == 1
154+
155+
def test_constructed_host_no_false_positives(self, get, admin, organization):
156+
source_inv = Inventory.objects.create(name='source', organization=organization)
157+
constructed_inv = Inventory.objects.create(name='constructed', kind='constructed', organization=organization)
158+
159+
source_host = Host.objects.create(name='server1', inventory=source_inv)
160+
constructed_host = Host.objects.create(name='server1', inventory=constructed_inv, instance_id=str(source_host.pk))
161+
162+
project = Project.objects.create(name='test-proj')
163+
jt = JobTemplate.objects.create(name='test-jt', inventory=source_inv, project=project)
164+
job = Job.objects.create(inventory=source_inv, job_template=jt, status='successful')
165+
166+
# Summary linked to source host only, not the constructed one
167+
JobHostSummary.objects.create(job=job, host=source_host, host_name='server1', ok=1)
168+
169+
url = reverse('api:host_job_host_summaries_list', kwargs={'pk': constructed_host.pk})
170+
resp = get(url, user=admin, expect=200)
171+
assert resp.data['count'] == 0
172+
173+
174+
@pytest.mark.django_db
175+
class TestConstructedGroupJobSummariesAPI:
176+
"""The job_host_summaries endpoint for groups in a constructed inventory
177+
should use the constructed_host FK."""
178+
179+
def test_constructed_group_summaries_returned(self, get, admin, organization):
180+
source_inv = Inventory.objects.create(name='source', organization=organization)
181+
constructed_inv = Inventory.objects.create(name='constructed', kind='constructed', organization=organization)
182+
183+
source_host = Host.objects.create(name='server1', inventory=source_inv)
184+
constructed_host = Host.objects.create(name='server1', inventory=constructed_inv, instance_id=str(source_host.pk))
185+
186+
group = Group.objects.create(name='webservers', inventory=constructed_inv)
187+
group.hosts.add(constructed_host)
188+
189+
project = Project.objects.create(name='test-proj')
190+
jt = JobTemplate.objects.create(name='test-jt', inventory=constructed_inv, project=project)
191+
job = Job.objects.create(inventory=constructed_inv, job_template=jt, status='successful')
192+
193+
JobHostSummary.objects.create(
194+
job=job, host=source_host, constructed_host=constructed_host,
195+
host_name='server1', ok=1
196+
)
197+
198+
url = reverse('api:group_job_host_summaries_list', kwargs={'pk': group.pk})
199+
resp = get(url, user=admin, expect=200)
200+
assert resp.data['count'] == 1

0 commit comments

Comments
 (0)