From 4ff9eac27493b611455fe81adda20bdd58de1dfc Mon Sep 17 00:00:00 2001 From: Sven Luethi Date: Sun, 16 Aug 2026 13:10:26 +0200 Subject: [PATCH] Initial commit --- .flake8 | 2 + .gitignore | 7 + LICENSE.md | 18 ++ MANIFEST.in | 2 + README.md | 114 ++++++++ conftest.py | 13 + dev/Dockerfile | 1 + dev/configuration.py | 46 ++++ dev/docker-compose.yml | 45 ++++ dev/entrypoint.sh | 25 ++ netbox_services/__init__.py | 21 ++ netbox_services/api/__init__.py | 0 netbox_services/api/serializers.py | 36 +++ netbox_services/api/urls.py | 9 + netbox_services/api/views.py | 62 +++++ netbox_services/engine/__init__.py | 0 netbox_services/engine/allocators.py | 78 ++++++ netbox_services/engine/context.py | 52 ++++ netbox_services/engine/registry.py | 64 +++++ netbox_services/engine/resolver.py | 245 +++++++++++++++++ netbox_services/engine/selectors.py | 104 +++++++ netbox_services/filtersets.py | 42 +++ netbox_services/forms.py | 197 ++++++++++++++ netbox_services/migrations/0001_initial.py | 87 ++++++ netbox_services/migrations/__init__.py | 0 netbox_services/models.py | 255 ++++++++++++++++++ netbox_services/navigation.py | 24 ++ netbox_services/search.py | 23 ++ netbox_services/signals.py | 37 +++ netbox_services/tables.py | 39 +++ .../templates/netbox_services/_tree_node.html | 29 ++ .../templates/netbox_services/service.html | 80 ++++++ .../netbox_services/service_resolve.html | 119 ++++++++ .../netbox_services/servicetemplate.html | 67 +++++ netbox_services/tests/__init__.py | 0 netbox_services/tests/conftest.py | 171 ++++++++++++ netbox_services/tests/test_allocators.py | 43 +++ netbox_services/tests/test_api.py | 111 ++++++++ netbox_services/tests/test_lock.py | 46 ++++ netbox_services/tests/test_resolver.py | 244 +++++++++++++++++ netbox_services/tests/test_selectors.py | 76 ++++++ netbox_services/tests/test_views.py | 33 +++ netbox_services/urls.py | 11 + netbox_services/views.py | 193 +++++++++++++ pyproject.toml | 40 +++ 45 files changed, 2911 insertions(+) create mode 100644 .flake8 create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 MANIFEST.in create mode 100644 README.md create mode 100644 conftest.py create mode 100644 dev/Dockerfile create mode 100644 dev/configuration.py create mode 100644 dev/docker-compose.yml create mode 100755 dev/entrypoint.sh create mode 100644 netbox_services/__init__.py create mode 100644 netbox_services/api/__init__.py create mode 100644 netbox_services/api/serializers.py create mode 100644 netbox_services/api/urls.py create mode 100644 netbox_services/api/views.py create mode 100644 netbox_services/engine/__init__.py create mode 100644 netbox_services/engine/allocators.py create mode 100644 netbox_services/engine/context.py create mode 100644 netbox_services/engine/registry.py create mode 100644 netbox_services/engine/resolver.py create mode 100644 netbox_services/engine/selectors.py create mode 100644 netbox_services/filtersets.py create mode 100644 netbox_services/forms.py create mode 100644 netbox_services/migrations/0001_initial.py create mode 100644 netbox_services/migrations/__init__.py create mode 100644 netbox_services/models.py create mode 100644 netbox_services/navigation.py create mode 100644 netbox_services/search.py create mode 100644 netbox_services/signals.py create mode 100644 netbox_services/tables.py create mode 100644 netbox_services/templates/netbox_services/_tree_node.html create mode 100644 netbox_services/templates/netbox_services/service.html create mode 100644 netbox_services/templates/netbox_services/service_resolve.html create mode 100644 netbox_services/templates/netbox_services/servicetemplate.html create mode 100644 netbox_services/tests/__init__.py create mode 100644 netbox_services/tests/conftest.py create mode 100644 netbox_services/tests/test_allocators.py create mode 100644 netbox_services/tests/test_api.py create mode 100644 netbox_services/tests/test_lock.py create mode 100644 netbox_services/tests/test_resolver.py create mode 100644 netbox_services/tests/test_selectors.py create mode 100644 netbox_services/tests/test_views.py create mode 100644 netbox_services/urls.py create mode 100644 netbox_services/views.py create mode 100644 pyproject.toml diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..7da1f96 --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +max-line-length = 100 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b98146 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +dist/ +build/ +__pycache__/ +venv/ +.venv/ +netbox_plugin_services.egg-info/ +*.pyc diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..f1cbede --- /dev/null +++ b/LICENSE.md @@ -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. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..e6c21c1 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +recursive-include netbox_services *.py +recursive-include netbox_services/templates *.html diff --git a/README.md b/README.md new file mode 100644 index 0000000..6f5e912 --- /dev/null +++ b/README.md @@ -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//resolve/` — Resolve a service + - Body: `{"mode": "dry_run"}` or `{"mode": "commit"}` + +## License + +MIT diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..94b9550 --- /dev/null +++ b/conftest.py @@ -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() diff --git a/dev/Dockerfile b/dev/Dockerfile new file mode 100644 index 0000000..7e2ff7a --- /dev/null +++ b/dev/Dockerfile @@ -0,0 +1 @@ +FROM docker.io/netboxcommunity/netbox:v4.6.5 diff --git a/dev/configuration.py b/dev/configuration.py new file mode 100644 index 0000000..1a29ce7 --- /dev/null +++ b/dev/configuration.py @@ -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"}, + }, +} diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml new file mode 100644 index 0000000..8a1576f --- /dev/null +++ b/dev/docker-compose.yml @@ -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 diff --git a/dev/entrypoint.sh b/dev/entrypoint.sh new file mode 100755 index 0000000..1783877 --- /dev/null +++ b/dev/entrypoint.sh @@ -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 diff --git a/netbox_services/__init__.py b/netbox_services/__init__.py new file mode 100644 index 0000000..ce5dd68 --- /dev/null +++ b/netbox_services/__init__.py @@ -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 diff --git a/netbox_services/api/__init__.py b/netbox_services/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/netbox_services/api/serializers.py b/netbox_services/api/serializers.py new file mode 100644 index 0000000..b5747a6 --- /dev/null +++ b/netbox_services/api/serializers.py @@ -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") diff --git a/netbox_services/api/urls.py b/netbox_services/api/urls.py new file mode 100644 index 0000000..8397b7b --- /dev/null +++ b/netbox_services/api/urls.py @@ -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 diff --git a/netbox_services/api/views.py b/netbox_services/api/views.py new file mode 100644 index 0000000..8c2ae78 --- /dev/null +++ b/netbox_services/api/views.py @@ -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) diff --git a/netbox_services/engine/__init__.py b/netbox_services/engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/netbox_services/engine/allocators.py b/netbox_services/engine/allocators.py new file mode 100644 index 0000000..583d072 --- /dev/null +++ b/netbox_services/engine/allocators.py @@ -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'.") diff --git a/netbox_services/engine/context.py b/netbox_services/engine/context.py new file mode 100644 index 0000000..5cd9d12 --- /dev/null +++ b/netbox_services/engine/context.py @@ -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 diff --git a/netbox_services/engine/registry.py b/netbox_services/engine/registry.py new file mode 100644 index 0000000..86404c5 --- /dev/null +++ b/netbox_services/engine/registry.py @@ -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) diff --git a/netbox_services/engine/resolver.py b/netbox_services/engine/resolver.py new file mode 100644 index 0000000..a5dc393 --- /dev/null +++ b/netbox_services/engine/resolver.py @@ -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() diff --git a/netbox_services/engine/selectors.py b/netbox_services/engine/selectors.py new file mode 100644 index 0000000..5535976 --- /dev/null +++ b/netbox_services/engine/selectors.py @@ -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() diff --git a/netbox_services/filtersets.py b/netbox_services/filtersets.py new file mode 100644 index 0000000..d0352ac --- /dev/null +++ b/netbox_services/filtersets.py @@ -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), + ) diff --git a/netbox_services/forms.py b/netbox_services/forms.py new file mode 100644 index 0000000..d2d8a40 --- /dev/null +++ b/netbox_services/forms.py @@ -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, + ) diff --git a/netbox_services/migrations/0001_initial.py b/netbox_services/migrations/0001_initial.py new file mode 100644 index 0000000..b55e06a --- /dev/null +++ b/netbox_services/migrations/0001_initial.py @@ -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), + ), + ] diff --git a/netbox_services/migrations/__init__.py b/netbox_services/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/netbox_services/models.py b/netbox_services/models.py new file mode 100644 index 0000000..bf11e09 --- /dev/null +++ b/netbox_services/models.py @@ -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}" diff --git a/netbox_services/navigation.py b/netbox_services/navigation.py new file mode 100644 index 0000000..7bc46e9 --- /dev/null +++ b/netbox_services/navigation.py @@ -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", +) diff --git a/netbox_services/search.py b/netbox_services/search.py new file mode 100644 index 0000000..b71e9bd --- /dev/null +++ b/netbox_services/search.py @@ -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") diff --git a/netbox_services/signals.py b/netbox_services/signals.py new file mode 100644 index 0000000..401391d --- /dev/null +++ b/netbox_services/signals.py @@ -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) diff --git a/netbox_services/tables.py b/netbox_services/tables.py new file mode 100644 index 0000000..914b9ad --- /dev/null +++ b/netbox_services/tables.py @@ -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") diff --git a/netbox_services/templates/netbox_services/_tree_node.html b/netbox_services/templates/netbox_services/_tree_node.html new file mode 100644 index 0000000..4f6f8f8 --- /dev/null +++ b/netbox_services/templates/netbox_services/_tree_node.html @@ -0,0 +1,29 @@ +{% load helpers %} +{% load i18n %} +{% if nodes %} + +{% endif %} diff --git a/netbox_services/templates/netbox_services/service.html b/netbox_services/templates/netbox_services/service.html new file mode 100644 index 0000000..bae6bdc --- /dev/null +++ b/netbox_services/templates/netbox_services/service.html @@ -0,0 +1,80 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load i18n %} + +{% block content %} +
+
+
+
{% trans "Service" %}
+
+ + + + + + + + + + + + + + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Service Template" %}{{ object.service_template }}
{% trans "Tenant" %}{{ object.tenant|default:'—' }}
{% trans "Status" %}{{ object.get_status_display }}
{% trans "Description" %}{{ object.description|placeholder }}
+
+
+
+
+
+
{% trans "Actions" %}
+
+ + {% trans "Resolve" %} + +

