Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions awx/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3200,6 +3200,7 @@ class Meta:
'scm_branch',
'forks',
'limit',
'job_slice_pinned_hosts',
'verbosity',
'extra_vars',
'job_tags',
Expand Down
2 changes: 1 addition & 1 deletion awx/api/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3270,7 +3270,7 @@ def post(self, request, *args, **kwargs):
if not jt:
raise ParseError(_('Cannot relaunch slice workflow job orphaned from job template.'))
elif getattr(obj.inventory, 'kind', None) != 'federated' and (
not obj.inventory or min(obj.inventory.hosts.count(), jt.job_slice_count) != obj.workflow_nodes.count()
not obj.inventory or jt.get_effective_slice_ct({'inventory': obj.inventory}) != obj.workflow_nodes.count()
):
raise ParseError(_('Cannot relaunch sliced workflow job after slice count has changed.'))
if from_failed:
Expand Down
31 changes: 31 additions & 0 deletions awx/main/migrations/0204_job_slice_pinned_hosts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Generated by Django 5.2.15 on 2026-07-05 00:05

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('main', '0203_workflow_node_retries'),
]

operations = [
migrations.AddField(
model_name='job',
name='job_slice_pinned_hosts',
field=models.TextField(
blank=True,
default='',
help_text='Comma separated list of host names to include in every slice of a sliced job, in addition to the hosts of the slice itself. Useful when a play targets a coordinating host, such as localhost, that all slices depend on. Names are matched exactly against inventory hosts; groups and patterns are not supported. Has no effect unless the job is sliced.',
),
),
migrations.AddField(
model_name='jobtemplate',
name='job_slice_pinned_hosts',
field=models.TextField(
blank=True,
default='',
help_text='Comma separated list of host names to include in every slice of a sliced job, in addition to the hosts of the slice itself. Useful when a play targets a coordinating host, such as localhost, that all slices depend on. Names are matched exactly against inventory hosts; groups and patterns are not supported. Has no effect unless the job is sliced.',
),
),
]
15 changes: 12 additions & 3 deletions awx/main/models/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,11 +281,15 @@ def parse_slice_params(slice_str):
raise ParseError(_('Slice number must be 1 or higher.'))
return (number, step)

