From 376f964a40f7b11200740335058e35024bbc1972 Mon Sep 17 00:00:00 2001 From: Dirk Julich Date: Fri, 24 Apr 2026 16:17:04 +0200 Subject: [PATCH] [devel backport] AAP-41742: Fix workflow node update failing when JT has unprompted labels (#16426) * AAP-41742: Fix workflow node update failing when JT has unprompted labels PATCH extra_data on a workflow node fails with {"labels":["Field is not configured to prompt on launch."]} when the node has labels associated but the JT has ask_labels_on_launch=False. The serializer was passing all persisted M2M state from prompts_dict() to _accept_or_ignore_job_kwargs() on every PATCH, re-validating unchanged fields. Fix scopes validation to only the fields in the request; full re-validation still occurs when unified_job_template is being changed. * Capture attrs keys before _build_mock_obj mutates them _build_mock_obj() pops pseudo-fields (limit, scm_branch, job_tags, etc.) from attrs. Computing requested_prompt_fields after the pop would miss those fields and skip their ask_on_launch validation. * Include survey_passwords when validating extra_vars prompts prompts_dict() emits survey_passwords alongside extra_vars. _accept_or_ignore_job_kwargs uses it to decrypt encrypted survey values before validation. Without it, encrypted password blobs are validated as-is against the survey spec. --------- Co-authored-by: Claude Opus 4.6 --- awx/api/serializers.py | 23 ++++++++++++++-- .../functional/api/test_workflow_node.py | 26 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index e916550d82..c65a00cc9d 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4142,9 +4142,28 @@ class LaunchConfigurationBaseSerializer(BaseSerializer): attrs['extra_data'][key] = db_extra_data[key] # Build unsaved version of this config, use it to detect prompts errors + # Capture keys before _build_mock_obj pops pseudo-fields from attrs + incoming_attr_keys = set(attrs.keys()) mock_obj = self._build_mock_obj(attrs) - if set(list(ujt.get_ask_mapping().keys()) + ['extra_data']) & set(attrs.keys()): - accepted, rejected, errors = ujt._accept_or_ignore_job_kwargs(_exclude_errors=self.exclude_errors, **mock_obj.prompts_dict()) + ask_mapping_keys = set(ujt.get_ask_mapping().keys()) + requested_prompt_fields = incoming_attr_keys & ask_mapping_keys + if 'extra_data' in incoming_attr_keys: + requested_prompt_fields.add('extra_vars') + requested_prompt_fields.add('survey_passwords') + + # prompts_dict() pulls persisted M2M state (labels, credentials, + # instance_groups) via the instance pk. Only re-validate the full prompt + # state when the caller is switching the underlying template; otherwise + # restrict validation to the fields the request explicitly provided. + if 'unified_job_template' in attrs: + prompts_to_validate = mock_obj.prompts_dict() + elif requested_prompt_fields: + prompts_to_validate = {k: v for k, v in mock_obj.prompts_dict().items() if k in requested_prompt_fields} + else: + prompts_to_validate = None + + if prompts_to_validate is not None: + accepted, rejected, errors = ujt._accept_or_ignore_job_kwargs(_exclude_errors=self.exclude_errors, **prompts_to_validate) else: # Only perform validation of prompts if prompts fields are provided errors = {} diff --git a/awx/main/tests/functional/api/test_workflow_node.py b/awx/main/tests/functional/api/test_workflow_node.py index 71874085d7..02860d06b6 100644 --- a/awx/main/tests/functional/api/test_workflow_node.py +++ b/awx/main/tests/functional/api/test_workflow_node.py @@ -13,6 +13,7 @@ from awx.main.models.workflow import ( WorkflowJobTemplateNode, ) from awx.main.models.credential import Credential +from awx.main.models.label import Label from awx.main.scheduler import TaskManager, WorkflowManager, DependencyManager # Django @@ -51,6 +52,31 @@ def test_node_accepts_prompted_fields(inventory, project, workflow_job_template, post(url, {'unified_job_template': job_template.pk, 'limit': 'webservers'}, user=admin_user, expect=201) +@pytest.mark.django_db +def test_node_extra_data_patch_with_unprompted_labels(inventory, project, organization, workflow_job_template, patch, admin_user): + """AAP-41742: PATCH extra_data on a workflow node should succeed even when + the node has labels associated but the JT has ask_labels_on_launch=False.""" + jt = JobTemplate.objects.create( + inventory=inventory, + project=project, + playbook='helloworld.yml', + ask_variables_on_launch=True, + ask_labels_on_launch=False, + ) + label = Label.objects.create(name='repro-label', organization=organization) + + node = WorkflowJobTemplateNode.objects.create( + workflow_job_template=workflow_job_template, + unified_job_template=jt, + extra_data={'foo': 'bar'}, + ) + node.labels.add(label) + + url = reverse('api:workflow_job_template_node_detail', kwargs={'pk': node.pk}) + r = patch(url, {'extra_data': {'foo': 'edited'}}, user=admin_user, expect=200) + assert r.data['extra_data'] == {'foo': 'edited'} + + @pytest.mark.django_db @pytest.mark.parametrize( "field_name, field_value",