Repository: nautobot/nautobot

Issue:
Setting for file size limit on job generated files
### As ...

Patti - Platform Admin

### I want ...

A global configuration setting that allows me to specify a max file size for files generated by a job.

### So that ...

Job authors and their uses cannot accidentally (or nefariously) overwhelm the storage backend with large files.

### I know this is done when...

- A new configuration setting for job_generated_file_max_size
- Default of 10 MB (or some agreeable, sane default)
- Available in constance

It would be ideal if the enforcement of the file size is not easily overridable by a knowledgeable job author, but if this proves to be a large effort, this is acceptable as a guardrail only.

### Optional - Feature groups this request pertains to.

### Database Changes

_No response_

### External Dependencies

_No response_
Provides ability to reverse from IPAddress to the custom through table IPAddressToInterface


<!--
    Thank you for your interest in contributing to Nautobot! Please note
    that our contribution policy recommends that a feature request or bug
    report be opened for approval prior to filing a pull request. This
    helps avoid wasting time and effort on something that we might not
    be able to accept.

    Please indicate the relevant feature request or bug report below.
-->
# Closes: #4893 
# What's Changed
<!--
    Please include:
    - A summary of the proposed changes
    - A sectioned breakdown for larger features under ## subheadings
    - Screenshots, example payloads where relevant:
      - Before/After for bugfixes
      - Using a new feature
-->
Added related name on `IPAddressToInterface.ip_address` to allow for reverse capability from IPAddress to the custom through table to be able to more easily interact with the meta data held in the through table.

# TODO
<!--
    Please feel free to update todos to keep track of your own notes for WIP PRs.
