Skip to content

Commit 5251a30

Browse files
Add context_template to workflow approval nodes (#578)
* Add context_template to workflow approval nodes for approver visibility Approval nodes can now include a Jinja2 context_template that gets rendered with upstream set_stats artifacts when the approval is created. The rendered result is stored as context_message on the WorkflowApproval instance and displayed to the approver in the UI detail view and in the pending approval notification. This lets workflow authors surface relevant context (dry-run output, planned changes, terraform plan summaries) directly to the person who needs to approve, without them having to go find the previous job output. * Harden approval context_template rendering against resource exhaustion The Jinja2 sandbox blocks attribute escapes but not templates that burn CPU or memory, so rendering now happens in a short lived forked process with RLIMIT_CPU and RLIMIT_AS applied, and the task manager abandons and kills it after a hard timeout instead of blocking the scheduler loop. The rendered output is capped at 64KB. Also render with the artifacts passed as a context dict instead of **kwargs, so set_stats keys that are not valid identifiers cannot raise TypeError, and allow static templates to render when a node has no upstream artifacts. Any child process failure is logged and the approval is simply created without a context message. Adds functional tests covering rendering, static templates, weird artifact keys, template errors, the render timeout, the CPU and memory limits and output truncation. * Keep context_message out of the workflow approval list serializer The rendered context can be large, so only the detail endpoint returns it. The UI detail view already reads it from the detail endpoint. Adds an API test asserting the field shows up on detail and not on the list.
1 parent 6cd4c2f commit 5251a30

16 files changed

Lines changed: 242 additions & 6 deletions

File tree

awx/api/serializers.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3996,7 +3996,7 @@ class WorkflowApprovalSerializer(UnifiedJobSerializer):
39963996

39973997
class Meta:
39983998
model = WorkflowApproval
3999-
fields = ('*', '-controller_node', '-execution_node', 'can_approve_or_deny', 'approval_expiration', 'timed_out')
3999+
fields = ('*', '-controller_node', '-execution_node', 'can_approve_or_deny', 'approval_expiration', 'timed_out', 'context_message')
40004000

40014001
def get_approval_expiration(self, obj):
40024002
if obj.status != 'pending' or obj.timeout == 0:
@@ -4033,13 +4033,19 @@ class WorkflowApprovalActivityStreamSerializer(WorkflowApprovalSerializer):
40334033

40344034
class WorkflowApprovalListSerializer(WorkflowApprovalSerializer, UnifiedJobListSerializer):
40354035
class Meta:
4036-
fields = ('*', '-controller_node', '-execution_node', 'can_approve_or_deny', 'approval_expiration', 'timed_out')
4036+
fields = ('*', '-controller_node', '-execution_node', 'can_approve_or_deny', 'approval_expiration', 'timed_out', '-context_message')
4037+
4038+
def get_field_names(self, declared_fields, info):
4039+
field_names = super(WorkflowApprovalListSerializer, self).get_field_names(declared_fields, info)
4040+
# keep the potentially large rendered context out of list payloads,
4041+
# the detail view is the one that shows it
4042+
return tuple(x for x in field_names if x != 'context_message')
40374043

40384044

40394045
class WorkflowApprovalTemplateSerializer(UnifiedJobTemplateSerializer):
40404046
class Meta:
40414047
model = WorkflowApprovalTemplate
4042-
fields = ('*', 'timeout', 'name')
4048+
fields = ('*', 'timeout', 'name', 'context_template')
40434049

40444050
def get_related(self, obj):
40454051
res = super(WorkflowApprovalTemplateSerializer, self).get_related(obj)
@@ -4365,7 +4371,7 @@ def build_relational_field(self, field_name, relation_info):
43654371
class WorkflowJobTemplateNodeCreateApprovalSerializer(BaseSerializer):
43664372
class Meta:
43674373
model = WorkflowApprovalTemplate
4368-
fields = ('timeout', 'name', 'description')
4374+
fields = ('timeout', 'name', 'description', 'context_template')
43694375

43704376
def to_representation(self, obj):
43714377
return {}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from django.db import migrations, models
2+
3+
4+
class Migration(migrations.Migration):
5+
6+
dependencies = [
7+
('main', '0204_job_slice_pinned_hosts'),
8+
]
9+
10+
operations = [
11+
migrations.AddField(
12+
model_name='workflowapprovaltemplate',
13+
name='context_template',
14+
field=models.TextField(
15+
blank=True,
16+
default='',
17+
help_text=(
18+
"A Jinja2 template rendered with upstream set_stats artifacts when the approval is created. "
19+
"The result is stored on the approval as context_message and shown to the approver."
20+
),
21+
),
22+
),
23+
migrations.AddField(
24+
model_name='workflowapproval',
25+
name='context_message',
26+
field=models.TextField(
27+
blank=True,
28+
default='',
29+
help_text=(
30+
"The rendered context from the approval template's context_template, "
31+
"populated with upstream set_stats artifacts when the approval is created."
32+
),
33+
),
34+
),
35+
]

awx/main/models/workflow.py

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# Python
55
import json
66
import logging
7+
import multiprocessing
78
from uuid import uuid4
89
from copy import copy
910
from urllib.parse import urljoin
@@ -1115,6 +1116,7 @@ class WorkflowApprovalTemplate(UnifiedJobTemplate, RelatedJobsMixin):
11151116
FIELDS_TO_PRESERVE_AT_COPY = [
11161117
'description',
11171118
'timeout',
1119+
'context_template',
11181120
]
11191121

11201122
class Meta:
@@ -1125,6 +1127,14 @@ class Meta:
11251127
default=0,
11261128
help_text=_("The amount of time (in seconds) before the approval node expires and fails."),
11271129
)
1130+
context_template = models.TextField(
1131+
blank=True,
1132+
default='',
1133+
help_text=_(
1134+
"A Jinja2 template rendered with upstream set_stats artifacts when the approval is created. "
1135+
"The result is stored on the approval as context_message and shown to the approver."
1136+
),
1137+
)
11281138

