Initial commit
This commit is contained in:
commit
4ff9eac274
45 files changed
+2911
No files matched your search
@@ -0,0 +1,7 @@
|
||||
dist/
|
||||
build/
|
||||
__pycache__/
|
||||
venv/
|
||||
.venv/
|
||||
netbox_plugin_services.egg-info/
|
||||
*.pyc
|
||||
+18
@@ -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.
|
||||
@@ -0,0 +1,2 @@
|
||||
recursive-include netbox_services *.py
|
||||
recursive-include netbox_services/templates *.html
|
||||
@@ -0,0 +1,114 @@
|
||||
# NetBox Services Plugin
|
||||
|
||||
A NetBox plugin for declarative service templates with requirement trees.
|
||||
|
||||
## Overview
|
||||
|
||||
This plugin lets you define **service templates** that specify what NetBox
|
||||
resources a service requires, without attaching any resources to the template
|
||||
itself. Templates contain a **requirement tree** — a stacked, declarative
|
||||
description of what objects are needed (devices, interfaces, IP addresses, etc.)
|
||||
and how to select or allocate them.
|
||||
|
||||
One or more **services** are then spawned from these templates. When a service is
|
||||
**resolved**, the resolver engine walks the requirement tree, evaluates selectors
|
||||
and allocators against live NetBox data, and creates **resource bindings** that
|
||||
link the service to real NetBox objects.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **ServiceTemplate** — Reusable template with a declarative requirement tree
|
||||
stored as JSON (editable as YAML or JSON in the UI). Locked once any service
|
||||
instances are spawned from it.
|
||||
- **Service** — A concrete instance spawned from a ServiceTemplate. Requires a
|
||||
tenant.
|
||||
- **ServiceResourceBinding** — Internal model (hidden from UI) that links a
|
||||
service to a real NetBox object via a generic foreign key.
|
||||
|
||||
## Tree Structure
|
||||
|
||||
The requirement tree is a list of nodes. Each node specifies:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `name` | Unique name for the node |
|
||||
| `object_type` | NetBox model as `app_label.model_name` (e.g. `dcim.device`) |
|
||||
| `quantity` | How many objects to select/allocate (default: 1) |
|
||||
| `selector` | How to find existing objects (see Selector Types) |
|
||||
| `allocation` | How to create new objects (see Allocation Strategies) |
|
||||
| `scope` | `service` (resolve once) or `parent` (resolve per selected parent) |
|
||||
| `children` | List of child nodes (recursive) |
|
||||
|
||||
A node must have either a `selector` or an `allocation` (not both).
|
||||
|
||||
## Selector Types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `netbox_filter` | Apply NetBox filter parameters to find existing objects |
|
||||
| `reference` | Resolve a dotted context path (e.g. `parent.site`) |
|
||||
| `prefix` | Find ipam.Prefix objects by filter or reference |
|
||||
|
||||
## Allocation Strategies
|
||||
|
||||
| Strategy | Description |
|
||||
|----------|-------------|
|
||||
| `first_free` | Allocate the first available IP from a resolved prefix |
|
||||
| `specific` | Use a specific object by address or context path |
|
||||
|
||||
## Reconciliation
|
||||
|
||||
When a service is re-resolved, the resolver performs **set reconciliation**:
|
||||
- Objects in the new plan but not currently bound → **added**
|
||||
- Objects currently bound but not in the new plan → **removed** (binding only; the underlying NetBox object is not deleted)
|
||||
- Objects in both → **kept** unchanged
|
||||
|
||||
For allocation nodes, existing bound objects of the same type are reused before
|
||||
allocating new ones (avoids duplicate IPs on re-resolve).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cd dev/
|
||||
podman-compose up -d
|
||||
```
|
||||
|
||||
Access at `http://localhost:8001` (admin/admin).
|
||||
|
||||
## Example
|
||||
|
||||
A service template for "2 routers, each with a management IP from a prefix pool":
|
||||
|
||||
```yaml
|
||||
- name: routers
|
||||
object_type: dcim.device
|
||||
quantity: 2
|
||||
selector:
|
||||
type: netbox_filter
|
||||
filters:
|
||||
role: router
|
||||
scope: service
|
||||
children:
|
||||
- name: mgmt-ip
|
||||
object_type: ipam.ipaddress
|
||||
quantity: 1
|
||||
allocation:
|
||||
strategy: first_free
|
||||
prefix:
|
||||
type: prefix
|
||||
filters:
|
||||
site: hq
|
||||
tag: mgmt
|
||||
scope: parent
|
||||
```
|
||||
|
||||
## REST API
|
||||
|
||||
- `GET/POST /api/plugins/services/service-templates/` — CRUD for templates
|
||||
- `GET/POST /api/plugins/services/services/` — CRUD for services
|
||||
- `POST /api/plugins/services/services/<id>/resolve/` — Resolve a service
|
||||
- Body: `{"mode": "dry_run"}` or `{"mode": "commit"}`
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
+13
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
FROM docker.io/netboxcommunity/netbox:v4.6.5
|
||||
@@ -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"},
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
Executable
+25
@@ -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
|
||||
@@ -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
|
||||
Whitespace-only changes.
@@ -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")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Whitespace-only changes.
@@ -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'.")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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),
|
||||
)
|
||||
@@ -0,0 +1,197 @@
|
||||
import json
|
||||
|
||||
import yaml
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from netbox.forms import (
|
||||
NetBoxModelBulkEditForm,
|
||||
NetBoxModelFilterSetForm,
|
||||
NetBoxModelForm,
|
||||
NetBoxModelImportForm,
|
||||
PrimaryModelForm,
|
||||
)
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms.fields import (
|
||||
DynamicModelChoiceField,
|
||||
DynamicModelMultipleChoiceField,
|
||||
TagFilterField,
|
||||
)
|
||||
from utilities.forms.rendering import FieldSet
|
||||
|
||||
from .models import (
|
||||
Service,
|
||||
ServiceStatusChoices,
|
||||
ServiceTemplate,
|
||||
ServiceTemplateStatusChoices,
|
||||
validate_tree,
|
||||
)
|
||||
|
||||
|
||||
# ── Service Template ─────────────────────────────────────────────────
|
||||
|
||||
class ServiceTemplateForm(PrimaryModelForm):
|
||||
tree_raw = forms.CharField(
|
||||
label=_("Requirement tree"),
|
||||
required=False,
|
||||
widget=forms.Textarea(
|
||||
attrs={
|
||||
"rows": 20,
|
||||
"class": "font-monospace",
|
||||
"placeholder": (
|
||||
'- name: routers\n'
|
||||
' object_type: dcim.device\n'
|
||||
' quantity: 2\n'
|
||||
' selector:\n'
|
||||
' type: netbox_filter\n'
|
||||
' filters:\n'
|
||||
' role: router\n'
|
||||
' children:\n'
|
||||
' - name: mgmt-ip\n'
|
||||
' object_type: ipam.ipaddress\n'
|
||||
' allocation:\n'
|
||||
' strategy: first_free\n'
|
||||
' prefix:\n'
|
||||
' type: prefix\n'
|
||||
' filters: {}\n'
|
||||
),
|
||||
},
|
||||
),
|
||||
help_text=_("Enter the requirement tree as YAML or JSON."),
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
FieldSet("name", "status", "tree_raw", name=_("Service Template")),
|
||||
FieldSet("description", "tags", name=_("Metadata")),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ServiceTemplate
|
||||
fields = ("name", "status", "description", "comments")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.instance and self.instance.pk and self.instance.tree:
|
||||
self.fields["tree_raw"].initial = yaml.dump(
|
||||
self.instance.tree, default_flow_style=False, sort_keys=False,
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
raw = self.cleaned_data.get("tree_raw", "").strip()
|
||||
if not raw:
|
||||
self.instance.tree = []
|
||||
return self.cleaned_data
|
||||
|
||||
tree = None
|
||||
try:
|
||||
tree = json.loads(raw)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
if tree is None:
|
||||
try:
|
||||
tree = yaml.safe_load(raw)
|
||||
except yaml.YAMLError as exc:
|
||||
self.add_error("tree_raw", _(f"Invalid YAML or JSON: {exc}"))
|
||||
return self.cleaned_data
|
||||
|
||||
if tree is None:
|
||||
tree = []
|
||||
|
||||
self.instance.tree = tree
|
||||
self.instance._tree_validated_by_form = True
|
||||
|
||||
try:
|
||||
validate_tree(tree)
|
||||
except ValidationError as exc:
|
||||
self.add_error("tree_raw", ValidationError(exc.messages))
|
||||
|
||||
return self.cleaned_data
|
||||
|
||||
|
||||
class ServiceTemplateFilterSetForm(NetBoxModelFilterSetForm):
|
||||
model = ServiceTemplate
|
||||
name = forms.CharField(required=False)
|
||||
status = forms.MultipleChoiceField(
|
||||
choices=ServiceTemplateStatusChoices,
|
||||
required=False,
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm):
|
||||
model = ServiceTemplate
|
||||
status = forms.ChoiceField(
|
||||
choices=ServiceTemplateStatusChoices,
|
||||
required=False,
|
||||
)
|
||||
description = forms.CharField(required=False)
|
||||
nullable_fields = ("description",)
|
||||
|
||||
|
||||
class ServiceTemplateImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = ServiceTemplate
|
||||
fields = ("name", "status", "description", "comments", "tags")
|
||||
|
||||
|
||||
# ── Service ──────────────────────────────────────────────────────────
|
||||
|
||||
class ServiceForm(PrimaryModelForm):
|
||||
service_template = DynamicModelChoiceField(
|
||||
queryset=ServiceTemplate.objects.all(),
|
||||
)
|
||||
tenant = DynamicModelChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
FieldSet("name", "service_template", "tenant", "status", "description", "tags", name=_("Service")),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Service
|
||||
fields = ("name", "service_template", "tenant", "status", "description", "comments")
|
||||
|
||||
|
||||
class ServiceFilterSetForm(NetBoxModelFilterSetForm):
|
||||
model = Service
|
||||
name = forms.CharField(required=False)
|
||||
service_template = DynamicModelMultipleChoiceField(
|
||||
queryset=ServiceTemplate.objects.all(),
|
||||
required=False,
|
||||
)
|
||||
tenant = DynamicModelMultipleChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
required=False,
|
||||
)
|
||||
status = forms.MultipleChoiceField(
|
||||
choices=ServiceStatusChoices,
|
||||
required=False,
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class ServiceBulkEditForm(NetBoxModelBulkEditForm):
|
||||
model = Service
|
||||
status = forms.ChoiceField(
|
||||
choices=ServiceStatusChoices,
|
||||
required=False,
|
||||
)
|
||||
description = forms.CharField(required=False)
|
||||
nullable_fields = ("description",)
|
||||
|
||||
|
||||
# ── Resolve form ─────────────────────────────────────────────────────
|
||||
|
||||
class ResolveForm(forms.Form):
|
||||
mode = forms.ChoiceField(
|
||||
choices=(
|
||||
("dry_run", _("Dry Run")),
|
||||
("commit", _("Commit")),
|
||||
),
|
||||
initial="dry_run",
|
||||
widget=forms.RadioSelect,
|
||||
)
|
||||
@@ -0,0 +1,87 @@
|
||||
# Generated by Django 6.0.7 on 2026-08-15 20:08
|
||||
|
||||
import django.db.models.deletion
|
||||
import netbox.models.deletion
|
||||
import taggit.managers
|
||||
import utilities.json
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('extras', '0140_imageattachment_image_size'),
|
||||
('tenancy', '0024_default_ordering_indexes'),
|
||||
('users', '0016_default_ordering_indexes'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ServiceTemplate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True, null=True)),
|
||||
('last_updated', models.DateTimeField(auto_now=True, null=True)),
|
||||
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
|
||||
('description', models.CharField(blank=True, max_length=200)),
|
||||
('comments', models.TextField(blank=True)),
|
||||
('name', models.CharField(max_length=100, unique=True)),
|
||||
('status', models.CharField(default='draft', max_length=20)),
|
||||
('tree', models.JSONField(blank=True, default=list)),
|
||||
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='netbox_services_templates', to='users.owner')),
|
||||
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'service template',
|
||||
'verbose_name_plural': 'service templates',
|
||||
'ordering': ('name',),
|
||||
},
|
||||
bases=(netbox.models.deletion.DeleteMixin, models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Service',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True, null=True)),
|
||||
('last_updated', models.DateTimeField(auto_now=True, null=True)),
|
||||
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
|
||||
('description', models.CharField(blank=True, max_length=200)),
|
||||
('comments', models.TextField(blank=True)),
|
||||
('name', models.CharField(max_length=100, unique=True)),
|
||||
('status', models.CharField(default='planned', max_length=20)),
|
||||
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='netbox_services_services', to='users.owner')),
|
||||
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
|
||||
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='netbox_services_services', to='tenancy.tenant')),
|
||||
('service_template', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='services', to='netbox_services.servicetemplate')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'service',
|
||||
'verbose_name_plural': 'services',
|
||||
'ordering': ('name',),
|
||||
},
|
||||
bases=(netbox.models.deletion.DeleteMixin, models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ServiceResourceBinding',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True, null=True)),
|
||||
('last_updated', models.DateTimeField(auto_now=True, null=True)),
|
||||
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
|
||||
('object_id', models.PositiveBigIntegerField()),
|
||||
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')),
|
||||
('service', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bindings', to='netbox_services.service')),
|
||||
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'service resource binding',
|
||||
'verbose_name_plural': 'service resource bindings',
|
||||
'ordering': ('service', 'content_type', 'object_id'),
|
||||
'indexes': [models.Index(fields=['content_type', 'object_id'], name='netbox_serv_content_c47db1_idx')],
|
||||
},
|
||||
bases=(netbox.models.deletion.DeleteMixin, models.Model),
|
||||
),
|
||||
]
|
||||
Whitespace-only changes.
@@ -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}"
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -0,0 +1,39 @@
|
||||
import django_tables2 as tables
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from netbox.tables import NetBoxTable
|
||||
from netbox.tables.columns import (
|
||||
ChoiceFieldColumn,
|
||||
TagColumn,
|
||||
)
|
||||
|
||||
from .models import Service, ServiceTemplate
|
||||
|
||||
|
||||
class ServiceTemplateTable(NetBoxTable):
|
||||
name = tables.Column(linkify=True)
|
||||
status = ChoiceFieldColumn()
|
||||
services_count = tables.Column(
|
||||
verbose_name=_("Services"),
|
||||
accessor="services__count",
|
||||
orderable=False,
|
||||
)
|
||||
tags = TagColumn()
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ServiceTemplate
|
||||
fields = ("pk", "id", "name", "status", "description", "services_count", "tags")
|
||||
default_columns = ("pk", "name", "status", "description", "services_count")
|
||||
|
||||
|
||||
class ServiceTable(NetBoxTable):
|
||||
name = tables.Column(linkify=True)
|
||||
service_template = tables.Column(linkify=True)
|
||||
tenant = tables.Column(linkify=True)
|
||||
status = ChoiceFieldColumn()
|
||||
tags = TagColumn()
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Service
|
||||
fields = ("pk", "id", "name", "service_template", "tenant", "status", "description", "tags")
|
||||
default_columns = ("pk", "name", "service_template", "tenant", "status", "description")
|
||||
@@ -0,0 +1,29 @@
|
||||
{% load helpers %}
|
||||
{% load i18n %}
|
||||
{% if nodes %}
|
||||
<ul class="tree" style="list-style: none; padding-left: {{ depth|add:'20' }}px;">
|
||||
{% for node in nodes %}
|
||||
<li>
|
||||
<strong>{{ node.name }}</strong>
|
||||
— <code>{{ node.object_type }}</code>
|
||||
<span class="badge bg-secondary">{{ node.quantity|default:1 }}×</span>
|
||||
<span class="badge bg-info">{{ node.scope|default:'parent' }}</span>
|
||||
{% if node.selector %}
|
||||
<details class="d-inline">
|
||||
<summary class="badge bg-primary">{% trans "selector" %}</summary>
|
||||
<pre class="small mt-1">{{ node.selector|json }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% if node.allocation %}
|
||||
<details class="d-inline">
|
||||
<summary class="badge bg-success">{% trans "allocation" %}</summary>
|
||||
<pre class="small mt-1">{{ node.allocation|json }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% if node.children %}
|
||||
{% include 'netbox_services/_tree_node.html' with nodes=node.children depth=depth|add:'1' %}
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,80 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-7">
|
||||
<div class="card">
|
||||
<h5 class="card-header">{% trans "Service" %}</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Service Template" %}</th>
|
||||
<td><a href="{{ object.service_template.get_absolute_url }}">{{ object.service_template }}</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Tenant" %}</th>
|
||||
<td>{{ object.tenant|default:'—' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Status" %}</th>
|
||||
<td>{{ object.get_status_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col col-md-5">
|
||||
<div class="card">
|
||||
<h5 class="card-header">{% trans "Actions" %}</h5>
|
||||
<div class="card-body">
|
||||
<a href="{% url 'plugins:netbox_services:service_resolve' pk=object.pk %}" class="btn btn-primary mb-2">
|
||||
<i class="mdi mdi-play"></i> {% trans "Resolve" %}
|
||||
</a>
|
||||
<p class="text-muted small mb-0">
|
||||
{% trans "Run the resolver to preview and/or reconcile the service's resource bindings." %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<div class="card">
|
||||
<h5 class="card-header">{% trans "Resource Bindings" %}</h5>
|
||||
<div class="card-body">
|
||||
{% if bindings %}
|
||||
<table class="table table-sm table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Object Type" %}</th>
|
||||
<th>{% trans "Object" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for binding in bindings %}
|
||||
<tr>
|
||||
<td>{{ binding.content_type }}</td>
|
||||
<td>{{ binding.content_object|default:'—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<span class="text-muted">{% trans "No bindings. Resolve the service to create bindings." %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,119 @@
|
||||
{% extends 'base/layout.html' %}
|
||||
{% load helpers %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{{ service }} — {% trans "Resolve" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-12">
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
{% trans "Resolution Preview" %} — {{ service }}
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
{% if result.errors %}
|
||||
<div class="alert alert-danger">
|
||||
<h6>{% trans "Errors" %}</h6>
|
||||
<ul>
|
||||
{% for error in result.errors %}
|
||||
<li>{{ error }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if result.warnings %}
|
||||
<div class="alert alert-warning">
|
||||
<h6>{% trans "Warnings" %}</h6>
|
||||
<ul>
|
||||
{% for warning in result.warnings %}
|
||||
<li>{{ warning }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-4">
|
||||
<div class="card border-success">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="text-success">{{ result.to_add|length }}</h3>
|
||||
<p class="mb-0">{% trans "to add" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col col-md-4">
|
||||
<div class="card border-danger">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="text-danger">{{ result.to_remove|length }}</h3>
|
||||
<p class="mb-0">{% trans "to remove" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col col-md-4">
|
||||
<div class="card border-secondary">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="text-secondary">{{ result.total_planned|add:'0' }}</h3>
|
||||
<p class="mb-0">{% trans "total planned" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if planned_preview %}
|
||||
<table class="table table-sm table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Node" %}</th>
|
||||
<th>{% trans "Object" %}</th>
|
||||
<th>{% trans "Action" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in planned_preview %}
|
||||
<tr>
|
||||
<td>{{ item.node }}</td>
|
||||
<td>{{ item.object }}</td>
|
||||
<td>
|
||||
{% if item.action == "add" %}
|
||||
<span class="badge bg-success">{% trans "add" %}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{% trans "keep" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-muted">{% trans "No objects would be bound." %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h5 class="card-header">{% trans "Commit" %}</h5>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">
|
||||
{% trans "Committing will add/remove bindings. Allocated objects (e.g. IP addresses) are not deleted on removal — only the binding is removed." %}
|
||||
</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="mdi mdi-check"></i> {% trans "Commit" %}
|
||||
</button>
|
||||
<a href="{% url 'plugins:netbox_services:service' pk=service.pk %}" class="btn btn-secondary">
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,67 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-7">
|
||||
<div class="card">
|
||||
<h5 class="card-header">{% trans "Service Template" %}</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Status" %}</th>
|
||||
<td>{{ object.get_status_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Locked" %}</th>
|
||||
<td>
|
||||
{% if is_locked %}
|
||||
<span class="text-warning">{% trans "Yes — services have been spawned" %}</span>
|
||||
{% else %}
|
||||
<span class="text-success">{% trans "No — editable" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col col-md-5">
|
||||
<div class="card">
|
||||
<h5 class="card-header">{% trans "Stats" %}</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Services" %}</th>
|
||||
<td><a href="{% url 'plugins:netbox_services:service_list' %}?service_template={{ object.pk }}">{{ object.services.count }}</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<div class="card">
|
||||
<h5 class="card-header">{% trans "Requirement Tree" %}</h5>
|
||||
<div class="card-body">
|
||||
{% if object.tree %}
|
||||
{% include 'netbox_services/_tree_node.html' with nodes=object.tree depth=0 %}
|
||||
{% else %}
|
||||
<span class="text-muted">{% trans "No requirements defined." %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Whitespace-only changes.
@@ -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
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
from django.urls import include, path
|
||||
from utilities.urls import get_model_urls
|
||||
|
||||
from . import views # noqa: F401 — ensure @register_model_view decorators run
|
||||
|
||||
urlpatterns = [
|
||||
path("service-templates/", include(get_model_urls("netbox_services", "servicetemplate", detail=False))),
|
||||
path("service-templates/<int:pk>/", include(get_model_urls("netbox_services", "servicetemplate"))),
|
||||
path("services/", include(get_model_urls("netbox_services", "service", detail=False))),
|
||||
path("services/<int:pk>/", include(get_model_urls("netbox_services", "service"))),
|
||||
]
|
||||
@@ -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",
|
||||
})
|
||||
@@ -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__" }
|
||||
Reference in new issue
Block a user