Skip to content

Commit

Permalink
Merge pull request #8343 from netbox-community/1591-service-templates
Browse files Browse the repository at this point in the history
Closes #1591: Service templates
  • Loading branch information
jeremystretch authored Jan 13, 2022
2 parents cf89984 + b21b623 commit 5077ff1
Show file tree
Hide file tree
Showing 26 changed files with 574 additions and 41 deletions.
1 change: 1 addition & 0 deletions docs/core-functionality/services.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# Service Mapping

{!models/ipam/servicetemplate.md!}
{!models/ipam/service.md!}
3 changes: 3 additions & 0 deletions docs/models/ipam/servicetemplate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Service Templates

Service templates can be used to instantiate services on devices and virtual machines. A template defines a name, protocol, and port number(s), and may optionally include a description. Services can be instantiated from templates and applied to devices and/or virtual machines, and may be associated with specific IP addresses.
5 changes: 5 additions & 0 deletions docs/release-notes/version-3.2.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@

### New Features

#### Service Templates ([#1591](https://github.com/netbox-community/netbox/issues/1591))

A new service template model has been introduced to assist in standardizing the definition and application of layer four services to devices and virtual machines. As an alternative to manually defining a name, protocol, and port(s) each time a service is created, a user now has the option of selecting a pre-defined template from which these values will be populated.

#### Automatic Provisioning of Next Available VLANs ([#2658](https://github.com/netbox-community/netbox/issues/2658))

A new REST API endpoint has been added at `/api/ipam/vlan-groups/<pk>/available-vlans/`. A GET request to this endpoint will return a list of available VLANs within the group. A POST request can be made to this endpoint specifying the name(s) of one or more VLANs to create within the group, and their VLAN IDs will be assigned automatically.
Expand Down Expand Up @@ -83,6 +87,7 @@ Inventory item templates can be arranged hierarchically within a device type, an
* `/api/dcim/module-bays/`
* `/api/dcim/module-bay-templates/`
* `/api/dcim/module-types/`
* `/api/extras/service-templates/`
* circuits.ProviderNetwork
* Added `service_id` field
* dcim.ConsolePort
Expand Down
9 changes: 9 additions & 0 deletions netbox/ipam/api/nested_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
'NestedRoleSerializer',
'NestedRouteTargetSerializer',
'NestedServiceSerializer',
'NestedServiceTemplateSerializer',
'NestedVLANGroupSerializer',
'NestedVLANSerializer',
'NestedVRFSerializer',
Expand Down Expand Up @@ -175,6 +176,14 @@ class Meta:
# Services
#

class NestedServiceTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:servicetemplate-detail')

class Meta:
model = models.ServiceTemplate
fields = ['id', 'url', 'display', 'name', 'protocol', 'ports']


class NestedServiceSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail')

Expand Down
12 changes: 12 additions & 0 deletions netbox/ipam/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,18 @@ def to_representation(self, instance):
# Services
#

class ServiceTemplateSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:servicetemplate-detail')
protocol = ChoiceField(choices=ServiceProtocolChoices, required=False)

class Meta:
model = ServiceTemplate
fields = [
'id', 'url', 'display', 'name', 'ports', 'protocol', 'description', 'tags', 'custom_fields', 'created',
'last_updated',
]


class ServiceSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail')
device = NestedDeviceSerializer(required=False, allow_null=True)
Expand Down
1 change: 1 addition & 0 deletions netbox/ipam/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
router.register('vlans', views.VLANViewSet)

# Services
router.register('service-templates', views.ServiceTemplateViewSet)
router.register('services', views.ServiceViewSet)

app_name = 'ipam-api'
Expand Down
8 changes: 7 additions & 1 deletion netbox/ipam/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,13 @@ class VLANViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.VLANFilterSet


class ServiceViewSet(ModelViewSet):
class ServiceTemplateViewSet(CustomFieldModelViewSet):
queryset = ServiceTemplate.objects.prefetch_related('tags')
serializer_class = serializers.ServiceTemplateSerializer
filterset_class = filtersets.ServiceTemplateFilterSet


