Skip to content

Commit 2e0f21f

Browse files
Route hosts to instance groups from an inventory variable
In deployments with several isolated network zones, each zone has its own execution nodes grouped in an instance group, because firewalls only let those nodes reach the hosts of their zone. A job runs on a single instance group, so the same baseline template (sysctl, users, patching) has to be duplicated per zone with a different limit and instance group. This adds an instance_group_routing_var field to job templates: the name of an inventory variable (usually a group var) whose value names the instance group each host should run on. At launch, hosts are grouped by the resolved value and the launch creates the same implicit workflow job that sliced jobs use, with one node per bucket: each job runs restricted to its bucket's hosts and assigned to the routed instance group. Hosts that do not resolve the variable run in an extra job with the normal instance group selection, and if everything lands in one bucket a plain job is launched on it. Resolution follows a simplified version of the Ansible precedence rules (host vars win, then groups by depth, priority and name) and only considers enabled hosts. Instance groups are protected by RBAC and the routing variable is inventory data, so it is not trusted blindly: the launch endpoint checks the launching user's use permission on every referenced instance group, the same rule already applied to instance groups prompted at launch, and unknown names reject the launch instead of falling back silently. The validated buckets are handed to the job creation so the launch routes to exactly what was checked. The same checks run again when relaunching a routed workflow, which recomputes the buckets from current inventory data. Routing is mutually exclusive with job slicing (also when the slice count is prompted at launch), prompting instance groups overrides it, and provisioning callbacks never route, like they never slice. Launch paths without a requesting user stay loud but safe when a routing value names a missing instance group: a workflow containing the routed template is marked failed with the reason instead of crashing the workflow manager cycle, webhook launches answer 400, and relaunching a routed child job keeps targeting its original bucket even if the template's routing variable changed since. Refs #566
1 parent c8eeb60 commit 2e0f21f

18 files changed

Lines changed: 872 additions & 21 deletions

File tree

awx/api/serializers.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3180,6 +3180,7 @@ class Meta:
31803180
'scm_branch',
31813181
'forks',
31823182
'limit',
3183+
'instance_group_routing_var',
31833184
'verbosity',
31843185
'extra_vars',
31853186
'job_tags',
@@ -3385,6 +3386,13 @@ def get_field_from_model_or_attrs(fd):
33853386
elif inventory is None and not get_field_from_model_or_attrs('ask_inventory_on_launch'):
33863387
raise serializers.ValidationError({'inventory': prompting_error_message})
33873388

3389+
instance_group_routing_var = get_field_from_model_or_attrs('instance_group_routing_var')
3390+
if instance_group_routing_var:
3391+
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', instance_group_routing_var):
3392+
raise serializers.ValidationError({'instance_group_routing_var': _("Must be a valid variable name.")})
3393+
if (get_field_from_model_or_attrs('job_slice_count') or 1) > 1:
3394+
raise serializers.ValidationError({'instance_group_routing_var': _("Instance group routing cannot be combined with job slicing.")})
3395+
33883396
return super(JobTemplateSerializer, self).validate(attrs)
33893397

33903398
def validate_extra_vars(self, value):
@@ -3435,6 +3443,7 @@ class Meta:
34353443
'diff_mode',
34363444
'job_slice_number',
34373445
'job_slice_count',
3446+
'instance_group_routing_value',
34383447
'webhook_service',
34393448
'webhook_credential',
34403449
'webhook_guid',

awx/api/views/__init__.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2435,6 +2435,14 @@ def post(self, request, *args, **kwargs):
24352435
if not request.user.can_access(models.JobLaunchConfig, 'add', serializer.validated_data, template=obj):
24362436
raise PermissionDenied()
24372437

2438+
ig_routing_error, ig_routing_buckets = obj.get_ig_routing_launch_error(request.user, serializer.validated_data)
2439+
if ig_routing_error:
2440+
return Response(dict(errors=[ig_routing_error]), status=status.HTTP_400_BAD_REQUEST)
2441+
if ig_routing_buckets is not None:
2442+
# hand the validated buckets to create_unified_job, so the launch
2443+
# routes to exactly the instance groups that passed the check
2444+
serializer.validated_data['_ig_routing_buckets'] = ig_routing_buckets
2445+
24382446
passwords = serializer.validated_data.pop('credential_passwords', {})
24392447
new_job = obj.create_unified_job(**serializer.validated_data)
24402448
result = new_job.signal_start(**passwords)
@@ -2856,6 +2864,7 @@ def post(self, request, *args, **kwargs):
28562864
extra_vars_redacted, removed = extract_ansible_vars(extra_vars)
28572865
kv['extra_vars'] = extra_vars_redacted
28582866
kv['_prevent_slicing'] = True # will only run against 1 host, so no point
2867+
kv['_prevent_ig_routing'] = True # same reason: a single host needs no routing fan-out
28592868
with transaction.atomic():
28602869
job = job_template.create_job(**kv)
28612870