def get_sliced_hosts(self, host_queryset, slice_number, slice_count):
def get_sliced_hosts(self, host_queryset, slice_number, slice_count, pinned_hosts=None):
"""
Returns a slice of Hosts given a slice number and total slice count, or
the original queryset if slicing is not requested.

Hosts named in pinned_hosts are taken out of the round robin
distribution and included in every slice instead, so that plays which
target them (a coordinating host like localhost) run in all slices.

NOTE: If slicing is performed, this will return a List[Host] with the
resulting slice. If slicing is not performed it will return the
original queryset (not evaluating it or forcing it to a list). This
Expand All @@ -297,18 +301,23 @@ def get_sliced_hosts(self, host_queryset, slice_number, slice_count):

if slice_count > 1 and slice_number > 0:
offset = slice_number - 1
if pinned_hosts:
pinned = list(host_queryset.filter(name__in=pinned_hosts))
if pinned:
sliced = list(host_queryset.exclude(name__in=pinned_hosts)[offset::slice_count])
return sorted(sliced + pinned, key=lambda host: host.name)
host_queryset = host_queryset[offset::slice_count]
return host_queryset

def get_script_data(self, hostvars=False, towervars=False, show_all=False, slice_number=1, slice_count=1):
def get_script_data(self, hostvars=False, towervars=False, show_all=False, slice_number=1, slice_count=1, slice_pinned_hosts=None):
hosts_kw = dict()
if not show_all:
hosts_kw['enabled'] = True
fetch_fields = ['name', 'id', 'variables', 'inventory_id']
if towervars:
fetch_fields.append('enabled')
host_queryset = self.hosts.filter(**hosts_kw).order_by('name').only(*fetch_fields)
hosts = self.get_sliced_hosts(host_queryset, slice_number, slice_count)
hosts = self.get_sliced_hosts(host_queryset, slice_number, slice_count, pinned_hosts=slice_pinned_hosts)

data = dict()
all_group = data.setdefault('all', dict())
Expand Down
30 changes: 28 additions & 2 deletions awx/main/models/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,17 @@ class Meta:
blank=True,
default='',
)
job_slice_pinned_hosts = models.TextField(
blank=True,
default='',
help_text=_(
'Comma separated list of host names to include in every slice of a sliced job, '
'in addition to the hosts of the slice itself. Useful when a play targets a '
'coordinating host, such as localhost, that all slices depend on. '
'Names are matched exactly against inventory hosts; groups and patterns are '
'not supported. Has no effect unless the job is sliced.'
),
)
verbosity = models.PositiveIntegerField(
choices=VERBOSITY_CHOICES,
blank=True,
Expand Down Expand Up @@ -162,6 +173,10 @@ class Meta:

extra_vars_dict = VarsDictProperty('extra_vars', True)

@property
def job_slice_pinned_hosts_list(self):
return [name.strip() for name in self.job_slice_pinned_hosts.split(',') if name.strip()]

@property
def machine_credential(self):
return self.credentials.filter(credential_type__kind='ssh').first()
Expand Down Expand Up @@ -374,7 +389,14 @@ def get_effective_slice_ct(self, kwargs):
if self.ask_job_slice_count_on_launch and 'job_slice_count' in kwargs:
actual_slice_count = kwargs['job_slice_count']
if actual_inventory:
return min(actual_slice_count, actual_inventory.hosts.count())
host_count = actual_inventory.hosts.count()
pinned_names = self.job_slice_pinned_hosts_list
if pinned_names and host_count and actual_slice_count > 1:
# Pinned hosts are repeated in every slice, so they do not add
# to the number of slices worth spawning.
pinned_ct = actual_inventory.hosts.filter(name__in=pinned_names).count()
host_count = max(host_count - pinned_ct, 1)
return min(actual_slice_count, host_count)
else:
return actual_slice_count

Expand Down Expand Up @@ -759,8 +781,12 @@ def _get_task_impact(self):
if self.inventory is not None:
count_hosts = self.inventory.total_hosts
if self.job_slice_count > 1:
pinned_ct = self.inventory.hosts.filter(name__in=self.job_slice_pinned_hosts_list).count()
count_hosts = max(count_hosts - pinned_ct, 0)
# Integer division intentional
count_hosts = (count_hosts + self.job_slice_count - self.job_slice_number) // self.job_slice_count
# pinned hosts run in every slice on top of its share
count_hosts += pinned_ct
else:
count_hosts = 5 if self.forks == 0 else self.forks
return min(count_hosts, 5 if self.forks == 0 else self.forks) + 1
Expand Down Expand Up @@ -922,7 +948,7 @@ def get_hosts_for_fact_cache(self):
host_qs = self.inventory.hosts

host_qs = host_qs.only(*HOST_FACTS_FIELDS)
host_qs = self.inventory.get_sliced_hosts(host_qs, self.job_slice_number, self.job_slice_count)
host_qs = self.inventory.get_sliced_hosts(host_qs, self.job_slice_number, self.job_slice_count, pinned_hosts=self.job_slice_pinned_hosts_list)
return host_qs


Expand Down
1 change: 1 addition & 0 deletions awx/main/tasks/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@ def build_inventory(self, instance, private_data_dir):
if hasattr(instance, 'job_slice_number'):
script_params['slice_number'] = instance.job_slice_number
script_params['slice_count'] = instance.job_slice_count
script_params['slice_pinned_hosts'] = instance.job_slice_pinned_hosts_list

return self.write_inventory_file(instance.inventory, private_data_dir, 'hosts', script_params)

Expand Down
16 changes: 16 additions & 0 deletions awx/main/tests/functional/api/test_workflow_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,22 @@ def test_workflow_job_relaunch_federated_inventory(organization, job_template, p
post(url, user=admin_user, expect=201)


@pytest.mark.django_db
def test_workflow_job_relaunch_with_pinned_hosts(slice_jt_factory, post, admin_user):
"""A sliced job with pinned hosts spawns fewer nodes than
min(hosts, slice_count); the relaunch check must apply the same
arithmetic instead of flagging a slice count change."""
slice_jt = slice_jt_factory(3, jt_kwargs={'job_slice_pinned_hosts': 'foo0'})
wfj = slice_jt.create_unified_job()
wfj.status = 'successful'
wfj.save()
assert wfj.is_sliced_job
assert wfj.workflow_nodes.count() == 2

url = reverse("api:workflow_job_relaunch", kwargs={'pk': wfj.pk})
post(url, user=admin_user, expect=201)


@pytest.mark.django_db
@pytest.mark.parametrize(
"is_admin, status",
Expand Down
25 changes: 25 additions & 0 deletions awx/main/tests/functional/models/test_inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,31 @@ def test_slice_subset_with_groups(self, inventory):
data.pop('all')
assert data == expected_data

def test_slice_subset_with_pinned_hosts(self, inventory):
for i in range(7):
inventory.hosts.create(name='host{}'.format(i))
# host0 is pinned, the remaining 6 hosts are distributed round robin
for i in range(3):
data = inventory.get_script_data(slice_number=i + 1, slice_count=3, slice_pinned_hosts=['host0'])
assert data == {'all': {'hosts': ['host0', 'host{}'.format(i + 1), 'host{}'.format(i + 4)]}}

def test_slice_pinned_host_keeps_group_membership(self, inventory):
group = inventory.groups.create(name='central_hosts')
group.hosts.add(inventory.hosts.create(name='central'))
for i in range(2):
inventory.hosts.create(name='host{}'.format(i))
for i in range(2):
data = inventory.get_script_data(slice_number=i + 1, slice_count=2, slice_pinned_hosts=['central'])
assert data['central_hosts'] == {'hosts': ['central']}
assert data['all']['hosts'] == ['host{}'.format(i)]

def test_slice_pinned_hosts_unknown_names_ignored(self, inventory):
for i in range(2):
inventory.hosts.create(name='host{}'.format(i))
for i in range(2):
data = inventory.get_script_data(slice_number=i + 1, slice_count=2, slice_pinned_hosts=['does-not-exist'])
assert data == {'all': {'hosts': ['host{}'.format(i)]}}


@pytest.mark.django_db
class TestActiveCount:
Expand Down
52 changes: 51 additions & 1 deletion awx/main/tests/functional/models/test_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,57 @@ def test_slice_count_prompt_limited_by_inventory(self, job_template, inventory,
unified_job = job_template.create_unified_job(job_slice_count=2)
assert isinstance(unified_job, Job)

def test_effective_slice_count_with_pinned_hosts(self, job_template, inventory):
job_template.inventory = inventory
job_template.job_slice_count = 5
job_template.job_slice_pinned_hosts = ' localhost, missing-host ,'
assert job_template.job_slice_pinned_hosts_list == ['localhost', 'missing-host']
inventory.hosts.create(name='localhost')
for i in range(3):
inventory.hosts.create(name='foo{}'.format(i))
# localhost is repeated in every slice, so only 3 hosts are worth distributing
assert job_template.get_effective_slice_ct({}) == 3

def test_effective_slice_count_all_hosts_pinned(self, job_template, inventory):
job_template.inventory = inventory
job_template.job_slice_count = 3
job_template.job_slice_pinned_hosts = 'foo0,foo1'
for i in range(2):
inventory.hosts.create(name='foo{}'.format(i))
# slicing is pointless when every host would end up in every slice
assert job_template.get_effective_slice_ct({}) == 1

def test_pinned_hosts_inherited_by_slice_jobs(self, slice_job_factory):
# factory creates hosts foo0..foo2 with slice count 3; foo0 pinned leaves
# two hosts to distribute, so only two slices get spawned
workflow_job = slice_job_factory(3, jt_kwargs={'job_slice_pinned_hosts': 'foo0'}, spawn=True)
assert workflow_job.workflow_nodes.count() == 2
seen_hosts = []
for node in workflow_job.workflow_nodes.all():
job = node.job
assert job.job_slice_pinned_hosts == 'foo0'
# same call the task inventory build makes for the slice
data = job.inventory.get_script_data(
slice_number=job.job_slice_number, slice_count=job.job_slice_count, slice_pinned_hosts=job.job_slice_pinned_hosts_list
)
hosts = data['all']['hosts']
assert 'foo0' in hosts
assert len(hosts) == 2
seen_hosts.extend(hosts)
# the two remaining hosts got distributed, one per slice
assert sorted(seen_hosts) == ['foo0', 'foo0', 'foo1', 'foo2']

def test_pinned_hosts_fact_cache_alignment(self, slice_job_factory):
# the fact cache host set of each slice must match the inventory the
# slice actually runs against
workflow_job = slice_job_factory(3, jt_kwargs={'job_slice_pinned_hosts': 'foo0', 'use_fact_cache': True}, spawn=True)
for node in workflow_job.workflow_nodes.all():
job = node.job
data = job.inventory.get_script_data(
slice_number=job.job_slice_number, slice_count=job.job_slice_count, slice_pinned_hosts=job.job_slice_pinned_hosts_list
)
assert sorted(host.name for host in job.get_hosts_for_fact_cache()) == data['all']['hosts']


# ---------------------------------------------------------------------------
# Federated inventory launch tests
Expand Down Expand Up @@ -308,4 +359,3 @@ def test_complex_pattern_failsafe(self, organization):
assert _federated_inventory_has_matching_hosts(inv, 'web:db') is True
assert _federated_inventory_has_matching_hosts(inv, 'web&db') is True
assert _federated_inventory_has_matching_hosts(inv, '!web') is True

Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ function JobTemplateDetail({ template }) {
forks,
host_config_key,
job_slice_count,
job_slice_pinned_hosts,
job_tags,
job_type,
name,
Expand Down Expand Up @@ -289,6 +290,12 @@ function JobTemplateDetail({ template }) {
dataCy="jt-detail-job-slice-count"
helpText={helpText.jobSlicing}
/>
<Detail
label={t`Job Slice Pinned Hosts`}
value={job_slice_pinned_hosts}
dataCy="jt-detail-job-slice-pinned-hosts"
helpText={helpText.jobSlicePinnedHosts}
/>
{host_config_key && (
<>
<Detail
Expand Down
1 change: 1 addition & 0 deletions awx/ui/src/screens/Template/shared/JobTemplate.helptext.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ function getHelpText(t) {
limit: t`Provide a host pattern to further constrain the list of hosts that will be managed or affected by the playbook. Multiple patterns are allowed. Refer to Ansible documentation for more information and examples on patterns.`,
verbosity: t`Control the level of output ansible will produce as the playbook executes.`,
jobSlicing: t`Divide the work done by this job template into the specified number of job slices, each running the same tasks against a portion of the inventory.`,
jobSlicePinnedHosts: t`Optional comma separated list of host names to include in every job slice, in addition to the hosts of the slice itself. Useful when a play targets a coordinating host, such as localhost, that all slices depend on. Names are matched exactly against inventory hosts; groups and patterns are not supported. Pinned hosts run their plays once per slice.`,
timeout: t`The amount of time (in seconds) to run before the job is canceled. Defaults to 0 for no job timeout.`,
showChanges: t`If enabled, show the changes made by Ansible tasks, where supported. This is equivalent to Ansible's --diff mode.`,
instanceGroups: t`Select the Instance Groups for this Job Template to run on.`,
Expand Down
10 changes: 10 additions & 0 deletions awx/ui/src/screens/Template/shared/JobTemplateForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,15 @@ function JobTemplateForm({
min="1"
/>
</FieldWithPrompt>
{Number(jobSliceCountField.value) > 1 && (
<FormField
id="template-job-slice-pinned-hosts"
name="job_slice_pinned_hosts"
type="text"
label={t`Job Slice Pinned Hosts`}
tooltip={helpText.jobSlicePinnedHosts}
/>
)}
<FieldWithPrompt
fieldId="template-timeout"
label={t`Timeout`}
Expand Down Expand Up @@ -732,6 +741,7 @@ const FormikApp = withFormik({
instanceGroups: [],
inventory: summary_fields?.inventory || null,
job_slice_count: template.job_slice_count || 1,
job_slice_pinned_hosts: template.job_slice_pinned_hosts || '',
job_tags: template.job_tags || '',
job_type: template.job_type || 'run',
labels: summary_fields.labels.results || [],
Expand Down
30 changes: 30 additions & 0 deletions awx/ui/src/screens/Template/shared/JobTemplateForm.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,36 @@ describe('<JobTemplateForm />', () => {
);
});

test('job slice pinned hosts field only shows when slicing is enabled', async () => {
renderWithContexts(
<JobTemplateForm
template={mockData}
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
/>
);
await screen.findByRole('button', { name: 'Save' });

expect(
document.getElementById('template-job-slice-pinned-hosts')
).toBeNull();

const sliceInput = document.getElementById('template-job-slicing');
fireEvent.change(sliceInput, {
target: { value: '3', name: 'job_slice_count' },
});

const pinnedInput = await waitFor(() => {
const el = document.getElementById('template-job-slice-pinned-hosts');
if (!el) throw new Error('pinned hosts field not rendered yet');
return el;
});
fireEvent.change(pinnedInput, {
target: { value: 'localhost', name: 'job_slice_pinned_hosts' },
});
expect(pinnedInput.value).toBe('localhost');
});

test('webhooks and enable concurrent jobs functions properly', async () => {
// WebhookSubForm reads the template id from useParams(), so mount the form
// under a real v6 route whose concrete URL provides id=1.
Expand Down
Loading