class ServiceViewSet(CustomFieldModelViewSet):
queryset = Service.objects.prefetch_related(
'device', 'virtual_machine', 'tags', 'ipaddresses'
)
Expand Down
23 changes: 23 additions & 0 deletions netbox/ipam/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
'RoleFilterSet',
'RouteTargetFilterSet',
'ServiceFilterSet',
'ServiceTemplateFilterSet',
'VLANFilterSet',
'VLANGroupFilterSet',
'VRFFilterSet',
Expand Down Expand Up @@ -854,6 +855,28 @@ def get_for_virtualmachine(self, queryset, name, value):
return queryset.get_for_virtualmachine(value)


class ServiceTemplateFilterSet(PrimaryModelFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
port = NumericArrayFilter(
field_name='ports',
lookup_expr='contains'
)
tag = TagFilter()

class Meta:
model = ServiceTemplate
fields = ['id', 'name', 'protocol']

def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = Q(name__icontains=value) | Q(description__icontains=value)
return queryset.filter(qs_filter)


class ServiceFilterSet(PrimaryModelFilterSet):
q = django_filters.CharFilter(
method='search',
Expand Down
12 changes: 10 additions & 2 deletions netbox/ipam/forms/bulk_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
'RoleBulkEditForm',
'RouteTargetBulkEditForm',
'ServiceBulkEditForm',
'ServiceTemplateBulkEditForm',
'VLANBulkEditForm',
'VLANGroupBulkEditForm',
'VRFBulkEditForm',
Expand Down Expand Up @@ -433,9 +434,9 @@ class Meta:
]


class ServiceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class ServiceTemplateBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Service.objects.all(),
queryset=ServiceTemplate.objects.all(),
widget=forms.MultipleHiddenInput()
)
protocol = forms.ChoiceField(
Expand All @@ -459,3 +460,10 @@ class Meta:
nullable_fields = [
'description',
]


class ServiceBulkEditForm(ServiceTemplateBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Service.objects.all(),
widget=forms.MultipleHiddenInput()
)
12 changes: 12 additions & 0 deletions netbox/ipam/forms/bulk_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
'RoleCSVForm',
'RouteTargetCSVForm',
'ServiceCSVForm',
'ServiceTemplateCSVForm',
'VLANCSVForm',
'VLANGroupCSVForm',
'VRFCSVForm',
Expand Down Expand Up @@ -392,6 +393,17 @@ class Meta:
}


class ServiceTemplateCSVForm(CustomFieldModelCSVForm):
protocol = CSVChoiceField(
choices=ServiceProtocolChoices,
help_text='IP protocol'
)

class Meta:
model = ServiceTemplate
fields = ('name', 'protocol', 'ports', 'description')


class ServiceCSVForm(CustomFieldModelCSVForm):
device = CSVModelChoiceField(
queryset=Device.objects.all(),
Expand Down
9 changes: 7 additions & 2 deletions netbox/ipam/forms/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
'RoleFilterForm',
'RouteTargetFilterForm',
'ServiceFilterForm',
'ServiceTemplateFilterForm',
'VLANFilterForm',
'VLANGroupFilterForm',
'VRFFilterForm',
Expand Down Expand Up @@ -447,8 +448,8 @@ class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
tag = TagFilterField(model)


class ServiceFilterForm(CustomFieldModelFilterForm):
model = Service
class ServiceTemplateFilterForm(CustomFieldModelFilterForm):
model = ServiceTemplate
field_groups = (
('q', 'tag'),
('protocol', 'port'),
Expand All @@ -462,3 +463,7 @@ class ServiceFilterForm(CustomFieldModelFilterForm):
required=False,
)
tag = TagFilterField(model)


class ServiceFilterForm(ServiceTemplateFilterForm):
model = Service
56 changes: 56 additions & 0 deletions netbox/ipam/forms/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
'RoleForm',
'RouteTargetForm',
'ServiceForm',
'ServiceCreateForm',
'ServiceTemplateForm',
'VLANForm',
'VLANGroupForm',
'VRFForm',
Expand Down Expand Up @@ -815,6 +817,27 @@ class Meta:
}


