Skip to content

Commit 0cbdbbc

Browse files
committed
Add ability to run project updates on mesh nodes
1 parent 994a2b3 commit 0cbdbbc

8 files changed

Lines changed: 242 additions & 29 deletions

File tree

‎awx/api/serializers.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1382,6 +1382,7 @@ def get_related(self, obj):
13821382
access_list=self.reverse('api:project_access_list', kwargs={'pk': obj.pk}),
13831383
object_roles=self.reverse('api:project_object_roles_list', kwargs={'pk': obj.pk}),
13841384
copy=self.reverse('api:project_copy', kwargs={'pk': obj.pk}),
1385+
instance_groups=self.reverse('api:project_instance_groups_list', kwargs={'pk': obj.pk}),
13851386
)
13861387
)
13871388
if obj.organization:

‎awx/api/urls/project.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
ProjectNotificationTemplatesSuccessList,
2020
ProjectObjectRolesList,
2121
ProjectAccessList,
22+
ProjectInstanceGroupsList,
2223
ProjectCopy,
2324
)
2425

@@ -48,6 +49,7 @@
4849
),
4950
re_path(r'^(?P<pk>[0-9]+)/object_roles/$', ProjectObjectRolesList.as_view(), name='project_object_roles_list'),
5051
re_path(r'^(?P<pk>[0-9]+)/access_list/$', ProjectAccessList.as_view(), name='project_access_list'),
52+
re_path(r'^(?P<pk>[0-9]+)/instance_groups/$', ProjectInstanceGroupsList.as_view(), name='project_instance_groups_list'),
5153
re_path(r'^(?P<pk>[0-9]+)/copy/$', ProjectCopy.as_view(), name='project_copy'),
5254
]
5355

‎awx/api/views/__init__.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1211,6 +1211,15 @@ class ProjectCopy(CopyAPIView):
12111211
resource_purpose = 'copy of a project'
12121212

12131213

1214+
class ProjectInstanceGroupsList(SubListAttachDetachAPIView):
1215+
model = models.InstanceGroup
1216+
serializer_class = serializers.InstanceGroupSerializer
1217+
parent_model = models.Project
1218+
relationship = 'instance_groups'
1219+
filter_read_permission = False
1220+
resource_purpose = 'instance groups of a project'
1221+
1222+
12141223
class UserList(ListCreateAPIView):
12151224
model = models.User
12161225
serializer_class = serializers.UserSerializer

‎awx/main/access.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1399,6 +1399,20 @@ def can_start(self, obj, validate_license=True):
13991399
def can_delete(self, obj):
14001400
return self.can_change(obj, None)
14011401

1402+
@check_superuser
1403+
def can_attach(self, obj, sub_obj, relationship, data, skip_sub_obj_read_check=False):
1404+
if relationship == "instance_groups":
1405+
if not obj.organization:
1406+
return False
1407+
return self.user in sub_obj.use_role and self.user in obj.admin_role
1408+
return super(ProjectAccess, self).can_attach(obj, sub_obj, relationship, data, skip_sub_obj_read_check=skip_sub_obj_read_check)
1409+
1410+
@check_superuser
1411+
def can_unattach(self, obj, sub_obj, relationship, *args, **kwargs):
1412+
if relationship == "instance_groups":
1413+
return self.can_attach(obj, sub_obj, relationship, *args, **kwargs)
1414+
return super(ProjectAccess, self).can_unattach(obj, sub_obj, relationship, *args, **kwargs)
1415+
14021416

14031417
class ProjectUpdateAccess(BaseAccess):
14041418
"""

‎awx/main/models/projects.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,26 @@ def get_notification_templates(self):
671671
def get_notification_friendly_name(self):
672672
return "Project Update"
673673

674+
@property
675+
def preferred_instance_groups(self):
676+
"""Return instance groups for this project update."""
677+
if self.project and self.project.instance_groups.exists():
678+
return list(self.project.instance_groups.all())
679+
# No instance groups - use control plane (current behavior)
680+
return self.control_plane_instance_group
681+
682+
@property
683+
def capacity_type(self):
684+
"""Return capacity type - 'execution' if project has instance groups."""
685+
if self.project and self.project.instance_groups.exists():
686+
return 'execution'
687+
return 'control'
688+
689+
@property
690+
def is_remote_update(self):
691+
"""Returns True if running on a remote mesh node."""
692+
return bool(self.execution_node and self.controller_node and self.execution_node != self.controller_node)
693+
674694
def save(self, *args, **kwargs):
675695
added_update_fields = []
676696
if not self.job_tags:

‎awx/main/tasks/callback.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,28 @@ def __init__(self, *args, **kwargs):
309309
super(RunnerCallbackForProjectUpdate, self).__init__(*args, **kwargs)
310310
self.playbook_new_revision = None
311311
self.host_map = {}
312+
self.project_artifact_path = None
313+
self.roles_artifact_path = None
314+
self.collections_artifact_path = None
315+
316+
def artifacts_handler(self, artifact_dir):
317+
"""Process artifacts including project files for remote updates."""
318+
super().artifacts_handler(artifact_dir)
319+
320+
project_update_path = os.path.join(artifact_dir, 'project_update')
321+
if os.path.exists(project_update_path):
322+
self.project_artifact_path = project_update_path
323+
logger.debug(f'Found project artifacts at {project_update_path}')
324+
325+
project_roles_path = os.path.join(artifact_dir, 'project_roles')
326+
if os.path.exists(project_roles_path):
327+
self.roles_artifact_path = project_roles_path
328+
logger.debug(f'Found roles artifacts at {project_roles_path}')
329+
330+
project_collections_path = os.path.join(artifact_dir, 'project_collections')
331+
if os.path.exists(project_collections_path):
332+
self.collections_artifact_path = project_collections_path
333+
logger.debug(f'Found collections artifacts at {project_collections_path}')
312334

313335
def event_handler(self, event_data):
314336
super_return_value = super(RunnerCallbackForProjectUpdate, self).event_handler(event_data)

‎awx/main/tasks/jobs.py‎

Lines changed: 110 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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)
186210
def 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

Comments
 (0)