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:
sven committed 2026-08-17 15:12:42 +02:00
1 parent 4ff9eac274
commit b1c9c7a40b
12 files changed
+1133 -33

No files matched your search

+71 -1
View File
@@ -1,6 +1,10 @@
import pytest
from netbox_services.engine.allocators import FirstFreeIPAddressAllocator, SpecificAllocator
from netbox_services.engine.allocators import (
CreateAllocator,
FirstFreeIPAddressAllocator,
SpecificAllocator,
)
@pytest.mark.django_db
@@ -41,3 +45,69 @@ class TestSpecificAllocator:
allocator = SpecificAllocator({"strategy": "specific"})
with pytest.raises(ValueError):
allocator.allocate(Context(service=None))
@pytest.mark.django_db
class TestCreateAllocator:
def test_create_returns_unsaved_instance(self, db):
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from netbox_services.engine.context import Context
mfr = Manufacturer.objects.create(name="alloc-mfr", slug="alloc-mfr")
dt = DeviceType.objects.create(manufacturer=mfr, model="alloc-model", slug="alloc-model")
role = DeviceRole.objects.create(name="alloc-role", slug="alloc-role")
site = Site.objects.create(name="alloc-site", slug="alloc-site")
allocator = CreateAllocator({
"strategy": "create",
"attrs": {
"name": "test-device",
"device_type": dt.pk,
"role": role.pk,
"site": site.pk,
"status": "active",
},
})
ctx = Context(service=None)
obj = allocator.allocate(ctx, model=Device)
assert obj.pk is None
assert obj.name == "test-device"
assert obj.device_type == dt
assert obj.role == role
assert obj.site == site
assert obj.status == "active"
def test_create_with_jinja2_attrs(self, db):
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from tenancy.models import Tenant
from netbox_services.engine.context import Context
mfr = Manufacturer.objects.create(name="jinja-mfr", slug="jinja-mfr")
dt = DeviceType.objects.create(manufacturer=mfr, model="jinja-model", slug="jinja-model")
role = DeviceRole.objects.create(name="jinja-role", slug="jinja-role")
site = Site.objects.create(name="jinja-site", slug="jinja-site")
tenant = Tenant.objects.create(name="jinja-tenant", slug="jinja-tenant")
service = type("FakeService", (), {"tenant": tenant})()
parent = Device(name="parent-dev", device_type=dt, role=role, site=site)
allocator = CreateAllocator({
"strategy": "create",
"attrs": {
"name": "rtr-{{ service.tenant.slug }}-{{ index + 1 }}",
"device_type": dt.pk,
"role": role.pk,
"site": "{{ ..site.pk }}",
"status": "active",
},
})
ctx = Context(service=service, parent_object=parent)
obj = allocator.allocate(ctx, model=Device, index=0, ancestors=[parent])
assert obj.name == "rtr-jinja-tenant-1"
assert obj.site == site
def test_create_requires_model(self, db):
from netbox_services.engine.context import Context
allocator = CreateAllocator({"strategy": "create", "attrs": {}})
with pytest.raises(ValueError):
allocator.allocate(Context(service=None))
+82
View File
@@ -0,0 +1,82 @@
"""Tests for the central link mapper."""
from netbox_services.engine.mappers import map_link, register_link, _LINK_MAP
class TestMapLink:
def test_interface_to_device(self):
from dcim.models import Device, Interface
assert map_link(Interface, Device) == "device"
def test_ipaddress_to_interface(self):
from dcim.models import Interface
from ipam.models import IPAddress
assert map_link(IPAddress, Interface) == "assigned_object"
def test_ipaddress_to_vminterface(self):
from ipam.models import IPAddress
from virtualization.models import VMInterface
assert map_link(IPAddress, VMInterface) == "assigned_object"
def test_interface_to_module(self):
from dcim.models import Interface, Module
assert map_link(Interface, Module) == "module"
def test_device_to_site(self):
from dcim.models import Device, Site
assert map_link(Device, Site) == "site"
def test_device_to_rack(self):
from dcim.models import Device, Rack
assert map_link(Device, Rack) == "rack"
def test_prefix_to_site_scope(self):
from dcim.models import Site
from ipam.models import Prefix
assert map_link(Prefix, Site) == "scope"
def test_vlan_to_vlangroup(self):
from ipam.models import VLAN, VLANGroup
assert map_link(VLAN, VLANGroup) == "group"
def test_interface_self_referential(self):
from dcim.models import Interface
assert map_link(Interface, Interface) == "parent"
def test_vminterface_to_virtualmachine(self):
from virtualization.models import VMInterface, VirtualMachine
assert map_link(VMInterface, VirtualMachine) == "virtual_machine"
def test_poweroutlet_to_powerport(self):
from dcim.models import PowerOutlet, PowerPort
assert map_link(PowerOutlet, PowerPort) == "power_port"
def test_frontport_to_rearport(self):
from dcim.models import FrontPort, RearPort
assert map_link(FrontPort, RearPort) == "rear_port"
def test_unmapped_returns_none(self):
from dcim.models import Device, Rack
from ipam.models import IPAddress
assert map_link(IPAddress, Device) is None
assert map_link(Device, Device) is None
assert map_link(Rack, Device) is None
class TestRegisterLink:
def test_register_new_link(self):
from dcim.models import Device, Rack
assert map_link(Rack, Device) is None
register_link("dcim.rack", "dcim.device", "device")
assert map_link(Rack, Device) == "device"
# cleanup
del _LINK_MAP[("dcim.rack", "dcim.device")]
assert map_link(Rack, Device) is None
def test_override_existing_link(self):
from dcim.models import Interface
original = map_link(Interface, Interface)
register_link("dcim.interface", "dcim.interface", "lag")
assert map_link(Interface, Interface) == "lag"
# restore
register_link("dcim.interface", "dcim.interface", original)
+309 -14
View File
@@ -115,21 +115,20 @@ class TestResolverCommit:
def test_commit_nested(self, db, devices, prefix, tenant):
from ipam.models import IPAddress
from dcim.models import Interface
for dev in devices:
Interface.objects.create(device=dev, name="eth0")
tmpl = ServiceTemplate.objects.create(
name="nested-tmpl",
status="active",
tree=[{
"name": "routers",
"object_type": "dcim.device",
"quantity": 1,
"selector": {"type": "netbox_filter", "filters": {"role": ["resolver-role"]}},
"scope": "service",
"children": [{
tree=[
{
"name": "routers",
"object_type": "dcim.device",
"quantity": 1,
"selector": {"type": "netbox_filter", "filters": {"role": ["resolver-role"]}},
"scope": "service",
"children": [],
},
{
"name": "ip",
"object_type": "ipam.ipaddress",
"quantity": 1,
@@ -137,10 +136,10 @@ class TestResolverCommit:
"strategy": "first_free",
"prefix": {"type": "prefix", "filters": {}},
},
"scope": "parent",
"scope": "service",
"children": [],
}],
}],
},
],
)
svc = Service.objects.create(name="nested-svc", service_template=tmpl, tenant=tenant)
@@ -242,3 +241,299 @@ class TestConflictDetection:
b1 = ServiceResourceBinding.objects.filter(service=svc1).first()
b2 = ServiceResourceBinding.objects.filter(service=svc2).first()
assert b1.object_id != b2.object_id
@pytest.mark.django_db
class TestAutoLinking:
@pytest.fixture
def create_tree_data(self, db, site, device_type, role, prefix, tenant):
return {
"site": site,
"device_type": device_type,
"role": role,
"prefix": prefix,
"tenant": tenant,
}
def test_device_interface_ip_chain(self, create_tree_data):
from dcim.models import Device, Interface
from ipam.models import IPAddress
d = create_tree_data
tmpl = ServiceTemplate.objects.create(
name="chain-tmpl",
status="active",
tree=[{
"name": "device",
"object_type": "dcim.device",
"quantity": 1,
"allocation": {
"strategy": "create",
"attrs": {
"name": "chain-rtr-01",
"device_type": d["device_type"].pk,
"role": d["role"].pk,
"site": d["site"].pk,
"status": "active",
},
},
"scope": "service",
"children": [{
"name": "interface",
"object_type": "dcim.interface",
"quantity": 1,
"allocation": {
"strategy": "create",
"attrs": {"name": "eth0", "type": "1000base-t"},
},
"scope": "parent",
"children": [{
"name": "ip",
"object_type": "ipam.ipaddress",
"quantity": 1,
"allocation": {
"strategy": "first_free",
"prefix": {"type": "prefix", "filters": {}},
},
"scope": "parent",
"children": [],
}],
}],
}],
)
svc = Service.objects.create(name="chain-svc", service_template=tmpl, tenant=d["tenant"])
resolver = Resolver()
result = resolver.resolve(svc, mode="commit")
assert result.succeeded
assert ServiceResourceBinding.objects.filter(service=svc).count() == 3
dev = Device.objects.get(name="chain-rtr-01")
iface = Interface.objects.get(name="eth0", device=dev)
ip = IPAddress.objects.filter(address__net_contains="10.200.0.1").first()
assert iface.device == dev
assert ip is not None
assert ip.assigned_object == iface
def test_reresolve_idempotent_with_links(self, create_tree_data):
from dcim.models import Device, Interface
from ipam.models import IPAddress
d = create_tree_data
tmpl = ServiceTemplate.objects.create(
name="idem-tmpl",
status="active",
tree=[{
"name": "device",
"object_type": "dcim.device",
"quantity": 1,
"allocation": {
"strategy": "create",
"attrs": {
"name": "idem-rtr-01",
"device_type": d["device_type"].pk,
"role": d["role"].pk,
"site": d["site"].pk,
"status": "active",
},
},
"scope": "service",
"children": [{
"name": "interface",
"object_type": "dcim.interface",
"quantity": 1,
"allocation": {
"strategy": "create",
"attrs": {"name": "eth0", "type": "1000base-t"},
},
"scope": "parent",
"children": [],
}],
}],
)
svc = Service.objects.create(name="idem-svc", service_template=tmpl, tenant=d["tenant"])
resolver = Resolver()
resolver.resolve(svc, mode="commit")
dev_count = Device.objects.count()
iface_count = Interface.objects.count()
result = resolver.resolve(svc, mode="commit")
assert len(result.to_add) == 0
assert len(result.to_remove) == 0
assert Device.objects.count() == dev_count
assert Interface.objects.count() == iface_count
def test_multi_device_no_cross_parent_reuse(self, create_tree_data):
from dcim.models import Device, Interface
d = create_tree_data
tmpl = ServiceTemplate.objects.create(
name="multi-tmpl",
status="active",
tree=[{
"name": "devices",
"object_type": "dcim.device",
"quantity": 2,
"allocation": {
"strategy": "create",
"attrs": {
"name": "multi-rtr-{{ index + 1 }}",
"device_type": d["device_type"].pk,
"role": d["role"].pk,
"site": d["site"].pk,
"status": "active",
},
},
"scope": "service",
"children": [{
"name": "interface",
"object_type": "dcim.interface",
"quantity": 1,
"allocation": {
"strategy": "create",
"attrs": {"name": "eth0", "type": "1000base-t"},
},
"scope": "parent",
"children": [],
}],
}],
)
svc = Service.objects.create(name="multi-svc", service_template=tmpl, tenant=d["tenant"])
resolver = Resolver()
result = resolver.resolve(svc, mode="commit")
assert result.succeeded
assert ServiceResourceBinding.objects.filter(service=svc).count() == 4
devs = Device.objects.filter(name__startswith="multi-rtr-")
assert devs.count() == 2
for dev in devs:
ifaces = Interface.objects.filter(device=dev)
assert ifaces.count() == 1
assert ifaces[0].name == "eth0"
# Re-run: should not create new interfaces or reassign existing ones
result2 = resolver.resolve(svc, mode="commit")
assert len(result2.to_add) == 0
assert Interface.objects.filter(name="eth0").count() == 2
def test_existing_link_not_overridden(self, create_tree_data):
from dcim.models import Device, Interface
d = create_tree_data
tmpl = ServiceTemplate.objects.create(
name="lock-tmpl",
status="active",
tree=[{
"name": "device",
"object_type": "dcim.device",
"quantity": 1,
"allocation": {
"strategy": "create",
"attrs": {
"name": "lock-rtr-01",
"device_type": d["device_type"].pk,
"role": d["role"].pk,
"site": d["site"].pk,
"status": "active",
},
},
"scope": "service",
"children": [{
"name": "interface",
"object_type": "dcim.interface",
"quantity": 1,
"allocation": {
"strategy": "create",
"attrs": {"name": "eth0", "type": "1000base-t"},
},
"scope": "parent",
"children": [],
}],
}],
)
svc = Service.objects.create(name="lock-svc", service_template=tmpl, tenant=d["tenant"])
resolver = Resolver()
resolver.resolve(svc, mode="commit")
dev = Device.objects.get(name="lock-rtr-01")
iface = Interface.objects.get(name="eth0", device=dev)
assert iface.device == dev
# Manually reassign the interface to a different device
other_dev = Device.objects.create(
name="other-dev", site=d["site"], device_type=d["device_type"], role=d["role"],
)
iface.device = other_dev
iface.save()
# Re-resolve: the interface is already linked (to other_dev),
# so the resolver must NOT override it back to dev.
result = resolver.resolve(svc, mode="commit")
iface.refresh_from_db()
assert iface.device == other_dev
@pytest.mark.django_db
class TestMissingMapping:
@pytest.fixture
def create_tree_data(self, db, site, device_type, role, prefix, tenant):
return {
"site": site,
"device_type": device_type,
"role": role,
"prefix": prefix,
"tenant": tenant,
}
def test_missing_mapping_raises_error(self, create_tree_data):
from netbox_services.engine.resolver import ResolutionError
d = create_tree_data
# ipam.ipaddress under dcim.device with create strategy — no mapping
# exists for (ipam.ipaddress, dcim.device).
tmpl = ServiceTemplate.objects.create(
name="missing-map-tmpl",
status="active",
tree=[{
"name": "device",
"object_type": "dcim.device",
"quantity": 1,
"allocation": {
"strategy": "create",
"attrs": {
"name": "missing-map-rtr",
"device_type": d["device_type"].pk,
"role": d["role"].pk,
"site": d["site"].pk,
"status": "active",
},
},
"scope": "service",
"children": [{
"name": "ip",
"object_type": "ipam.ipaddress",
"quantity": 1,
"allocation": {
"strategy": "create",
"attrs": {"address": "10.99.99.1/24", "status": "active"},
},
"scope": "parent",
"children": [],
}],
}],
)
svc = Service.objects.create(name="missing-map-svc", service_template=tmpl, tenant=d["tenant"])
resolver = Resolver()
with pytest.raises(ResolutionError) as exc_info:
resolver.resolve(svc, mode="dry_run")
msg = str(exc_info.value)
assert "no link mapping" in msg
assert "ipam.ipaddress" in msg
assert "dcim.device" in msg
assert "register_link" in msg
+130
View File
@@ -0,0 +1,130 @@
"""Tests for Jinja2 attribute templating with .. / ... parent access."""
import pytest
from netbox_services.engine.templating import (
preprocess_dots,
render_value,
render_attrs,
)
class TestPreprocessDots:
def test_double_dot(self):
assert preprocess_dots("{{ ..name }}") == "{{ p1.name }}"
def test_triple_dot(self):
assert preprocess_dots("{{ ...site.slug }}") == "{{ p2.site.slug }}"
def test_quadruple_dot(self):
assert preprocess_dots("{{ ....region.slug }}") == "{{ p3.region.slug }}"
def test_no_dots(self):
assert preprocess_dots("1000base-t") == "1000base-t"
def test_ip_address_preserved(self):
assert preprocess_dots("10.0.0.1/24") == "10.0.0.1/24"
def test_service_ref_preserved(self):
assert preprocess_dots("{{ service.tenant.slug }}") == "{{ service.tenant.slug }}"
def test_mixed(self):
assert (
preprocess_dots("rtr-{{ service.tenant.slug }}-{{ ..name }}-{{ ...site.slug }}")
== "rtr-{{ service.tenant.slug }}-{{ p1.name }}-{{ p2.site.slug }}"
)
def test_plain_string_no_braces(self):
assert preprocess_dots("active") == "active"
def test_empty_string(self):
assert preprocess_dots("") == ""
class TestRenderValue:
def test_literal_int(self):
assert render_value(42, None, [], 0) == 42
def test_literal_bool(self):
assert render_value(True, None, [], 0) is True
def test_plain_string(self):
assert render_value("active", None, [], 0) == "active"
def test_ip_address_string(self):
assert render_value("10.0.0.1/24", None, [], 0) == "10.0.0.1/24"
def test_jinja2_with_parent(self):
parent = {"name": "rtr-01"}
result = render_value("{{ p1.name }}", None, [parent], 0)
assert result == "rtr-01"
def test_jinja2_with_grandparent(self):
parent = {"name": "dev", "site": None}
grandparent = {"site": {"slug": "hq"}}
result = render_value("{{ p2.site.slug }}", None, [parent, grandparent], 0)
assert result == "hq"
def test_jinja2_with_service(self):
service = {"tenant": {"slug": "acme"}}
result = render_value("{{ service.tenant.slug }}", service, [], 0)
assert result == "acme"
def test_jinja2_with_index(self):
result = render_value("rtr-{{ index + 1 }}", None, [], 0)
assert result == "rtr-1"
def test_jinja2_dotted_parent_access(self):
parent = {"name": "edge-router", "site": {"slug": "hq"}}
result = render_value("{{ ..site.slug }}", None, [parent], 0)
assert result == "hq"
def test_jinja2_dotted_parent_name(self):
parent = {"name": "edge-router"}
result = render_value("iface-{{ ..name }}", None, [parent], 0)
assert result == "iface-edge-router"
def test_django_model_traversal(self):
from dcim.models import Device, DeviceRole, Site
site = Site(name="HQ", slug="hq")
role = DeviceRole(name="Router", slug="router")
parent = Device(name="rtr-01", site=site, role=role)
result = render_value("{{ p1.site.slug }}-{{ p1.role.slug }}", None, [parent], 0)
assert result == "hq-router"
class TestRenderAttrs:
def test_plain_attrs(self):
attrs = {"name": "eth0", "type": "1000base-t", "mtu": 1500}
result = render_attrs(attrs, None, [], 0)
assert result == {"name": "eth0", "type": "1000base-t", "mtu": 1500}
def test_templated_attrs(self):
parent = {"name": "rtr-01"}
attrs = {"name": "iface-{{ ..name }}", "type": "1000base-t"}
result = render_attrs(attrs, None, [parent], 0)
assert result == {"name": "iface-rtr-01", "type": "1000base-t"}
def test_empty_attrs(self):
assert render_attrs({}, None, [], 0) == {}
def test_none_attrs(self):
assert render_attrs(None, None, [], 0) == {}
def test_index_in_attrs(self):
attrs = {"name": "rtr-{{ index + 1 }}"}
assert render_attrs(attrs, None, [], 0) == {"name": "rtr-1"}
assert render_attrs(attrs, None, [], 2) == {"name": "rtr-3"}
class TestSandboxSecurity:
def test_blocks_dunder_class(self):
parent = {"name": "test"}
result = render_value("{{ p1.__class__ }}", None, [parent], 0)
assert "dict" not in str(result)
def test_blocks_method_call(self):
parent = {"name": "test"}
with pytest.raises(Exception):
render_value("{{ p1.pop() }}", None, [parent], 0)