11291139
@classmethod
11301140
def _get_unified_job_class(cls):
@@ -1149,6 +1159,33 @@ def _get_related_jobs(self):
11491159
return UnifiedJob.objects.filter(unified_job_template=self)
11501160

11511161

1162+
# context_template is user-supplied Jinja2. The sandbox stops attribute escapes but
1163+
# not resource exhaustion (e.g. {{ "x" * 10**9 }}, {{ 10 ** 10 ** 10 }} or a huge
1164+
# loop), so rendering happens in a disposable forked process with CPU and memory
1165+
# limits, and the parent abandons it after a hard timeout.
1166+
CONTEXT_TEMPLATE_TIMEOUT = 10 # seconds
1167+
CONTEXT_TEMPLATE_MEMORY_LIMIT = 256 * 1024 * 1024 # address space the render may allocate beyond what is already in use
1168+
CONTEXT_MESSAGE_MAX_LENGTH = 65536
1169+
1170+
1171+
def _render_context_template(conn, template_str, variables):
1172+
"""Runs in a forked child process; must never touch the database and only
1173+
reports back through conn. The parent kills it if it outlives the timeout."""
1174+
try:
1175+
import resource
1176+
1177+
current = int(open('/proc/self/statm').read().split()[0]) * resource.getpagesize()
1178+
resource.setrlimit(resource.RLIMIT_AS, (current + CONTEXT_TEMPLATE_MEMORY_LIMIT, current + CONTEXT_TEMPLATE_MEMORY_LIMIT))
1179+
resource.setrlimit(resource.RLIMIT_CPU, (CONTEXT_TEMPLATE_TIMEOUT, CONTEXT_TEMPLATE_TIMEOUT))
1180+
except Exception:
1181+
pass # limits are best effort, the parent still enforces the timeout
1182+
try:
1183+
rendered = sandbox.ImmutableSandboxedEnvironment().from_string(template_str).render(variables)
1184+
conn.send(('ok', rendered[:CONTEXT_MESSAGE_MAX_LENGTH]))
1185+
except Exception as e:
1186+
conn.send(('error', '{}: {}'.format(type(e).__name__, e)[:1024]))
1187+
1188+
11521189
class WorkflowApproval(UnifiedJob, JobNotificationMixin):
11531190
class Meta:
11541191
app_label = 'main'
@@ -1181,6 +1218,13 @@ class Meta:
11811218
editable=False,
11821219
on_delete=models.SET_NULL,
11831220
)
1221+
context_message = models.TextField(
1222+
blank=True,
1223+
default='',
1224+
help_text=_(
1225+
"The rendered context from the approval template's context_template, populated with upstream set_stats artifacts when the approval is created."
1226+
),
1227+
)
11841228

11851229
def _set_default_dependencies_processed(self):
11861230
self.dependencies_processed = True
@@ -1202,6 +1246,40 @@ def get_ui_url(self):
12021246
def _get_parent_field_name(self):
12031247
return 'workflow_approval_template'
12041248

