Skip to content

Commit ea5c337

Browse files
committed
Closes #11558: Add support for remote data sources (#11646)
* WIP * WIP * Add git sync * Fix file hashing * Add last_synced to DataSource * Build out UI & API resources * Add status field to DataSource * Add UI control to sync data source * Add API endpoint to sync data sources * Fix display of DataSource job results * DataSource password should be write-only * General cleanup * Add data file UI view * Punt on HTTP, FTP support for now * Add DataSource URL validation * Add HTTP proxy support to git fetcher * Add management command to sync data sources * DataFile REST API endpoints should be read-only * Refactor fetch methods into backend classes * Replace auth & git branch fields with general-purpose parameters * Fix last_synced time * Render discrete form fields for backend parameters * Enable dynamic edit form for DataSource * Register DataBackend classes in application registry * Add search indexers for DataSource, DataFile * Add single & bulk delete views for DataFile * Add model documentation * Convert DataSource to a primary model * Introduce pre_sync & post_sync signals * Clean up migrations * Rename url to source_url * Clean up filtersets * Add API & filterset tests * Add view tests * Add initSelect() to HTMX refresh handler * Render DataSourceForm fieldsets dynamically * Update compiled static resources
1 parent a0e4019 commit ea5c337

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+1865
-14
lines changed