+ {% trans "Run the resolver to preview and/or reconcile the service's resource bindings." %} +

+
+
+
+
+ +
+
+
+
{% trans "Resource Bindings" %}
+
+ {% if bindings %} + + + + + + + + + {% for binding in bindings %} + + + + + {% endfor %} + +
{% trans "Object Type" %}{% trans "Object" %}
{{ binding.content_type }}{{ binding.content_object|default:'—' }}
+ {% else %} + {% trans "No bindings. Resolve the service to create bindings." %} + {% endif %} +
+
+
+
+{% endblock %} diff --git a/netbox_services/templates/netbox_services/service_resolve.html b/netbox_services/templates/netbox_services/service_resolve.html new file mode 100644 index 0000000..a4bb191 --- /dev/null +++ b/netbox_services/templates/netbox_services/service_resolve.html @@ -0,0 +1,119 @@ +{% extends 'base/layout.html' %} +{% load helpers %} +{% load i18n %} + +{% block title %}{{ service }} — {% trans "Resolve" %}{% endblock %} + +{% block content %} +
+
+
+
+ {% trans "Resolution Preview" %} — {{ service }} +
+
+ {% if result.errors %} +
+
{% trans "Errors" %}
+
    + {% for error in result.errors %} +
  • {{ error }}
  • + {% endfor %} +
