@@ -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"
0 commit comments