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
This commit is contained in:
1 parent
4ff9eac274
commit
b1c9c7a40b
12 files changed
+1133
-33
No files matched your search
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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}\", \"<field_name>\")"
|
||||
)
|
||||
|
||||
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."
|
||||
|
||||
@@ -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"(?<![.\w])\.{2,}")
|
||||
|
||||
_env = SandboxedEnvironment(
|
||||
variable_start_string="{{",
|
||||
variable_end_string="}}",
|
||||
autoescape=False,
|
||||
)
|
||||
|
||||
|
||||
def preprocess_dots(s):
|
||||
"""Replace ``..`` → ``p1.``, ``...`` → ``p2.``, etc.
|
||||
|
||||
Runs of 2+ dots not preceded by a word char or dot are replaced.
|
||||
This preserves single dots (attribute access, IP addresses like
|
||||
``10.0.0.1``) and literal strings without ``{{ }}``.
|
||||
"""
|
||||
def replacer(m):
|
||||
depth = len(m.group(0)) - 1
|
||||
return f"p{depth}."
|
||||
|
||||
return _DOT_RE.sub(replacer, s)
|
||||
|
||||
|
||||
def _build_jinja_ctx(service, ancestors, index):
|
||||
ctx = {"service": service, "index": index}
|
||||
for i, obj in enumerate(ancestors):
|
||||
ctx[f"p{i + 1}"] = obj
|
||||
return ctx
|
||||
|
||||
|
||||
def render_value(value, service, ancestors, index):
|
||||
"""Render a single attribute value.
|
||||
|
||||
Strings are preprocessed (``..`` → ``p1.``) then Jinja2-rendered.
|
||||
Non-strings (int, bool, list, dict) are returned as-is.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
|
||||
if "{{" not in value:
|
||||
return value
|
||||
|
||||
processed = preprocess_dots(value)
|
||||
template = _env.from_string(processed)
|
||||
return template.render(**_build_jinja_ctx(service, ancestors, index))
|
||||
|
||||
|
||||
def render_attrs(attrs, service, ancestors, index):
|
||||
"""Render all values in an attrs dict.
|
||||
|
||||
*ancestors* is a list where ``ancestors[0]`` is the immediate parent,
|
||||
``ancestors[1]`` is the grandparent, etc.
|
||||
"""
|
||||
if not attrs:
|
||||
return {}
|
||||
return {
|
||||
key: render_value(val, service, ancestors, index)
|
||||
for key, val in attrs.items()
|
||||
}
|
||||
@@ -31,6 +31,127 @@ from .models import (
|
||||
|
||||
# ── Service Template ─────────────────────────────────────────────────
|
||||
|
||||
EXAMPLE_TREE_YAML = """\
|
||||
# Requirement tree — defines what objects a service needs.
|
||||
# Delete this example and write your own. YAML or JSON accepted.
|
||||
#
|
||||
# Node parameters:
|
||||
# name (required) unique identifier within the tree
|
||||
# object_type (required) NetBox model as "app_label.model_name"
|
||||
# quantity (optional, default 1) how many objects to resolve
|
||||
# scope (optional, default "parent")
|
||||
# "parent" = each parent gets its own children;
|
||||
# "service" = children resolved once at service
|
||||
# level (only meaningful when quantity > 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()
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user