class ServiceTemplateForm(CustomFieldModelForm):
ports = NumericArrayField(
base_field=forms.IntegerField(
min_value=SERVICE_PORT_MIN,
max_value=SERVICE_PORT_MAX
),
help_text="Comma-separated list of one or more port numbers. A range may be specified using a hyphen."
)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)

class Meta:
model = ServiceTemplate
fields = ('name', 'protocol', 'ports', 'description', 'tags')
widgets = {
'protocol': StaticSelect(),
}


class ServiceForm(CustomFieldModelForm):
device = DynamicModelChoiceField(
queryset=Device.objects.all(),
Expand Down Expand Up @@ -858,3 +881,36 @@ class Meta:
'protocol': StaticSelect(),
'ipaddresses': StaticSelectMultiple(),
}


class ServiceCreateForm(ServiceForm):
service_template = DynamicModelChoiceField(
queryset=ServiceTemplate.objects.all(),
required=False
)

class Meta(ServiceForm.Meta):
fields = [
'device', 'virtual_machine', 'service_template', 'name', 'protocol', 'ports', 'ipaddresses', 'description',
'tags',
]

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

# Fields which may be populated from a ServiceTemplate are not required
for field in ('name', 'protocol', 'ports'):
self.fields[field].required = False
del(self.fields[field].widget.attrs['required'])

def clean(self):
if self.cleaned_data['service_template']:
# Create a new Service from the specified template
service_template = self.cleaned_data['service_template']
self.cleaned_data['name'] = service_template.name
self.cleaned_data['protocol'] = service_template.protocol
self.cleaned_data['ports'] = service_template.ports
if not self.cleaned_data['description']:
self.cleaned_data['description'] = service_template.description
elif not all(self.cleaned_data[f] for f in ('name', 'protocol', 'ports')):
raise forms.ValidationError("Must specify name, protocol, and port(s) if not using a service template.")
3 changes: 3 additions & 0 deletions netbox/ipam/graphql/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ class IPAMQuery(graphene.ObjectType):
service = ObjectField(ServiceType)
service_list = ObjectListField(ServiceType)

service_template = ObjectField(ServiceTemplateType)
service_template_list = ObjectListField(ServiceTemplateType)

fhrp_group = ObjectField(FHRPGroupType)
fhrp_group_list = ObjectListField(FHRPGroupType)

Expand Down
9 changes: 9 additions & 0 deletions netbox/ipam/graphql/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
'RoleType',
'RouteTargetType',
'ServiceType',
'ServiceTemplateType',
'VLANType',
'VLANGroupType',
'VRFType',
Expand Down Expand Up @@ -120,6 +121,14 @@ class Meta:
filterset_class = filtersets.ServiceFilterSet


class ServiceTemplateType(PrimaryObjectType):

class Meta:
model = models.ServiceTemplate
fields = '__all__'
filterset_class = filtersets.ServiceTemplateFilterSet


class VLANType(PrimaryObjectType):

class Meta:
Expand Down
33 changes: 33 additions & 0 deletions netbox/ipam/migrations/0055_servicetemplate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import django.contrib.postgres.fields
import django.core.serializers.json
import django.core.validators
from django.db import migrations, models
import taggit.managers


class Migration(migrations.Migration):

dependencies = [
('extras', '0070_customlink_enabled'),
('ipam', '0054_vlangroup_min_max_vids'),
]

operations = [
migrations.CreateModel(
name='ServiceTemplate',
fields=[
('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)),
('protocol', models.CharField(max_length=50)),
('ports', django.contrib.postgres.fields.ArrayField(base_field=models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65535)]), size=None)),
('description', models.CharField(blank=True, max_length=200)),
('name', models.CharField(max_length=100, unique=True)),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
'ordering': ('name',),
},
),
]
1 change: 1 addition & 0 deletions netbox/ipam/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
'Role',
'RouteTarget',
'Service',
'ServiceTemplate',
'VLAN',
'VLANGroup',
'VRF',
Expand Down
Loading

0 comments on commit 5077ff1

Please sign in to comment.