"""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. """ import logging from django.contrib.contenttypes.models import ContentType from django.db import transaction from ..models import ( ResolutionModeChoices, ServiceResourceBinding, ) from .context import Context 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") objects = self._resolve_objects( node, service, result, context, model, quantity, selector_spec, allocation_spec, ) for obj in objects: 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 _resolve_objects(self, node, service, result, context, model, quantity, selector_spec, allocation_spec): """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, ) 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): """Allocate new objects, but reuse existing bindings of the same type if present.""" existing = self._get_existing_bound_objects(model, service) 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 _ in range(needed): obj = allocator.allocate(context) 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()