-->
- [ ] Explanation of Change(s)
- [ ] Added change log fragment(s) (for more information see [the documentation](https://docs.nautobot.com/projects/core/en/stable/development/#creating-changelog-fragments))
- [ ] Attached Screenshots, Payload Example
- [ ] Unit, Integration Tests
- [ ] Documentation Updates (when adding/changing features)
- [ ] Example Plugin Updates (when adding/changing features)
- [ ] Outline Remaining Work, Constraints from Design



Discussion:
As part of this work, we should enhance the FileProxy admin UI table to display the size of each stored file.


Resolving patch:
diff --git a/nautobot/core/settings.py b/nautobot/core/settings.py
--- a/nautobot/core/settings.py
+++ b/nautobot/core/settings.py
@@ -614,6 +614,13 @@
         help_text="Whether to show the Feedback button in the new UI sidebar.",
         field_type=bool,
     ),
+    "JOB_CREATE_FILE_MAX_SIZE": ConstanceConfigItem(
+        default=10 << 20,
+        help_text=mark_safe(  # noqa: S308
+            "Maximum size (in bytes) of any single file generated by a <code>Job.create_file()</code> call."
+        ),
+        field_type=int,
+    ),
     "LOCATION_NAME_AS_NATURAL_KEY": ConstanceConfigItem(
         default=False,
         help_text="Location names are not guaranteed globally-unique by Nautobot but in practice they often are. "
@@ -694,7 +701,7 @@
     "Installation Metrics": ["DEPLOYMENT_ID"],
     "Natural Keys": ["DEVICE_NAME_AS_NATURAL_KEY", "LOCATION_NAME_AS_NATURAL_KEY"],
     "Pagination": ["PAGINATE_COUNT", "MAX_PAGE_SIZE", "PER_PAGE_DEFAULTS"],
-    "Performance": ["DYNAMIC_GROUPS_MEMBER_CACHE_TIMEOUT"],
+    "Performance": ["DYNAMIC_GROUPS_MEMBER_CACHE_TIMEOUT", "JOB_CREATE_FILE_MAX_SIZE"],
     "Rack Elevation Rendering": ["RACK_ELEVATION_DEFAULT_UNIT_HEIGHT", "RACK_ELEVATION_DEFAULT_UNIT_WIDTH"],
     "Release Checking": ["RELEASE_CHECK_URL", "RELEASE_CHECK_TIMEOUT"],
     "User Interface": ["FEEDBACK_BUTTON_ENABLED", "SUPPORT_MESSAGE"],
diff --git a/nautobot/extras/jobs.py b/nautobot/extras/jobs.py
--- a/nautobot/extras/jobs.py
+++ b/nautobot/extras/jobs.py
@@ -40,6 +40,7 @@
     DynamicModelMultipleChoiceField,
     JSONField,
 )
+from nautobot.core.utils.config import get_settings_or_config
 from nautobot.core.utils.lookup import get_model_from_name
 from nautobot.extras.choices import ObjectChangeActionChoices, ObjectChangeEventContextChoices
 from nautobot.extras.context_managers import web_request_context
@@ -291,15 +292,13 @@ def after_return(self, status, retval, task_id, args, kwargs, einfo):
         """
         Handler called after the task returns.
 
-        Parameters
-            status - Current task state.
-            retval - Task return value/exception.
-            task_id - Unique id of the task.
-            args - Original arguments for the task that returned.
-            kwargs - Original keyword arguments for the task that returned.
-
-        Keyword Arguments
-            einfo - ExceptionInfo instance, containing the traceback (if any).
+        Arguments:
+            status (str): Current task state.
+            retval (Any): Task return value/exception.
+            task_id (str): Unique id of the task.
+            args (tuple):  Original arguments for the task that returned.
+            kwargs (dict): Original keyword arguments for the task that returned.
+            einfo (ExceptionInfo): ExceptionInfo instance, containing the traceback (if any).
 
         Returns:
             (None): The return value of this handler is ignored.
@@ -400,10 +399,10 @@ def class_path(cls) -> str:  # pylint: disable=no-self-argument
         Unique identifier of a specific Job class, in the form <module_name>.<ClassName>.
 
         Examples:
-        my_script.MyScript - Local Job
-        nautobot.core.jobs.MySystemJob - System Job
-        my_plugin.jobs.MyPluginJob - App-provided Job
-        git_repository.jobs.myjob.MyJob - GitRepository Job
+        - my_script.MyScript - Local Job
+        - nautobot.core.jobs.MySystemJob - System Job
+        - my_plugin.jobs.MyPluginJob - App-provided Job
+        - git_repository.jobs.myjob.MyJob - GitRepository Job
         """
         return f"{cls.__module__}.{cls.__name__}"
 
@@ -837,11 +836,18 @@ def create_file(self, filename, content):
             filename (str): Name of the file to create, including extension
             content (str, bytes): Content to populate the created file with.
 
+        Raises:
+            (ValueError): if the provided content exceeds JOB_CREATE_FILE_MAX_SIZE in length
+
         Returns:
             (FileProxy): record that was created
         """
         if isinstance(content, str):
             content = content.encode("utf-8")
+        max_size = get_settings_or_config("JOB_CREATE_FILE_MAX_SIZE")
+        actual_size = len(content)
+        if actual_size > max_size:
+            raise ValueError(f"Provided {actual_size} bytes of content, but JOB_CREATE_FILE_MAX_SIZE is {max_size}")
         fp = FileProxy.objects.create(
             name=filename, job_result=self.job_result, file=ContentFile(content, name=filename)
         )
@@ -922,7 +928,7 @@ def __init__(self, min_length=None, max_length=None, regex=None, *args, **kwargs
 
 class TextVar(ScriptVariable):
     """
-    Free-form text data. Renders as a <textarea>.
+    Free-form text data. Renders as a `<textarea>`.
     """
 
     form_field = forms.CharField
@@ -1015,10 +1021,11 @@ class ObjectVar(ScriptVariable):
     """
     A single object within Nautobot.
 
-    :param model: The Nautobot model being referenced
-    :param display_field: The attribute of the returned object to display in the selection list (default: 'name')
-    :param query_params: A dictionary of additional query parameters to attach when making REST API requests (optional)
-    :param null_option: The label to use as a "null" selection option (optional)
+    Args:
+        model (Model): The Nautobot model being referenced
+        display_field (str): The attribute of the returned object to display in the selection list
+        query_params (dict): Additional query parameters to attach when making REST API requests
+        null_option (str): The label to use as a "null" selection option
     """
 
     form_field = DynamicModelChoiceField
@@ -1139,9 +1146,10 @@ def receive_job_hook(self, change, action, changed_object):
         """
         Method to be implemented by concrete JobHookReceiver subclasses.
 
-        :param change: an instance of `nautobot.extras.models.ObjectChange`
-        :param action: a string with the action performed on the changed object ("create", "update" or "delete")
-        :param changed_object: an instance of the object that was changed, or `None` if the object has been deleted
+        Args:
+            change (ObjectChange): an instance of `nautobot.extras.models.ObjectChange`
+            action (str): a string with the action performed on the changed object ("create", "update" or "delete")
+            changed_object (Model): an instance of the object that was changed, or `None` if the object has been deleted
         """
         raise NotImplementedError
 
@@ -1166,7 +1174,8 @@ def receive_job_button(self, obj):
         """
         Method to be implemented by concrete JobButtonReceiver subclasses.
 
-        :param obj: an instance of the object
+        Args:
+            obj (Model): an instance of the object that triggered this job
         """
         raise NotImplementedError
 
@@ -1190,7 +1199,7 @@ def is_variable(obj):
 
 def get_job(class_path):
     """
-    Retrieve a specific job class by its class_path (<module_name>.<JobClassName>).
+    Retrieve a specific job class by its class_path (`<module_name>.<JobClassName>`).
 
     May return None if the job isn't properly registered with Celery at this time.
     """


Test patch:
diff --git a/nautobot/extras/tests/test_jobs.py b/nautobot/extras/tests/test_jobs.py
--- a/nautobot/extras/tests/test_jobs.py
+++ b/nautobot/extras/tests/test_jobs.py
@@ -7,6 +7,7 @@
 import uuid
 import tempfile
 
+from constance.test import override_config
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.core.files.uploadedfile import SimpleUploadedFile
@@ -620,6 +621,34 @@ def test_output_file_to_database(self):
     # unfortunately with FileField(storage=callable), the callable gets evaluated only at declaration time, not at
     # usage/runtime, so override_settings(JOB_FILE_IO_STORAGE) doesn't work the way you'd hope it would.
 
+    def test_output_file_too_large(self):
+        module = "file_output"
+        name = "FileOutputJob"
+        data = {"lines": 1}
+
+        # Exactly JOB_CREATE_FILE_MAX_SIZE bytes should be okay:
+        with override_config(JOB_CREATE_FILE_MAX_SIZE=len("Hello world!\n")):
+            job_result = create_job_result_and_run_job(module, name, **data)
+            self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS, job_result.traceback)
+            self.assertEqual(1, job_result.files.count())
+            self.assertEqual(job_result.files.first().name, "output.txt")
+            self.assertEqual(job_result.files.first().file.read().decode("utf-8"), "Hello World!\n")
+
+        # Even one byte over is too much:
+        with override_config(JOB_CREATE_FILE_MAX_SIZE=len("Hello world!\n") - 1):
+            job_result = create_job_result_and_run_job(module, name, **data)
+            self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
+            self.assertIn("ValueError", job_result.traceback)
+            self.assertEqual(0, job_result.files.count())
+
+        # settings takes precedence over constance config
+        with override_config(JOB_CREATE_FILE_MAX_SIZE=10 << 20):
+            with override_settings(JOB_CREATE_FILE_MAX_SIZE=len("Hello world!\n") - 1):
+                job_result = create_job_result_and_run_job(module, name, **data)
+                self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
+                self.assertIn("ValueError", job_result.traceback)
+                self.assertEqual(0, job_result.files.count())
+
 
 class RunJobManagementCommandTest(TransactionTestCase):
     """Test cases for the `nautobot-server runjob` management command."""