@@ -3223,6 +3232,34 @@ def post(self, request, *args, **kwargs):
32233232
jt = obj.job_template
32243233
if not jt:
32253234
raise ParseError(_('Cannot relaunch slice workflow job orphaned from job template.'))
3235+
# classify by what actually spawned the workflow, not by the current
3236+
# (editable) template configuration
3237+
is_ig_routed_workflow = any('ig_routing_value' in node.ancestor_artifacts for node in obj.workflow_nodes.all())
3238+
# the relaunch re-applies the original prompts, so it targets the
3239+
# prompted inventory when the template asks for one, or whatever
3240+
# inventory the template has now otherwise
3241+
relaunch_inventory = obj.inventory if (jt.ask_inventory_on_launch and obj.inventory) else jt.inventory
3242+
if is_ig_routed_workflow:
3243+
if not jt.instance_group_routing_var:
3244+
raise ParseError(
3245+
_('Cannot relaunch instance group routed workflow job: the job template no longer routes by variable. Launch the job template instead.')
3246+
)
3247+
if relaunch_inventory is None:
3248+
raise ParseError(_('Cannot relaunch instance group routed workflow job without an inventory.'))
3249+
if getattr(relaunch_inventory, 'kind', None) != 'federated':
3250+
# routing buckets are recomputed at relaunch, so only require
3251+
# that the inventory still fans out and that the relaunching
3252+
# user may use the routed instance groups
3253+
relaunch_kwargs = {'inventory': relaunch_inventory} if jt.ask_inventory_on_launch else {}
3254+
ig_routing_error, ig_routing_buckets = jt.get_ig_routing_launch_error(request.user, relaunch_kwargs)
3255+
if ig_routing_error:
3256+
raise ParseError(ig_routing_error)
3257+
if ig_routing_buckets is None or len(ig_routing_buckets) <= 1:
3258+
raise ParseError(
3259+
_(
3260+
'Cannot relaunch instance group routed workflow job: the inventory no longer routes to multiple instance groups. Launch the job template instead.'
3261+
)
3262+
)
32263263
elif getattr(obj.inventory, 'kind', None) != 'federated' and (
32273264
not obj.inventory or min(obj.inventory.hosts.count(), jt.job_slice_count) != obj.workflow_nodes.count()
32283265
):

awx/api/views/webhooks.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import logging
44
import urllib.parse
55

6+
from django.core.exceptions import ValidationError as DjangoValidationError
67
from django.utils.encoding import force_bytes
78
from django.utils.translation import gettext_lazy as _
89
from django.views.decorators.csrf import csrf_exempt
@@ -167,7 +168,12 @@ def post(self, request, *args, **kwargs_in):
167168
kwargs['extra_vars']['{}_webhook_status_api'.format(name)] = status_api
168169
kwargs['extra_vars']['{}_webhook_payload'.format(name)] = request.data
169170

170-
new_job = obj.create_unified_job(**kwargs)
171+
try:
172+
new_job = obj.create_unified_job(**kwargs)
173+
except DjangoValidationError as exc:
174+
# e.g. instance group routing pointing at an instance group that does
175+
# not exist; answer with a 4xx instead of letting the SCM see a 500
176+
return Response(dict(errors=exc.messages), status=status.HTTP_400_BAD_REQUEST)
171177
new_job.signal_start()
172178

173179
return Response({'message': "Job queued."}, status=status.HTTP_202_ACCEPTED)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Generated by Django 5.2.15 on 2026-07-05 12:00
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('main', '0200_add_list_ordering'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='job',
15+
name='instance_group_routing_var',
16+
field=models.CharField(
17+
blank=True,
18+
default='',
19+
help_text='Name of an inventory variable used to route hosts to instance groups at launch. When set, hosts are grouped by the value of this variable (host variables win over group variables) and the launch spawns one job per group, restricted to its hosts and assigned to the instance group named by the value. Hosts without the variable run in an extra job with the normal instance group selection. Launch fails if a value does not name an existing instance group or the launching user lacks use permission on it. Cannot be combined with job slicing.',
20+
max_length=1024,
21+
),
22+
),
23+
migrations.AddField(
24+
model_name='jobtemplate',
25+
name='instance_group_routing_var',
26+
field=models.CharField(
27+
blank=True,
28+
default='',
29+
help_text='Name of an inventory variable used to route hosts to instance groups at launch. When set, hosts are grouped by the value of this variable (host variables win over group variables) and the launch spawns one job per group, restricted to its hosts and assigned to the instance group named by the value. Hosts without the variable run in an extra job with the normal instance group selection. Launch fails if a value does not name an existing instance group or the launching user lacks use permission on it. Cannot be combined with job slicing.',
30+
max_length=1024,
31+
),
32+
),
33+
migrations.AddField(
34+
model_name='job',
35+
name='instance_group_routing_value',
36+
field=models.TextField(
37+
default=None,
38+
editable=False,
39+
help_text="If created by instance group routing, the routing variable value that selected this job's bucket of hosts. An empty string means the bucket of hosts that do not resolve the variable. Null when the job was not routed.",
40+
null=True,
41+
),
42+
),
43+
]

