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
@@ -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."
|
||||
|
||||
Reference in new issue
Block a user