1249+
def render_context_message(self, ancestor_artifacts):
1250+
template_str = getattr(self.workflow_approval_template, 'context_template', '')
1251+
if not template_str:
1252+
return
1253+
rendered = None
1254+
ctx = multiprocessing.get_context('fork')
1255+
parent_conn, child_conn = ctx.Pipe(duplex=False)
1256+
worker = ctx.Process(target=_render_context_template, args=(child_conn, template_str, dict(ancestor_artifacts or {})))
1257+
try:
1258+
worker.start()
1259+
child_conn.close()
1260+
if parent_conn.poll(CONTEXT_TEMPLATE_TIMEOUT):
1261+
status, payload = parent_conn.recv()
1262+
if status == 'ok':
1263+
rendered = payload
1264+
else:
1265+
logger.warning('Failed to render context_template for approval %s: %s', self.pk, payload)
1266+
else:
1267+
logger.warning('Rendering context_template for approval %s did not finish within %s seconds', self.pk, CONTEXT_TEMPLATE_TIMEOUT)
1268+
except EOFError:
1269+
logger.warning('Rendering context_template for approval %s exited without producing output', self.pk)
1270+
except Exception:
1271+
logger.exception('Unexpected error rendering context_template for approval %s', self.pk)
1272+
finally:
1273+
parent_conn.close()
1274+
if worker.pid is not None:
1275+
worker.join(1)
1276+
if worker.is_alive():
1277+
worker.kill()
1278+
worker.join()
1279+
if rendered and rendered.strip():
1280+
self.context_message = rendered
1281+
self.save(update_fields=['context_message'])
1282+
12051283
def save(self, *args, **kwargs):
12061284
update_fields = list(kwargs.get('update_fields', []))
12071285
if self.timeout != 0 and ((not self.pk) or (not update_fields) or ('timeout' in update_fields)):
@@ -1319,12 +1397,15 @@ def build_approval_notification_message(self, nt, approval_status):
13191397