awx/main/models/inventory.py

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,101 @@ def get_sliced_hosts(self, host_queryset, slice_number, slice_count):
300300
host_queryset = host_queryset[offset::slice_count]
301301
return host_queryset
302302

303-
def get_script_data(self, hostvars=False, towervars=False, show_all=False, slice_number=1, slice_count=1):
303+
def resolve_host_variable(self, var_name):
304+
"""
305+
Resolve the value of an inventory variable for every enabled host,
306+
following a simplified version of the Ansible precedence rules: host
307+
variables win over group variables, and groups are merged sorted by
308+
depth, then ansible_group_priority, then name, later ones winning.
309+
310+
Returns a dict mapping host name to value, containing only the hosts
311+
where the variable resolves to a non empty string; hosts where it is
312+
unset, empty or not a string are left out. Disabled hosts are ignored,
313+
matching the hosts a job actually runs against.
314+
"""
315+
group_sort_keys = {}
316+
group_raw_values = {}
317+
for group in self.groups.only('id', 'name', 'variables'):
318+
group_vars = group.variables_dict
319+
try:
320+
priority = int(group_vars.get('ansible_group_priority', 1))
321+
except (TypeError, ValueError):
322+
priority = 1
323+
group_sort_keys[group.id] = (priority, group.name)
324+
if var_name in group_vars:
325+
group_raw_values[group.id] = group_vars[var_name]
326+
327+
parent_map = self.get_group_parents_map() if group_sort_keys else {}
328+
329+
depths = {}
330+
331+
def group_depth(group_id, visiting):
332+
if group_id in depths:
333+
return depths[group_id]
334+
depth = 1
335+
for parent_id in parent_map.get(group_id, ()):
336+
if parent_id in visiting:
337+
continue # the API prevents group cycles, guard just in case
338+
depth = max(depth, group_depth(parent_id, visiting | {parent_id}) + 1)
339+
depths[group_id] = depth
340+
return depth
341+
342+
ancestor_map = {}
343+
344+
def group_ancestors(group_id, visiting):
345+
if group_id in ancestor_map:
346+
return ancestor_map[group_id]
347+
found = set()
348+
for parent_id in parent_map.get(group_id, ()):
349+
if parent_id in visiting:
350+
continue
351+
found.add(parent_id)
352+
found |= group_ancestors(parent_id, visiting | {parent_id})
353+
ancestor_map[group_id] = found
354+
return found
355+
356+
host_group_map = {}
357+
if group_sort_keys:
358+
for group_id, host_ids in self.get_group_hosts_map().items():
359+
for host_id in host_ids:
360+
host_group_map.setdefault(host_id, set()).add(group_id)
361+
362+
result = {}
363+
for host in self.hosts.filter(enabled=True).only('id', 'name', 'variables'):
364+
host_vars = host.variables_dict
365+
if var_name in host_vars:
366+
value = host_vars[var_name]
367+
else:
368+
value = None
369+
candidate_ids = set()
370+
for group_id in host_group_map.get(host.id, ()):
371+
candidate_ids.add(group_id)
372+
candidate_ids |= group_ancestors(group_id, {group_id})
373+
best_key = None
374+
for group_id in candidate_ids:
375+
if group_id not in group_raw_values:
376+
continue
377+
priority, group_name = group_sort_keys[group_id]
378+
key = (group_depth(group_id, {group_id}), priority, group_name)
379+
if best_key is None or key > best_key:
380+
best_key = key
381+
value = group_raw_values[group_id]
382+
if isinstance(value, str) and value:
383+
result[host.name] = value
384+
return result
385+
386+
def filter_hosts_to_routing_bucket(self, hosts, var_name, value):
387+
"""
388+
Restrict hosts to the instance group routing bucket for the given value:
389+
the hosts whose routing variable resolves to it, or the hosts that do
390+
not resolve the variable at all when the value is an empty string.
391+
"""
392+
routed_values = self.resolve_host_variable(var_name)
393+
if value:
394+
return [host for host in hosts if routed_values.get(host.name) == value]
395+
return [host for host in hosts if host.name not in routed_values]
396+
397+
def get_script_data(self, hostvars=False, towervars=False, show_all=False, slice_number=1, slice_count=1, ig_routing_var=None, ig_routing_value=None):
304398
hosts_kw = dict()
305399
if not show_all:
306400
hosts_kw['enabled'] = True
@@ -309,6 +403,9 @@ def get_script_data(self, hostvars=False, towervars=False, show_all=False, slice
309403
fetch_fields.append('enabled')
310404
host_queryset = self.hosts.filter(**hosts_kw).order_by('name').only(*fetch_fields)
311405
hosts = self.get_sliced_hosts(host_queryset, slice_number, slice_count)
406+
if ig_routing_var:
407+
# Restrict the inventory to the routing bucket of this job
408+
hosts = self.filter_hosts_to_routing_bucket(hosts, ig_routing_var, ig_routing_value)
312409

313410
data = dict()
314411
all_group = data.setdefault('all', dict())

0 commit comments

Comments
 (0)