From 0ff280a363645216232a3662a3f79d99d8f94e8b Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Wed, 22 Jul 2015 12:36:38 -0400 Subject: [PATCH 1/8] Initial support for downloading stdout --- awx/api/renderers.py | 3 +++ awx/api/views.py | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/awx/api/renderers.py b/awx/api/renderers.py index 5f3eee81db..fd60520db2 100644 --- a/awx/api/renderers.py +++ b/awx/api/renderers.py @@ -45,6 +45,9 @@ class PlainTextRenderer(renderers.BaseRenderer): data = unicode(data) return data.encode(self.charset) +class DownloadTextRenderer(PlainTextRenderer): + format = "txt_download" + class AnsiTextRenderer(PlainTextRenderer): media_type = 'text/plain' diff --git a/awx/api/views.py b/awx/api/views.py index bb6ff67f2c..3e5b7c89a2 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -2788,7 +2788,7 @@ class UnifiedJobStdout(RetrieveAPIView): serializer_class = UnifiedJobStdoutSerializer renderer_classes = [BrowsableAPIRenderer, renderers.StaticHTMLRenderer, - PlainTextRenderer, AnsiTextRenderer, + PlainTextRenderer, AnsiTextRenderer, DownloadTextRenderer, renderers.JSONRenderer] filter_backends = () new_in_148 = True @@ -2820,6 +2820,10 @@ class UnifiedJobStdout(RetrieveAPIView): return Response(data) elif request.accepted_renderer.format == 'ansi': return Response(unified_job.result_stdout_raw) + elif request.accepted_renderer.format == 'txt_download': + content = unified_job.result_stdout + headers = {"Content-Disposition": 'attachment; filename="job_%s.txt"' % str(unified_job.id)} + return Response(content, headers=headers) else: return super(UnifiedJobStdout, self).retrieve(request, *args, **kwargs) From 55da712a95f568a13652e0b2557b562ae410885d Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Wed, 22 Jul 2015 14:13:48 -0400 Subject: [PATCH 2/8] Protect against very large stdout fields * Defer loading result_stdout_text until specifically needed * Conditionally display it based on the size of the field * Provide a helpful message otherwise --- awx/api/serializers.py | 10 +++++++++- awx/api/views.py | 23 +++++++++++++++++++++++ awx/main/models/unified_jobs.py | 9 ++++++++- awx/settings/defaults.py | 2 ++ 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 871eb88f4c..c381f43b3b 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -464,7 +464,8 @@ class UnifiedJobTemplateSerializer(BaseSerializer): class UnifiedJobSerializer(BaseSerializer): - result_stdout = serializers.CharField(source='result_stdout', label='result stdout', read_only=True) + #result_stdout = serializers.CharField(source='result_stdout', label='result stdout', read_only=True) + result_stdout = serializers.SerializerMethodField('get_result_stdout') unified_job_template = serializers.Field(source='unified_job_template_id', label='unified job template') job_env = serializers.CharField(source='job_env', label='job env', read_only=True) @@ -475,6 +476,13 @@ class UnifiedJobSerializer(BaseSerializer): 'job_cwd', 'job_env', 'job_explanation', 'result_stdout', 'result_traceback') + + def get_result_stdout(self, obj): + obj_size = obj.result_stdout_size + if obj_size > settings.STDOUT_MAX_BYTES_DISPLAY: + return "Standard Output too large to display (%d bytes), only download supported for sizes over %d bytes" % (obj_size, settings.STDOUT_MAX_BYTES_DISPLAY) + return obj.result_stdout + def get_types(self): if type(self) is UnifiedJobSerializer: return ['project_update', 'inventory_update', 'job', 'ad_hoc_command', 'system_job'] diff --git a/awx/api/views.py b/awx/api/views.py index 3e5b7c89a2..69eed277b0 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -2109,11 +2109,20 @@ class JobList(ListCreateAPIView): model = Job serializer_class = JobListSerializer + def get_queryset(self): + qs = self.request.user.get_queryset(self.model).defer('result_stdout_text') + return qs + class JobDetail(RetrieveUpdateDestroyAPIView): model = Job serializer_class = JobSerializer + + def get_queryset(self): + qs = super(JobDetail, self).get_queryset().defer('result_stdout_text') + return qs + def update(self, request, *args, **kwargs): obj = self.get_object() # Only allow changes (PUT/PATCH) when job status is "new". @@ -2783,6 +2792,11 @@ class UnifiedJobList(ListAPIView): model = UnifiedJob serializer_class = UnifiedJobListSerializer new_in_148 = True + + def get_queryset(self): + qs = self.request.user.get_queryset(self.model).defer('result_stdout_text') + return qs + class UnifiedJobStdout(RetrieveAPIView): @@ -2793,8 +2807,17 @@ class UnifiedJobStdout(RetrieveAPIView): filter_backends = () new_in_148 = True + def get_queryset(self): + qs = super(UnifiedJobStdout, self).get_queryset().defer('result_stdout_text') + return qs + def retrieve(self, request, *args, **kwargs): unified_job = self.get_object() + obj_size = unified_job.result_stdout_size + if request.accepted_renderer.format != 'txt_download' and obj_size > settings.STDOUT_MAX_BYTES_DISPLAY: + return Response("Standard Output too large to display (%d bytes), " + "only download supported for sizes over %d bytes" % (obj_size, settings.STDOUT_MAX_BYTES_DISPLAY)) + if request.accepted_renderer.format in ('html', 'api', 'json'): start_line = request.QUERY_PARAMS.get('start_line', 0) end_line = request.QUERY_PARAMS.get('end_line', None) diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index a04ac2bdb0..f496c91afd 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -12,7 +12,7 @@ from StringIO import StringIO # Django from django.conf import settings -from django.db import models +from django.db import models, connection from django.core.exceptions import NON_FIELD_ERRORS from django.utils.datastructures import SortedDict from django.utils.translation import ugettext_lazy as _ @@ -657,6 +657,13 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique def result_stdout(self): return self._result_stdout_raw(escape_ascii=True) + @property + def result_stdout_size(self): + cursor = connection.cursor() + cursor.execute("select length(result_stdout_text) from main_unifiedjob where id = %d" % self.pk) + record_size = cursor.fetchone()[0] + return record_size + def _result_stdout_raw_limited(self, start_line=0, end_line=None, redact_sensitive=True, escape_ascii=False): return_buffer = u"" if end_line is not None: diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index dcfdebe2a2..88b184346f 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -562,6 +562,8 @@ FACT_CACHE_PORT = 6564 ORG_ADMINS_CAN_SEE_ALL_USERS = True +STDOUT_MAX_BYTES_DISPLAY = 1000000 + # Logging configuration. LOGGING = { 'version': 1, From b1b49ba2861779f0f113a936f794f6d0c2a38d83 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Wed, 22 Jul 2015 15:08:37 -0400 Subject: [PATCH 3/8] Efficient stdout downloader implementation * Temporarily dump stdout contents to a configurable temp location * Implement file streaming to the host via a new format specifier in the api view --- awx/api/views.py | 9 ++++++--- awx/main/models/unified_jobs.py | 10 ++++++++++ awx/settings/defaults.py | 1 + 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/awx/api/views.py b/awx/api/views.py index 69eed277b0..9fced1e980 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -25,6 +25,8 @@ from django.utils.safestring import mark_safe from django.utils.timezone import now from django.views.decorators.csrf import csrf_exempt from django.template.loader import render_to_string +from django.core.servers.basehttp import FileWrapper +from django.http import HttpResponse # Django REST Framework from rest_framework.exceptions import PermissionDenied, ParseError @@ -2844,9 +2846,10 @@ class UnifiedJobStdout(RetrieveAPIView): elif request.accepted_renderer.format == 'ansi': return Response(unified_job.result_stdout_raw) elif request.accepted_renderer.format == 'txt_download': - content = unified_job.result_stdout - headers = {"Content-Disposition": 'attachment; filename="job_%s.txt"' % str(unified_job.id)} - return Response(content, headers=headers) + content_fd = open(unified_job.dump_result_stdout(), 'r') + response = HttpResponse(FileWrapper(content_fd), content_type='text/plain') + response["Content-Disposition"] = 'attachment; filename="job_%s.txt"' % str(unified_job.id) + return response else: return super(UnifiedJobStdout, self).retrieve(request, *args, **kwargs) diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index f496c91afd..9d89aa0179 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -5,6 +5,7 @@ import codecs import json import logging +import uuid import re import os import os.path @@ -664,6 +665,15 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique record_size = cursor.fetchone()[0] return record_size + def dump_result_stdout(self): + tower_file = "towerjob-%s" % str(uuid.uuid1())[:8] + out_path = os.path.join(settings.STDOUT_TEMP_DIR, tower_file) + tower_fd = open(out_path, 'w') + cursor = connection.cursor() + cursor.copy_expert("copy (select result_stdout_text from main_unifiedjob where id = %d) to stdout" % (self.pk), tower_fd) + tower_fd.close() + return out_path + def _result_stdout_raw_limited(self, start_line=0, end_line=None, redact_sensitive=True, escape_ascii=False): return_buffer = u"" if end_line is not None: diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 88b184346f..912fe25e1e 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -563,6 +563,7 @@ FACT_CACHE_PORT = 6564 ORG_ADMINS_CAN_SEE_ALL_USERS = True STDOUT_MAX_BYTES_DISPLAY = 1000000 +STDOUT_TEMP_DIR = "/tmp" # Logging configuration. LOGGING = { From 7937b4fbbd104c37fd0df00ad75f48b5a0c72c73 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Wed, 22 Jul 2015 15:24:52 -0400 Subject: [PATCH 4/8] Implement a stdout tmp cleanup task * Cleans up stdout tempfiles generated for download * Runs every 3 hours, cleans up files older than 1 day --- awx/main/tasks.py | 11 +++++++++++ awx/settings/defaults.py | 4 ++++ 2 files changed, 15 insertions(+) diff --git a/awx/main/tasks.py b/awx/main/tasks.py index da798a59f2..ece7e1e7a6 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -114,6 +114,17 @@ def tower_periodic_scheduler(self): new_unified_job.socketio_emit_status("failed") emit_websocket_notification('/socket.io/schedules', 'schedule_changed', dict(id=schedule.id)) +@task(bind=True) +def clean_stdout_tempfiles(self): + nowtime = time.time() + removed = 0 + for this_file in os.listdir(settings.STDOUT_TEMP_DIR): + this_file = os.path.join(settings.STDOUT_TEMP_DIR, this_file) + if "towerjob" in this_file and os.stat(this_file).st_mtime < nowtime - 86400: + os.remove(this_file) + removed += 1 + print("Removed %d files" % removed) + @task() def notify_task_runner(metadata_dict): """Add the given task into the Tower task manager's queue, to be consumed diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 912fe25e1e..f99d389e52 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -303,6 +303,10 @@ CELERYBEAT_SCHEDULE = { 'task': 'awx.main.tasks.tower_periodic_scheduler', 'schedule': timedelta(seconds=30) }, + 'job_stdout_cleanup': { + 'task': 'awx.main.tasks.clean_stdout_tempfiles', + 'schedule': timedelta(hours=3) + }, } # Any ANSIBLE_* settings will be passed to the subprocess environment by the From 2a6600fb42611a5da98f7fd039c221504e55c0bd Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Wed, 22 Jul 2015 15:28:25 -0400 Subject: [PATCH 5/8] Update unified job stdout help text --- awx/api/templates/api/unified_job_stdout.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/awx/api/templates/api/unified_job_stdout.md b/awx/api/templates/api/unified_job_stdout.md index 70e1e1a993..4ea726412d 100644 --- a/awx/api/templates/api/unified_job_stdout.md +++ b/awx/api/templates/api/unified_job_stdout.md @@ -12,6 +12,7 @@ Use the `format` query string parameter to specify the output format. * Plain Text: `?format=txt` * Plain Text with ANSI color codes: `?format=ansi` * JSON structure: `?format=json` +* Downloaded Plain Text: `?format=txt_download` (_New in Ansible Tower 2.0.0_) When using the Browsable API, HTML and JSON formats, the `start_line` and `end_line` query string parameters can be used @@ -20,4 +21,7 @@ to specify a range of line numbers to retrieve. Use `dark=1` or `dark=0` as a query string parameter to force or disable a dark background. +Files over 1MB (configurable) will not display in the browser. Use the `txt_download` +format to download the file directly to view it. + {% include "api/_new_in_awx.md" %} From d9f5dee773d5c149bd3321fae390526c18dc77ae Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Wed, 22 Jul 2015 16:31:43 -0400 Subject: [PATCH 6/8] Change default stdout temp directory * Use /var/lib/awx on production configurations * Use /tmp for development configurations --- awx/settings/defaults.py | 3 ++- awx/settings/development.py | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index f99d389e52..a0b7f2497b 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -566,8 +566,9 @@ FACT_CACHE_PORT = 6564 ORG_ADMINS_CAN_SEE_ALL_USERS = True +# Control when we display stdout and where to store the temporary files for downloading STDOUT_MAX_BYTES_DISPLAY = 1000000 -STDOUT_TEMP_DIR = "/tmp" +STDOUT_TEMP_DIR = "/var/lib/awx/" # Logging configuration. LOGGING = { diff --git a/awx/settings/development.py b/awx/settings/development.py index eec9673ef9..7b31279787 100644 --- a/awx/settings/development.py +++ b/awx/settings/development.py @@ -64,6 +64,8 @@ PASSWORD_HASHERS = ( # Configure a default UUID for development only. SYSTEM_UUID = '00000000-0000-0000-0000-000000000000' +STDOUT_TEMP_DIR = "/tmp" + # If there is an `/etc/tower/settings.py`, include it. # If there is a `/etc/tower/conf.d/*.py`, include them. include(optional('/etc/tower/settings.py'), scope=locals()) From 88128a2e87f0ec023dba10a34d723101009ce6d1 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Wed, 22 Jul 2015 16:35:03 -0400 Subject: [PATCH 7/8] Return useful errors when downloading stdout --- awx/api/views.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/awx/api/views.py b/awx/api/views.py index 9fced1e980..5d6fe0f011 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -2846,10 +2846,13 @@ class UnifiedJobStdout(RetrieveAPIView): elif request.accepted_renderer.format == 'ansi': return Response(unified_job.result_stdout_raw) elif request.accepted_renderer.format == 'txt_download': - content_fd = open(unified_job.dump_result_stdout(), 'r') - response = HttpResponse(FileWrapper(content_fd), content_type='text/plain') - response["Content-Disposition"] = 'attachment; filename="job_%s.txt"' % str(unified_job.id) - return response + try: + content_fd = open(unified_job.dump_result_stdout(), 'r') + response = HttpResponse(FileWrapper(content_fd), content_type='text/plain') + response["Content-Disposition"] = 'attachment; filename="job_%s.txt"' % str(unified_job.id) + return response + except Exception, e: + return Response({"error": "Error generating stdout download file: %s" % str(e)}, status=status.HTTP_400_BAD_REQUEST) else: return super(UnifiedJobStdout, self).retrieve(request, *args, **kwargs) From 3041fce5034c2e293f77cea9d085dc6714f10527 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Thu, 23 Jul 2015 12:07:34 -0400 Subject: [PATCH 8/8] Allow settings access from api templates --- awx/api/generics.py | 1 + awx/api/templates/api/unified_job_stdout.md | 2 +- awx/settings/defaults.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/awx/api/generics.py b/awx/api/generics.py index 2628ce2400..d0f90c8766 100644 --- a/awx/api/generics.py +++ b/awx/api/generics.py @@ -202,6 +202,7 @@ class GenericAPIView(generics.GenericAPIView, APIView): 'model_verbose_name_plural': unicode(self.model._meta.verbose_name_plural), }) d.update({'serializer_fields': self.get_serializer().metadata()}) + d['settings'] = settings return d def metadata(self, request): diff --git a/awx/api/templates/api/unified_job_stdout.md b/awx/api/templates/api/unified_job_stdout.md index 4ea726412d..5e50ef352e 100644 --- a/awx/api/templates/api/unified_job_stdout.md +++ b/awx/api/templates/api/unified_job_stdout.md @@ -21,7 +21,7 @@ to specify a range of line numbers to retrieve. Use `dark=1` or `dark=0` as a query string parameter to force or disable a dark background. -Files over 1MB (configurable) will not display in the browser. Use the `txt_download` +Files over {{ settings.STDOUT_MAX_BYTES_DISPLAY|filesizeformat }} (configurable) will not display in the browser. Use the `txt_download` format to download the file directly to view it. {% include "api/_new_in_awx.md" %} diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index a0b7f2497b..7a3e948398 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -567,7 +567,7 @@ FACT_CACHE_PORT = 6564 ORG_ADMINS_CAN_SEE_ALL_USERS = True # Control when we display stdout and where to store the temporary files for downloading -STDOUT_MAX_BYTES_DISPLAY = 1000000 +STDOUT_MAX_BYTES_DISPLAY = 1048576 STDOUT_TEMP_DIR = "/var/lib/awx/" # Logging configuration.