13201398
def context(self, approval_status):
13211399
workflow_url = urljoin(settings.TOWER_URL_BASE, '/#/jobs/workflow/{}'.format(self.workflow_job.id))
1322-
return {
1400+
ctx = {
13231401
'approval_status': approval_status,
13241402
'approval_node_name': self.workflow_approval_template.name,
13251403
'workflow_url': workflow_url,
13261404
'job_metadata': json.dumps(self.notification_data(), indent=4),
13271405
}
1406+
if self.context_message:
1407+
ctx['context_message'] = self.context_message
1408+
return ctx
13281409

13291410
@property
13301411
def workflow_job_template(self):

awx/main/notifications/custom_notification_base.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ class CustomNotificationBase(object):
99

1010
DEFAULT_APPROVAL_RUNNING_MSG = 'The approval node "{{ approval_node_name }}" needs review. This node can be viewed at: {{ workflow_url }}'
1111
DEFAULT_APPROVAL_RUNNING_BODY = (
12-
'The approval node "{{ approval_node_name }}" needs review. This approval node can be viewed at: {{ workflow_url }}\n\n{{ job_metadata }}'
12+
'The approval node "{{ approval_node_name }}" needs review. This approval node can be viewed at: {{ workflow_url }}'
13+
'{% if context_message %}\n\nContext:\n{{ context_message }}{% endif %}'
14+
'\n\n{{ job_metadata }}'
1315
)
1416

1517
DEFAULT_APPROVAL_APPROVED_MSG = 'The approval node "{{ approval_node_name }}" was approved. {{ workflow_url }}'

awx/main/scheduler/task_manager.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,8 @@ def spawn_workflow_graph_jobs(self):
234234
if is_retry:
235235
spawn_node.retry_attempts += 1
236236
spawn_node.save()
237+
if isinstance(job, WorkflowApproval):
238+
job.render_context_message(spawn_node.ancestor_artifacts)
237239
if is_retry:
238240
# keep the superseded attempt linked to the node so it stays
239241
# protected from deletion and traceable to this workflow

awx/main/tests/functional/api/test_workflow_node.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,17 @@ def test_approval_node_cleanup(self, post, approval_node, admin_user, get):
243243
assert WorkflowApprovalTemplate.objects.count() == 0
244244
get(url, admin_user, expect=404)
245245

246+
def test_approval_context_message_detail_only(self, get, admin_user):
247+
# context_message can be large, so the list endpoint leaves it out
248+
# and only the detail endpoint exposes it
249+
template = WorkflowApprovalTemplate.objects.create(name='approve deploy', timeout=0)
250+
approval = WorkflowApproval.objects.create(workflow_approval_template=template, context_message='terraform plan output')
251+
detail = get(reverse('api:workflow_approval_detail', kwargs={'pk': approval.pk}), user=admin_user, expect=200)
252+
assert detail.data['context_message'] == 'terraform plan output'
253+
listed = get(reverse('api:workflow_approval_list'), user=admin_user, expect=200)
254+
assert listed.data['count'] == 1
255+
assert 'context_message' not in listed.data['results'][0]
256+
246257
def test_changed_approval_deletion(self, post, approval_node, admin_user, workflow_job_template, job_template):
247258
# This test verifies that when an approval node changes into something else
248259
# (in this case, a job template), then the previously-set WorkflowApprovalTemplate

awx/main/tests/functional/models/test_workflow.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
import json
55

66
# AWX
7+
from awx.main.models import workflow as workflow_models
78
from awx.main.models.workflow import (
9+
WorkflowApproval,
10+
WorkflowApprovalTemplate,
811
WorkflowJob,
912
WorkflowJobNode,
1013
WorkflowJobTemplateNode,
@@ -945,3 +948,47 @@ def test_bad_data_with_artifacts(self, organization):
945948
WorkflowJobNode.objects.create(workflow_job=wfj, job=job)
946949
# mostly, we just care that this assertion finishes in finite time
947950
assert wfj.get_effective_artifacts() == {'foo': 'bar'}
951+
952+
953+
@pytest.mark.django_db
954+
class TestApprovalContextMessage:
955+
@pytest.fixture
956+
def approval(self):
957+
template = WorkflowApprovalTemplate.objects.create(name='approve deploy', timeout=0)
958+
return WorkflowApproval.objects.create(workflow_approval_template=template)
959+
960+
def _render(self, approval, template_str, artifacts):
961+
approval.workflow_approval_template.context_template = template_str
962+
approval.render_context_message(artifacts)
963+
approval.refresh_from_db()
964+
return approval.context_message
965+
966+
def test_renders_ancestor_artifacts(self, approval):
967+
assert self._render(approval, 'plan: {{ plan_output }}', {'plan_output': '2 to add'}) == 'plan: 2 to add'
968+
969+
def test_static_template_without_artifacts(self, approval):
970+
assert self._render(approval, 'review the plan attached to the ticket', None) == 'review the plan attached to the ticket'
971+
972+
def test_non_identifier_artifact_keys(self, approval):
973+
# set_stats keys are arbitrary strings, they must not break rendering
974+
assert self._render(approval, '{{ ok }}', {'not-an-identifier!': 1, 'ok': 'yes'}) == 'yes'
975+
976+
def test_template_error_does_not_raise(self, approval):
977+
assert self._render(approval, '{% if %}', {'x': 1}) == ''
978+
979+
def test_render_timeout(self, approval, monkeypatch):
980+
# the sandbox caps a single range() at MAX_RANGE, so burn CPU with nested loops
981+
monkeypatch.setattr(workflow_models, 'CONTEXT_TEMPLATE_TIMEOUT', 1)
982+
assert self._render(approval, '{% for i in range(100000) %}{% for j in range(100000) %}{% endfor %}{% endfor %}', {}) == ''
983+
984+
def test_render_cpu_limit(self, approval, monkeypatch):
985+
# huge exponent gets the render process killed by its rlimits before it can reply
986+
monkeypatch.setattr(workflow_models, 'CONTEXT_TEMPLATE_TIMEOUT', 1)
987+
assert self._render(approval, '{{ 10 ** (10 ** 10) }}', {}) == ''
988+
989+
def test_render_memory_limit(self, approval):
990+
assert self._render(approval, '{{ "x" * 999999999 }}', {}) == ''
991+
992+
def test_output_truncated(self, approval):
993+
rendered = self._render(approval, '{{ "x" * 100000 }}', {})
994+
assert len(rendered) == workflow_models.CONTEXT_MESSAGE_MAX_LENGTH

awx/ui/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeAddModal.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ function NodeAddModal() {
7070
description: approvalDescription,
7171
name: approvalName,
7272
timeout: Number(timeoutMinutes) * 60 + Number(timeoutSeconds),
73+
context_template: values.contextTemplate || '',
7374
type: 'workflow_approval_template',
7475
};
7576
} else {

awx/ui/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeEditModal.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ function NodeEditModal() {
3737
description: approvalDescription,
3838
name: approvalName,
3939
timeout: Number(timeoutMinutes) * 60 + Number(timeoutSeconds),
40+
context_template: values.contextTemplate || '',
4041
type: 'workflow_approval_template',
4142
},
4243
identifier,

awx/ui/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ function NodeModalForm({
133133
delete values.approvalDescription;
134134
delete values.timeoutMinutes;
135135
delete values.timeoutSeconds;
136+
delete values.contextTemplate;
136137
}
137138

138139
if (
@@ -410,6 +411,7 @@ const NodeModal = ({ onSave, askLinkType, title }) => {
410411
initialValues={{
411412
approvalName: '',
412413
approvalDescription: '',
414+
contextTemplate: '',
413415
daysToKeep: 30,
414416
identifier: nodeToEdit?.identifier || '',
415417
timeoutMinutes: 0,

0 commit comments

Comments
 (0)