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
540 lines
20 KiB
Python
540 lines
20 KiB
Python
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
|