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 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": [{ "name": "ip", "object_type": "ipam.ipaddress", "quantity": 1, "allocation": { "strategy": "first_free", "prefix": {"type": "prefix", "filters": {}}, }, "scope": "parent", "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