Initial commit

This commit is contained in:
sven committed 2026-08-16 13:10:26 +02:00
commit 4ff9eac274
45 files changed
+2911

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
[flake8]
max-line-length = 100
+7
View File
@@ -0,0 +1,7 @@
dist/
build/
__pycache__/
venv/
.venv/
netbox_plugin_services.egg-info/
*.pyc
+18
View File
@@ -0,0 +1,18 @@
MIT License
Copyright (c) 2025-2026 Sven Luethi
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+2
View File
@@ -0,0 +1,2 @@
recursive-include netbox_services *.py
recursive-include netbox_services/templates *.html
+114
View File
@@ -0,0 +1,114 @@
# NetBox Services Plugin
A NetBox plugin for declarative service templates with requirement trees.
## Overview
This plugin lets you define **service templates** that specify what NetBox
resources a service requires, without attaching any resources to the template
itself. Templates contain a **requirement tree** — a stacked, declarative
description of what objects are needed (devices, interfaces, IP addresses, etc.)
and how to select or allocate them.
One or more **services** are then spawned from these templates. When a service is
**resolved**, the resolver engine walks the requirement tree, evaluates selectors
and allocators against live NetBox data, and creates **resource bindings** that
link the service to real NetBox objects.
## Key Concepts
- **ServiceTemplate** — Reusable template with a declarative requirement tree
stored as JSON (editable as YAML or JSON in the UI). Locked once any service
instances are spawned from it.
- **Service** — A concrete instance spawned from a ServiceTemplate. Requires a
tenant.
- **ServiceResourceBinding** — Internal model (hidden from UI) that links a
service to a real NetBox object via a generic foreign key.
## Tree Structure
The requirement tree is a list of nodes. Each node specifies:
| Field | Description |
|-------|-------------|
| `name` | Unique name for the node |
| `object_type` | NetBox model as `app_label.model_name` (e.g. `dcim.device`) |
| `quantity` | How many objects to select/allocate (default: 1) |
| `selector` | How to find existing objects (see Selector Types) |
| `allocation` | How to create new objects (see Allocation Strategies) |
| `scope` | `service` (resolve once) or `parent` (resolve per selected parent) |
| `children` | List of child nodes (recursive) |
A node must have either a `selector` or an `allocation` (not both).
## Selector Types
| Type | Description |
|------|-------------|
| `netbox_filter` | Apply NetBox filter parameters to find existing objects |
| `reference` | Resolve a dotted context path (e.g. `parent.site`) |
| `prefix` | Find ipam.Prefix objects by filter or reference |
## Allocation Strategies
| Strategy | Description |
|----------|-------------|
| `first_free` | Allocate the first available IP from a resolved prefix |
| `specific` | Use a specific object by address or context path |
## Reconciliation
When a service is re-resolved, the resolver performs **set reconciliation**:
- Objects in the new plan but not currently bound → **added**
- Objects currently bound but not in the new plan → **removed** (binding only; the underlying NetBox object is not deleted)
- Objects in both → **kept** unchanged
For allocation nodes, existing bound objects of the same type are reused before
allocating new ones (avoids duplicate IPs on re-resolve).
## Quick Start
```bash
cd dev/
podman-compose up -d
```
Access at `http://localhost:8001` (admin/admin).
## Example
A service template for "2 routers, each with a management IP from a prefix pool":
```yaml
- name: routers
object_type: dcim.device
quantity: 2
selector:
type: netbox_filter
filters:
role: router
scope: service
children:
- name: mgmt-ip
object_type: ipam.ipaddress
quantity: 1
allocation:
strategy: first_free
prefix:
type: prefix
filters:
site: hq
tag: mgmt
scope: parent
```
## REST API
- `GET/POST /api/plugins/services/service-templates/` — CRUD for templates
- `GET/POST /api/plugins/services/services/` — CRUD for services
- `POST /api/plugins/services/services/<id>/resolve/` — Resolve a service
- Body: `{"mode": "dry_run"}` or `{"mode": "commit"}`
## License
MIT
+13
View File
@@ -0,0 +1,13 @@
"""Root conftest: ensure Django is configured before any test conftest imports
models. pytest-django normally handles this, but our tests-dir conftest imports
plugin modules at collection time, so we force ``django.setup()`` here
unconditionally and early.
"""
import os
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings")
import django # noqa: E402
if not django.apps.apps.ready:
django.setup()
+1
View File
@@ -0,0 +1 @@
FROM docker.io/netboxcommunity/netbox:v4.6.5
+46
View File
@@ -0,0 +1,46 @@
ALLOWED_HOSTS = ["*"]
SECRET_KEY = "services-dev-secret-key-change-in-production-0123456789"
DEBUG = True
DEVELOPER = True
DATABASE = {
"NAME": "netbox",
"USER": "netbox",
"PASSWORD": "netbox",
"HOST": "nb-services-postgres",
"PORT": 5432,
"CONN_MAX_AGE": 300,
"ENGINE": "django.db.backends.postgresql",
}
REDIS = {
"tasks": {
"HOST": "nb-services-redis",
"PORT": 6379,
"DB": 0,
"SSL": False,
},
"caching": {
"HOST": "nb-services-redis",
"PORT": 6379,
"DB": 1,
"SSL": False,
},
}
PLUGINS = ["netbox_services"]
PLUGINS_CONFIG = {
"netbox_services": {},
}
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {"class": "logging.StreamHandler"},
},
"loggers": {
"django": {"handlers": ["console"], "level": "INFO"},
"netbox_services": {"handlers": ["console"], "level": "DEBUG"},
},
}
+45
View File
@@ -0,0 +1,45 @@
version: "3.8"
services:
postgres:
image: docker.io/library/postgres:16-alpine
container_name: nb-services-postgres
environment:
POSTGRES_DB: netbox
POSTGRES_USER: netbox
POSTGRES_PASSWORD: netbox
volumes:
- nb-services-pgdata:/var/lib/postgresql/data
networks:
- nb-services
redis:
image: docker.io/library/redis:7-alpine
container_name: nb-services-redis
networks:
- nb-services
netbox:
build:
context: ..
dockerfile: dev/Dockerfile
container_name: nb-services-netbox
depends_on:
- postgres
- redis
volumes:
- ..:/opt/netbox-plugin-services
- ./configuration.py:/etc/netbox/config/configuration.py:Z
- ./entrypoint.sh:/opt/netbox-plugin-services/dev/entrypoint.sh:Z
ports:
- "8001:8000"
entrypoint: ["/bin/bash", "/opt/netbox-plugin-services/dev/entrypoint.sh"]
networks:
- nb-services
volumes:
nb-services-pgdata:
networks:
nb-services:
driver: bridge
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
set -e
echo "=== Installing local plugin (editable) ==="
uv pip install -e /opt/netbox-plugin-services
echo "=== Running migrations ==="
python manage.py migrate --no-input
echo "=== Collecting static files ==="
python manage.py collectstatic --no-input
echo "=== Creating superuser ==="
python manage.py shell -c "
from django.contrib.auth import get_user_model
User = get_user_model()
if not User.objects.filter(username='admin').exists():
User.objects.create_superuser('admin', 'admin@example.com', 'admin')
print('Superuser admin created (password: admin)')
else:
print('Superuser admin already exists')
"
echo "=== Starting dev server ==="
python manage.py runserver 0.0.0.0:8000
+21
View File
@@ -0,0 +1,21 @@
from netbox.plugins import PluginConfig
__version__ = "0.1.0"
class NetBoxServicesConfig(PluginConfig):
name = "netbox_services"
verbose_name = "NetBox Services"
description = "Declarative service templates with requirement trees."
version = __version__
author = "Sven Luethi"
author_email = "dev@sven.luethi.co"
base_url = "services"
min_version = "4.6.0"
def ready(self):
super().ready()
from . import signals # noqa: F401
config = NetBoxServicesConfig
View File
Whitespace-only changes.
+36
View File
@@ -0,0 +1,36 @@
from rest_framework import serializers
from netbox.api.serializers import NetBoxModelSerializer
from ..models import Service, ServiceTemplate
class ServiceTemplateSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(
view_name="plugins-api:netbox_services-api:servicetemplate-detail",
)
class Meta:
model = ServiceTemplate
fields = (
"id", "url", "display", "name", "status",
"tree", "description", "comments", "tags",
"created", "last_updated",
)
brief_fields = ("id", "url", "display", "name", "status")
class ServiceSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(
view_name="plugins-api:netbox_services-api:service-detail",
)
service_template = ServiceTemplateSerializer(nested=True)
class Meta:
model = Service
fields = (
"id", "url", "display", "name", "service_template",
"tenant", "status", "description", "comments", "tags",
"created", "last_updated",
)
brief_fields = ("id", "url", "display", "name", "status")
+9
View File
@@ -0,0 +1,9 @@
from netbox.api.routers import NetBoxRouter
from .views import ServiceTemplateViewSet, ServiceViewSet
router = NetBoxRouter()
router.register("service-templates", ServiceTemplateViewSet)
router.register("services", ServiceViewSet)
urlpatterns = router.urls
+62
View File
@@ -0,0 +1,62 @@
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.response import Response
from netbox.api.viewsets import NetBoxModelViewSet
from ..engine.resolver import Resolver
from ..models import Service, ServiceTemplate
from .serializers import ServiceSerializer, ServiceTemplateSerializer
class ServiceTemplateViewSet(NetBoxModelViewSet):
queryset = ServiceTemplate.objects.all()
serializer_class = ServiceTemplateSerializer
class ServiceViewSet(NetBoxModelViewSet):
queryset = Service.objects.all()
serializer_class = ServiceSerializer
@action(detail=True, methods=["post"])
def resolve(self, request, pk=None):
"""Resolve a service's requirement tree.
Request body: ``{"mode": "dry_run" | "commit"}``
"""
service = self.get_object()
mode = request.data.get("mode", "dry_run")
resolver = Resolver()
try:
result = resolver.resolve(service, mode=mode, user=request.user)
except Exception as exc:
return Response(
{"error": str(exc)},
status=status.HTTP_400_BAD_REQUEST,
)
planned_data = []
for node_name, obj in result.planned_objects:
ct = obj.__class__._meta
planned_data.append({
"node": node_name,
"object_type": f"{ct.app_label}.{ct.model_name}",
"object_id": obj.pk,
"object": str(obj),
})
response_data = {
"mode": mode,
"succeeded": result.succeeded,
"total_planned": result.total_planned,
"total_current": result.total_current,
"to_add": len(result.to_add),
"to_remove": len(result.to_remove),
"planned": planned_data,
"errors": result.errors,
"warnings": result.warnings,
"summary": result.summary,
}
return Response(response_data)
View File
Whitespace-only changes.
+78
View File
@@ -0,0 +1,78 @@
"""Allocators create or acquire objects that don't exist yet (e.g. IP addresses)."""
from .registry import register_allocator, get_selector
@register_allocator("first_free")
class FirstFreeIPAddressAllocator:
"""Allocate the first available IP address from a resolved prefix.
Spec shape::
{"strategy": "first_free", "prefix": {"type": "reference", "path": "parent.site.mgmt_prefix"}}
{"strategy": "first_free", "prefix": {"type": "prefix", "filters": {"site": "hq"}}}
"""
def __init__(self, spec):
self.spec = spec
self.prefix_spec = spec.get("prefix", {})
def resolve_prefix(self, context):
from ipam.models import Prefix
selector = get_selector(self.prefix_spec)
qs = selector.resolve(context, Prefix)
return qs.first()
def allocate(self, context):
from ipam.models import IPAddress
from ipam.choices import IPAddressStatusChoices
prefix = self.resolve_prefix(context)
if prefix is None:
raise ValueError("No prefix resolved for first_free allocation.")
available = prefix.get_first_available_ip()
if available is None:
raise ValueError(
f"No available IP addresses in prefix {prefix.prefix}."
)
ip = IPAddress.objects.create(
address=available,
status=IPAddressStatusChoices.STATUS_ACTIVE,
)
return ip
@register_allocator("specific")
class SpecificAllocator:
"""Use a specific object identified by content type + ID or context path.
Spec shape (by reference)::
{"strategy": "specific", "path": "parent.site.primary_ip4"}
Spec shape (by value, for IP addresses)::
{"strategy": "specific", "address": "10.0.0.1/24"}
"""
def __init__(self, spec):
self.spec = spec
self.path = spec.get("path")
self.address = spec.get("address")
def allocate(self, context):
if self.address:
from ipam.models import IPAddress
from ipam.choices import IPAddressStatusChoices
ip, _ = IPAddress.objects.get_or_create(
address=self.address,
defaults={"status": IPAddressStatusChoices.STATUS_ACTIVE},
)
return ip
if self.path:
return context.resolve_path(self.path)
raise ValueError("SpecificAllocator requires 'path' or 'address'.")
+52
View File
@@ -0,0 +1,52 @@
"""Resolution context — carries service and parent references during tree walk."""
class Context:
"""Evaluation frame for the resolver.
Top-level context has ``service`` set and ``parent_object`` as None.
Each selected/allocated object pushes a child context so children
can reference ``parent`` and traverse its relations.
"""
def __init__(self, service, parent_object=None):
self.service = service
self.parent_object = parent_object
def child(self, obj):
return Context(
service=self.service,
parent_object=obj,
)
@property
def parent(self):
return self.parent_object
def resolve_path(self, path):
"""Resolve a dotted reference like ``parent.site.management_prefix``.
Supported roots:
- ``service`` → the Service instance
- ``parent`` → the parent object (or None at service scope)
Returns None if any segment is None or missing.
"""
parts = path.split(".")
root = parts[0]
if root == "service":
obj = self.service
elif root == "parent":
obj = self.parent_object
else:
raise KeyError(f"Unknown context root: {root!r}")
for part in parts[1:]:
if obj is None:
return None
if isinstance(obj, dict):
obj = obj.get(part)
else:
obj = getattr(obj, part, None)
return obj
+64
View File
@@ -0,0 +1,64 @@
"""Registry for selectors and allocators — pluggable extension points."""
_selectors = {}
_allocators = {}
class RegistryError(Exception):
pass
class SelectorNotFoundError(RegistryError):
pass
class AllocatorNotFoundError(RegistryError):
pass
def register_selector(type_name):
def decorator(cls):
_selectors[type_name] = cls
cls.type = type_name
return cls
return decorator
def get_selector(spec):
if not spec or not isinstance(spec, dict):
raise ValueError(f"Invalid selector spec: {spec!r}")
type_name = spec.get("type")
if type_name not in _selectors:
raise SelectorNotFoundError(
f"No selector registered for type {type_name!r}. "
f"Available: {list(_selectors.keys())}"
)
return _selectors[type_name](spec)
def register_allocator(strategy_name):
def decorator(cls):
_allocators[strategy_name] = cls
cls.strategy = strategy_name
return cls
return decorator
def get_allocator(spec):
if not spec or not isinstance(spec, dict):
raise ValueError(f"Invalid allocation spec: {spec!r}")
strategy = spec.get("strategy")
if strategy not in _allocators:
raise AllocatorNotFoundError(
f"No allocator registered for strategy {strategy!r}. "
f"Available: {list(_allocators.keys())}"
)
return _allocators[strategy](spec)
def registered_selectors():
return dict(_selectors)
def registered_allocators():
return dict(_allocators)
+245
View File
@@ -0,0 +1,245 @@
"""The resolver walks a service template's requirement tree (stored as JSON)
and reconciles the service's bindings against real NetBox objects.
No intermediate PlannedBinding objects — the resolver works with plain
sets of (content_type_id, object_id) tuples for reconciliation and a
flat list of resolved objects for dry-run preview.
"""
import logging
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from ..models import (
ResolutionModeChoices,
ServiceResourceBinding,
)
from .context import Context
from .registry import get_allocator, get_selector
logger = logging.getLogger("netbox_services.resolver")
class ResolutionError(Exception):
pass
class ConflictError(ResolutionError):
"""An object is already bound to another service."""
def __init__(self, obj, existing_service):
self.obj = obj
self.existing_service = existing_service
super().__init__(
f"Object {obj!r} (pk={obj.pk}) is already bound to service "
f"'{existing_service}'."
)
class Resolver:
"""Walks the template's JSON tree and reconciles bindings."""
def resolve(self, service, mode="dry_run", user=None):
if mode not in (ResolutionModeChoices.MODE_DRY_RUN, ResolutionModeChoices.MODE_COMMIT):
raise ValueError(f"Invalid mode: {mode!r}")
tree = service.service_template.tree
result = ResolutionResult(mode=mode, service=service)
try:
with transaction.atomic():
self._resolve_tree(tree, service, result)
result.compute_diff()
if mode == ResolutionModeChoices.MODE_DRY_RUN:
transaction.set_rollback(True)
else:
for ct_id, obj_id in result.to_add:
ServiceResourceBinding.objects.create(
service=service,
content_type_id=ct_id,
object_id=obj_id,
)
for binding in result.to_remove_bindings:
binding.delete()
except Exception as exc:
result.errors.append(str(exc))
logger.exception("Resolution failed for service %s", service)
raise
return result
def _resolve_tree(self, tree, service, result):
"""Walk the JSON tree and populate result.planned_set + result.planned_objects."""
context = Context(service=service)
for node in tree:
self._resolve_node(node, service, result, context)
def _resolve_node(self, node, service, result, context):
"""Resolve a single tree node, then recurse into children."""
object_type_str = node["object_type"]
app_label, model_name = object_type_str.split(".")
ot = ContentType.objects.get_by_natural_key(app_label, model_name)
model = ot.model_class()
if model is None:
raise ResolutionError(
f"Node '{node['name']}': object_type '{object_type_str}' has no model class."
)
quantity = node.get("quantity", 1)
selector_spec = node.get("selector")
allocation_spec = node.get("allocation")
objects = self._resolve_objects(
node, service, result, context, model, quantity, selector_spec, allocation_spec,
)
for obj in objects:
ct = ContentType.objects.get_for_model(obj.__class__)
key = (ct.id, obj.pk)
result.planned_set.add(key)
result.planned_objects.append((node["name"], obj))
self._check_conflict(obj, service)
child_context = context.child(obj)
children = node.get("children", [])
for child_node in children:
scope = child_node.get("scope", "parent")
if scope == "parent" or len(objects) <= 1:
self._resolve_node(child_node, service, result, child_context)
elif scope == "service":
self._resolve_node(child_node, service, result, context)
def _resolve_objects(self, node, service, result, context, model, quantity, selector_spec, allocation_spec):
"""Return a list of resolved objects for this node."""
if allocation_spec:
return self._allocate_with_reuse(
node, service, result, context, model, quantity, allocation_spec,
)
if selector_spec:
return self._select(node, service, result, context, model, quantity)
raise ResolutionError(
f"Node '{node['name']}': must have either 'selector' or 'allocation'."
)
def _select(self, node, service, result, context, model, quantity):
"""Select existing objects via the selector registry."""
selector = get_selector(node["selector"])
qs = selector.resolve(context, model)
bound_ids = self._get_bound_ids(model, service)
if bound_ids:
qs = qs.exclude(pk__in=bound_ids)
objects = list(qs[:quantity])
result.summary["selected"] = result.summary.get("selected", 0) + len(objects)
if len(objects) < quantity:
result.warnings.append(
f"Node '{node['name']}': only {len(objects)} of {quantity} objects available."
)
return objects
def _allocate_with_reuse(self, node, service, result, context, model, quantity, allocation_spec):
"""Allocate new objects, but reuse existing bindings of the same type if present."""
existing = self._get_existing_bound_objects(model, service)
if len(existing) >= quantity:
objects = existing[:quantity]
result.summary["reused"] = result.summary.get("reused", 0) + len(objects)
return objects
objects = list(existing)
needed = quantity - len(existing)
allocator = get_allocator(allocation_spec)
for _ in range(needed):
obj = allocator.allocate(context)
if obj is None:
raise ResolutionError(
f"Node '{node['name']}': allocator returned None."
)
objects.append(obj)
result.summary["allocated"] = result.summary.get("allocated", 0) + len(objects) - len(existing)
return objects
def _get_bound_ids(self, model, service):
"""Return PKs of model instances already bound to other services."""
ct = ContentType.objects.get_for_model(model)
qs = (
ServiceResourceBinding.objects
.filter(content_type=ct)
.exclude(service=service)
)
return set(qs.values_list("object_id", flat=True))
def _get_existing_bound_objects(self, model, service):
"""Return model instances already bound to this service."""
ct = ContentType.objects.get_for_model(model)
binding_ids = (
ServiceResourceBinding.objects
.filter(service=service, content_type=ct)
.values_list("object_id", flat=True)
)
return list(model.objects.filter(pk__in=binding_ids))
def _check_conflict(self, obj, service):
"""Raise if obj is already bound to another service."""
ct = ContentType.objects.get_for_model(obj.__class__)
if (
ServiceResourceBinding.objects
.filter(content_type=ct, object_id=obj.pk)
.exclude(service=service)
.exists()
):
existing = (
ServiceResourceBinding.objects
.filter(content_type=ct, object_id=obj.pk)
.exclude(service=service)
.first()
)
raise ConflictError(obj, existing.service.name)
class ResolutionResult:
def __init__(self, mode, service):
self.mode = mode
self.service = service
self.planned_set = set()
self.planned_objects = []
self.errors = []
self.warnings = []
self.summary = {}
self.to_add = set()
self.to_remove = set()
self.to_remove_bindings = []
def compute_diff(self):
"""Compare planned_set against current bindings."""
current_qs = ServiceResourceBinding.objects.filter(service=self.service)
current_set = set()
current_map = {}
for binding in current_qs:
key = (binding.content_type_id, binding.object_id)
current_set.add(key)
current_map[key] = binding
self.to_add = self.planned_set - current_set
self.to_remove = current_set - self.planned_set
self.to_remove_bindings = [current_map[k] for k in self.to_remove]
@property
def succeeded(self):
return len(self.errors) == 0
@property
def total_planned(self):
return len(self.planned_set)
@property
def total_current(self):
return ServiceResourceBinding.objects.filter(service=self.service).count()
+104
View File
@@ -0,0 +1,104 @@
"""Selectors resolve a specification into a queryset of matching NetBox objects."""
import importlib
from django.db.models import Q
from .registry import register_selector
def _get_filterset_for_model(model):
"""Look up a model's FilterSet by convention: {app_label}.filtersets.{ModelName}FilterSet."""
app_label = model._meta.app_label
model_name = model._meta.model_name
class_name = f"{model.__name__}FilterSet"
try:
module = importlib.import_module(f"{app_label}.filtersets")
return getattr(module, class_name, None)
except (ImportError, AttributeError):
return None
@register_selector("netbox_filter")
class NetBoxFilterSelector:
"""Select objects by applying NetBox filter parameters.
Spec shape::
{"type": "netbox_filter", "filters": {"role": "router", "site__in": ["hq"]}}
"""
def __init__(self, spec):
self.spec = spec
self.filters = spec.get("filters", {})
def resolve(self, context, model):
qs = model.objects.all()
filterset_cls = _get_filterset_for_model(model)
if filterset_cls is not None:
fs = filterset_cls(self.filters, queryset=qs, request=None)
qs = fs.qs
else:
qs = qs.filter(**self._build_q(self.filters))
return qs
@staticmethod
def _build_q(filters):
"""Best-effort: turn the filter dict into ORM kwargs (no fancy lookups)."""
return {k: v for k, v in filters.items()}
@register_selector("reference")
class ReferenceSelector:
"""Select a single object via a dotted context path.
Spec shape::
{"type": "reference", "path": "parent.site"}
"""
def __init__(self, spec):
self.spec = spec
self.path = spec.get("path")
def resolve(self, context, model):
obj = context.resolve_path(self.path)
if obj is None:
return model.objects.none()
if isinstance(obj, (list, tuple)):
return model.objects.filter(pk__in=[o.pk for o in obj])
return model.objects.filter(pk=obj.pk)
@register_selector("prefix")
class PrefixSelector:
"""Select ipam.Prefix objects by filter or context reference.
Spec shape (filter)::
{"type": "prefix", "filters": {"site": "hq", "tag": "mgmt"}}
Spec shape (reference)::
{"type": "prefix", "path": "parent.site.management_prefix"}
"""
def __init__(self, spec):
self.spec = spec
self.filters = spec.get("filters")
self.path = spec.get("path")
def resolve(self, context, model):
from ipam.models import Prefix
if self.path:
obj = context.resolve_path(self.path)
if obj is None:
return Prefix.objects.none()
return Prefix.objects.filter(pk=obj.pk)
if self.filters is not None:
filterset_cls = _get_filterset_for_model(Prefix)
qs = Prefix.objects.all()
if filterset_cls is not None:
fs = filterset_cls(self.filters, queryset=qs, request=None)
return fs.qs
return qs.filter(**self.filters)
return Prefix.objects.none()
+42
View File
@@ -0,0 +1,42 @@
import django_filters
from django.db.models import Q
from netbox.filtersets import NetBoxModelFilterSet
from utilities.filtersets import register_filterset
from .models import Service, ServiceTemplate
@register_filterset
class ServiceTemplateFilterSet(NetBoxModelFilterSet):
class Meta:
model = ServiceTemplate
fields = ("id", "name", "status")
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value)
| Q(description__icontains=value),
)
@register_filterset
class ServiceFilterSet(NetBoxModelFilterSet):
service_template = django_filters.ModelMultipleChoiceFilter(
field_name="service_template",
queryset=ServiceTemplate.objects.all(),
)
class Meta:
model = Service
fields = ("id", "name", "status", "service_template", "tenant")
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value)
| Q(description__icontains=value),
)
+197
View File
@@ -0,0 +1,197 @@
import json
import yaml
from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from netbox.forms import (
NetBoxModelBulkEditForm,
NetBoxModelFilterSetForm,
NetBoxModelForm,
NetBoxModelImportForm,
PrimaryModelForm,
)
from tenancy.models import Tenant
from utilities.forms.fields import (
DynamicModelChoiceField,
DynamicModelMultipleChoiceField,
TagFilterField,
)
from utilities.forms.rendering import FieldSet
from .models import (
Service,
ServiceStatusChoices,
ServiceTemplate,
ServiceTemplateStatusChoices,
validate_tree,
)
# ── Service Template ─────────────────────────────────────────────────
class ServiceTemplateForm(PrimaryModelForm):
tree_raw = forms.CharField(
label=_("Requirement tree"),
required=False,
widget=forms.Textarea(
attrs={
"rows": 20,
"class": "font-monospace",
"placeholder": (
'- name: routers\n'
' object_type: dcim.device\n'
' quantity: 2\n'
' selector:\n'
' type: netbox_filter\n'
' filters:\n'
' role: router\n'
' children:\n'
' - name: mgmt-ip\n'
' object_type: ipam.ipaddress\n'
' allocation:\n'
' strategy: first_free\n'
' prefix:\n'
' type: prefix\n'
' filters: {}\n'
),
},
),
help_text=_("Enter the requirement tree as YAML or JSON."),
)
fieldsets = (
FieldSet("name", "status", "tree_raw", name=_("Service Template")),
FieldSet("description", "tags", name=_("Metadata")),
)
class Meta:
model = ServiceTemplate
fields = ("name", "status", "description", "comments")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance and self.instance.pk and self.instance.tree:
self.fields["tree_raw"].initial = yaml.dump(
self.instance.tree, default_flow_style=False, sort_keys=False,
)
def clean(self):
super().clean()
raw = self.cleaned_data.get("tree_raw", "").strip()
if not raw:
self.instance.tree = []
return self.cleaned_data
tree = None
try:
tree = json.loads(raw)
except (json.JSONDecodeError, ValueError):
pass
if tree is None:
try:
tree = yaml.safe_load(raw)
except yaml.YAMLError as exc:
self.add_error("tree_raw", _(f"Invalid YAML or JSON: {exc}"))
return self.cleaned_data
if tree is None:
tree = []
self.instance.tree = tree
self.instance._tree_validated_by_form = True
try:
validate_tree(tree)
except ValidationError as exc:
self.add_error("tree_raw", ValidationError(exc.messages))
return self.cleaned_data
class ServiceTemplateFilterSetForm(NetBoxModelFilterSetForm):
model = ServiceTemplate
name = forms.CharField(required=False)
status = forms.MultipleChoiceField(
choices=ServiceTemplateStatusChoices,
required=False,
)
tag = TagFilterField(model)
class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm):
model = ServiceTemplate
status = forms.ChoiceField(
choices=ServiceTemplateStatusChoices,
required=False,
)
description = forms.CharField(required=False)
nullable_fields = ("description",)
class ServiceTemplateImportForm(NetBoxModelImportForm):
class Meta:
model = ServiceTemplate
fields = ("name", "status", "description", "comments", "tags")
# ── Service ──────────────────────────────────────────────────────────
class ServiceForm(PrimaryModelForm):
service_template = DynamicModelChoiceField(
queryset=ServiceTemplate.objects.all(),
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
)
fieldsets = (
FieldSet("name", "service_template", "tenant", "status", "description", "tags", name=_("Service")),
)
class Meta:
model = Service
fields = ("name", "service_template", "tenant", "status", "description", "comments")
class ServiceFilterSetForm(NetBoxModelFilterSetForm):
model = Service
name = forms.CharField(required=False)
service_template = DynamicModelMultipleChoiceField(
queryset=ServiceTemplate.objects.all(),
required=False,
)
tenant = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(),
required=False,
)
status = forms.MultipleChoiceField(
choices=ServiceStatusChoices,
required=False,
)
tag = TagFilterField(model)
class ServiceBulkEditForm(NetBoxModelBulkEditForm):
model = Service
status = forms.ChoiceField(
choices=ServiceStatusChoices,
required=False,
)
description = forms.CharField(required=False)
nullable_fields = ("description",)
# ── Resolve form ─────────────────────────────────────────────────────
class ResolveForm(forms.Form):
mode = forms.ChoiceField(
choices=(
("dry_run", _("Dry Run")),
("commit", _("Commit")),
),
initial="dry_run",
widget=forms.RadioSelect,
)
@@ -0,0 +1,87 @@
# Generated by Django 6.0.7 on 2026-08-15 20:08
import django.db.models.deletion
import netbox.models.deletion
import taggit.managers
import utilities.json
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('extras', '0140_imageattachment_image_size'),
('tenancy', '0024_default_ordering_indexes'),
('users', '0016_default_ordering_indexes'),
]
operations = [
migrations.CreateModel(
name='ServiceTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(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=utilities.json.CustomFieldJSONEncoder)),
('description', models.CharField(blank=True, max_length=200)),
('comments', models.TextField(blank=True)),
('name', models.CharField(max_length=100, unique=True)),
('status', models.CharField(default='draft', max_length=20)),
('tree', models.JSONField(blank=True, default=list)),
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='netbox_services_templates', to='users.owner')),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
'verbose_name': 'service template',
'verbose_name_plural': 'service templates',
'ordering': ('name',),
},
bases=(netbox.models.deletion.DeleteMixin, models.Model),
),
migrations.CreateModel(
name='Service',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(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=utilities.json.CustomFieldJSONEncoder)),
('description', models.CharField(blank=True, max_length=200)),
('comments', models.TextField(blank=True)),
('name', models.CharField(max_length=100, unique=True)),
('status', models.CharField(default='planned', max_length=20)),
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='netbox_services_services', to='users.owner')),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='netbox_services_services', to='tenancy.tenant')),
('service_template', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='services', to='netbox_services.servicetemplate')),
],
options={
'verbose_name': 'service',
'verbose_name_plural': 'services',
'ordering': ('name',),
},
bases=(netbox.models.deletion.DeleteMixin, models.Model),
),
migrations.CreateModel(
name='ServiceResourceBinding',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(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=utilities.json.CustomFieldJSONEncoder)),
('object_id', models.PositiveBigIntegerField()),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')),
('service', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bindings', to='netbox_services.service')),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
'verbose_name': 'service resource binding',
'verbose_name_plural': 'service resource bindings',
'ordering': ('service', 'content_type', 'object_id'),
'indexes': [models.Index(fields=['content_type', 'object_id'], name='netbox_serv_content_c47db1_idx')],
},
bases=(netbox.models.deletion.DeleteMixin, models.Model),
),
]
Whitespace-only changes.
+255
View File
@@ -0,0 +1,255 @@
import json
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from core.models import ObjectType
from netbox.models import NetBoxModel, PrimaryModel
from utilities.choices import ChoiceSet
class ServiceTemplateStatusChoices(ChoiceSet):
key = "ServiceTemplate.status"
STATUS_DRAFT = "draft"
STATUS_ACTIVE = "active"
STATUS_DISCONTINUED = "discontinued"
CHOICES = [
(STATUS_DRAFT, _("Draft"), "cyan"),
(STATUS_ACTIVE, _("Active"), "green"),
(STATUS_DISCONTINUED, _("Discontinued"), "gray"),
]
class ServiceStatusChoices(ChoiceSet):
key = "Service.status"
STATUS_PLANNED = "planned"
STATUS_ACTIVE = "active"
STATUS_DISCONTINUED = "discontinued"
CHOICES = [
(STATUS_PLANNED, _("Planned"), "cyan"),
(STATUS_ACTIVE, _("Active"), "green"),
(STATUS_DISCONTINUED, _("Discontinued"), "gray"),
]
class RequirementScopeChoices(ChoiceSet):
key = "ServiceRequirement.scope"
SCOPE_SERVICE = "service"
SCOPE_PARENT = "parent"
CHOICES = [
(SCOPE_SERVICE, _("Service"), "blue"),
(SCOPE_PARENT, _("Parent"), "green"),
]
class ResolutionModeChoices(ChoiceSet):
key = "Resolution.mode"
MODE_DRY_RUN = "dry_run"
MODE_COMMIT = "commit"
CHOICES = [
(MODE_DRY_RUN, _("Dry Run"), "cyan"),
(MODE_COMMIT, _("Commit"), "green"),
]
def validate_tree(tree):
"""Validate the declarative requirement tree structure."""
if not isinstance(tree, list):
raise ValidationError({"tree": _("Tree must be a list of requirement nodes.")})
seen_names = set()
def _validate_node(node, path="root"):
if not isinstance(node, dict):
raise ValidationError({"tree": _(f"Node at {path} must be a dict.")})
name = node.get("name")
if not name:
raise ValidationError({"tree": _(f"Node at {path} must have a 'name'.")})
node_path = f"{path}/{name}"
if name in seen_names:
raise ValidationError({"tree": _(f"Duplicate node name '{name}'.")})
seen_names.add(name)
object_type = node.get("object_type")
if not object_type or not isinstance(object_type, str):
raise ValidationError({"tree": _(f"Node '{name}' must have an 'object_type' string (e.g. 'dcim.device').")})
parts = object_type.split(".")
if len(parts) != 2:
raise ValidationError({"tree": _(f"Node '{name}': object_type must be 'app_label.model_name'.")})
try:
ot = ObjectType.objects.get_by_natural_key(parts[0], parts[1])
except ObjectType.DoesNotExist:
raise ValidationError({"tree": _(f"Node '{name}': object_type '{object_type}' not found.")})
if ot.model_class() is None:
raise ValidationError({"tree": _(f"Node '{name}': object_type '{object_type}' has no model class.")})
quantity = node.get("quantity", 1)
if not isinstance(quantity, int) or quantity < 1:
raise ValidationError({"tree": _(f"Node '{name}': quantity must be a positive integer.")})
selector = node.get("selector")
allocation = node.get("allocation")
if not selector and not allocation:
raise ValidationError({"tree": _(f"Node '{name}': must have either 'selector' or 'allocation'.")})
if selector and not isinstance(selector, dict):
raise ValidationError({"tree": _(f"Node '{name}': selector must be a dict.")})
if allocation and not isinstance(allocation, dict):
raise ValidationError({"tree": _(f"Node '{name}': allocation must be a dict.")})
scope = node.get("scope", "parent")
if scope not in RequirementScopeChoices.values():
raise ValidationError({"tree": _(f"Node '{name}': scope must be 'service' or 'parent'.")})
children = node.get("children", [])
if not isinstance(children, list):
raise ValidationError({"tree": _(f"Node '{name}': children must be a list.")})
for i, child in enumerate(children):
_validate_node(child, node_path)
for i, node in enumerate(tree):
_validate_node(node, f"root[{i}]")
class ServiceTemplate(PrimaryModel):
name = models.CharField(
verbose_name=_("name"),
max_length=100,
unique=True,
)
status = models.CharField(
verbose_name=_("status"),
max_length=20,
choices=ServiceTemplateStatusChoices,
default=ServiceTemplateStatusChoices.STATUS_DRAFT,
)
tree = models.JSONField(
verbose_name=_("requirement tree"),
default=list,
blank=True,
)
owner = models.ForeignKey(
to="users.Owner",
on_delete=models.PROTECT,
blank=True,
null=True,
related_name="netbox_services_templates",
)
clone_fields = ("description", "comments", "tree")
class Meta:
ordering = ("name",)
verbose_name = _("service template")
verbose_name_plural = _("service templates")
def __str__(self):
return self.name
def get_absolute_url(self):
from utilities.views import get_viewname
return reverse(get_viewname(self), args=[self.pk])
@property
def is_locked(self):
return self.services.exists()
def clean(self):
super().clean()
if not getattr(self, "_tree_validated_by_form", False):
try:
validate_tree(self.tree)
except ValidationError as e:
raise ValidationError(e.messages)
class Service(PrimaryModel):
service_template = models.ForeignKey(
to=ServiceTemplate,
on_delete=models.PROTECT,
related_name="services",
)
name = models.CharField(
verbose_name=_("name"),
max_length=100,
unique=True,
)
tenant = models.ForeignKey(
to="tenancy.Tenant",
on_delete=models.PROTECT,
related_name="netbox_services_services",
verbose_name=_("tenant"),
)
status = models.CharField(
verbose_name=_("status"),
max_length=20,
choices=ServiceStatusChoices,
default=ServiceStatusChoices.STATUS_PLANNED,
)
owner = models.ForeignKey(
to="users.Owner",
on_delete=models.PROTECT,
blank=True,
null=True,
related_name="netbox_services_services",
)
clone_fields = ("service_template", "tenant", "description", "comments")
class Meta:
ordering = ("name",)
verbose_name = _("service")
verbose_name_plural = _("services")
def __str__(self):
return self.name
def get_absolute_url(self):
from utilities.views import get_viewname
return reverse(get_viewname(self), args=[self.pk])
class ServiceResourceBinding(NetBoxModel):
_netbox_private = True
service = models.ForeignKey(
to=Service,
on_delete=models.CASCADE,
related_name="bindings",
)
content_type = models.ForeignKey(
to=ContentType,
on_delete=models.PROTECT,
related_name="+",
)
object_id = models.PositiveBigIntegerField()
content_object = GenericForeignKey("content_type", "object_id")
class Meta:
ordering = ("service", "content_type", "object_id")
verbose_name = _("service resource binding")
verbose_name_plural = _("service resource bindings")
indexes = [
models.Index(fields=["content_type", "object_id"]),
]
def __str__(self):
obj = self.content_object
label = str(obj) if obj is not None else f"{self.content_type} #{self.object_id}"
return f"{self.service} → {label}"
+24
View File
@@ -0,0 +1,24 @@
from django.utils.translation import gettext_lazy as _
from netbox.plugins import PluginMenu, PluginMenuItem
templates_item = PluginMenuItem(
link="plugins:netbox_services:servicetemplate_list",
link_text=_("Service Templates"),
permissions=["netbox_services.view_servicetemplate"],
buttons=(),
)
services_item = PluginMenuItem(
link="plugins:netbox_services:service_list",
link_text=_("Services"),
permissions=["netbox_services.view_service"],
buttons=(),
)
menu = PluginMenu(
label=_("Services"),
groups=(
(_("Services"), (templates_item, services_item)),
),
icon_class="mdi mdi-file-tree",
)
+23
View File
@@ -0,0 +1,23 @@
from netbox.search import SearchIndex, register_search
from .models import Service, ServiceTemplate
@register_search
class ServiceTemplateSearchIndex(SearchIndex):
model = ServiceTemplate
fields = (
("name", 100),
("description", 500),
)
display_attrs = ("status",)
@register_search
class ServiceSearchIndex(SearchIndex):
model = Service
fields = (
("name", 100),
("description", 500),
)
display_attrs = ("service_template", "status", "tenant")
+37
View File
@@ -0,0 +1,37 @@
from django.db.models.signals import pre_delete, pre_save
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _
from .models import Service, ServiceTemplate
class TemplateLockedError(Exception):
"""Raised when attempting to modify a locked template."""
def __init__(self, obj, message=None):
self.obj = obj
self.message = message or _(
"Cannot modify {}: it is locked because one or more services "
"have been spawned from it."
).format(obj)
super().__init__(self.message)
@receiver(pre_save, sender=ServiceTemplate)
def lock_service_template_save(sender, instance, **kwargs):
if not instance.pk:
return
if instance.services.exists():
try:
old = ServiceTemplate.objects.get(pk=instance.pk)
except ServiceTemplate.DoesNotExist:
return
for field in ("name", "tree"):
if getattr(instance, field) != getattr(old, field):
raise TemplateLockedError(instance)
@receiver(pre_delete, sender=ServiceTemplate)
def lock_service_template_delete(sender, instance, **kwargs):
if instance.services.exists():
raise TemplateLockedError(instance)
+39
View File
@@ -0,0 +1,39 @@
import django_tables2 as tables
from django.utils.translation import gettext_lazy as _
from netbox.tables import NetBoxTable
from netbox.tables.columns import (
ChoiceFieldColumn,
TagColumn,
)
from .models import Service, ServiceTemplate
class ServiceTemplateTable(NetBoxTable):
name = tables.Column(linkify=True)
status = ChoiceFieldColumn()
services_count = tables.Column(
verbose_name=_("Services"),
accessor="services__count",
orderable=False,
)
tags = TagColumn()
class Meta(NetBoxTable.Meta):
model = ServiceTemplate
fields = ("pk", "id", "name", "status", "description", "services_count", "tags")
default_columns = ("pk", "name", "status", "description", "services_count")
class ServiceTable(NetBoxTable):
name = tables.Column(linkify=True)
service_template = tables.Column(linkify=True)
tenant = tables.Column(linkify=True)
status = ChoiceFieldColumn()
tags = TagColumn()
class Meta(NetBoxTable.Meta):
model = Service
fields = ("pk", "id", "name", "service_template", "tenant", "status", "description", "tags")
default_columns = ("pk", "name", "service_template", "tenant", "status", "description")
@@ -0,0 +1,29 @@
{% load helpers %}
{% load i18n %}
{% if nodes %}
<ul class="tree" style="list-style: none; padding-left: {{ depth|add:'20' }}px;">
{% for node in nodes %}
<li>
<strong>{{ node.name }}</strong>
— <code>{{ node.object_type }}</code>
<span class="badge bg-secondary">{{ node.quantity|default:1 }}×</span>
<span class="badge bg-info">{{ node.scope|default:'parent' }}</span>
{% if node.selector %}
<details class="d-inline">
<summary class="badge bg-primary">{% trans "selector" %}</summary>
<pre class="small mt-1">{{ node.selector|json }}</pre>
</details>
{% endif %}
{% if node.allocation %}
<details class="d-inline">
<summary class="badge bg-success">{% trans "allocation" %}</summary>
<pre class="small mt-1">{{ node.allocation|json }}</pre>
</details>
{% endif %}
{% if node.children %}
{% include 'netbox_services/_tree_node.html' with nodes=node.children depth=depth|add:'1' %}
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
@@ -0,0 +1,80 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load i18n %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-7">
<div class="card">
<h5 class="card-header">{% trans "Service" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Service Template" %}</th>
<td><a href="{{ object.service_template.get_absolute_url }}">{{ object.service_template }}</a></td>
</tr>
<tr>
<th scope="row">{% trans "Tenant" %}</th>
<td>{{ object.tenant|default:'—' }}</td>
</tr>
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{{ object.get_status_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
</div>
</div>
</div>
<div class="col col-md-5">
<div class="card">
<h5 class="card-header">{% trans "Actions" %}</h5>
<div class="card-body">
<a href="{% url 'plugins:netbox_services:service_resolve' pk=object.pk %}" class="btn btn-primary mb-2">
<i class="mdi mdi-play"></i> {% trans "Resolve" %}
</a>
<p class="text-muted small mb-0">
{% trans "Run the resolver to preview and/or reconcile the service's resource bindings." %}
</p>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">{% trans "Resource Bindings" %}</h5>
<div class="card-body">
{% if bindings %}
<table class="table table-sm table-hover">
<thead>
<tr>
<th>{% trans "Object Type" %}</th>
<th>{% trans "Object" %}</th>
</tr>
</thead>
<tbody>
{% for binding in bindings %}
<tr>
<td>{{ binding.content_type }}</td>
<td>{{ binding.content_object|default:'—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<span class="text-muted">{% trans "No bindings. Resolve the service to create bindings." %}</span>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,119 @@
{% extends 'base/layout.html' %}
{% load helpers %}
{% load i18n %}
{% block title %}{{ service }} — {% trans "Resolve" %}{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">
{% trans "Resolution Preview" %} — {{ service }}
</h5>
<div class="card-body">
{% if result.errors %}
<div class="alert alert-danger">
<h6>{% trans "Errors" %}</h6>
<ul>
{% for error in result.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if result.warnings %}
<div class="alert alert-warning">
<h6>{% trans "Warnings" %}</h6>
<ul>
{% for warning in result.warnings %}
<li>{{ warning }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<div class="row mb-3">
<div class="col col-md-4">
<div class="card border-success">
<div class="card-body text-center">
<h3 class="text-success">{{ result.to_add|length }}</h3>
<p class="mb-0">{% trans "to add" %}</p>
</div>
</div>
</div>
<div class="col col-md-4">
<div class="card border-danger">
<div class="card-body text-center">
<h3 class="text-danger">{{ result.to_remove|length }}</h3>
<p class="mb-0">{% trans "to remove" %}</p>
</div>
</div>
</div>
<div class="col col-md-4">
<div class="card border-secondary">
<div class="card-body text-center">
<h3 class="text-secondary">{{ result.total_planned|add:'0' }}</h3>
<p class="mb-0">{% trans "total planned" %}</p>
</div>
</div>
</div>
</div>
{% if planned_preview %}
<table class="table table-sm table-hover">
<thead>
<tr>
<th>{% trans "Node" %}</th>
<th>{% trans "Object" %}</th>
<th>{% trans "Action" %}</th>
</tr>
</thead>
<tbody>
{% for item in planned_preview %}
<tr>
<td>{{ item.node }}</td>
<td>{{ item.object }}</td>
<td>
{% if item.action == "add" %}
<span class="badge bg-success">{% trans "add" %}</span>
{% else %}
<span class="badge bg-secondary">{% trans "keep" %}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted">{% trans "No objects would be bound." %}</p>
{% endif %}
</div>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">{% trans "Commit" %}</h5>
<div class="card-body">
<p class="text-muted">
{% trans "Committing will add/remove bindings. Allocated objects (e.g. IP addresses) are not deleted on removal — only the binding is removed." %}
</p>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn btn-success">
<i class="mdi mdi-check"></i> {% trans "Commit" %}
</button>
<a href="{% url 'plugins:netbox_services:service' pk=service.pk %}" class="btn btn-secondary">
{% trans "Cancel" %}
</a>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,67 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load i18n %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-7">
<div class="card">
<h5 class="card-header">{% trans "Service Template" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{{ object.get_status_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Locked" %}</th>
<td>
{% if is_locked %}
<span class="text-warning">{% trans "Yes — services have been spawned" %}</span>
{% else %}
<span class="text-success">{% trans "No — editable" %}</span>
{% endif %}
</td>
</tr>
</table>
</div>
</div>
</div>
<div class="col col-md-5">
<div class="card">
<h5 class="card-header">{% trans "Stats" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Services" %}</th>
<td><a href="{% url 'plugins:netbox_services:service_list' %}?service_template={{ object.pk }}">{{ object.services.count }}</a></td>
</tr>
</table>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">{% trans "Requirement Tree" %}</h5>
<div class="card-body">
{% if object.tree %}
{% include 'netbox_services/_tree_node.html' with nodes=object.tree depth=0 %}
{% else %}
<span class="text-muted">{% trans "No requirements defined." %}</span>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
View File
Whitespace-only changes.
+171
View File
@@ -0,0 +1,171 @@
import pytest
from netbox_services.models import Service, ServiceResourceBinding, ServiceTemplate
@pytest.fixture
def tenant(db):
from tenancy.models import Tenant
return Tenant.objects.create(name="test-tenant", slug="test-tenant")
@pytest.fixture
def service_template(db):
return ServiceTemplate.objects.create(
name="test-template",
status="draft",
description="A test service template",
tree=[
{
"name": "device",
"object_type": "dcim.device",
"quantity": 1,
"selector": {"type": "netbox_filter", "filters": {}},
"scope": "service",
"children": [],
}
],
)
@pytest.fixture
def service(db, service_template, tenant):
return Service.objects.create(
name="test-service",
service_template=service_template,
tenant=tenant,
status="planned",
)
@pytest.fixture
def admin_user(db):
from users.models import User
return User.objects.create_superuser("test-admin", "admin@test.local", "test")
@pytest.fixture
def admin_client(db, admin_user):
from django.test import Client
client = Client()
client.force_login(admin_user)
return client
@pytest.fixture
def api_client(db, admin_user):
from rest_framework.test import APIClient
client = APIClient()
client.force_authenticate(user=admin_user)
return client
@pytest.mark.django_db
class TestServiceTemplate:
def test_str(self, service_template):
assert str(service_template) == "test-template"
def test_default_status(self, db):
st = ServiceTemplate.objects.create(name="default-status")
assert st.status == "draft"
def test_default_tree(self, db):
st = ServiceTemplate.objects.create(name="default-tree")
assert st.tree == []
def test_is_locked_false(self, service_template):
assert service_template.is_locked is False
def test_is_locked_true(self, service_template, service):
assert service_template.is_locked is True
def test_ordering(self, db):
ServiceTemplate.objects.create(name="z-last")
ServiceTemplate.objects.create(name="a-first")
names = list(ServiceTemplate.objects.values_list("name", flat=True))
assert names == ["a-first", "z-last"]
def test_tree_validation_valid(self, db):
st = ServiceTemplate(
name="valid-tree",
tree=[{
"name": "dev",
"object_type": "dcim.device",
"quantity": 1,
"selector": {"type": "netbox_filter", "filters": {}},
"scope": "service",
"children": [],
}],
)
st.clean()
def test_tree_validation_missing_name(self, db):
from django.core.exceptions import ValidationError
st = ServiceTemplate(
name="bad-tree",
tree=[{"object_type": "dcim.device", "quantity": 1, "selector": {"type": "netbox_filter", "filters": {}}}],
)
with pytest.raises(ValidationError):
st.clean()
def test_tree_validation_missing_object_type(self, db):
from django.core.exceptions import ValidationError
st = ServiceTemplate(
name="bad-tree2",
tree=[{"name": "dev", "quantity": 1, "selector": {"type": "netbox_filter", "filters": {}}}],
)
with pytest.raises(ValidationError):
st.clean()
def test_tree_validation_no_selector_or_allocation(self, db):
from django.core.exceptions import ValidationError
st = ServiceTemplate(
name="bad-tree3",
tree=[{"name": "dev", "object_type": "dcim.device", "quantity": 1}],
)
with pytest.raises(ValidationError):
st.clean()
@pytest.mark.django_db
class TestService:
def test_str(self, service):
assert str(service) == "test-service"
def test_default_status(self, db, service_template, tenant):
s = Service.objects.create(
name="status-test", service_template=service_template, tenant=tenant,
)
assert s.status == "planned"
def test_tenant_required(self, db, service_template):
from django.db import IntegrityError
with pytest.raises(IntegrityError):
Service.objects.create(
name="no-tenant", service_template=service_template,
)
def test_protect_on_template_delete(self, service, service_template):
from django.db.models import ProtectedError
with pytest.raises(ProtectedError):
service_template.delete()
@pytest.mark.django_db
class TestServiceResourceBinding:
def test_str(self, service):
from dcim.models import Site
from django.contrib.contenttypes.models import ContentType
site = Site.objects.create(name="bind-test-site", slug="bind-test-site")
ct = ContentType.objects.get_for_model(Site)
binding = ServiceResourceBinding.objects.create(
service=service,
content_type=ct,
object_id=site.pk,
)
assert "test-service" in str(binding)
assert "bind-test-site" in str(binding)
def test_private(self):
assert ServiceResourceBinding._netbox_private is True
+43
View File
@@ -0,0 +1,43 @@
import pytest
from netbox_services.engine.allocators import FirstFreeIPAddressAllocator, SpecificAllocator
@pytest.mark.django_db
class TestFirstFreeIPAddressAllocator:
def test_allocate_creates_ip(self, db):
from ipam.models import IPAddress, Prefix
from netbox_services.engine.context import Context
Prefix.objects.create(prefix="10.99.0.0/24")
allocator = FirstFreeIPAddressAllocator({
"strategy": "first_free",
"prefix": {"type": "prefix", "filters": {}},
})
ip = allocator.allocate(Context(service=None))
assert ip is not None
assert "10.99.0." in str(ip.address)
def test_no_prefix_raises(self, db):
from netbox_services.engine.context import Context
allocator = FirstFreeIPAddressAllocator({
"strategy": "first_free",
"prefix": {"type": "prefix", "filters": {"description": "no-such-prefix"}},
})
with pytest.raises(ValueError):
allocator.allocate(Context(service=None))
@pytest.mark.django_db
class TestSpecificAllocator:
def test_specific_address(self, db):
from netbox_services.engine.context import Context
allocator = SpecificAllocator({"strategy": "specific", "address": "10.50.0.1/24"})
ip = allocator.allocate(Context(service=None))
assert str(ip.address) == "10.50.0.1/24"
def test_specific_requires_address_or_path(self, db):
from netbox_services.engine.context import Context
allocator = SpecificAllocator({"strategy": "specific"})
with pytest.raises(ValueError):
allocator.allocate(Context(service=None))
+111
View File
@@ -0,0 +1,111 @@
import pytest
from netbox_services.models import Service, ServiceTemplate
@pytest.mark.django_db
class TestServiceTemplateAPI:
def test_list(self, api_client):
ServiceTemplate.objects.create(name="api-tmpl")
resp = api_client.get("/api/plugins/services/service-templates/")
assert resp.status_code == 200
assert resp.data["count"] >= 1
def test_create_with_tree(self, api_client):
resp = api_client.post("/api/plugins/services/service-templates/", {
"name": "api-created-tmpl",
"status": "draft",
"tree": [{"name": "dev", "object_type": "dcim.device", "quantity": 1, "selector": {"type": "netbox_filter", "filters": {}}, "scope": "service", "children": []}],
}, format="json")
assert resp.status_code == 201
assert resp.data["name"] == "api-created-tmpl"
assert len(resp.data["tree"]) == 1
def test_retrieve(self, api_client):
st = ServiceTemplate.objects.create(name="api-retrieve-tmpl")
resp = api_client.get(f"/api/plugins/services/service-templates/{st.pk}/")
assert resp.status_code == 200
def test_delete(self, api_client):
st = ServiceTemplate.objects.create(name="api-delete-tmpl")
resp = api_client.delete(f"/api/plugins/services/service-templates/{st.pk}/")
assert resp.status_code == 204
@pytest.mark.django_db
class TestServiceAPI:
def test_list(self, api_client, service_template, tenant):
Service.objects.create(name="api-svc", service_template=service_template, tenant=tenant)
resp = api_client.get("/api/plugins/services/services/")
assert resp.status_code == 200
assert resp.data["count"] >= 1
def test_create(self, api_client, service_template, tenant):
resp = api_client.post("/api/plugins/services/services/", {
"name": "api-created-svc",
"service_template": service_template.pk,
"tenant": tenant.pk,
"status": "planned",
}, format="json")
assert resp.status_code == 201
def test_resolve_dry_run(self, api_client, db, tenant):
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
site = Site.objects.create(name="api-res-site", slug="api-res-site")
mfr = Manufacturer.objects.create(name="api-res-mfr", slug="api-res-mfr")
dt = DeviceType.objects.create(manufacturer=mfr, model="api-model", slug="api-model")
role = DeviceRole.objects.create(name="api-role", slug="api-role")
Device.objects.create(name="api-dev", site=site, device_type=dt, role=role)
tmpl = ServiceTemplate.objects.create(
name="api-res-tmpl",
status="active",
tree=[{
"name": "device",
"object_type": "dcim.device",
"quantity": 1,
"selector": {"type": "netbox_filter", "filters": {"role": ["api-role"]}},
"scope": "service",
"children": [],
}],
)
svc = Service.objects.create(name="api-res-svc", service_template=tmpl, tenant=tenant)
resp = api_client.post(f"/api/plugins/services/services/{svc.pk}/resolve/", {
"mode": "dry_run",
}, format="json")
assert resp.status_code == 200
assert resp.data["mode"] == "dry_run"
assert resp.data["total_planned"] == 1
def test_resolve_commit(self, api_client, db, tenant):
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from netbox_services.models import ServiceResourceBinding
site = Site.objects.create(name="api-commit-site", slug="api-commit-site")
mfr = Manufacturer.objects.create(name="api-commit-mfr", slug="api-commit-mfr")
dt = DeviceType.objects.create(manufacturer=mfr, model="api-commit-model", slug="api-commit-model")
role = DeviceRole.objects.create(name="api-commit-role", slug="api-commit-role")
Device.objects.create(name="api-commit-dev", site=site, device_type=dt, role=role)
tmpl = ServiceTemplate.objects.create(
name="api-commit-tmpl",
status="active",
tree=[{
"name": "device",
"object_type": "dcim.device",
"quantity": 1,
"selector": {"type": "netbox_filter", "filters": {"role": ["api-commit-role"]}},
"scope": "service",
"children": [],
}],
)
svc = Service.objects.create(name="api-commit-svc", service_template=tmpl, tenant=tenant)
resp = api_client.post(f"/api/plugins/services/services/{svc.pk}/resolve/", {
"mode": "commit",
}, format="json")
assert resp.status_code == 200
assert resp.data["mode"] == "commit"
assert ServiceResourceBinding.objects.filter(service=svc).count() == 1
+46
View File
@@ -0,0 +1,46 @@
import pytest
from netbox_services.models import Service, ServiceTemplate
from netbox_services.signals import TemplateLockedError
@pytest.fixture
def template_with_service(db, tenant):
st = ServiceTemplate.objects.create(name="locked-tmpl", status="active", tree=[])
Service.objects.create(name="locked-svc", service_template=st, tenant=tenant)
return st
@pytest.fixture
def tenant(db):
from tenancy.models import Tenant
return Tenant.objects.create(name="lock-tenant", slug="lock-tenant")
@pytest.mark.django_db
class TestTemplateLock:
def test_cannot_edit_name_when_locked(self, template_with_service):
template_with_service.name = "renamed"
with pytest.raises(TemplateLockedError):
template_with_service.save()
def test_cannot_edit_tree_when_locked(self, template_with_service):
template_with_service.tree = [{"name": "x", "object_type": "dcim.device", "quantity": 1, "selector": {"type": "netbox_filter", "filters": {}}}]
with pytest.raises(TemplateLockedError):
template_with_service.save()
def test_cannot_delete_when_locked(self, template_with_service):
from django.db.models import ProtectedError
with pytest.raises((ProtectedError, TemplateLockedError)):
template_with_service.delete()
def test_can_edit_when_unlocked(self, db):
st = ServiceTemplate.objects.create(name="unlocked", status="draft", tree=[])
st.name = "renamed-unlocked"
st.save()
assert ServiceTemplate.objects.get(pk=st.pk).name == "renamed-unlocked"
def test_can_delete_when_unlocked(self, db):
st = ServiceTemplate.objects.create(name="deletable", tree=[])
st.delete()
assert not ServiceTemplate.objects.filter(pk=st.pk).exists()
+244
View File
@@ -0,0 +1,244 @@
import pytest
from netbox_services.engine.resolver import Resolver, ConflictError
from netbox_services.models import Service, ServiceResourceBinding, ServiceTemplate
@pytest.fixture
def site(db):
from dcim.models import Site
return Site.objects.create(name="resolver-site", slug="resolver-site")
@pytest.fixture
def device_type(db):
from dcim.models import DeviceType, Manufacturer
mfr = Manufacturer.objects.create(name="resolver-mfr", slug="resolver-mfr")
return DeviceType.objects.create(manufacturer=mfr, model="resolver-model", slug="resolver-model")
@pytest.fixture
def role(db):
from dcim.models import DeviceRole
return DeviceRole.objects.create(name="resolver-role", slug="resolver-role")
@pytest.fixture
def devices(site, device_type, role, db):
from dcim.models import Device
return [
Device.objects.create(name="dev-a", site=site, device_type=device_type, role=role),
Device.objects.create(name="dev-b", site=site, device_type=device_type, role=role),
]
@pytest.fixture
def prefix(db):
from ipam.models import Prefix
return Prefix.objects.create(prefix="10.200.0.0/24")
@pytest.fixture
def tenant(db):
from tenancy.models import Tenant
return Tenant.objects.create(name="res-tenant", slug="res-tenant")
@pytest.fixture
def service_with_devices(db, devices, tenant):
tmpl = ServiceTemplate.objects.create(
name="dev-tmpl",
status="active",
tree=[{
"name": "routers",
"object_type": "dcim.device",
"quantity": 2,
"selector": {"type": "netbox_filter", "filters": {"role": ["resolver-role"]}},
"scope": "service",
"children": [],
}],
)
return Service.objects.create(name="dev-svc", service_template=tmpl, tenant=tenant)
@pytest.fixture
def service_with_ip_alloc(db, prefix, tenant):
tmpl = ServiceTemplate.objects.create(
name="ip-tmpl",
status="active",
tree=[{
"name": "ip",
"object_type": "ipam.ipaddress",
"quantity": 1,
"allocation": {
"strategy": "first_free",
"prefix": {"type": "prefix", "filters": {}},
},
"scope": "service",
"children": [],
}],
)
return Service.objects.create(name="ip-svc", service_template=tmpl, tenant=tenant)
@pytest.mark.django_db
class TestResolverDryRun:
def test_dry_run_no_bindings(self, service_with_devices):
resolver = Resolver()
result = resolver.resolve(service_with_devices, mode="dry_run")
assert result.total_planned == 2
assert ServiceResourceBinding.objects.filter(service=service_with_devices).count() == 0
def test_dry_run_rolls_back_allocation(self, service_with_ip_alloc):
from ipam.models import IPAddress
resolver = Resolver()
result = resolver.resolve(service_with_ip_alloc, mode="dry_run")
assert result.total_planned == 1
assert IPAddress.objects.count() == 0
assert ServiceResourceBinding.objects.count() == 0
@pytest.mark.django_db
class TestResolverCommit:
def test_commit_creates_bindings(self, service_with_devices):
resolver = Resolver()
result = resolver.resolve(service_with_devices, mode="commit")
assert result.succeeded
assert ServiceResourceBinding.objects.filter(service=service_with_devices).count() == 2
def test_commit_allocates_ip(self, service_with_ip_alloc):
from ipam.models import IPAddress
resolver = Resolver()
result = resolver.resolve(service_with_ip_alloc, mode="commit")
assert IPAddress.objects.count() == 1
assert ServiceResourceBinding.objects.filter(service=service_with_ip_alloc).count() == 1
def test_commit_nested(self, db, devices, prefix, tenant):
from ipam.models import IPAddress
from dcim.models import Interface
for dev in devices:
Interface.objects.create(device=dev, name="eth0")
tmpl = ServiceTemplate.objects.create(
name="nested-tmpl",
status="active",
tree=[{
"name": "routers",
"object_type": "dcim.device",
"quantity": 1,
"selector": {"type": "netbox_filter", "filters": {"role": ["resolver-role"]}},
"scope": "service",
"children": [{
"name": "ip",
"object_type": "ipam.ipaddress",
"quantity": 1,
"allocation": {
"strategy": "first_free",
"prefix": {"type": "prefix", "filters": {}},
},
"scope": "parent",
"children": [],
}],
}],
)
svc = Service.objects.create(name="nested-svc", service_template=tmpl, tenant=tenant)
resolver = Resolver()
result = resolver.resolve(svc, mode="commit")
assert result.succeeded
assert ServiceResourceBinding.objects.filter(service=svc).count() == 2
@pytest.mark.django_db
class TestReconciliation:
def test_reresolve_no_change(self, service_with_devices):
resolver = Resolver()
resolver.resolve(service_with_devices, mode="commit")
result = resolver.resolve(service_with_devices, mode="commit")
assert len(result.to_add) == 0
assert len(result.to_remove) == 0
assert ServiceResourceBinding.objects.filter(service=service_with_devices).count() == 2
def test_reresolve_reuses_allocated(self, service_with_ip_alloc):
from ipam.models import IPAddress
resolver = Resolver()
resolver.resolve(service_with_ip_alloc, mode="commit")
ip_count_before = IPAddress.objects.count()
result = resolver.resolve(service_with_ip_alloc, mode="commit")
assert len(result.to_add) == 0
assert IPAddress.objects.count() == ip_count_before
def test_reresolve_removes_stale(self, service_with_devices, devices):
resolver = Resolver()
result = resolver.resolve(service_with_devices, mode="commit")
assert ServiceResourceBinding.objects.filter(service=service_with_devices).count() == 2
svc_tmpl = service_with_devices.service_template
svc_tmpl.tree[0]["quantity"] = 1
# Template is locked — bypass via queryset
ServiceTemplate.objects.filter(pk=svc_tmpl.pk).update(tree=svc_tmpl.tree)
service_with_devices.refresh_from_db()
service_with_devices.service_template.refresh_from_db()
result2 = resolver.resolve(service_with_devices, mode="commit")
assert len(result2.to_remove) >= 1
assert ServiceResourceBinding.objects.filter(service=service_with_devices).count() == 1
@pytest.mark.django_db
class TestConflictDetection:
def test_exclusion_when_device_already_bound(self, db, site, device_type, role, tenant):
from dcim.models import Device
dev = Device.objects.create(name="conflict-dev", site=site, device_type=device_type, role=role)
tmpl = ServiceTemplate.objects.create(
name="conflict-tmpl",
status="active",
tree=[{
"name": "dev",
"object_type": "dcim.device",
"quantity": 1,
"selector": {"type": "netbox_filter", "filters": {"role": ["resolver-role"]}},
"scope": "service",
"children": [],
}],
)
svc1 = Service.objects.create(name="c-svc-1", service_template=tmpl, tenant=tenant)
svc2 = Service.objects.create(name="c-svc-2", service_template=tmpl, tenant=tenant)
resolver = Resolver()
r1 = resolver.resolve(svc1, mode="commit")
assert r1.succeeded
assert ServiceResourceBinding.objects.filter(service=svc1).count() == 1
r2 = resolver.resolve(svc2, mode="commit")
assert r2.succeeded
assert ServiceResourceBinding.objects.filter(service=svc2).count() == 0
assert any("only 0 of 1" in w for w in r2.warnings)
def test_no_conflict_different_devices(self, db, devices, tenant):
tmpl = ServiceTemplate.objects.create(
name="noconf-tmpl",
status="active",
tree=[{
"name": "dev",
"object_type": "dcim.device",
"quantity": 1,
"selector": {"type": "netbox_filter", "filters": {"role": ["resolver-role"]}},
"scope": "service",
"children": [],
}],
)
svc1 = Service.objects.create(name="nc-svc-1", service_template=tmpl, tenant=tenant)
svc2 = Service.objects.create(name="nc-svc-2", service_template=tmpl, tenant=tenant)
resolver = Resolver()
r1 = resolver.resolve(svc1, mode="commit")
assert r1.succeeded
r2 = resolver.resolve(svc2, mode="commit")
assert r2.succeeded
b1 = ServiceResourceBinding.objects.filter(service=svc1).first()
b2 = ServiceResourceBinding.objects.filter(service=svc2).first()
assert b1.object_id != b2.object_id
+76
View File
@@ -0,0 +1,76 @@
import pytest
from netbox_services.engine.context import Context
from netbox_services.engine.selectors import NetBoxFilterSelector, ReferenceSelector
@pytest.mark.django_db
class TestContext:
def test_resolve_service(self, service):
ctx = Context(service=service)
assert ctx.resolve_path("service") is service
def test_resolve_parent_none(self, service):
ctx = Context(service=service)
assert ctx.resolve_path("parent") is None
def test_resolve_dotted_path(self, service, service_template):
ctx = Context(service=service)
assert ctx.resolve_path("service.service_template") is service_template
def test_resolve_deep_path(self, service, service_template):
ctx = Context(service=service)
assert ctx.resolve_path("service.service_template.name") == service_template.name
def test_resolve_missing_attr(self, service):
ctx = Context(service=service)
assert ctx.resolve_path("service.nonexistent") is None
def test_child_context(self, service):
ctx = Context(service=service)
parent_obj = object()
child = ctx.child(parent_obj)
assert child.service is service
assert child.parent_object is parent_obj
@pytest.mark.django_db
class TestNetBoxFilterSelector:
def test_filter_devices(self, db):
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
site = Site.objects.create(name="sel-site", slug="sel-site")
mfr = Manufacturer.objects.create(name="sel-mfr", slug="sel-mfr")
dt = DeviceType.objects.create(manufacturer=mfr, model="sel-model", slug="sel-model")
role = DeviceRole.objects.create(name="sel-router", slug="sel-router")
Device.objects.create(name="r1", site=site, device_type=dt, role=role)
Device.objects.create(name="r2", site=site, device_type=dt, role=role)
sel = NetBoxFilterSelector({"type": "netbox_filter", "filters": {"role": ["sel-router"]}})
qs = sel.resolve(Context(service=None), Device)
assert qs.count() == 2
@pytest.mark.django_db
class TestReferenceSelector:
def test_resolve_reference(self, db):
from dcim.models import Site
site = Site.objects.create(name="ref-site", slug="ref-site")
ctx = Context(service=None, parent_object=site)
sel = ReferenceSelector({"type": "reference", "path": "parent"})
qs = sel.resolve(ctx, Site)
assert qs.first() == site
@pytest.mark.django_db
class TestPrefixSelector:
def test_prefix_by_filter(self, db):
from ipam.models import Prefix
from netbox_services.engine.selectors import PrefixSelector
Prefix.objects.create(prefix="10.0.0.0/24")
Prefix.objects.create(prefix="10.1.0.0/24")
sel = PrefixSelector({"type": "prefix", "filters": {}})
qs = sel.resolve(Context(service=None), Prefix)
assert qs.count() == 2
+33
View File
@@ -0,0 +1,33 @@
import pytest
from netbox_services.models import Service, ServiceTemplate
@pytest.mark.django_db
class TestViews:
def test_template_list(self, admin_client):
ServiceTemplate.objects.create(name="view-tmpl")
resp = admin_client.get("/plugins/services/service-templates/")
assert resp.status_code == 200
def test_template_detail(self, admin_client):
st = ServiceTemplate.objects.create(name="view-detail-tmpl", tree=[])
resp = admin_client.get(f"/plugins/services/service-templates/{st.pk}/")
assert resp.status_code == 200
def test_template_add(self, admin_client):
resp = admin_client.get("/plugins/services/service-templates/add/")
assert resp.status_code == 200
def test_service_list(self, admin_client, service_template, tenant):
Service.objects.create(name="view-svc", service_template=service_template, tenant=tenant)
resp = admin_client.get("/plugins/services/services/")
assert resp.status_code == 200
def test_service_detail(self, admin_client, service):
resp = admin_client.get(f"/plugins/services/services/{service.pk}/")
assert resp.status_code == 200
def test_service_resolve_get(self, admin_client, service):
resp = admin_client.get(f"/plugins/services/services/{service.pk}/resolve/")
assert resp.status_code == 200
+11
View File
@@ -0,0 +1,11 @@
from django.urls import include, path
from utilities.urls import get_model_urls
from . import views # noqa: F401 — ensure @register_model_view decorators run
urlpatterns = [
path("service-templates/", include(get_model_urls("netbox_services", "servicetemplate", detail=False))),
path("service-templates/<int:pk>/", include(get_model_urls("netbox_services", "servicetemplate"))),
path("services/", include(get_model_urls("netbox_services", "service", detail=False))),
path("services/<int:pk>/", include(get_model_urls("netbox_services", "service"))),
]
+193
View File
@@ -0,0 +1,193 @@
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.translation import gettext_lazy as _
from netbox.views import generic
from utilities.views import register_model_view
from .engine.resolver import Resolver
from .filtersets import ServiceFilterSet, ServiceTemplateFilterSet
from .forms import (
ResolveForm,
ServiceBulkEditForm,
ServiceFilterSetForm,
ServiceForm,
ServiceTemplateBulkEditForm,
ServiceTemplateFilterSetForm,
ServiceTemplateForm,
ServiceTemplateImportForm,
)
from .models import Service, ServiceTemplate
from .tables import ServiceTable, ServiceTemplateTable
def _build_preview(result):
"""Transform result.planned_objects into a list of dicts with pre-computed action."""
preview = []
for node_name, obj in result.planned_objects:
ct = ContentType.objects.get_for_model(obj.__class__)
key = (ct.id, obj.pk)
action = "add" if key in result.to_add else "keep"
preview.append({"node": node_name, "object": obj, "action": action})
return preview
# ════════════════════════════════════════════════════════════════════
# Service Template
# ════════════════════════════════════════════════════════════════════
@register_model_view(ServiceTemplate, "list", path="", detail=False)
class ServiceTemplateListView(generic.ObjectListView):
queryset = ServiceTemplate.objects.all()
table = ServiceTemplateTable
filterset = ServiceTemplateFilterSet
filterset_form = ServiceTemplateFilterSetForm
@register_model_view(ServiceTemplate, "add", path="add", detail=False)
@register_model_view(ServiceTemplate, "edit")
class ServiceTemplateEditView(generic.ObjectEditView):
queryset = ServiceTemplate.objects.all()
form = ServiceTemplateForm
@register_model_view(ServiceTemplate, "delete")
class ServiceTemplateDeleteView(generic.ObjectDeleteView):
queryset = ServiceTemplate.objects.all()
@register_model_view(ServiceTemplate, "bulk_edit", path="edit", detail=False)
class ServiceTemplateBulkEditView(generic.BulkEditView):
queryset = ServiceTemplate.objects.all()
table = ServiceTemplateTable
form = ServiceTemplateBulkEditForm
@register_model_view(ServiceTemplate, "bulk_delete", path="delete", detail=False)
class ServiceTemplateBulkDeleteView(generic.BulkDeleteView):
queryset = ServiceTemplate.objects.all()
table = ServiceTemplateTable
@register_model_view(ServiceTemplate, "bulk_import", path="import", detail=False)
class ServiceTemplateBulkImportView(generic.BulkImportView):
queryset = ServiceTemplate.objects.all()
model_form = ServiceTemplateImportForm
table = ServiceTemplateTable
@register_model_view(ServiceTemplate)
class ServiceTemplateView(generic.ObjectView):
queryset = ServiceTemplate.objects.all()
def get_extra_context(self, request, instance):
context = super().get_extra_context(request, instance)
context["is_locked"] = instance.is_locked
return context
# ════════════════════════════════════════════════════════════════════
# Service
# ════════════════════════════════════════════════════════════════════
@register_model_view(Service, "list", path="", detail=False)
class ServiceListView(generic.ObjectListView):
queryset = Service.objects.all()
table = ServiceTable
filterset = ServiceFilterSet
filterset_form = ServiceFilterSetForm
@register_model_view(Service, "add", path="add", detail=False)
@register_model_view(Service, "edit")
class ServiceEditView(generic.ObjectEditView):
queryset = Service.objects.all()
form = ServiceForm
@register_model_view(Service, "delete")
class ServiceDeleteView(generic.ObjectDeleteView):
queryset = Service.objects.all()
@register_model_view(Service, "bulk_edit", path="edit", detail=False)
class ServiceBulkEditView(generic.BulkEditView):
queryset = Service.objects.all()
table = ServiceTable
form = ServiceBulkEditForm
@register_model_view(Service, "bulk_delete", path="delete", detail=False)
class ServiceBulkDeleteView(generic.BulkDeleteView):
queryset = Service.objects.all()
table = ServiceTable
@register_model_view(Service)
class ServiceView(generic.ObjectView):
queryset = Service.objects.all()
def get_extra_context(self, request, instance):
context = super().get_extra_context(request, instance)
context["bindings"] = instance.bindings.select_related(
"content_type",
).all()
return context
@register_model_view(Service, "resolve", path="resolve")
class ServiceResolveView(generic.ObjectView):
"""Dry-run preview (GET) and commit (POST) for service resolution."""
queryset = Service.objects.all()
def get(self, request, pk):
service = get_object_or_404(self.queryset, pk=pk)
resolver = Resolver()
try:
result = resolver.resolve(service, mode="dry_run", user=request.user)
except Exception as exc:
messages.error(request, f"Resolution failed: {exc}")
return redirect("plugins:netbox_services:service", pk=service.pk)
return render(request, "netbox_services/service_resolve.html", {
"object": service,
"service": service,
"result": result,
"planned_preview": _build_preview(result),
"form": ResolveForm(initial={"mode": "commit"}),
"active_tab": "resolve",
})
def post(self, request, pk):
service = get_object_or_404(self.queryset, pk=pk)
form = ResolveForm(request.POST)
mode = form.cleaned_data.get("mode", "dry_run") if form.is_valid() else "dry_run"
resolver = Resolver()
try:
result = resolver.resolve(service, mode=mode, user=request.user)
except Exception as exc:
messages.error(request, f"Resolution failed: {exc}")
return redirect("plugins:netbox_services:service", pk=service.pk)
if mode == "commit":
messages.success(
request,
_("Resolution committed: {added} added, {removed} removed, {kept} unchanged.").format(
added=len(result.to_add),
removed=len(result.to_remove),
kept=result.total_planned - len(result.to_add),
),
)
return redirect("plugins:netbox_services:service", pk=service.pk)
return render(request, "netbox_services/service_resolve.html", {
"object": service,
"service": service,
"result": result,
"planned_preview": _build_preview(result),
"form": ResolveForm(initial={"mode": "commit"}),
"active_tab": "resolve",
})
+40
View File
@@ -0,0 +1,40 @@
[build-system]
requires = ["setuptools>=61", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "netbox-plugin-services"
dynamic = ["version"]
description = "Declarative service templates with requirement trees for NetBox."
authors = [
{ name = "Sven Luethi" },
]
license = { file = "LICENSE.md" }
readme = "README.md"
requires-python = ">=3.12"
dependencies = []
[project.optional-dependencies]
test = [
"pytest>=8.0",
"pytest-django>=4.8",
]
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "netbox.settings"
python_files = ["test_*.py"]
testpaths = ["netbox_services/tests"]
[tool.setuptools]
include-package-data = true
zip-safe = false
package-dir = { "" = "." }
[tool.setuptools.package-data]
netbox_services = ["templates/**/*.html"]
[tool.setuptools.packages.find]
where = ["."]
[tool.setuptools.dynamic]
version = { attr = "netbox_services.__version__" }