"""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}\", \"\")" ) 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()