Initial commit

This commit is contained in:
sven committed 2026-08-16 13:10:26 +02:00
commit 4ff9eac274
45 files changed
+2911

No files matched your search

View File
Whitespace-only changes.
+78
View File
@@ -0,0 +1,78 @@
"""Allocators create or acquire objects that don't exist yet (e.g. IP addresses)."""
from .registry import register_allocator, get_selector
@register_allocator("first_free")
class FirstFreeIPAddressAllocator:
"""Allocate the first available IP address from a resolved prefix.
Spec shape::
{"strategy": "first_free", "prefix": {"type": "reference", "path": "parent.site.mgmt_prefix"}}
{"strategy": "first_free", "prefix": {"type": "prefix", "filters": {"site": "hq"}}}
"""
def __init__(self, spec):
self.spec = spec
self.prefix_spec = spec.get("prefix", {})
def resolve_prefix(self, context):
from ipam.models import Prefix
selector = get_selector(self.prefix_spec)
qs = selector.resolve(context, Prefix)
return qs.first()
def allocate(self, context):
from ipam.models import IPAddress
from ipam.choices import IPAddressStatusChoices
prefix = self.resolve_prefix(context)
if prefix is None:
raise ValueError("No prefix resolved for first_free allocation.")
available = prefix.get_first_available_ip()
if available is None:
raise ValueError(
f"No available IP addresses in prefix {prefix.prefix}."
)
ip = IPAddress.objects.create(
address=available,
status=IPAddressStatusChoices.STATUS_ACTIVE,
)
return ip
@register_allocator("specific")
class SpecificAllocator:
"""Use a specific object identified by content type + ID or context path.
Spec shape (by reference)::
{"strategy": "specific", "path": "parent.site.primary_ip4"}
Spec shape (by value, for IP addresses)::
{"strategy": "specific", "address": "10.0.0.1/24"}
"""
def __init__(self, spec):
self.spec = spec
self.path = spec.get("path")
self.address = spec.get("address")
def allocate(self, context):
if self.address:
from ipam.models import IPAddress
from ipam.choices import IPAddressStatusChoices
ip, _ = IPAddress.objects.get_or_create(
address=self.address,
defaults={"status": IPAddressStatusChoices.STATUS_ACTIVE},
)
return ip
if self.path:
return context.resolve_path(self.path)
raise ValueError("SpecificAllocator requires 'path' or 'address'.")
+52
View File
@@ -0,0 +1,52 @@
"""Resolution context — carries service and parent references during tree walk."""
class Context:
"""Evaluation frame for the resolver.
Top-level context has ``service`` set and ``parent_object`` as None.
Each selected/allocated object pushes a child context so children
can reference ``parent`` and traverse its relations.
"""
def __init__(self, service, parent_object=None):
self.service = service
self.parent_object = parent_object
def child(self, obj):
return Context(
service=self.service,
parent_object=obj,
)
@property
def parent(self):
return self.parent_object
def resolve_path(self, path):
"""Resolve a dotted reference like ``parent.site.management_prefix``.
Supported roots:
- ``service`` → the Service instance
- ``parent`` → the parent object (or None at service scope)
Returns None if any segment is None or missing.
"""
parts = path.split(".")
root = parts[0]
if root == "service":
obj = self.service
elif root == "parent":
obj = self.parent_object
else:
raise KeyError(f"Unknown context root: {root!r}")
for part in parts[1:]:
if obj is None:
return None
if isinstance(obj, dict):
obj = obj.get(part)
else:
obj = getattr(obj, part, None)
return obj
+64
View File
@@ -0,0 +1,64 @@
"""Registry for selectors and allocators — pluggable extension points."""
_selectors = {}
_allocators = {}
class RegistryError(Exception):
pass
class SelectorNotFoundError(RegistryError):
pass
class AllocatorNotFoundError(RegistryError):
pass
def register_selector(type_name):
def decorator(cls):
_selectors[type_name] = cls
cls.type = type_name
return cls
return decorator
def get_selector(spec):
if not spec or not isinstance(spec, dict):
raise ValueError(f"Invalid selector spec: {spec!r}")
type_name = spec.get("type")
if type_name not in _selectors:
raise SelectorNotFoundError(
f"No selector registered for type {type_name!r}. "
f"Available: {list(_selectors.keys())}"
)
return _selectors[type_name](spec)
def register_allocator(strategy_name):
def decorator(cls):
_allocators[strategy_name] = cls
cls.strategy = strategy_name
return cls
return decorator
def get_allocator(spec):
if not spec or not isinstance(spec, dict):
raise ValueError(f"Invalid allocation spec: {spec!r}")
strategy = spec.get("strategy")
if strategy not in _allocators:
raise AllocatorNotFoundError(
f"No allocator registered for strategy {strategy!r}. "
f"Available: {list(_allocators.keys())}"
)
return _allocators[strategy](spec)
def registered_selectors():
return dict(_selectors)
def registered_allocators():
return dict(_allocators)
+245
View File
@@ -0,0 +1,245 @@
"""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()
+104
View File
@@ -0,0 +1,104 @@
"""Selectors resolve a specification into a queryset of matching NetBox objects."""
import importlib
from django.db.models import Q
from .registry import register_selector
def _get_filterset_for_model(model):
"""Look up a model's FilterSet by convention: {app_label}.filtersets.{ModelName}FilterSet."""
app_label = model._meta.app_label
model_name = model._meta.model_name
class_name = f"{model.__name__}FilterSet"
try:
module = importlib.import_module(f"{app_label}.filtersets")
return getattr(module, class_name, None)
except (ImportError, AttributeError):
return None
@register_selector("netbox_filter")
class NetBoxFilterSelector:
"""Select objects by applying NetBox filter parameters.
Spec shape::
{"type": "netbox_filter", "filters": {"role": "router", "site__in": ["hq"]}}
"""
def __init__(self, spec):
self.spec = spec
self.filters = spec.get("filters", {})
def resolve(self, context, model):
qs = model.objects.all()
filterset_cls = _get_filterset_for_model(model)
if filterset_cls is not None:
fs = filterset_cls(self.filters, queryset=qs, request=None)
qs = fs.qs
else:
qs = qs.filter(**self._build_q(self.filters))
return qs
@staticmethod
def _build_q(filters):
"""Best-effort: turn the filter dict into ORM kwargs (no fancy lookups)."""
return {k: v for k, v in filters.items()}
@register_selector("reference")
class ReferenceSelector:
"""Select a single object via a dotted context path.
Spec shape::
{"type": "reference", "path": "parent.site"}
"""
def __init__(self, spec):
self.spec = spec
self.path = spec.get("path")
def resolve(self, context, model):
obj = context.resolve_path(self.path)
if obj is None:
return model.objects.none()
if isinstance(obj, (list, tuple)):
return model.objects.filter(pk__in=[o.pk for o in obj])
return model.objects.filter(pk=obj.pk)
@register_selector("prefix")
class PrefixSelector:
"""Select ipam.Prefix objects by filter or context reference.
Spec shape (filter)::
{"type": "prefix", "filters": {"site": "hq", "tag": "mgmt"}}
Spec shape (reference)::
{"type": "prefix", "path": "parent.site.management_prefix"}
"""
def __init__(self, spec):
self.spec = spec
self.filters = spec.get("filters")
self.path = spec.get("path")
def resolve(self, context, model):
from ipam.models import Prefix
if self.path:
obj = context.resolve_path(self.path)
if obj is None:
return Prefix.objects.none()
return Prefix.objects.filter(pk=obj.pk)
if self.filters is not None:
filterset_cls = _get_filterset_for_model(Prefix)
qs = Prefix.objects.all()
if filterset_cls is not None:
fs = filterset_cls(self.filters, queryset=qs, request=None)
return fs.qs
return qs.filter(**self.filters)
return Prefix.objects.none()