+
+ {% endif %} + + {% if result.warnings %} +
+
{% trans "Warnings" %}
+
    + {% for warning in result.warnings %} +
  • {{ warning }}
  • + {% endfor %} +
+
+ {% endif %} + +
+
+
+
+

{{ result.to_add|length }}

+

{% trans "to add" %}

+
+
+
+
+
+
+

{{ result.to_remove|length }}

+

{% trans "to remove" %}

+
+
+
+
+
+
+

{{ result.total_planned|add:'0' }}

+

{% trans "total planned" %}

+
+
+
+
+ + {% if planned_preview %} + + + + + + + + + + {% for item in planned_preview %} + + + + + + {% endfor %} + +
{% trans "Node" %}{% trans "Object" %}{% trans "Action" %}
{{ item.node }}{{ item.object }} + {% if item.action == "add" %} + {% trans "add" %} + {% else %} + {% trans "keep" %} + {% endif %} +
+ {% else %} +

{% trans "No objects would be bound." %}

+ {% endif %} +
+
+
+
+ +
+
+
+
{% trans "Commit" %}
+
+

+ {% trans "Committing will add/remove bindings. Allocated objects (e.g. IP addresses) are not deleted on removal — only the binding is removed." %} +

+
+ {% csrf_token %} + {{ form.as_p }} + + + {% trans "Cancel" %} + +
+
+
+
+
+{% endblock %} diff --git a/netbox_services/templates/netbox_services/servicetemplate.html b/netbox_services/templates/netbox_services/servicetemplate.html new file mode 100644 index 0000000..cdbf0f4 --- /dev/null +++ b/netbox_services/templates/netbox_services/servicetemplate.html @@ -0,0 +1,67 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load i18n %} + +{% block content %} +
+
+
+
{% trans "Service Template" %}
+
+ + + + + + + + + + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Status" %}{{ object.get_status_display }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Locked" %} + {% if is_locked %} + {% trans "Yes — services have been spawned" %} + {% else %} + {% trans "No — editable" %} + {% endif %} +
+
+
+
+
+
+
{% trans "Stats" %}
+
+ + + + + +
{% trans "Services" %}{{ object.services.count }}
+
+
+
+
+ +
+
+
+
{% trans "Requirement Tree" %}
+
+ {% if object.tree %} + {% include 'netbox_services/_tree_node.html' with nodes=object.tree depth=0 %} + {% else %} + {% trans "No requirements defined." %} + {% endif %} +
+
+
+
+{% endblock %} diff --git a/netbox_services/tests/__init__.py b/netbox_services/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/netbox_services/tests/conftest.py b/netbox_services/tests/conftest.py new file mode 100644 index 0000000..75059f8 --- /dev/null +++ b/netbox_services/tests/conftest.py @@ -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 diff --git a/netbox_services/tests/test_allocators.py b/netbox_services/tests/test_allocators.py new file mode 100644 index 0000000..c4beb3f --- /dev/null +++ b/netbox_services/tests/test_allocators.py @@ -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)) diff --git a/netbox_services/tests/test_api.py b/netbox_services/tests/test_api.py new file mode 100644 index 0000000..3ab941e --- /dev/null +++ b/netbox_services/tests/test_api.py @@ -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 diff --git a/netbox_services/tests/test_lock.py b/netbox_services/tests/test_lock.py new file mode 100644 index 0000000..3801734 --- /dev/null +++ b/netbox_services/tests/test_lock.py @@ -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() diff --git a/netbox_services/tests/test_resolver.py b/netbox_services/tests/test_resolver.py new file mode 100644 index 0000000..fcd0cca --- /dev/null +++ b/netbox_services/tests/test_resolver.py @@ -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 diff --git a/netbox_services/tests/test_selectors.py b/netbox_services/tests/test_selectors.py new file mode 100644 index 0000000..9d6b846 --- /dev/null +++ b/netbox_services/tests/test_selectors.py @@ -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 diff --git a/netbox_services/tests/test_views.py b/netbox_services/tests/test_views.py new file mode 100644 index 0000000..08cbd8a --- /dev/null +++ b/netbox_services/tests/test_views.py @@ -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 diff --git a/netbox_services/urls.py b/netbox_services/urls.py new file mode 100644 index 0000000..f2ffbcc --- /dev/null +++ b/netbox_services/urls.py @@ -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//", include(get_model_urls("netbox_services", "servicetemplate"))), + path("services/", include(get_model_urls("netbox_services", "service", detail=False))), + path("services//", include(get_model_urls("netbox_services", "service"))), +] diff --git a/netbox_services/views.py b/netbox_services/views.py new file mode 100644 index 0000000..00e14a3 --- /dev/null +++ b/netbox_services/views.py @@ -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", + }) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bdd2f59 --- /dev/null +++ b/pyproject.toml @@ -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__" }