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
308 lines
11 KiB
Python
308 lines
11 KiB
Python
"""The resolver walks a service template's requirement tree (stored as JSON)
|
|
and reconciles the service's bindings against real NetBox objects.
|
|
|
|
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
|
|
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.db import transaction
|
|
|
|
from ..models import (
|
|
ResolutionModeChoices,
|
|
ServiceResourceBinding,
|
|
)
|
|
from .context import Context
|
|
from .mappers import map_link
|
|
from .registry import get_allocator, get_selector
|
|
|
|
logger = logging.getLogger("netbox_services.resolver")
|
|
|
|
|
|
class ResolutionError(Exception):
|
|
pass
|
|
|
|
|
|
class ConflictError(ResolutionError):
|
|
"""An object is already bound to another service."""
|
|
|
|
def __init__(self, obj, existing_service):
|
|
self.obj = obj
|
|
self.existing_service = existing_service
|
|
super().__init__(
|
|
f"Object {obj!r} (pk={obj.pk}) is already bound to service "
|
|
f"'{existing_service}'."
|
|
)
|
|
|
|
|
|
class Resolver:
|
|
"""Walks the template's JSON tree and reconciles bindings."""
|
|
|
|
def resolve(self, service, mode="dry_run", user=None):
|
|
if mode not in (ResolutionModeChoices.MODE_DRY_RUN, ResolutionModeChoices.MODE_COMMIT):
|
|
raise ValueError(f"Invalid mode: {mode!r}")
|
|
|
|
tree = service.service_template.tree
|
|
result = ResolutionResult(mode=mode, service=service)
|
|
|
|
try:
|
|
with transaction.atomic():
|
|
self._resolve_tree(tree, service, result)
|
|
|
|
result.compute_diff()
|
|
|
|
if mode == ResolutionModeChoices.MODE_DRY_RUN:
|
|
transaction.set_rollback(True)
|
|
else:
|
|
for ct_id, obj_id in result.to_add:
|
|
ServiceResourceBinding.objects.create(
|
|
service=service,
|
|
content_type_id=ct_id,
|
|
object_id=obj_id,
|
|
)
|
|
for binding in result.to_remove_bindings:
|
|
binding.delete()
|
|
|
|
except Exception as exc:
|
|
result.errors.append(str(exc))
|
|
logger.exception("Resolution failed for service %s", service)
|
|
raise
|
|
|
|
return result
|
|
|
|
def _resolve_tree(self, tree, service, result):
|
|
"""Walk the JSON tree and populate result.planned_set + result.planned_objects."""
|
|
context = Context(service=service)
|
|
for node in tree:
|
|
self._resolve_node(node, service, result, context)
|
|
|
|
def _resolve_node(self, node, service, result, context):
|
|
"""Resolve a single tree node, then recurse into children."""
|
|
object_type_str = node["object_type"]
|
|
app_label, model_name = object_type_str.split(".")
|
|
ot = ContentType.objects.get_by_natural_key(app_label, model_name)
|
|
model = ot.model_class()
|
|
if model is None:
|
|
raise ResolutionError(
|
|
f"Node '{node['name']}': object_type '{object_type_str}' has no model class."
|
|
)
|
|
|
|
quantity = node.get("quantity", 1)
|
|
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, link_field, parent,
|
|
)
|
|
|
|
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)
|
|
result.planned_objects.append((node["name"], obj))
|
|
|
|
self._check_conflict(obj, service)
|
|
|
|
child_context = context.child(obj)
|
|
children = node.get("children", [])
|
|
for child_node in children:
|
|
scope = child_node.get("scope", "parent")
|
|
if scope == "parent" or len(objects) <= 1:
|
|
self._resolve_node(child_node, service, result, child_context)
|
|
elif scope == "service":
|
|
self._resolve_node(child_node, service, result, context)
|
|
|
|
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, link_field, parent,
|
|
)
|
|
|
|
if selector_spec:
|
|
return self._select(node, service, result, context, model, quantity)
|
|
|
|
raise ResolutionError(
|
|
f"Node '{node['name']}': must have either 'selector' or 'allocation'."
|
|
)
|
|
|
|
def _select(self, node, service, result, context, model, quantity):
|
|
"""Select existing objects via the selector registry."""
|
|
selector = get_selector(node["selector"])
|
|
qs = selector.resolve(context, model)
|
|
|
|
bound_ids = self._get_bound_ids(model, service)
|
|
if bound_ids:
|
|
qs = qs.exclude(pk__in=bound_ids)
|
|
|
|
objects = list(qs[:quantity])
|
|
|
|
result.summary["selected"] = result.summary.get("selected", 0) + len(objects)
|
|
if len(objects) < quantity:
|
|
result.warnings.append(
|
|
f"Node '{node['name']}': only {len(objects)} of {quantity} objects available."
|
|
)
|
|
return objects
|
|
|
|
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)
|
|
return objects
|
|
|
|
objects = list(existing)
|
|
needed = quantity - len(existing)
|
|
allocator = get_allocator(allocation_spec)
|
|
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."
|
|
)
|
|
objects.append(obj)
|
|
result.summary["allocated"] = result.summary.get("allocated", 0) + len(objects) - len(existing)
|
|
return objects
|
|
|
|
def _get_bound_ids(self, model, service):
|
|
"""Return PKs of model instances already bound to other services."""
|
|
ct = ContentType.objects.get_for_model(model)
|
|
qs = (
|
|
ServiceResourceBinding.objects
|
|
.filter(content_type=ct)
|
|
.exclude(service=service)
|
|
)
|
|
return set(qs.values_list("object_id", flat=True))
|
|
|
|
def _get_existing_bound_objects(self, model, service):
|
|
"""Return model instances already bound to this service."""
|
|
ct = ContentType.objects.get_for_model(model)
|
|
binding_ids = (
|
|
ServiceResourceBinding.objects
|
|
.filter(service=service, content_type=ct)
|
|
.values_list("object_id", flat=True)
|
|
)
|
|
return list(model.objects.filter(pk__in=binding_ids))
|
|
|
|
def _check_conflict(self, obj, service):
|
|
"""Raise if obj is already bound to another service."""
|
|
ct = ContentType.objects.get_for_model(obj.__class__)
|
|
if (
|
|
ServiceResourceBinding.objects
|
|
.filter(content_type=ct, object_id=obj.pk)
|
|
.exclude(service=service)
|
|
.exists()
|
|
):
|
|
existing = (
|
|
ServiceResourceBinding.objects
|
|
.filter(content_type=ct, object_id=obj.pk)
|
|
.exclude(service=service)
|
|
.first()
|
|
)
|
|
raise ConflictError(obj, existing.service.name)
|
|
|
|
|
|
class ResolutionResult:
|
|
def __init__(self, mode, service):
|
|
self.mode = mode
|
|
self.service = service
|
|
self.planned_set = set()
|
|
self.planned_objects = []
|
|
self.errors = []
|
|
self.warnings = []
|
|
self.summary = {}
|
|
self.to_add = set()
|
|
self.to_remove = set()
|
|
self.to_remove_bindings = []
|
|
|
|
def compute_diff(self):
|
|
"""Compare planned_set against current bindings."""
|
|
current_qs = ServiceResourceBinding.objects.filter(service=self.service)
|
|
current_set = set()
|
|
current_map = {}
|
|
for binding in current_qs:
|
|
key = (binding.content_type_id, binding.object_id)
|
|
current_set.add(key)
|
|
current_map[key] = binding
|
|
|
|
self.to_add = self.planned_set - current_set
|
|
self.to_remove = current_set - self.planned_set
|
|
self.to_remove_bindings = [current_map[k] for k in self.to_remove]
|
|
|
|
@property
|
|
def succeeded(self):
|
|
return len(self.errors) == 0
|
|
|
|
@property
|
|
def total_planned(self):
|
|
return len(self.planned_set)
|
|
|
|
@property
|
|
def total_current(self):
|
|
return ServiceResourceBinding.objects.filter(service=self.service).count()
|