Skip to content

Commit 1b4e00a

Browse files
authored
18896 Replace STORAGE_BACKEND with STORAGES and support Script running from S3 (#18680)
1 parent ffe0355 commit 1b4e00a

File tree

14 files changed

+247
-78
lines changed

14 files changed

+247
-78
lines changed

base_requirements.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ django-rich
4242
# https://github.com/rq/django-rq/blob/master/CHANGELOG.md
4343
django-rq
4444

45+
# Provides a variety of storage backends
46+
# https://github.com/jschneier/django-storages/blob/master/CHANGELOG.rst
47+
django-storages
48+
4549
# Abstraction models for rendering and paginating HTML tables
4650
# https://github.com/jieter/django-tables2/blob/master/CHANGELOG.md
4751
django-tables2

docs/administration/replicating-netbox.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ pg_dump --username netbox --password --host localhost -s netbox > netbox_schema.
5454
By default, NetBox stores uploaded files (such as image attachments) in its media directory. To fully replicate an instance of NetBox, you'll need to copy both the database and the media files.
5555

5656
!!! note
57-
These operations are not necessary if your installation is utilizing a [remote storage backend](../configuration/system.md#storage_backend).
57+
These operations are not necessary if your installation is utilizing a [remote storage backend](../configuration/system.md#storages).
5858

5959
### Archive the Media Directory
6060

docs/configuration/system.md

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -196,23 +196,46 @@ The dotted path to the desired search backend class. `CachedValueSearchBackend`
196196

197197
---
198198

199-
## STORAGE_BACKEND
199+
## STORAGES
200200

201-
Default: None (local storage)
201+
The backend storage engine for handling uploaded files such as [image attachments](../models/extras/imageattachment.md) and [custom scripts](../customization/custom-scripts.md). NetBox integrates with the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) and [`django-storage-swift`](https://github.com/dennisv/django-storage-swift) libraries, which provide backends for several popular file storage services. If not configured, local filesystem storage will be used.
202202

203-
The backend storage engine for handling uploaded files (e.g. image attachments). NetBox supports integration with the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) and [`django-storage-swift`](https://github.com/dennisv/django-storage-swift) packages, which provide backends for several popular file storage services. If not configured, local filesystem storage will be used.
203+
By default, the following configuration is used:
204204

205-
The configuration parameters for the specified storage backend are defined under the `STORAGE_CONFIG` setting.
205+
```python
206+
STORAGES = {
207+
"default": {
208+
"BACKEND": "django.core.files.storage.FileSystemStorage",
209+
},
210+
"staticfiles": {
211+
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
212+
},
213+
"scripts": {
214+
"BACKEND": "extras.storage.ScriptFileSystemStorage",
215+
},
216+
}
217+
```
206218

207-
---
219+
Within the `STORAGES` dictionary, `"default"` is used for image uploads, "staticfiles" is for static files and `"scripts"` is used for custom scripts.
208220

209-
## STORAGE_CONFIG
221+
If using a remote storage like S3, define the config as `STORAGES[key]["OPTIONS"]` for each storage item as needed. For example:
210222

211-
Default: Empty
223+
```python
224+
STORAGES = {
225+
"scripts": {
226+
"BACKEND": "storages.backends.s3boto3.S3Boto3Storage",
227+
"OPTIONS": {
228+
'access_key': 'access key',
229+
'secret_key': 'secret key',
230+
}
231+
},
232+
}
233+
```
212234

213-
A dictionary of configuration parameters for the storage backend configured as `STORAGE_BACKEND`. The specific parameters to be used here are specific to each backend; see the documentation for your selected backend ([`django-storages`](https://django-storages.readthedocs.io/en/stable/) or [`django-storage-swift`](https://github.com/dennisv/django-storage-swift)) for more detail.
235+
The specific configuration settings for each storage backend can be found in the [django-storages documentation](https://django-storages.readthedocs.io/en/latest/index.html).
214236

215-
If `STORAGE_BACKEND` is not defined, this setting will be ignored.
237+
!!! note
238+
Any keys defined in the `STORAGES` configuration parameter replace those in the default configuration. It is only necessary to define keys within the `STORAGES` for the specific backend(s) you wish to configure.
216239

217240
---
218241

docs/customization/custom-scripts.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@ The Script class provides two convenience methods for reading data from files:
140140

141141
These two methods will load data in YAML or JSON format, respectively, from files within the local path (i.e. `SCRIPTS_ROOT`).
142142

143+
**Note:** These convenience methods are deprecated and will be removed in NetBox v4.4. These only work if running scripts within the local path, they will not work if using a storage other than ScriptFileSystemStorage.
144+
143145
## Logging
144146

145147
The Script object provides a set of convenient functions for recording messages at different severity levels:

docs/installation/3-netbox.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ All Python packages required by NetBox are listed in `requirements.txt` and will
207207

208208
### Remote File Storage
209209

210-
By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](../configuration/system.md#storage_backend) in `configuration.py`.
210+
By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](../configuration/system.md#storages) in `configuration.py`.
211211

212212
```no-highlight
213213
sudo sh -c "echo 'django-storages' >> /opt/netbox/local_requirements.txt"

netbox/core/models/data.py

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -357,17 +357,6 @@ def refresh_from_disk(self, source_root):
357357

358358
return is_modified
359359

360-
def write_to_disk(self, path, overwrite=False):
361-
"""
362-
Write the object's data to disk at the specified path
363-
"""
364-
# Check whether file already exists
365-
if os.path.isfile(path) and not overwrite:
366-
raise FileExistsError()
367-
368-
with open(path, 'wb+') as new_file:
369-
new_file.write(self.data)
370-
371360

372361
class AutoSyncRecord(models.Model):
373362
"""

netbox/core/models/files.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import logging
22
import os
3+
from functools import cached_property
34

45
from django.conf import settings
56
from django.core.exceptions import ValidationError
67
from django.db import models
8+
from django.core.files.storage import storages
79
from django.urls import reverse
810
from django.utils.translation import gettext as _
911

1012
from ..choices import ManagedFileRootPathChoices
13+
from extras.storage import ScriptFileSystemStorage
1114
from netbox.models.features import SyncedDataMixin
1215
from utilities.querysets import RestrictedQuerySet
1316

@@ -76,15 +79,35 @@ def full_path(self):
7679
return os.path.join(self._resolve_root_path(), self.file_path)
7780

7881
def _resolve_root_path(self):
79-
return {
80-
'scripts': settings.SCRIPTS_ROOT,
81-
'reports': settings.REPORTS_ROOT,
82-
}[self.file_root]
82+
storage = self.storage
83+
if isinstance(storage, ScriptFileSystemStorage):
84+
return {
85+
'scripts': settings.SCRIPTS_ROOT,
86+
'reports': settings.REPORTS_ROOT,
87+
}[self.file_root]
88+
else:
89+
return ""
8390

8491
def sync_data(self):
8592
if self.data_file:
8693
self.file_path = os.path.basename(self.data_path)
87-
self.data_file.write_to_disk(self.full_path, overwrite=True)
94+
self._write_to_disk(self.full_path, overwrite=True)
95+
96+
def _write_to_disk(self, path, overwrite=False):
97+
"""
98+
Write the object's data to disk at the specified path
99+
"""
100+
# Check whether file already exists
101+
storage = self.storage
102+
if storage.exists(path) and not overwrite:
103+
raise FileExistsError()
104+
105+
with storage.open(path, 'wb+') as new_file:
106+
new_file.write(self.data)
107+
108+
@cached_property
109+
def storage(self):
110+
return storages.create_storage(storages.backends["scripts"])
88111

89112
def clean(self):
90113
super().clean()
@@ -104,8 +127,9 @@ def clean(self):
104127

105128
def delete(self, *args, **kwargs):
106129
# Delete file from disk
130+
storage = self.storage
107131
try:
108-
os.remove(self.full_path)
132+
storage.delete(self.full_path)
109133
except FileNotFoundError:
110134
pass
111135

netbox/extras/forms/scripts.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
1+
import os
2+
13
from django import forms
4+
from django.conf import settings
5+
from django.core.files.storage import storages
26
from django.utils.translation import gettext_lazy as _
37

8+
from core.forms import ManagedFileForm
49
from extras.choices import DurationChoices
10+
from extras.storage import ScriptFileSystemStorage
511
from utilities.forms.widgets import DateTimePicker, NumberWithOptions
612
from utilities.datetime import local_now
713

814
__all__ = (
15+
'ScriptFileForm',
916
'ScriptForm',
1017
)
1118

@@ -55,3 +62,26 @@ def clean(self):
5562
self.cleaned_data['_schedule_at'] = local_now()
5663

5764
return self.cleaned_data
65+
66+
67+
class ScriptFileForm(ManagedFileForm):
68+
"""
69+
ManagedFileForm with a custom save method to use django-storages.
70+
"""
71+
def save(self, *args, **kwargs):
72+
# If a file was uploaded, save it to disk
73+
if self.cleaned_data['upload_file']:
74+
storage = storages.create_storage(storages.backends["scripts"])
75+
76+
filename = self.cleaned_data['upload_file'].name
77+
if isinstance(storage, ScriptFileSystemStorage):
78+
full_path = os.path.join(settings.SCRIPTS_ROOT, filename)
79+
else:
80+
full_path = filename
81+
82+
self.instance.file_path = full_path
83+
data = self.cleaned_data['upload_file']
84+
storage.save(filename, data)
85+
86+
# need to skip ManagedFileForm save method
87+
return super(ManagedFileForm, self).save(*args, **kwargs)

netbox/extras/models/mixins.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,31 @@
1+
import importlib.abc
2+
import importlib.util
13
import os
2-
from importlib.machinery import SourceFileLoader
4+
import sys
5+
from django.core.files.storage import storages
36

47
__all__ = (
58
'PythonModuleMixin',
69
)
710

811

12+
class CustomStoragesLoader(importlib.abc.Loader):
13+
"""
14+
Custom loader for exec_module to use django-storages instead of the file system.
15+
"""
16+
def __init__(self, filename):
17+
self.filename = filename
18+
19+
def create_module(self, spec):
20+
return None # Use default module creation
21+
22+
def exec_module(self, module):
23+
storage = storages.create_storage(storages.backends["scripts"])
24+
with storage.open(self.filename, 'rb') as f:
25+
code = f.read()
26+
exec(code, module.__dict__)
27+
28+
929
class PythonModuleMixin:
1030

1131
def get_jobs(self, name):
@@ -33,6 +53,16 @@ def python_name(self):
3353
return name
3454

3555
def get_module(self):
36-
loader = SourceFileLoader(self.python_name, self.full_path)
37-
module = loader.load_module()
56+
"""
57+
Load the module using importlib, but use a custom loader to use django-storages
58+
instead of the file system.
59+
"""
60+
spec = importlib.util.spec_from_file_location(self.python_name, self.name)
61+
if spec is None:
62+
raise ModuleNotFoundError(f"Could not find module: {self.python_name}")
63+
loader = CustomStoragesLoader(self.name)
64+
module = importlib.util.module_from_spec(spec)
65+
sys.modules[self.python_name] = module
66+
loader.exec_module(module)
67+
3868
return module

netbox/extras/scripts.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
import json
33
import logging
44
import os
5+
import re
56

67
import yaml
78
from django import forms
89
from django.conf import settings
10+
from django.core.files.storage import storages
911
from django.core.validators import RegexValidator
1012
from django.utils import timezone
1113
from django.utils.functional import classproperty
@@ -367,9 +369,46 @@ def scheduling_enabled(self):
367369
def filename(self):
368370
return inspect.getfile(self.__class__)
369371

372+
def findsource(self, object):
373+
storage = storages.create_storage(storages.backends["scripts"])
374+
with storage.open(os.path.basename(self.filename), 'r') as f:
375+
data = f.read()
376+
377+
# Break the source code into lines
378+
lines = [line + '\n' for line in data.splitlines()]
379+
380+
# Find the class definition
381+
name = object.__name__
382+
pat = re.compile(r'^(\s*)class\s*' + name + r'\b')
383+
# use the class definition with the least indentation
384+
candidates = []
385+
for i in range(len(lines)):
386+
match = pat.match(lines[i])
387+
if match:
388+
if lines[i][0] == 'c':
389+
return lines, i
390+
391+
candidates.append((match.group(1), i))
392+
if not candidates:
393+
raise OSError('could not find class definition')
394+
395+
# Sort the candidates by whitespace, and by line number
396+
candidates.sort()
397+
return lines, candidates[0][1]
398+
370399
@property
371400
def source(self):
372-
return inspect.getsource(self.__class__)
401+
# Can't use inspect.getsource() as it uses os to get the file
402+
# inspect uses ast, but that is overkill for this as we only do
403+
# classes.
404+
object = self.__class__
405+
406+
try:
407+
lines, lnum = self.findsource(object)
408+
lines = inspect.getblock(lines[lnum:])
409+
return ''.join(lines)
410+
except OSError:
411+
return ''
373412

374413
@classmethod
375414
def _get_vars(cls):
@@ -524,7 +563,12 @@ def log_failure(self, message=None, obj=None):
524563
def load_yaml(self, filename):
525564
"""
526565
Return data from a YAML file
566+
TODO: DEPRECATED: Remove this method in v4.4
527567
"""
568+
self._log(
569+
_("load_yaml is deprecated and will be removed in v4.4"),
570+
level=LogLevelChoices.LOG_WARNING
571+
)
528572
try:
529573
from yaml import CLoader as Loader
530574
except ImportError:
@@ -539,7 +583,12 @@ def load_yaml(self, filename):
539583
def load_json(self, filename):
540584
"""
541585
Return data from a JSON file
586+
TODO: DEPRECATED: Remove this method in v4.4
542587
"""
588+
self._log(
589+
_("load_json is deprecated and will be removed in v4.4"),
590+
level=LogLevelChoices.LOG_WARNING
591+
)
543592
file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
544593
with open(file_path, 'r') as datafile:
545594
data = json.load(datafile)
@@ -555,7 +604,6 @@ def run_tests(self):
555604
Run the report and save its results. Each test method will be executed in order.
556605
"""
557606
self.logger.info("Running report")
558-
559607
try:
560608
for test_name in self.tests:
561609
self._current_test = test_name

0 commit comments

Comments
 (0)