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

+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."