@@ -182,6 +182,30 @@ def _wrapped(self, *args, **kwargs):
182182 return _wrapped
183183
184184
185+ def select_execution_node_by_capacity (instances ):
186+ """
187+ Helper function for spawn_project_sync jobs since they need to work outside task manager context.
188+ """
189+ best_instance = None
190+ best_remaining = - 1
191+
192+ for instance in instances :
193+ if instance .capacity <= 0 :
194+ continue
195+ remaining = instance .remaining_capacity
196+ if remaining >= 0 and remaining > best_remaining :
197+ best_instance = instance
198+ best_remaining = remaining
199+
200+ # Fallback: return least-loaded node even if all are over capacity
201+ if best_instance is None :
202+ valid = [i for i in instances if i .capacity > 0 ]
203+ if valid :
204+ best_instance = max (valid , key = lambda i : i .remaining_capacity )
205+
206+ return best_instance
207+
208+
185209@task (on_duplicate = 'queue_one' , bind = True , queue = get_task_queuename )
186210def dispatch_waiting_jobs (binder ):
187211 for uj in UnifiedJob .objects .filter (status = 'waiting' , controller_node = settings .CLUSTER_HOST_ID ).only ('id' , 'status' , 'polymorphic_ctype' , 'celery_task_id' ):
@@ -816,8 +840,28 @@ def get_sync_needs(self, project, scm_branch=None):
816840 return sync_needs
817841
818842 def spawn_project_sync (self , project , sync_needs , scm_branch = None ):
819- pu_ig = self .instance .instance_group
820- pu_en = Instance .objects .my_hostname ()
843+ cn = Instance .objects .my_hostname ()
844+
845+ # Check if project has instance groups configured
846+ if project .instance_groups .exists ():
847+ # Use the project's first instance group
848+ project_ig = project .instance_groups .first ()
849+ # Find an execution node from the instance group with most remaining capacity
850+ candidate_instances = project_ig .instances .filter (enabled = True , node_type__in = ['execution' , 'hybrid' ])
851+ execution_instance = select_execution_node_by_capacity (candidate_instances )
852+
853+ if execution_instance and execution_instance .hostname != cn :
854+ # Remote execution on mesh node
855+ pu_ig = project_ig
856+ pu_en = execution_instance .hostname
857+ else :
858+ # Fallback to original workflow
859+ pu_ig = self .instance .instance_group
860+ pu_en = cn
861+ else :
862+ # No project instance groups - use existing workflow
863+ pu_ig = self .instance .instance_group
864+ pu_en = cn
821865
822866 sync_metafields = dict (
823867 launch_type = "sync" ,
@@ -826,7 +870,7 @@ def spawn_project_sync(self, project, sync_needs, scm_branch=None):
826870 status = 'running' ,
827871 instance_group = pu_ig ,
828872 execution_node = pu_en ,
829- controller_node = pu_en ,
873+ controller_node = cn ,
830874 celery_task_id = self .instance .celery_task_id ,
831875 )
832876 if scm_branch and scm_branch != project .scm_branch :
@@ -1368,8 +1412,15 @@ def build_args(self, project_update, private_data_dir, passwords):
13681412 args = []
13691413 if getattr (settings , 'PROJECT_UPDATE_VVV' , False ):
13701414 args .append ('-vvv' )
1371- if project_update .job_tags :
1372- args .extend (['-t' , project_update .job_tags ])
1415+
1416+ job_tags = project_update .job_tags
1417+
1418+ # Add remote_update tag for automation mesh execution. (value set in pre_run_hook)
1419+ if getattr (self , '_is_remote_update' , False ):
1420+ job_tags = f"{ job_tags } ,remote_update" if job_tags else "remote_update"
1421+
1422+ if job_tags :
1423+ args .extend (['-t' , job_tags ])
13731424 return args
13741425
13751426 def build_extra_vars_file (self , project_update , private_data_dir ):
@@ -1442,6 +1493,9 @@ def get_password_prompts(self, passwords={}):
14421493 def pre_run_hook (self , instance , private_data_dir ):
14431494 super (RunProjectUpdate , self ).pre_run_hook (instance , private_data_dir )
14441495 # re-create root project folder if a natural disaster has destroyed it
1496+ # Store remote update decision for use in build_args and post_run_hook
1497+ self .private_data_dir = private_data_dir
1498+ self ._is_remote_update = instance .is_remote_update
14451499 project_path = instance .project .get_project_path (check_if_exists = False )
14461500
14471501 if instance .launch_type != 'sync' :
@@ -1521,6 +1575,11 @@ def post_run_hook(self, instance, status):
15211575 instance .scm_revision = self .runner_callback .playbook_new_revision
15221576 instance .save (update_fields = ['scm_revision' ])
15231577
1578+ # Handle remote execution - copy project from artifacts
1579+ if getattr (self , '_is_remote_update' , False ) and status == 'successful' :
1580+ self ._copy_project_from_artifacts (instance )
1581+ self ._copy_requirements_from_artifacts (instance )
1582+
15241583 # Roles and collection folders copy to durable cache
15251584 base_path = instance .get_cache_path ()
15261585 stage_path = os .path .join (base_path , 'stage' )
@@ -1557,6 +1616,52 @@ def post_run_hook(self, instance, status):
15571616 p .inventory_files = p .inventories
15581617 p .save (update_fields = ['scm_revision' , 'playbook_files' , 'inventory_files' ])
15591618
1619+ def _copy_project_from_artifacts (self , instance ):
1620+ """Copy project files from artifacts to projects_root after remote execution."""
1621+ artifacts_project_path = self .runner_callback .project_artifact_path
1622+
1623+ if not artifacts_project_path :
1624+ logger .debug (f'{ instance .log_format } no project artifacts to copy' )
1625+ return
1626+
1627+ if not os .path .exists (artifacts_project_path ):
1628+ logger .warning (f'{ instance .log_format } project artifacts not found at: { artifacts_project_path } ' )
1629+ return
1630+
1631+ project_path = instance .get_project_path (check_if_exists = False )
1632+
1633+ if os .path .exists (project_path ):
1634+ shutil .rmtree (project_path )
1635+
1636+ shutil .copytree (artifacts_project_path , project_path , symlinks = True )
1637+
1638+ logger .info (f'Copied project from remote execution for { instance .log_format } ' )
1639+
1640+ def _copy_requirements_from_artifacts (self , instance ):
1641+ """Copy roles and collections from artifacts to cache stage directory after remote execution."""
1642+ base_path = instance .get_cache_path ()
1643+ stage_path = os .path .join (base_path , 'stage' )
1644+
1645+ # Mapping: artifact path attribute -> destination subdirectory
1646+ artifact_mappings = [
1647+ ('roles_artifact_path' , 'requirements_roles' ),
1648+ ('collections_artifact_path' , 'requirements_collections' ),
1649+ ]
1650+
1651+ for attr , dest_subdir in artifact_mappings :
1652+ artifact_path = getattr (self .runner_callback , attr , None )
1653+ if not artifact_path :
1654+ continue
1655+
1656+ if not os .path .exists (artifact_path ):
1657+ logger .debug (f'{ instance .log_format } { attr } path does not exist: { artifact_path } ' )
1658+ continue
1659+
1660+ dest_path = os .path .join (stage_path , dest_subdir )
1661+ shutil .copytree (artifact_path , dest_path , symlinks = True , dirs_exist_ok = True )
1662+
1663+ logger .info (f'{ instance .log_format } copied { dest_subdir } from remote execution artifacts' )
1664+
15601665 def build_execution_environment_params (self , instance , private_data_dir ):
15611666 if settings .IS_K8S :
15621667 return {}
0 commit comments