From b1c9c7a40b8b6684bd5d80e75265955808fbd1a1 Mon Sep 17 00:00:00 2001 From: Sven Luethi Date: Mon, 17 Aug 2026 11:04:28 +0200 Subject: [PATCH] modified: netbox_services/engine/__init__.py modified: netbox_services/engine/allocators.py modified: netbox_services/engine/context.py new file: netbox_services/engine/mappers.py modified: netbox_services/engine/resolver.py new file: netbox_services/engine/templating.py modified: netbox_services/forms.py modified: netbox_services/models.py modified: netbox_services/tests/test_allocators.py new file: netbox_services/tests/test_mappers.py modified: netbox_services/tests/test_resolver.py new file: netbox_services/tests/test_templating.py Simplify layout to template and service instances --- netbox_services/engine/__init__.py | 4 + netbox_services/engine/allocators.py | 68 ++++- netbox_services/engine/context.py | 15 +- netbox_services/engine/mappers.py | 183 +++++++++++++ netbox_services/engine/resolver.py | 84 +++++- netbox_services/engine/templating.py | 80 ++++++ netbox_services/forms.py | 123 +++++++++ netbox_services/models.py | 2 + netbox_services/tests/test_allocators.py | 72 ++++- netbox_services/tests/test_mappers.py | 82 ++++++ netbox_services/tests/test_resolver.py | 323 ++++++++++++++++++++++- netbox_services/tests/test_templating.py | 130 +++++++++ 12 files changed, 1133 insertions(+), 33 deletions(-) create mode 100644 netbox_services/engine/mappers.py create mode 100644 netbox_services/engine/templating.py create mode 100644 netbox_services/tests/test_mappers.py create mode 100644 netbox_services/tests/test_templating.py diff --git a/netbox_services/engine/__init__.py b/netbox_services/engine/__init__.py index e69de29..4e94673 100644 --- a/netbox_services/engine/__init__.py +++ b/netbox_services/engine/__init__.py @@ -0,0 +1,4 @@ +"""Importing this package registers all selectors and allocators.""" + +from . import selectors # noqa: F401 (registers via decorators) +from . import allocators # noqa: F401 (registers via decorators) diff --git a/netbox_services/engine/allocators.py b/netbox_services/engine/allocators.py index 583d072..7b5293a 100644 --- a/netbox_services/engine/allocators.py +++ b/netbox_services/engine/allocators.py @@ -1,6 +1,68 @@ -"""Allocators create or acquire objects that don't exist yet (e.g. IP addresses).""" +"""Allocators create or acquire objects that don't exist yet. + +Strategies: + - ``create`` — instantiate a new model instance from attrs (unsaved) + - ``first_free`` — allocate the first available IP from a prefix + - ``specific`` — use an existing object by address or context path +""" + +from django.db.models import Model from .registry import register_allocator, get_selector +from .templating import render_attrs + + +@register_allocator("create") +class CreateAllocator: + """Instantiate a new model instance from a dict of attrs. + + The instance is returned **unsaved** — the resolver saves it after + applying the parent link (if any). + + Attrs values may be: + - Plain literals (int, bool, list) — passed through + - Jinja2 strings with ``{{ }}`` — rendered with ``service``, + ``index``, ``p1`` (parent), ``p2`` (grandparent), etc. + - FK fields: the rendered value is treated as a **PK** and + resolved to the related model instance via ``.objects.get(pk=...)`` + + Spec shape:: + + {"strategy": "create", "attrs": {"name": "rtr-{{ service.tenant.slug }}-{{ index + 1 }}", "site": "{{ ..site.pk }}", "status": "active"}} + """ + + def __init__(self, spec): + self.spec = spec + self.attrs = spec.get("attrs", {}) + + def allocate(self, context, model=None, index=0, ancestors=None): + if model is None: + raise ValueError("CreateAllocator requires a model class.") + + rendered = render_attrs( + self.attrs, context.service, ancestors or [], index, + ) + + kwargs = {} + for key, value in rendered.items(): + try: + field = model._meta.get_field(key) + except Exception: + kwargs[key] = value + continue + + if ( + field.is_relation + and field.many_to_one + and not isinstance(value, Model) + and value is not None + ): + related = field.remote_field.model + kwargs[key] = related.objects.get(pk=int(value)) + else: + kwargs[key] = value + + return model(**kwargs) @register_allocator("first_free") @@ -23,7 +85,7 @@ class FirstFreeIPAddressAllocator: qs = selector.resolve(context, Prefix) return qs.first() - def allocate(self, context): + def allocate(self, context, model=None, index=0, ancestors=None): from ipam.models import IPAddress from ipam.choices import IPAddressStatusChoices @@ -62,7 +124,7 @@ class SpecificAllocator: self.path = spec.get("path") self.address = spec.get("address") - def allocate(self, context): + def allocate(self, context, model=None, index=0, ancestors=None): if self.address: from ipam.models import IPAddress from ipam.choices import IPAddressStatusChoices diff --git a/netbox_services/engine/context.py b/netbox_services/engine/context.py index 5cd9d12..b6e929e 100644 --- a/netbox_services/engine/context.py +++ b/netbox_services/engine/context.py @@ -1,22 +1,29 @@ -"""Resolution context — carries service and parent references during tree walk.""" +"""Resolution context — carries service and ancestor chain during tree walk.""" class Context: """Evaluation frame for the resolver. - Top-level context has ``service`` set and ``parent_object`` as None. + Top-level context has ``service`` set, ``parent_object`` as None, + and ``ancestors`` as an empty list. + Each selected/allocated object pushes a child context so children - can reference ``parent`` and traverse its relations. + can reference ``parent`` (immediate) and ``ancestors`` (full chain). + + ``ancestors[0]`` is the immediate parent (``p1`` in Jinja2), + ``ancestors[1]`` is the grandparent (``p2``), etc. """ - def __init__(self, service, parent_object=None): + def __init__(self, service, parent_object=None, ancestors=None): self.service = service self.parent_object = parent_object + self.ancestors = ancestors if ancestors is not None else [] def child(self, obj): return Context( service=self.service, parent_object=obj, + ancestors=[obj] + self.ancestors, ) @property diff --git a/netbox_services/engine/mappers.py b/netbox_services/engine/mappers.py new file mode 100644 index 0000000..11f4ff4 --- /dev/null +++ b/netbox_services/engine/mappers.py @@ -0,0 +1,183 @@ +"""Central mapper for parent→child containment links. + +Given a child model class and a parent model class, ``map_link`` returns +the field name on the child that should be set to connect it to its parent +in the NetBox object hierarchy (e.g. ``Interface.device``, ``IPAddress.assigned_object``). + +This keeps the resolver free of per-node ``link`` boilerplate — the tree +author never specifies how children attach; the mapper knows. +""" + +_LINK_MAP = { + # ── DCIM: Device components → Device ───────────────────── + ("dcim.consoleport", "dcim.device"): "device", + ("dcim.consoleserverport", "dcim.device"): "device", + ("dcim.powerport", "dcim.device"): "device", + ("dcim.poweroutlet", "dcim.device"): "device", + ("dcim.interface", "dcim.device"): "device", + ("dcim.frontport", "dcim.device"): "device", + ("dcim.rearport", "dcim.device"): "device", + ("dcim.modulebay", "dcim.device"): "device", + ("dcim.devicebay", "dcim.device"): "device", + ("dcim.inventoryitem", "dcim.device"): "device", + ("dcim.module", "dcim.device"): "device", + ("dcim.virtualdevicecontext", "dcim.device"): "device", + + # ── DCIM: Components → Module ──────────────────────────── + ("dcim.consoleport", "dcim.module"): "module", + ("dcim.consoleserverport", "dcim.module"): "module", + ("dcim.powerport", "dcim.module"): "module", + ("dcim.poweroutlet", "dcim.module"): "module", + ("dcim.interface", "dcim.module"): "module", + ("dcim.frontport", "dcim.module"): "module", + ("dcim.rearport", "dcim.module"): "module", + + # ── DCIM: Hierarchical (self-referential) ─────────────── + ("dcim.interface", "dcim.interface"): "parent", + ("dcim.modulebay", "dcim.modulebay"): "parent", + ("dcim.inventoryitem", "dcim.inventoryitem"): "parent", + ("dcim.devicerole", "dcim.devicerole"): "parent", + ("dcim.platform", "dcim.platform"): "parent", + ("dcim.region", "dcim.region"): "parent", + ("dcim.sitegroup", "dcim.sitegroup"): "parent", + ("dcim.location", "dcim.location"): "parent", + + # ── DCIM: Site hierarchy ───────────────────────────────── + ("dcim.site", "dcim.region"): "region", + ("dcim.site", "dcim.sitegroup"): "group", + ("dcim.location", "dcim.site"): "site", + + # ── DCIM: Device placement ─────────────────────────────── + ("dcim.device", "dcim.site"): "site", + ("dcim.device", "dcim.location"): "location", + ("dcim.device", "dcim.rack"): "rack", + ("dcim.device", "dcim.cluster"): "cluster", + ("dcim.device", "dcim.virtualchassis"): "virtual_chassis", + + # ── DCIM: Rack ─────────────────────────────────────────── + ("dcim.rack", "dcim.site"): "site", + ("dcim.rack", "dcim.location"): "location", + ("dcim.rack", "dcim.rackgroup"): "group", + ("dcim.rack", "dcim.rackrole"): "role", + ("dcim.rack", "dcim.racktype"): "rack_type", + ("dcim.rackreservation", "dcim.rack"): "rack", + + # ── DCIM: Power ────────────────────────────────────────── + ("dcim.powerpanel", "dcim.site"): "site", + ("dcim.powerpanel", "dcim.location"): "location", + ("dcim.powerfeed", "dcim.powerpanel"): "power_panel", + ("dcim.powerfeed", "dcim.rack"): "rack", + + # ── DCIM: Component inter-relations ────────────────────── + ("dcim.poweroutlet", "dcim.powerport"): "power_port", + ("dcim.frontport", "dcim.rearport"): "rear_port", + + # ── DCIM: Cables ───────────────────────────────────────── + ("dcim.cable", "dcim.cablebundle"): "bundle", + ("dcim.cabletermination", "dcim.cable"): "cable", + + # ── DCIM: GFK assignments ──────────────────────────────── + ("dcim.macaddress", "dcim.interface"): "assigned_object", + ("dcim.macaddress", "virtualization.vminterface"): "assigned_object", + + # ── DCIM: InventoryItem ────────────────────────────────── + ("dcim.inventoryitem", "dcim.inventoryitemrole"): "role", + ("dcim.inventoryitem", "dcim.manufacturer"): "manufacturer", + + # ── DCIM: Type hierarchy ───────────────────────────────── + ("dcim.devicetype", "dcim.manufacturer"): "manufacturer", + ("dcim.moduletype", "dcim.manufacturer"): "manufacturer", + ("dcim.platform", "dcim.manufacturer"): "manufacturer", + + # ── DCIM: Templates → DeviceType ───────────────────────── + ("dcim.consoleporttemplate", "dcim.devicetype"): "device_type", + ("dcim.consoleserverporttemplate", "dcim.devicetype"): "device_type", + ("dcim.powerporttemplate", "dcim.devicetype"): "device_type", + ("dcim.poweroutlettemplate", "dcim.devicetype"): "device_type", + ("dcim.interfacetemplate", "dcim.devicetype"): "device_type", + ("dcim.frontporttemplate", "dcim.devicetype"): "device_type", + ("dcim.rearporttemplate", "dcim.devicetype"): "device_type", + ("dcim.modulebaytemplate", "dcim.devicetype"): "device_type", + ("dcim.devicebaytemplate", "dcim.devicetype"): "device_type", + ("dcim.inventoryitemtemplate", "dcim.devicetype"): "device_type", + + # ── DCIM: Templates → ModuleType ───────────────────────── + ("dcim.consoleporttemplate", "dcim.moduletype"): "module_type", + ("dcim.consoleserverporttemplate", "dcim.moduletype"): "module_type", + ("dcim.powerporttemplate", "dcim.moduletype"): "module_type", + ("dcim.poweroutlettemplate", "dcim.moduletype"): "module_type", + ("dcim.interfacetemplate", "dcim.moduletype"): "module_type", + ("dcim.frontporttemplate", "dcim.moduletype"): "module_type", + ("dcim.rearporttemplate", "dcim.moduletype"): "module_type", + ("dcim.modulebaytemplate", "dcim.moduletype"): "module_type", + + # ── IPAM: IPAddress → Interface/VMInterface/FHRP (GFK) ─── + ("ipam.ipaddress", "dcim.interface"): "assigned_object", + ("ipam.ipaddress", "virtualization.vminterface"): "assigned_object", + ("ipam.ipaddress", "ipam.fhrpgroup"): "assigned_object", + + # ── IPAM: VLAN ─────────────────────────────────────────── + ("ipam.vlan", "ipam.vlangroup"): "group", + ("ipam.vlan", "dcim.site"): "site", + + # ── IPAM: Prefix scope (GFK) ───────────────────────────── + ("ipam.prefix", "dcim.site"): "scope", + ("ipam.prefix", "dcim.location"): "scope", + ("ipam.prefix", "dcim.region"): "scope", + ("ipam.prefix", "dcim.sitegroup"): "scope", + + # ── IPAM: VLANGroup scope (GFK) ────────────────────────── + ("ipam.vlangroup", "dcim.site"): "scope", + ("ipam.vlangroup", "dcim.location"): "scope", + ("ipam.vlangroup", "dcim.region"): "scope", + ("ipam.vlangroup", "dcim.sitegroup"): "scope", + + # ── IPAM: FHRPGroupAssignment ──────────────────────────── + ("ipam.fhrpgroupassignment", "ipam.fhrpgroup"): "group", + ("ipam.fhrpgroupassignment", "dcim.interface"): "interface", + ("ipam.fhrpgroupassignment", "virtualization.vminterface"): "interface", + + # ── IPAM: Other ────────────────────────────────────────── + ("ipam.aggregate", "ipam.rir"): "rir", + ("ipam.asn", "ipam.rir"): "rir", + ("ipam.asnrange", "ipam.rir"): "rir", + ("ipam.vlantranslationrule", "ipam.vlantranslationpolicy"): "policy", + + # ── IPAM: Service (NetBox core ipam.Service) ───────────── + ("ipam.service", "dcim.device"): "parent", + ("ipam.service", "virtualization.virtualmachine"): "parent", + + # ── Virtualization ─────────────────────────────────────── + ("virtualization.vminterface", "virtualization.virtualmachine"): "virtual_machine", + ("virtualization.vminterface", "virtualization.vminterface"): "parent", + ("virtualization.virtualdisk", "virtualization.virtualmachine"): "virtual_machine", + ("virtualization.virtualmachine", "virtualization.cluster"): "cluster", + ("virtualization.virtualmachine", "dcim.site"): "site", + ("virtualization.virtualmachine", "dcim.device"): "device", + ("virtualization.cluster", "virtualization.clustergroup"): "group", + ("virtualization.cluster", "virtualization.clustertype"): "type", + ("virtualization.cluster", "dcim.site"): "scope", + ("virtualization.cluster", "dcim.location"): "scope", + ("virtualization.cluster", "dcim.region"): "scope", + ("virtualization.cluster", "dcim.sitegroup"): "scope", +} + + +def _type_key(model): + return f"{model._meta.app_label}.{model._meta.model_name}" + + +def map_link(child_model, parent_model): + """Return the field name on *child_model* that links it to *parent_model*. + + Returns ``None`` when no containment relationship is known. + """ + return _LINK_MAP.get((_type_key(child_model), _type_key(parent_model))) + + +def register_link(child_type, parent_type, field): + """Register or override a containment link. + + *child_type* and *parent_type* are ``"app_label.model_name"`` strings. + """ + _LINK_MAP[(child_type, parent_type)] = field diff --git a/netbox_services/engine/resolver.py b/netbox_services/engine/resolver.py index a5dc393..9111503 100644 --- a/netbox_services/engine/resolver.py +++ b/netbox_services/engine/resolver.py @@ -1,9 +1,9 @@ """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. +After allocating/selecting objects, the resolver auto-links children to +parents via the central mapper (``engine.mappers.map_link``). Existing +linkages are never overridden. """ import logging @@ -16,6 +16,7 @@ from ..models import ( ServiceResourceBinding, ) from .context import Context +from .mappers import map_link from .registry import get_allocator, get_selector logger = logging.getLogger("netbox_services.resolver") @@ -93,11 +94,33 @@ class Resolver: selector_spec = node.get("selector") allocation_spec = node.get("allocation") + parent = context.parent_object + link_field = map_link(model, parent.__class__) if parent is not None else None + strategy = allocation_spec.get("strategy") if allocation_spec else None + + if ( + parent is not None + and allocation_spec + and strategy in ("create", "first_free") + and link_field is None + ): + child_type = f"{model._meta.app_label}.{model._meta.model_name}" + parent_type = f"{parent.__class__._meta.app_label}.{parent.__class__._meta.model_name}" + raise ResolutionError( + f"Node '{node['name']}': no link mapping for " + f"{child_type} → {parent_type}. " + f"Extend the mapper: " + f"register_link(\"{child_type}\", \"{parent_type}\", \"\")" + ) + objects = self._resolve_objects( - node, service, result, context, model, quantity, selector_spec, allocation_spec, + node, service, result, context, model, quantity, + selector_spec, allocation_spec, link_field, parent, ) - for obj in objects: + for i, obj in enumerate(objects): + self._apply_link_and_save(obj, link_field, parent, strategy, node) + ct = ContentType.objects.get_for_model(obj.__class__) key = (ct.id, obj.pk) result.planned_set.add(key) @@ -114,11 +137,39 @@ class Resolver: 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): + def _apply_link_and_save(self, obj, link_field, parent, strategy, node): + """Set the parent link on *obj* if applicable, then save. + + Rules: + - ``specific`` strategy: never linked (existing objects left as-is) + - ``create`` / ``first_free``: link set only if the field is + currently ``None`` — existing linkages are NEVER overridden + - ``full_clean()`` only on unsaved objects (pk is None) + """ + if link_field is not None and strategy in ("create", "first_free"): + existing_link = getattr(obj, link_field, None) + if existing_link is None: + setattr(obj, link_field, parent) + if obj.pk is None: + obj.full_clean() + obj.save() + else: + obj.save() + return + # Already linked — do not change + return + + if obj.pk is None: + obj.full_clean() + obj.save() + + def _resolve_objects(self, node, service, result, context, model, quantity, + selector_spec, allocation_spec, link_field, parent): """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, + node, service, result, context, model, quantity, + allocation_spec, link_field, parent, ) if selector_spec: @@ -146,9 +197,17 @@ class Resolver: ) 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.""" + def _allocate_with_reuse(self, node, service, result, context, model, quantity, + allocation_spec, link_field, parent): + """Allocate new objects, but reuse existing bindings of the same type if present. + + When *link_field* and *parent* are known, only objects already linked + to this specific parent are considered for reuse. + """ existing = self._get_existing_bound_objects(model, service) + if link_field and parent is not None: + existing = [o for o in existing if getattr(o, link_field, None) == parent] + if len(existing) >= quantity: objects = existing[:quantity] result.summary["reused"] = result.summary.get("reused", 0) + len(objects) @@ -157,8 +216,11 @@ class Resolver: objects = list(existing) needed = quantity - len(existing) allocator = get_allocator(allocation_spec) - for _ in range(needed): - obj = allocator.allocate(context) + for i in range(needed): + obj = allocator.allocate( + context, model=model, index=len(existing) + i, + ancestors=context.ancestors, + ) if obj is None: raise ResolutionError( f"Node '{node['name']}': allocator returned None." diff --git a/netbox_services/engine/templating.py b/netbox_services/engine/templating.py new file mode 100644 index 0000000..07a23bd --- /dev/null +++ b/netbox_services/engine/templating.py @@ -0,0 +1,80 @@ +"""Jinja2-based attribute templating with ``..`` / ``...`` parent access. + +Syntax inside ``{{ }}`` expressions: + - ``..`` → immediate parent (alias ``p1``) + - ``...`` → parent of parent (alias ``p2``) + - ``....`` → great-grandparent (alias ``p3``) + - ``service`` → the Service instance + - ``index`` → 0-based index within a ``quantity`` loop + +Example:: + + attrs: + name: "rtr-{{ service.tenant.slug }}-{{ index + 1 }}" + site: "{{ ..site.pk }}" # parent device's site PK + notes: "{{ ...region.slug }}" # grandparent region slug +""" + +import re + +from jinja2.sandbox import SandboxedEnvironment + +_DOT_RE = re.compile(r"(? 1) +# selector (mutually exclusive with allocation) find +# existing objects +# allocation (mutually exclusive with selector) create/acquire +# objects +# children (optional) list of child requirement nodes +# +# Selector types: +# netbox_filter — filter existing objects via NetBox filter +# params +# reference — resolve a single object via context path +# (parent.site) +# prefix — select ipam.Prefix by filter or reference +# +# Allocation strategies: +# create — instantiate a new model from attrs +# (returned unsaved, resolver saves after +# auto-linking to parent) +# first_free — allocate next available IP from a resolved +# prefix +# specific — use an existing object by address or context +# path +# +# Jinja2 templating in allocation.attrs values: +# {{ service.tenant.slug }} — the service's tenant +# {{ index + 1 }} — 0-based index within a quantity # loop +# {{ ..name }} — immediate parent's name +# (.. = parent) +# {{ ...site.slug }} — grandparent's site slug +# (... = grandparent) +# {{ ....region.slug }} — great-grandparent (.... = p3) +# FK fields must be PKs (integers), e.g. site: 1 or +# site: "{{ ..site.pk }}" +# +# Auto-linking: children created via create/first_free +# are automatically linked to their parent via the central mapper +# (engine/mappers.py). +# You do NOT specify the link field — the mapper knows. +# If a mapping is missing, resolution fails with an error telling +# you exactly which register_link() call to add. + +# ── Example 1: Select 2 existing routers, assign each a management IP ── +- name: routers + object_type: dcim.device + quantity: 2 + selector: + type: netbox_filter + filters: + role: router + site: hq + scope: service + children: + - name: mgmt_ip + object_type: ipam.ipaddress + quantity: 1 + allocation: + strategy: first_free + prefix: + type: prefix + filters: + prefix: 10.0.0.0/8 + scope: parent + +# ── Example 2: Create a new device + interface + IP (all auto-linked) ── +- name: edge_router + object_type: dcim.device + quantity: 1 + allocation: + strategy: create + attrs: + name: "rtr-{{ service.tenant.slug }}-{{ index + 1 }}" + device_type: 1 # PK of a DeviceType + role: 1 # PK of a DeviceRole + site: "{{ ..site.pk }}" # not valid at top-level (no parent); + # use a literal PK here, e.g. site: 1 + status: active + scope: service + children: + - name: uplink + object_type: dcim.interface + quantity: 1 + allocation: + strategy: create + attrs: + name: eth0 + type: 1000base-t + scope: parent + children: + - name: uplink_ip + object_type: ipam.ipaddress + quantity: 1 + allocation: + strategy: first_free + prefix: + type: prefix + filters: + prefix: 10.0.0.0/8 + scope: parent + +# ── Example 3: Use a specific existing IP address ── +- name: loopback + object_type: ipam.ipaddress + quantity: 1 + allocation: + strategy: specific + address: 10.255.255.1/32 + scope: service +""" + + class ServiceTemplateForm(PrimaryModelForm): tree_raw = forms.CharField( label=_("Requirement tree"), @@ -76,6 +197,8 @@ class ServiceTemplateForm(PrimaryModelForm): self.fields["tree_raw"].initial = yaml.dump( self.instance.tree, default_flow_style=False, sort_keys=False, ) + elif not self.instance.pk: + self.fields["tree_raw"].initial = EXAMPLE_TREE_YAML def clean(self): super().clean() diff --git a/netbox_services/models.py b/netbox_services/models.py index bf11e09..be9d909 100644 --- a/netbox_services/models.py +++ b/netbox_services/models.py @@ -112,6 +112,8 @@ def validate_tree(tree): 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.")}) + if allocation and "attrs" in allocation and not isinstance(allocation["attrs"], dict): + raise ValidationError({"tree": _(f"Node '{name}': allocation.attrs must be a dict.")}) scope = node.get("scope", "parent") if scope not in RequirementScopeChoices.values(): diff --git a/netbox_services/tests/test_allocators.py b/netbox_services/tests/test_allocators.py index c4beb3f..173c152 100644 --- a/netbox_services/tests/test_allocators.py +++ b/netbox_services/tests/test_allocators.py @@ -1,6 +1,10 @@ import pytest -from netbox_services.engine.allocators import FirstFreeIPAddressAllocator, SpecificAllocator +from netbox_services.engine.allocators import ( + CreateAllocator, + FirstFreeIPAddressAllocator, + SpecificAllocator, +) @pytest.mark.django_db @@ -41,3 +45,69 @@ class TestSpecificAllocator: allocator = SpecificAllocator({"strategy": "specific"}) with pytest.raises(ValueError): allocator.allocate(Context(service=None)) + + +@pytest.mark.django_db +class TestCreateAllocator: + def test_create_returns_unsaved_instance(self, db): + from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site + from netbox_services.engine.context import Context + + mfr = Manufacturer.objects.create(name="alloc-mfr", slug="alloc-mfr") + dt = DeviceType.objects.create(manufacturer=mfr, model="alloc-model", slug="alloc-model") + role = DeviceRole.objects.create(name="alloc-role", slug="alloc-role") + site = Site.objects.create(name="alloc-site", slug="alloc-site") + + allocator = CreateAllocator({ + "strategy": "create", + "attrs": { + "name": "test-device", + "device_type": dt.pk, + "role": role.pk, + "site": site.pk, + "status": "active", + }, + }) + ctx = Context(service=None) + obj = allocator.allocate(ctx, model=Device) + assert obj.pk is None + assert obj.name == "test-device" + assert obj.device_type == dt + assert obj.role == role + assert obj.site == site + assert obj.status == "active" + + def test_create_with_jinja2_attrs(self, db): + from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site + from tenancy.models import Tenant + from netbox_services.engine.context import Context + + mfr = Manufacturer.objects.create(name="jinja-mfr", slug="jinja-mfr") + dt = DeviceType.objects.create(manufacturer=mfr, model="jinja-model", slug="jinja-model") + role = DeviceRole.objects.create(name="jinja-role", slug="jinja-role") + site = Site.objects.create(name="jinja-site", slug="jinja-site") + tenant = Tenant.objects.create(name="jinja-tenant", slug="jinja-tenant") + + service = type("FakeService", (), {"tenant": tenant})() + parent = Device(name="parent-dev", device_type=dt, role=role, site=site) + + allocator = CreateAllocator({ + "strategy": "create", + "attrs": { + "name": "rtr-{{ service.tenant.slug }}-{{ index + 1 }}", + "device_type": dt.pk, + "role": role.pk, + "site": "{{ ..site.pk }}", + "status": "active", + }, + }) + ctx = Context(service=service, parent_object=parent) + obj = allocator.allocate(ctx, model=Device, index=0, ancestors=[parent]) + assert obj.name == "rtr-jinja-tenant-1" + assert obj.site == site + + def test_create_requires_model(self, db): + from netbox_services.engine.context import Context + allocator = CreateAllocator({"strategy": "create", "attrs": {}}) + with pytest.raises(ValueError): + allocator.allocate(Context(service=None)) diff --git a/netbox_services/tests/test_mappers.py b/netbox_services/tests/test_mappers.py new file mode 100644 index 0000000..681a02a --- /dev/null +++ b/netbox_services/tests/test_mappers.py @@ -0,0 +1,82 @@ +"""Tests for the central link mapper.""" + +from netbox_services.engine.mappers import map_link, register_link, _LINK_MAP + + +class TestMapLink: + def test_interface_to_device(self): + from dcim.models import Device, Interface + assert map_link(Interface, Device) == "device" + + def test_ipaddress_to_interface(self): + from dcim.models import Interface + from ipam.models import IPAddress + assert map_link(IPAddress, Interface) == "assigned_object" + + def test_ipaddress_to_vminterface(self): + from ipam.models import IPAddress + from virtualization.models import VMInterface + assert map_link(IPAddress, VMInterface) == "assigned_object" + + def test_interface_to_module(self): + from dcim.models import Interface, Module + assert map_link(Interface, Module) == "module" + + def test_device_to_site(self): + from dcim.models import Device, Site + assert map_link(Device, Site) == "site" + + def test_device_to_rack(self): + from dcim.models import Device, Rack + assert map_link(Device, Rack) == "rack" + + def test_prefix_to_site_scope(self): + from dcim.models import Site + from ipam.models import Prefix + assert map_link(Prefix, Site) == "scope" + + def test_vlan_to_vlangroup(self): + from ipam.models import VLAN, VLANGroup + assert map_link(VLAN, VLANGroup) == "group" + + def test_interface_self_referential(self): + from dcim.models import Interface + assert map_link(Interface, Interface) == "parent" + + def test_vminterface_to_virtualmachine(self): + from virtualization.models import VMInterface, VirtualMachine + assert map_link(VMInterface, VirtualMachine) == "virtual_machine" + + def test_poweroutlet_to_powerport(self): + from dcim.models import PowerOutlet, PowerPort + assert map_link(PowerOutlet, PowerPort) == "power_port" + + def test_frontport_to_rearport(self): + from dcim.models import FrontPort, RearPort + assert map_link(FrontPort, RearPort) == "rear_port" + + def test_unmapped_returns_none(self): + from dcim.models import Device, Rack + from ipam.models import IPAddress + assert map_link(IPAddress, Device) is None + assert map_link(Device, Device) is None + assert map_link(Rack, Device) is None + + +class TestRegisterLink: + def test_register_new_link(self): + from dcim.models import Device, Rack + assert map_link(Rack, Device) is None + register_link("dcim.rack", "dcim.device", "device") + assert map_link(Rack, Device) == "device" + # cleanup + del _LINK_MAP[("dcim.rack", "dcim.device")] + assert map_link(Rack, Device) is None + + def test_override_existing_link(self): + from dcim.models import Interface + original = map_link(Interface, Interface) + register_link("dcim.interface", "dcim.interface", "lag") + assert map_link(Interface, Interface) == "lag" + # restore + register_link("dcim.interface", "dcim.interface", original) diff --git a/netbox_services/tests/test_resolver.py b/netbox_services/tests/test_resolver.py index fcd0cca..4d6e09e 100644 --- a/netbox_services/tests/test_resolver.py +++ b/netbox_services/tests/test_resolver.py @@ -115,21 +115,20 @@ class TestResolverCommit: 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": [{ + 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, @@ -137,10 +136,10 @@ class TestResolverCommit: "strategy": "first_free", "prefix": {"type": "prefix", "filters": {}}, }, - "scope": "parent", + "scope": "service", "children": [], - }], - }], + }, + ], ) svc = Service.objects.create(name="nested-svc", service_template=tmpl, tenant=tenant) @@ -242,3 +241,299 @@ class TestConflictDetection: b1 = ServiceResourceBinding.objects.filter(service=svc1).first() b2 = ServiceResourceBinding.objects.filter(service=svc2).first() assert b1.object_id != b2.object_id + + +@pytest.mark.django_db +class TestAutoLinking: + @pytest.fixture + def create_tree_data(self, db, site, device_type, role, prefix, tenant): + return { + "site": site, + "device_type": device_type, + "role": role, + "prefix": prefix, + "tenant": tenant, + } + + def test_device_interface_ip_chain(self, create_tree_data): + from dcim.models import Device, Interface + from ipam.models import IPAddress + + d = create_tree_data + tmpl = ServiceTemplate.objects.create( + name="chain-tmpl", + status="active", + tree=[{ + "name": "device", + "object_type": "dcim.device", + "quantity": 1, + "allocation": { + "strategy": "create", + "attrs": { + "name": "chain-rtr-01", + "device_type": d["device_type"].pk, + "role": d["role"].pk, + "site": d["site"].pk, + "status": "active", + }, + }, + "scope": "service", + "children": [{ + "name": "interface", + "object_type": "dcim.interface", + "quantity": 1, + "allocation": { + "strategy": "create", + "attrs": {"name": "eth0", "type": "1000base-t"}, + }, + "scope": "parent", + "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="chain-svc", service_template=tmpl, tenant=d["tenant"]) + + resolver = Resolver() + result = resolver.resolve(svc, mode="commit") + assert result.succeeded + assert ServiceResourceBinding.objects.filter(service=svc).count() == 3 + + dev = Device.objects.get(name="chain-rtr-01") + iface = Interface.objects.get(name="eth0", device=dev) + ip = IPAddress.objects.filter(address__net_contains="10.200.0.1").first() + + assert iface.device == dev + assert ip is not None + assert ip.assigned_object == iface + + def test_reresolve_idempotent_with_links(self, create_tree_data): + from dcim.models import Device, Interface + from ipam.models import IPAddress + + d = create_tree_data + tmpl = ServiceTemplate.objects.create( + name="idem-tmpl", + status="active", + tree=[{ + "name": "device", + "object_type": "dcim.device", + "quantity": 1, + "allocation": { + "strategy": "create", + "attrs": { + "name": "idem-rtr-01", + "device_type": d["device_type"].pk, + "role": d["role"].pk, + "site": d["site"].pk, + "status": "active", + }, + }, + "scope": "service", + "children": [{ + "name": "interface", + "object_type": "dcim.interface", + "quantity": 1, + "allocation": { + "strategy": "create", + "attrs": {"name": "eth0", "type": "1000base-t"}, + }, + "scope": "parent", + "children": [], + }], + }], + ) + svc = Service.objects.create(name="idem-svc", service_template=tmpl, tenant=d["tenant"]) + + resolver = Resolver() + resolver.resolve(svc, mode="commit") + dev_count = Device.objects.count() + iface_count = Interface.objects.count() + + result = resolver.resolve(svc, mode="commit") + assert len(result.to_add) == 0 + assert len(result.to_remove) == 0 + assert Device.objects.count() == dev_count + assert Interface.objects.count() == iface_count + + def test_multi_device_no_cross_parent_reuse(self, create_tree_data): + from dcim.models import Device, Interface + + d = create_tree_data + tmpl = ServiceTemplate.objects.create( + name="multi-tmpl", + status="active", + tree=[{ + "name": "devices", + "object_type": "dcim.device", + "quantity": 2, + "allocation": { + "strategy": "create", + "attrs": { + "name": "multi-rtr-{{ index + 1 }}", + "device_type": d["device_type"].pk, + "role": d["role"].pk, + "site": d["site"].pk, + "status": "active", + }, + }, + "scope": "service", + "children": [{ + "name": "interface", + "object_type": "dcim.interface", + "quantity": 1, + "allocation": { + "strategy": "create", + "attrs": {"name": "eth0", "type": "1000base-t"}, + }, + "scope": "parent", + "children": [], + }], + }], + ) + svc = Service.objects.create(name="multi-svc", service_template=tmpl, tenant=d["tenant"]) + + resolver = Resolver() + result = resolver.resolve(svc, mode="commit") + assert result.succeeded + assert ServiceResourceBinding.objects.filter(service=svc).count() == 4 + + devs = Device.objects.filter(name__startswith="multi-rtr-") + assert devs.count() == 2 + for dev in devs: + ifaces = Interface.objects.filter(device=dev) + assert ifaces.count() == 1 + assert ifaces[0].name == "eth0" + + # Re-run: should not create new interfaces or reassign existing ones + result2 = resolver.resolve(svc, mode="commit") + assert len(result2.to_add) == 0 + assert Interface.objects.filter(name="eth0").count() == 2 + + def test_existing_link_not_overridden(self, create_tree_data): + from dcim.models import Device, Interface + + d = create_tree_data + tmpl = ServiceTemplate.objects.create( + name="lock-tmpl", + status="active", + tree=[{ + "name": "device", + "object_type": "dcim.device", + "quantity": 1, + "allocation": { + "strategy": "create", + "attrs": { + "name": "lock-rtr-01", + "device_type": d["device_type"].pk, + "role": d["role"].pk, + "site": d["site"].pk, + "status": "active", + }, + }, + "scope": "service", + "children": [{ + "name": "interface", + "object_type": "dcim.interface", + "quantity": 1, + "allocation": { + "strategy": "create", + "attrs": {"name": "eth0", "type": "1000base-t"}, + }, + "scope": "parent", + "children": [], + }], + }], + ) + svc = Service.objects.create(name="lock-svc", service_template=tmpl, tenant=d["tenant"]) + + resolver = Resolver() + resolver.resolve(svc, mode="commit") + + dev = Device.objects.get(name="lock-rtr-01") + iface = Interface.objects.get(name="eth0", device=dev) + assert iface.device == dev + + # Manually reassign the interface to a different device + other_dev = Device.objects.create( + name="other-dev", site=d["site"], device_type=d["device_type"], role=d["role"], + ) + iface.device = other_dev + iface.save() + + # Re-resolve: the interface is already linked (to other_dev), + # so the resolver must NOT override it back to dev. + result = resolver.resolve(svc, mode="commit") + iface.refresh_from_db() + assert iface.device == other_dev + + +@pytest.mark.django_db +class TestMissingMapping: + @pytest.fixture + def create_tree_data(self, db, site, device_type, role, prefix, tenant): + return { + "site": site, + "device_type": device_type, + "role": role, + "prefix": prefix, + "tenant": tenant, + } + + def test_missing_mapping_raises_error(self, create_tree_data): + from netbox_services.engine.resolver import ResolutionError + + d = create_tree_data + # ipam.ipaddress under dcim.device with create strategy — no mapping + # exists for (ipam.ipaddress, dcim.device). + tmpl = ServiceTemplate.objects.create( + name="missing-map-tmpl", + status="active", + tree=[{ + "name": "device", + "object_type": "dcim.device", + "quantity": 1, + "allocation": { + "strategy": "create", + "attrs": { + "name": "missing-map-rtr", + "device_type": d["device_type"].pk, + "role": d["role"].pk, + "site": d["site"].pk, + "status": "active", + }, + }, + "scope": "service", + "children": [{ + "name": "ip", + "object_type": "ipam.ipaddress", + "quantity": 1, + "allocation": { + "strategy": "create", + "attrs": {"address": "10.99.99.1/24", "status": "active"}, + }, + "scope": "parent", + "children": [], + }], + }], + ) + svc = Service.objects.create(name="missing-map-svc", service_template=tmpl, tenant=d["tenant"]) + + resolver = Resolver() + with pytest.raises(ResolutionError) as exc_info: + resolver.resolve(svc, mode="dry_run") + + msg = str(exc_info.value) + assert "no link mapping" in msg + assert "ipam.ipaddress" in msg + assert "dcim.device" in msg + assert "register_link" in msg diff --git a/netbox_services/tests/test_templating.py b/netbox_services/tests/test_templating.py new file mode 100644 index 0000000..d01f29d --- /dev/null +++ b/netbox_services/tests/test_templating.py @@ -0,0 +1,130 @@ +"""Tests for Jinja2 attribute templating with .. / ... parent access.""" + +import pytest + +from netbox_services.engine.templating import ( + preprocess_dots, + render_value, + render_attrs, +) + + +class TestPreprocessDots: + def test_double_dot(self): + assert preprocess_dots("{{ ..name }}") == "{{ p1.name }}" + + def test_triple_dot(self): + assert preprocess_dots("{{ ...site.slug }}") == "{{ p2.site.slug }}" + + def test_quadruple_dot(self): + assert preprocess_dots("{{ ....region.slug }}") == "{{ p3.region.slug }}" + + def test_no_dots(self): + assert preprocess_dots("1000base-t") == "1000base-t" + + def test_ip_address_preserved(self): + assert preprocess_dots("10.0.0.1/24") == "10.0.0.1/24" + + def test_service_ref_preserved(self): + assert preprocess_dots("{{ service.tenant.slug }}") == "{{ service.tenant.slug }}" + + def test_mixed(self): + assert ( + preprocess_dots("rtr-{{ service.tenant.slug }}-{{ ..name }}-{{ ...site.slug }}") + == "rtr-{{ service.tenant.slug }}-{{ p1.name }}-{{ p2.site.slug }}" + ) + + def test_plain_string_no_braces(self): + assert preprocess_dots("active") == "active" + + def test_empty_string(self): + assert preprocess_dots("") == "" + + +class TestRenderValue: + def test_literal_int(self): + assert render_value(42, None, [], 0) == 42 + + def test_literal_bool(self): + assert render_value(True, None, [], 0) is True + + def test_plain_string(self): + assert render_value("active", None, [], 0) == "active" + + def test_ip_address_string(self): + assert render_value("10.0.0.1/24", None, [], 0) == "10.0.0.1/24" + + def test_jinja2_with_parent(self): + parent = {"name": "rtr-01"} + result = render_value("{{ p1.name }}", None, [parent], 0) + assert result == "rtr-01" + + def test_jinja2_with_grandparent(self): + parent = {"name": "dev", "site": None} + grandparent = {"site": {"slug": "hq"}} + result = render_value("{{ p2.site.slug }}", None, [parent, grandparent], 0) + assert result == "hq" + + def test_jinja2_with_service(self): + service = {"tenant": {"slug": "acme"}} + result = render_value("{{ service.tenant.slug }}", service, [], 0) + assert result == "acme" + + def test_jinja2_with_index(self): + result = render_value("rtr-{{ index + 1 }}", None, [], 0) + assert result == "rtr-1" + + def test_jinja2_dotted_parent_access(self): + parent = {"name": "edge-router", "site": {"slug": "hq"}} + result = render_value("{{ ..site.slug }}", None, [parent], 0) + assert result == "hq" + + def test_jinja2_dotted_parent_name(self): + parent = {"name": "edge-router"} + result = render_value("iface-{{ ..name }}", None, [parent], 0) + assert result == "iface-edge-router" + + def test_django_model_traversal(self): + from dcim.models import Device, DeviceRole, Site + + site = Site(name="HQ", slug="hq") + role = DeviceRole(name="Router", slug="router") + parent = Device(name="rtr-01", site=site, role=role) + result = render_value("{{ p1.site.slug }}-{{ p1.role.slug }}", None, [parent], 0) + assert result == "hq-router" + + +class TestRenderAttrs: + def test_plain_attrs(self): + attrs = {"name": "eth0", "type": "1000base-t", "mtu": 1500} + result = render_attrs(attrs, None, [], 0) + assert result == {"name": "eth0", "type": "1000base-t", "mtu": 1500} + + def test_templated_attrs(self): + parent = {"name": "rtr-01"} + attrs = {"name": "iface-{{ ..name }}", "type": "1000base-t"} + result = render_attrs(attrs, None, [parent], 0) + assert result == {"name": "iface-rtr-01", "type": "1000base-t"} + + def test_empty_attrs(self): + assert render_attrs({}, None, [], 0) == {} + + def test_none_attrs(self): + assert render_attrs(None, None, [], 0) == {} + + def test_index_in_attrs(self): + attrs = {"name": "rtr-{{ index + 1 }}"} + assert render_attrs(attrs, None, [], 0) == {"name": "rtr-1"} + assert render_attrs(attrs, None, [], 2) == {"name": "rtr-3"} + + +class TestSandboxSecurity: + def test_blocks_dunder_class(self): + parent = {"name": "test"} + result = render_value("{{ p1.__class__ }}", None, [parent], 0) + assert "dict" not in str(result) + + def test_blocks_method_call(self): + parent = {"name": "test"} + with pytest.raises(Exception): + render_value("{{ p1.pop() }}", None, [parent], 0)