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:
sven committed 2026-08-17 15:12:42 +02:00
1 parent 4ff9eac274
commit b1c9c7a40b
12 files changed
+1133 -33

No files matched your search

+4
View File
@@ -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)
+65 -3
View File
@@ -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
+11 -4
View File
@@ -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
+183
View File
@@ -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
+73 -11
View File
@@ -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."
+80
View File
@@ -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()
}