import pytest from netbox_services.engine.resolver import Resolver, ConflictError from netbox_services.models import Service, ServiceResourceBinding, ServiceTemplate @pytest.fixture def site(db): from dcim.models import Site return Site.objects.create(name="resolver-site", slug="resolver-site") @pytest.fixture def device_type(db): from dcim.models import DeviceType, Manufacturer mfr = Manufacturer.objects.create(name="resolver-mfr", slug="resolver-mfr") return DeviceType.objects.create(manufacturer=mfr, model="resolver-model", slug="resolver-model") @pytest.fixture def role(db): from dcim.models import DeviceRole return DeviceRole.objects.create(name="resolver-role", slug="resolver-role") @pytest.fixture def devices(site, device_type, role, db): from dcim.models import Device return [ Device.objects.create(name="dev-a", site=site, device_type=device_type, role=role), Device.objects.create(name="dev-b", site=site, device_type=device_type, role=role), ] @pytest.fixture def prefix(db): from ipam.models import Prefix return Prefix.objects.create(prefix="10.200.0.0/24") @pytest.fixture def tenant(db): from tenancy.models import Tenant return Tenant.objects.create(name="res-tenant", slug="res-tenant") @pytest.fixture def service_with_devices(db, devices, tenant): tmpl = ServiceTemplate.objects.create( name="dev-tmpl", status="active", tree=[{ "name": "routers", "object_type": "dcim.device", "quantity": 2, "selector": {"type": "netbox_filter", "filters": {"role": ["resolver-role"]}}, "scope": "service", "children": [], }], ) return Service.objects.create(name="dev-svc", service_template=tmpl, tenant=tenant) @pytest.fixture def service_with_ip_alloc(db, prefix, tenant): tmpl = ServiceTemplate.objects.create( name="ip-tmpl", status="active", tree=[{ "name": "ip", "object_type": "ipam.ipaddress", "quantity": 1, "allocation": { "strategy": "first_free", "prefix": {"type": "prefix", "filters": {}}, }, "scope": "service", "children": [], }], ) return Service.objects.create(name="ip-svc", service_template=tmpl, tenant=tenant) @pytest.mark.django_db class TestResolverDryRun: def test_dry_run_no_bindings(self, service_with_devices): resolver = Resolver() result = resolver.resolve(service_with_devices, mode="dry_run") assert result.total_planned == 2 assert ServiceResourceBinding.objects.filter(service=service_with_devices).count() == 0 def test_dry_run_rolls_back_allocation(self, service_with_ip_alloc): from ipam.models import IPAddress resolver = Resolver() result = resolver.resolve(service_with_ip_alloc, mode="dry_run") assert result.total_planned == 1 assert IPAddress.objects.count() == 0 assert ServiceResourceBinding.objects.count() == 0 @pytest.mark.django_db class TestResolverCommit: def test_commit_creates_bindings(self, service_with_devices): resolver = Resolver() result = resolver.resolve(service_with_devices, mode="commit") assert result.succeeded assert ServiceResourceBinding.objects.filter(service=service_with_devices).count() == 2 def test_commit_allocates_ip(self, service_with_ip_alloc): from ipam.models import IPAddress resolver = Resolver() result = resolver.resolve(service_with_ip_alloc, mode="commit") assert IPAddress.objects.count() == 1 assert ServiceResourceBinding.objects.filter(service=service_with_ip_alloc).count() == 1 def test_commit_nested(self, db, devices, prefix, tenant): from ipam.models import IPAddress 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": [], }, { "name": "ip", "object_type": "ipam.ipaddress", "quantity": 1, "allocation": { "strategy": "first_free", "prefix": {"type": "prefix", "filters": {}}, }, "scope": "service", "children": [], }, ], ) svc = Service.objects.create(name="nested-svc", service_template=tmpl, tenant=tenant) resolver = Resolver() result = resolver.resolve(svc, mode="commit") assert result.succeeded assert ServiceResourceBinding.objects.filter(service=svc).count() == 2 @pytest.mark.django_db class TestReconciliation: def test_reresolve_no_change(self, service_with_devices): resolver = Resolver() resolver.resolve(service_with_devices, mode="commit") result = resolver.resolve(service_with_devices, mode="commit") assert len(result.to_add) == 0 assert len(result.to_remove) == 0 assert ServiceResourceBinding.objects.filter(service=service_with_devices).count() == 2 def test_reresolve_reuses_allocated(self, service_with_ip_alloc): from ipam.models import IPAddress resolver = Resolver() resolver.resolve(service_with_ip_alloc, mode="commit") ip_count_before = IPAddress.objects.count() result = resolver.resolve(service_with_ip_alloc, mode="commit") assert len(result.to_add) == 0 assert IPAddress.objects.count() == ip_count_before def test_reresolve_removes_stale(self, service_with_devices, devices): resolver = Resolver() result = resolver.resolve(service_with_devices, mode="commit") assert ServiceResourceBinding.objects.filter(service=service_with_devices).count() == 2 svc_tmpl = service_with_devices.service_template svc_tmpl.tree[0]["quantity"] = 1 # Template is locked — bypass via queryset ServiceTemplate.objects.filter(pk=svc_tmpl.pk).update(tree=svc_tmpl.tree) service_with_devices.refresh_from_db() service_with_devices.service_template.refresh_from_db() result2 = resolver.resolve(service_with_devices, mode="commit") assert len(result2.to_remove) >= 1 assert ServiceResourceBinding.objects.filter(service=service_with_devices).count() == 1 @pytest.mark.django_db class TestConflictDetection: def test_exclusion_when_device_already_bound(self, db, site, device_type, role, tenant): from dcim.models import Device dev = Device.objects.create(name="conflict-dev", site=site, device_type=device_type, role=role) tmpl = ServiceTemplate.objects.create( name="conflict-tmpl", status="active", tree=[{ "name": "dev", "object_type": "dcim.device", "quantity": 1, "selector": {"type": "netbox_filter", "filters": {"role": ["resolver-role"]}}, "scope": "service", "children": [], }], ) svc1 = Service.objects.create(name="c-svc-1", service_template=tmpl, tenant=tenant) svc2 = Service.objects.create(name="c-svc-2", service_template=tmpl, tenant=tenant) resolver = Resolver() r1 = resolver.resolve(svc1, mode="commit") assert r1.succeeded assert ServiceResourceBinding.objects.filter(service=svc1).count() == 1 r2 = resolver.resolve(svc2, mode="commit") assert r2.succeeded assert ServiceResourceBinding.objects.filter(service=svc2).count() == 0 assert any("only 0 of 1" in w for w in r2.warnings) def test_no_conflict_different_devices(self, db, devices, tenant): tmpl = ServiceTemplate.objects.create( name="noconf-tmpl", status="active", tree=[{ "name": "dev", "object_type": "dcim.device", "quantity": 1, "selector": {"type": "netbox_filter", "filters": {"role": ["resolver-role"]}}, "scope": "service", "children": [], }], ) svc1 = Service.objects.create(name="nc-svc-1", service_template=tmpl, tenant=tenant) svc2 = Service.objects.create(name="nc-svc-2", service_template=tmpl, tenant=tenant) resolver = Resolver() r1 = resolver.resolve(svc1, mode="commit") assert r1.succeeded r2 = resolver.resolve(svc2, mode="commit") assert r2.succeeded 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