docs/models/core/datafile.md

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Data Files
2+
3+
A data file object is the representation in NetBox's database of some file belonging to a remote [data source](./datasource.md). Data files are synchronized automatically, and cannot be modified locally (although they can be deleted).
4+
5+
## Fields
6+
7+
### Source
8+
9+
The [data source](./datasource.md) to which this file belongs.
10+
11+
### Path
12+
13+
The path to the file, relative to its source's URL. For example, a file at `/opt/config-data/routing/bgp/peer.yaml` with a source URL of `file:///opt/config-data/` would have its path set to `routing/bgp/peer.yaml`.
14+
15+
### Last Updated
16+
17+
The date and time at which the file most recently updated from its source. Note that this attribute is updated only when the file's contents have been modified. Re-synchronizing the data source will not update this timestamp if the upstream file's data has not changed.
18+
19+
### Size
20+
21+
The file's size, in bytes.
22+
23+
### Hash
24+
25+
A [SHA256 hash](https://en.wikipedia.org/wiki/SHA-2) of the file's data. This can be compared to a hash taken from the original file to determine whether any changes have been made.

docs/models/core/datasource.md

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Data Sources
2+
3+
A data source represents some external repository of data which NetBox can consume, such as a git repository. Files within the data source are synchronized to NetBox by saving them in the database as [data file](./datafile.md) objects.
4+
5+
## Fields
6+
7+
### Name
8+
9+
The data source's human-friendly name.
10+
11+
### Type
12+
13+
The type of data source. Supported options include:
14+
15+
* Local directory
16+
* git repository
17+
18+
### URL
19+
20+
The URL identifying the remote source. Some examples are included below.
21+
22+
| Type | Example URL |
23+
|------|-------------|
24+
| Local | file:///var/my/data/source/ |
25+
| git | https://https://github.com/my-organization/my-repo |
26+
27+
### Status
28+
29+
The source's current synchronization status. Note that this cannot be set manually: It is updated automatically when the source is synchronized.
30+
31+
### Enabled
32+
33+
If false, synchronization will be disabled.
34+
35+
### Ignore Rules
36+
37+
A set of rules (one per line) identifying filenames to ignore during synchronization. Some examples are provided below. See Python's [`fnmatch()` documentation](https://docs.python.org/3/library/fnmatch.html) for a complete reference.
38+
39+
| Rule | Description |
40+
|----------------|------------------------------------------|
41+
| `README` | Ignore any files named `README` |
42+
| `*.txt` | Ignore any files with a `.txt` extension |
43+
| `data???.json` | Ignore e.g. `data123.json` |
44+
45+
### Last Synced
46+
47+
The date and time at which the source was most recently synchronized successfully.

netbox/core/__init__.py

Whitespace-only changes.

netbox/core/api/__init__.py

Whitespace-only changes.

netbox/core/api/nested_serializers.py

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from rest_framework import serializers
2+
3+
from core.models import *
4+
from netbox.api.serializers import WritableNestedSerializer
5+
6+
__all__ = [
7+
'NestedDataFileSerializer',
8+
'NestedDataSourceSerializer',
9+
]
10+
11+
12+
class NestedDataSourceSerializer(WritableNestedSerializer):
13+
url = serializers.HyperlinkedIdentityField(view_name='core-api:datasource-detail')
14+
15+
class Meta:
16+
model = DataSource
17+
fields = ['id', 'url', 'display', 'name']
18+
19+
20+
class NestedDataFileSerializer(WritableNestedSerializer):
21+
url = serializers.HyperlinkedIdentityField(view_name='core-api:datafile-detail')
22+
23+
class Meta:
24+
model = DataFile
25+
fields = ['id', 'url', 'display', 'path']

netbox/core/api/serializers.py

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from rest_framework import serializers
2+
3+
from core.choices import *
4+
from core.models import *
5+
from netbox.api.fields import ChoiceField
6+
from netbox.api.serializers import NetBoxModelSerializer
7+
from .nested_serializers import *
8+
9+
__all__ = (
10+
'DataSourceSerializer',
11+
)
12+
13+
14+
class DataSourceSerializer(NetBoxModelSerializer):
15+
url = serializers.HyperlinkedIdentityField(
16+
view_name='core-api:datasource-detail'
17+
)
18+
type = ChoiceField(
19+
choices=DataSourceTypeChoices
20+
)
21+
status = ChoiceField(
22+
choices=DataSourceStatusChoices,
23+
read_only=True
24+
)
25+
26+
# Related object counts
27+
file_count = serializers.IntegerField(
28+
read_only=True
29+
)
30+
31+
class Meta:
32+
model = DataSource
33+
fields = [
34+
'id', 'url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description', 'comments',
35+
'parameters', 'ignore_rules', 'created', 'last_updated', 'file_count',
36+
]
37+
38+
39+
class DataFileSerializer(NetBoxModelSerializer):
40+
url = serializers.HyperlinkedIdentityField(
41+
view_name='core-api:datafile-detail'
42+
)
43+
source = NestedDataSourceSerializer(
44+
read_only=True
45+
)
46+
47+
class Meta:
48+
model = DataFile
49+
fields = [
50+
'id', 'url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
51+
]

netbox/core/api/urls.py

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from netbox.api.routers import NetBoxRouter
2+
from . import views
3+
4+
5+
router = NetBoxRouter()
6+
router.APIRootView = views.CoreRootView
7+
8+
# Data sources
9+
router.register('data-sources', views.DataSourceViewSet)
10+
router.register('data-files', views.DataFileViewSet)
11+
12+
app_name = 'core-api'
13+
urlpatterns = router.urls

netbox/core/api/views.py

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from django.shortcuts import get_object_or_404
2+
3+
from rest_framework.decorators import action
4+
from rest_framework.exceptions import PermissionDenied
5+
from rest_framework.response import Response
6+
from rest_framework.routers import APIRootView
7+
8+
from core import filtersets
9+
from core.models import *
10+
from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
11+
from utilities.utils import count_related
12+
from . import serializers
13+
14+
15+
class CoreRootView(APIRootView):
16+
"""
17+
Core API root view
18+
"""
19+
def get_view_name(self):
20+
return 'Core'
21+
22+
23+
#
24+
# Data sources
25+
#
26+
27+
class DataSourceViewSet(NetBoxModelViewSet):
28+
queryset = DataSource.objects.annotate(
29+
file_count=count_related(DataFile, 'source')
30+
)
31+
serializer_class = serializers.DataSourceSerializer
32+
filterset_class = filtersets.DataSourceFilterSet
33+
34+
@action(detail=True, methods=['post'])
35+
def sync(self, request, pk):
36+
"""
37+
Enqueue a job to synchronize the DataSource.
38+
"""
39+
if not request.user.has_perm('extras.sync_datasource'):
40+
raise PermissionDenied("Syncing data sources requires the core.sync_datasource permission.")
41+
42+
datasource = get_object_or_404(DataSource, pk=pk)
43+
datasource.enqueue_sync_job(request)
44+
serializer = serializers.DataSourceSerializer(datasource, context={'request': request})
45+
46+
return Response(serializer.data)
47+
48+
49+
class DataFileViewSet(NetBoxReadOnlyModelViewSet):
50+
queryset = DataFile.objects.defer('data').prefetch_related('source')
51+
serializer_class = serializers.DataFileSerializer
52+
filterset_class = filtersets.DataFileFilterSet

netbox/core/apps.py

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from django.apps import AppConfig
2+
3+
4+
class CoreConfig(AppConfig):
5+
name = "core"
6+
7+
def ready(self):
8+
from . import data_backends, search

netbox/core/choices.py

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from django.utils.translation import gettext as _
2+
3+
from utilities.choices import ChoiceSet
4+
5+
6+
#
7+
# Data sources
8+
#
9+
10+
class DataSourceTypeChoices(ChoiceSet):
11+
LOCAL = 'local'
12+
GIT = 'git'
13+
14+
CHOICES = (
15+
(LOCAL, _('Local'), 'gray'),
16+
(GIT, _('Git'), 'blue'),
17+
)
18+
19+
20+
class DataSourceStatusChoices(ChoiceSet):
21+
22+
NEW = 'new'
23+
QUEUED = 'queued'
24+
SYNCING = 'syncing'
25+
COMPLETED = 'completed'
26+
FAILED = 'failed'
27+
28+
CHOICES = (
29+
(NEW, _('New'), 'blue'),
30+
(QUEUED, _('Queued'), 'orange'),
31+
(SYNCING, _('Syncing'), 'cyan'),
32+
(COMPLETED, _('Completed'), 'green'),
33+
(FAILED, _('Failed'), 'red'),
34+
)

netbox/core/data_backends.py

+117
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import logging
2+
import subprocess
3+
import tempfile
4+
from contextlib import contextmanager
5+
from urllib.parse import quote, urlunparse, urlparse
6+
7+
from django import forms
8+
from django.conf import settings
9+
from django.utils.translation import gettext as _
10+
11+
from netbox.registry import registry
12+
from .choices import DataSourceTypeChoices
13+
from .exceptions import SyncError
14+
15+
__all__ = (
16+
'LocalBackend',
17+
'GitBackend',
18+
)
19+
20+
logger = logging.getLogger('netbox.data_backends')
21+
22+
23+
def register_backend(name):
24+
"""
25+
Decorator for registering a DataBackend class.
26+
"""
27+
def _wrapper(cls):
28+
registry['data_backends'][name] = cls
29+
return cls
30+
31+
return _wrapper
32+
33+
34+
class DataBackend:
35+
parameters = {}
36+
37+
def __init__(self, url, **kwargs):
38+
self.url = url
39+
self.params = kwargs
40+
41+
@property
42+
def url_scheme(self):
43+
return urlparse(self.url).scheme.lower()
44+
45+
@contextmanager
46+
def fetch(self):
47+
raise NotImplemented()
48+
49+
50+
@register_backend(DataSourceTypeChoices.LOCAL)
51+
class LocalBackend(DataBackend):
52+
53+
@contextmanager
54+
def fetch(self):
55+
logger.debug(f"Data source type is local; skipping fetch")
56+
local_path = urlparse(self.url).path # Strip file:// scheme
57+
58+
yield local_path
59+
60+
61+
@register_backend(DataSourceTypeChoices.GIT)
62+
class GitBackend(DataBackend):
63+
parameters = {
64+
'username': forms.CharField(
65+
required=False,
66+
label=_('Username'),
67+
widget=forms.TextInput(attrs={'class': 'form-control'})
68+
),
69+
'password': forms.CharField(
70+
required=False,
71+
label=_('Password'),
72+
widget=forms.TextInput(attrs={'class': 'form-control'})
73+
),
74+
'branch': forms.CharField(
75+
required=False,
76+
label=_('Branch'),
77+
widget=forms.TextInput(attrs={'class': 'form-control'})
78+
)
79+
}
80+
81+
@contextmanager
82+
def fetch(self):
83+
local_path = tempfile.TemporaryDirectory()
84+
85+
# Add authentication credentials to URL (if specified)
86+
username = self.params.get('username')
87+
password = self.params.get('password')
88+
if username and password:
89+
url_components = list(urlparse(self.url))
90+
# Prepend username & password to netloc
91+
url_components[1] = quote(f'{username}@{password}:') + url_components[1]
92+
url = urlunparse(url_components)
93+
else:
94+
url = self.url
95+
96+
# Compile git arguments
97+
args = ['git', 'clone', '--depth', '1']
98+
if branch := self.params.get('branch'):
99+
args.extend(['--branch', branch])
100+
args.extend([url, local_path.name])
101+
102+
# Prep environment variables
103+
env_vars = {}
104+
if settings.HTTP_PROXIES and self.url_scheme in ('http', 'https'):
105+
env_vars['http_proxy'] = settings.HTTP_PROXIES.get(self.url_scheme)
106+
107+
logger.debug(f"Cloning git repo: {' '.join(args)}")
108+
try:
109+
subprocess.run(args, check=True, capture_output=True, env=env_vars)
110+
except subprocess.CalledProcessError as e:
111+
raise SyncError(
112+
f"Fetching remote data failed: {e.stderr}"
113+
)
114+
115+
yield local_path.name
116+
117+
local_path.cleanup()

netbox/core/exceptions.py

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
class SyncError(Exception):
2+
pass

0 commit comments

Comments
 (0)