test_module_moves.py 94 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991
  1. import re
  2. import signal
  3. import uuid
  4. from contextlib import contextmanager
  5. from unittest.mock import patch
  6. from django.apps import apps
  7. from django.contrib.contenttypes.models import ContentType
  8. from django.core.exceptions import ValidationError
  9. from django.db import IntegrityError, OperationalError, connection, router, transaction
  10. from django.db.models import QuerySet
  11. from django.test import RequestFactory, TestCase, override_settings
  12. from django.test.utils import CaptureQueriesContext
  13. from circuits.models import Provider, ProviderNetwork, VirtualCircuit, VirtualCircuitTermination, VirtualCircuitType
  14. from core.models import ObjectChange
  15. from dcim.choices import InterfaceModeChoices, InterfaceTypeChoices, ModuleStatusChoices, PortTypeChoices
  16. from dcim.models import (
  17. Cable,
  18. ConsolePortTemplate,
  19. ConsoleServerPortTemplate,
  20. CoolingIntake,
  21. CoolingIntakeTemplate,
  22. CoolingOutflow,
  23. CoolingOutflowTemplate,
  24. Device,
  25. DeviceBay,
  26. DeviceRole,
  27. DeviceType,
  28. FrontPort,
  29. FrontPortTemplate,
  30. Interface,
  31. InterfaceTemplate,
  32. InventoryItem,
  33. MACAddress,
  34. Manufacturer,
  35. Module,
  36. ModuleBay,
  37. ModuleBayTemplate,
  38. ModuleType,
  39. PortMapping,
  40. PortTemplateMapping,
  41. PowerOutlet,
  42. PowerOutletTemplate,
  43. PowerPort,
  44. PowerPortTemplate,
  45. RearPort,
  46. RearPortTemplate,
  47. Site,
  48. VirtualDeviceContext,
  49. )
  50. from dcim.models.device_components import ModularComponentModel
  51. from dcim.models.module_moves import COMPONENT_TEMPLATE_ATTRS, ModuleMovePlan
  52. from dcim.utils import get_module_bay_positions, resolve_module_placeholder
  53. from ipam.choices import FHRPGroupProtocolChoices
  54. from ipam.models import VLAN, VRF, FHRPGroup, FHRPGroupAssignment, IPAddress, VLANTranslationPolicy
  55. from netbox.context_managers import event_tracking
  56. from users.models import User
  57. from utilities.exceptions import AbortRequest
  58. from utilities.ordering import naturalize_interface
  59. from utilities.testing import create_test_device
  60. from vpn.choices import L2VPNTypeChoices, TunnelEncapsulationChoices
  61. from vpn.models import L2VPN, L2VPNTermination, Tunnel, TunnelTermination
  62. from wireless.models import WirelessLAN, WirelessLink
  63. @contextmanager
  64. def fail_after(seconds):
  65. """
  66. Fail the enclosed block if it runs longer than the given number of seconds. Backstop
  67. for the cycle-guard tests: a regressed hang fails one test fast instead of stalling the run.
  68. """
  69. def on_alarm(signum, frame):
  70. raise AssertionError(f'Operation did not complete within {seconds} seconds.')
  71. previous_handler = signal.signal(signal.SIGALRM, on_alarm)
  72. signal.alarm(seconds)
  73. try:
  74. yield
  75. finally:
  76. signal.alarm(0)
  77. signal.signal(signal.SIGALRM, previous_handler)
  78. class ModuleMoveValidationTestCase(TestCase):
  79. @classmethod
  80. def setUpTestData(cls):
  81. cls.device_a = create_test_device('Device A')
  82. cls.device_b = create_test_device('Device B')
  83. cls.bays_a = [
  84. ModuleBay.objects.create(device=cls.device_a, name=f'Bay A{i}') for i in range(1, 4)
  85. ]
  86. cls.bays_b = [
  87. ModuleBay.objects.create(device=cls.device_b, name=f'Bay B{i}') for i in range(1, 3)
  88. ]
  89. manufacturer = Manufacturer.objects.create(name='Manufacturer M', slug='manufacturer-m')
  90. cls.module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type M')
  91. cls.other_module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type N')
  92. cls.module = Module.objects.create(
  93. device=cls.device_a, module_bay=cls.bays_a[0], module_type=cls.module_type
  94. )
  95. def test_create_in_occupied_bay_fails(self):
  96. module = Module(device=self.device_a, module_bay=self.bays_a[0], module_type=self.module_type)
  97. with self.assertRaises(ValidationError) as cm:
  98. module.full_clean()
  99. self.assertIn('module_bay', cm.exception.message_dict)
  100. def test_move_to_occupied_bay_fails(self):
  101. occupant = Module.objects.create(
  102. device=self.device_a, module_bay=self.bays_a[1], module_type=self.module_type
  103. )
  104. self.module.module_bay = self.bays_a[1]
  105. with self.assertRaises(ValidationError) as cm:
  106. self.module.full_clean()
  107. self.assertIn('module_bay', cm.exception.message_dict)
  108. self.assertIn(str(occupant), str(cm.exception.message_dict['module_bay']))
  109. def test_move_to_own_bay_passes(self):
  110. self.module.serial = 'ABC123'
  111. self.module.full_clean()
  112. def test_move_to_other_device_bay_without_device_change_fails(self):
  113. # Existing device/bay consistency rule must keep rejecting a half-specified move
  114. self.module.module_bay = self.bays_b[0]
  115. with self.assertRaises(ValidationError):
  116. self.module.full_clean()
  117. def test_move_with_module_type_change_fails(self):
  118. self.module.module_bay = self.bays_a[1]
  119. self.module.module_type = self.other_module_type
  120. with self.assertRaises(ValidationError) as cm:
  121. self.module.full_clean()
  122. self.assertIn('module_type', cm.exception.message_dict)
  123. def test_module_type_change_without_move_passes(self):
  124. self.module.module_type = self.other_module_type
  125. self.module.full_clean()
  126. self.module.save()
  127. self.module.refresh_from_db()
  128. self.assertEqual(self.module.module_type, self.other_module_type)
  129. class ModuleSameDeviceMoveTestCase(TestCase):
  130. @classmethod
  131. def setUpTestData(cls):
  132. cls.device = create_test_device('Device A')
  133. cls.bay_a = ModuleBay.objects.create(device=cls.device, name='Bay A', position='A')
  134. cls.bay_b = ModuleBay.objects.create(device=cls.device, name='Bay B', position='B')
  135. manufacturer = Manufacturer.objects.create(name='Manufacturer M', slug='manufacturer-m')
  136. cls.module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type M')
  137. cls.module = Module.objects.create(
  138. device=cls.device, module_bay=cls.bay_a, module_type=cls.module_type
  139. )
  140. cls.interface = Interface.objects.create(
  141. device=cls.device, module=cls.module, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED
  142. )
  143. cls.child_bay = ModuleBay.objects.create(
  144. device=cls.device, module=cls.module, name='Child Bay 1'
  145. )
  146. def test_move_to_empty_bay_updates_module_bay(self):
  147. self.module.module_bay = self.bay_b
  148. self.module.full_clean()
  149. self.module.save()
  150. self.module.refresh_from_db()
  151. self.assertEqual(self.module.module_bay, self.bay_b)
  152. def test_move_reparents_direct_child_bays(self):
  153. self.module.module_bay = self.bay_b
  154. self.module.save()
  155. self.child_bay.refresh_from_db()
  156. self.assertEqual(self.child_bay.parent_id, self.bay_b.pk)
  157. self.assertTrue(str(self.child_bay.path).startswith(f'{self.bay_b.path}.'))
  158. def test_move_keeps_components_untouched_without_templates(self):
  159. self.module.module_bay = self.bay_b
  160. self.module.save()
  161. self.interface.refresh_from_db()
  162. self.assertEqual(self.interface.name, 'eth0')
  163. self.assertEqual(self.interface.device, self.device)
  164. def test_save_without_move_skips_planner(self):
  165. with patch('dcim.models.modules.ModuleMovePlan.from_module') as mock_plan:
  166. self.module.serial = 'XYZ789'
  167. self.module.save()
  168. mock_plan.assert_not_called()
  169. def test_save_level_move_with_type_change_raises_abort_request(self):
  170. other_type = ModuleType.objects.create(
  171. manufacturer=self.module_type.manufacturer, model='Module Type N'
  172. )
  173. self.module.module_bay = self.bay_b
  174. self.module.module_type = other_type
  175. with self.assertRaises(AbortRequest):
  176. self.module.save()
  177. class ModuleSaveRoutingTestCase(TestCase):
  178. """Pins save() routing against concurrent, stale, deferred, and preset-pk instances."""
  179. @classmethod
  180. def setUpTestData(cls):
  181. manufacturer = Manufacturer.objects.create(name='Manufacturer M', slug='manufacturer-m')
  182. cls.device = create_test_device('Device A')
  183. cls.device_b = create_test_device('Device B')
  184. cls.bay_a = ModuleBay.objects.create(device=cls.device, name='Bay A')
  185. cls.bay_b = ModuleBay.objects.create(device=cls.device, name='Bay B')
  186. cls.bay_c = ModuleBay.objects.create(device=cls.device_b, name='Bay C')
  187. cls.module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type M')
  188. InterfaceTemplate.objects.create(
  189. module_type=cls.module_type, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED
  190. )
  191. cls.module = Module.objects.create(
  192. device=cls.device, module_bay=cls.bay_a, module_type=cls.module_type
  193. )
  194. cls.child_bay = ModuleBay.objects.create(device=cls.device, module=cls.module, name='Child Bay 1')
  195. def _create_nested_subtree(self):
  196. child_module = Module(
  197. device=self.device,
  198. module_bay=self.child_bay,
  199. module_type=self.module_type,
  200. )
  201. child_module._disable_replication = True
  202. child_module.save()
  203. root_interface = Interface.objects.get(module=self.module, name='eth0')
  204. child_interface = Interface.objects.create(
  205. device=self.device,
  206. module=child_module,
  207. name='child0',
  208. type=InterfaceTypeChoices.TYPE_1GE_FIXED,
  209. )
  210. return child_module, root_interface, child_interface
  211. @contextmanager
  212. def _move_to_other_device_before_locked_save(self):
  213. """Complete a competing move after save() routes the write but before the root lock."""
  214. original_save_existing = Module._save_existing
  215. moved = False
  216. def save_existing(instance, *args, **kwargs):
  217. nonlocal moved
  218. if not moved:
  219. moved = True
  220. concurrent = Module.objects.get(pk=self.module.pk)
  221. concurrent.device = self.device_b
  222. concurrent.module_bay = self.bay_c
  223. concurrent.save()
  224. return original_save_existing(instance, *args, **kwargs)
  225. with patch.object(Module, '_save_existing', autospec=True, side_effect=save_existing):
  226. yield
  227. self.assertTrue(moved)
  228. def test_plain_save_uses_locked_placement_for_routing(self):
  229. """A competing move landing before the root lock is resolved by the planner, not clobbered."""
  230. child_module, root_interface, child_interface = self._create_nested_subtree()
  231. stale = Module.objects.get(pk=self.module.pk)
  232. stale.comments = 'Stale session edit'
  233. with self._move_to_other_device_before_locked_save():
  234. stale.save()
  235. self.module.refresh_from_db()
  236. child_module.refresh_from_db()
  237. self.child_bay.refresh_from_db()
  238. root_interface.refresh_from_db()
  239. child_interface.refresh_from_db()
  240. self.assertEqual((self.module.device_id, self.module.module_bay_id), (self.device.pk, self.bay_a.pk))
  241. self.assertEqual(child_module.device_id, self.device.pk)
  242. self.assertEqual((self.child_bay.device_id, self.child_bay.parent_id), (self.device.pk, self.bay_a.pk))
  243. self.assertEqual(root_interface.device_id, self.device.pk)
  244. self.assertEqual(child_interface.device_id, self.device.pk)
  245. self.assertEqual(self.module.comments, 'Stale session edit')
  246. def test_update_fields_are_checked_against_locked_placement(self):
  247. """update_fields completeness is re-judged against the placement a competing move just committed."""
  248. child_module, root_interface, child_interface = self._create_nested_subtree()
  249. module = Module.objects.get(pk=self.module.pk)
  250. module.module_bay = self.bay_b
  251. with self._move_to_other_device_before_locked_save():
  252. with self.assertRaises(AbortRequest):
  253. module.save(update_fields=['module_bay'])
  254. self.module.refresh_from_db()
  255. child_module.refresh_from_db()
  256. self.child_bay.refresh_from_db()
  257. root_interface.refresh_from_db()
  258. child_interface.refresh_from_db()
  259. self.assertEqual((self.module.device_id, self.module.module_bay_id), (self.device_b.pk, self.bay_c.pk))
  260. self.assertEqual(child_module.device_id, self.device_b.pk)
  261. self.assertEqual((self.child_bay.device_id, self.child_bay.parent_id), (self.device_b.pk, self.bay_c.pk))
  262. self.assertEqual(root_interface.device_id, self.device_b.pk)
  263. self.assertEqual(child_interface.device_id, self.device_b.pk)
  264. def test_stale_instance_plain_save_after_move_keeps_children_consistent(self):
  265. instance_a = Module.objects.get(pk=self.module.pk)
  266. instance_b = Module.objects.get(pk=self.module.pk)
  267. instance_b.module_bay = self.bay_b
  268. instance_b.save()
  269. instance_a.comments = 'Stale session edit'
  270. instance_a.save()
  271. self.module.refresh_from_db()
  272. self.child_bay.refresh_from_db()
  273. self.assertEqual(self.module.comments, 'Stale session edit')
  274. self.assertEqual(self.child_bay.parent_id, self.module.module_bay_id)
  275. self.assertTrue(str(self.child_bay.path).startswith(f'{self.module.module_bay.path}.'))
  276. def test_deferred_device_field_save_with_update_fields_succeeds(self):
  277. module = Module.objects.defer('device').get(pk=self.module.pk)
  278. module.status = ModuleStatusChoices.STATUS_OFFLINE
  279. module.save(update_fields=['status'])
  280. module.refresh_from_db()
  281. self.assertEqual(module.status, ModuleStatusChoices.STATUS_OFFLINE)
  282. self.assertEqual(module.module_bay, self.bay_a)
  283. def test_out_of_band_placement_change_then_refresh_then_save_skips_planner(self):
  284. # Routing always enters the locked read; a refreshed instance shows no delta there, so no plan is built.
  285. Module.objects.filter(pk=self.module.pk).update(module_bay=self.bay_b)
  286. self.module.refresh_from_db()
  287. with patch.object(ModuleMovePlan, 'from_module') as mock_plan:
  288. self.module.serial = 'REFRESHED1'
  289. self.module.save()
  290. mock_plan.assert_not_called()
  291. self.module.refresh_from_db()
  292. self.assertEqual(self.module.serial, 'REFRESHED1')
  293. self.assertEqual(self.module.module_bay, self.bay_b)
  294. def test_preset_pk_save_with_unchanged_placement_does_not_replicate_components(self):
  295. interface_count_before = Interface.objects.filter(module=self.module).count()
  296. Module(
  297. pk=self.module.pk, device=self.module.device, module_bay=self.module.module_bay,
  298. module_type=self.module.module_type,
  299. ).save()
  300. self.assertEqual(Interface.objects.filter(module=self.module).count(), interface_count_before)
  301. def test_preset_unused_pk_save_creates_module(self):
  302. """A never-saved instance with an explicit unused pk is created, not treated as a move."""
  303. unused_pk = Module.objects.order_by('-pk').first().pk + 1000
  304. device = create_test_device('Device P')
  305. bay = ModuleBay.objects.create(device=device, name='Preset Bay')
  306. Module(pk=unused_pk, device=device, module_bay=bay, module_type=self.module_type).save()
  307. self.assertTrue(Module.objects.filter(pk=unused_pk).exists())
  308. self.assertTrue(Interface.objects.filter(module_id=unused_pk, name='eth0').exists())
  309. def test_pk_reassignment_clone_creates_new_module(self):
  310. """A fetched instance saved under a fresh unused pk creates a new row and keeps the original."""
  311. clone = Module.objects.get(pk=self.module.pk)
  312. device = create_test_device('Device C')
  313. bay = ModuleBay.objects.create(device=device, name='Clone Bay')
  314. clone.pk = Module.objects.order_by('-pk').first().pk + 1000
  315. clone.device = device
  316. clone.module_bay = bay
  317. clone.save()
  318. self.assertTrue(Module.objects.filter(pk=clone.pk).exists())
  319. self.assertTrue(Module.objects.filter(pk=self.module.pk).exists())
  320. def test_plain_save_after_concurrent_delete_recreates_module(self):
  321. """A stale full save whose row was deleted underneath falls through to create, like a plain Django save."""
  322. stale = Module.objects.get(pk=self.module.pk)
  323. Module.objects.filter(pk=stale.pk).delete()
  324. stale.comments = 'Recreated'
  325. stale.save()
  326. self.assertTrue(Module.objects.filter(pk=stale.pk, comments='Recreated').exists())
  327. def test_cross_device_stale_plain_save_moves_subtree_back_consistently(self):
  328. """
  329. A stale full save that reverts a committed cross-device move runs the planner,
  330. so the root module, child bays, and components land on one device together.
  331. """
  332. stale = Module.objects.get(pk=self.module.pk)
  333. mover = Module.objects.get(pk=self.module.pk)
  334. mover.device = self.device_b
  335. mover.module_bay = self.bay_c
  336. mover.save()
  337. stale.comments = 'Stale full save'
  338. stale.save()
  339. self.module.refresh_from_db()
  340. self.child_bay.refresh_from_db()
  341. interface = Interface.objects.get(module=self.module, name='eth0')
  342. self.assertEqual(self.module.comments, 'Stale full save')
  343. self.assertEqual(self.module.device, self.device)
  344. self.assertEqual(self.module.module_bay, self.bay_a)
  345. self.assertEqual(self.child_bay.device, self.device)
  346. self.assertEqual(self.child_bay.parent_id, self.bay_a.pk)
  347. self.assertEqual(interface.device, self.device)
  348. class ModuleMoveRaceTestCase(TestCase):
  349. @classmethod
  350. def setUpTestData(cls):
  351. cls.device = create_test_device('Device A')
  352. cls.bay_a = ModuleBay.objects.create(device=cls.device, name='Bay A')
  353. cls.bay_b = ModuleBay.objects.create(device=cls.device, name='Bay B')
  354. cls.bay_c = ModuleBay.objects.create(device=cls.device, name='Bay C')
  355. manufacturer = Manufacturer.objects.create(name='Manufacturer M', slug='manufacturer-m')
  356. cls.module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type M')
  357. cls.module_1 = Module.objects.create(
  358. device=cls.device, module_bay=cls.bay_a, module_type=cls.module_type
  359. )
  360. cls.module_2 = Module.objects.create(
  361. device=cls.device, module_bay=cls.bay_b, module_type=cls.module_type
  362. )
  363. def test_bulk_update_into_occupied_bay_hits_db_constraint(self):
  364. with self.assertRaises(IntegrityError):
  365. with transaction.atomic():
  366. Module.objects.filter(pk=self.module_2.pk).update(module_bay=self.bay_a)
  367. def test_locked_validation_catches_occupied_bay_bypassing_clean(self):
  368. # Simulate a TOCTOU window: clean() was never run (direct save), the target
  369. # bay is genuinely occupied, and the in-save locked validation must reject.
  370. self.module_2.module_bay = self.bay_a
  371. with self.assertRaises(AbortRequest):
  372. self.module_2.save()
  373. def test_move_into_own_subtree_bypassing_clean_is_rejected(self):
  374. child_bay = ModuleBay.objects.create(device=self.device, module=self.module_1, name='Child Bay 1')
  375. self.module_1.module_bay = child_bay
  376. with self.assertRaises(AbortRequest):
  377. self.module_1.save()
  378. def test_full_clean_on_cyclic_hierarchy_raises_validation_error_promptly(self):
  379. # A two-module cycle created via .update() (bypassing clean()) must raise promptly, not hang.
  380. child_bay_1 = ModuleBay.objects.create(device=self.device, module=self.module_1, name='Child Bay 1')
  381. child_bay_2 = ModuleBay.objects.create(device=self.device, module=self.module_2, name='Child Bay 2')
  382. Module.objects.filter(pk=self.module_1.pk).update(module_bay=child_bay_2)
  383. Module.objects.filter(pk=self.module_2.pk).update(module_bay=child_bay_1)
  384. module = Module.objects.get(pk=self.module_1.pk)
  385. module.module_bay = self.bay_c
  386. with fail_after(15):
  387. with self.assertRaises(ValidationError) as cm:
  388. module.full_clean()
  389. self.assertIn('module_bay', cm.exception.message_dict)
  390. self.assertIn('contains a cycle', str(cm.exception.message_dict['module_bay']))
  391. def test_move_into_cyclic_hierarchy_bypassing_clean_raises_abort_request(self):
  392. # Same cycle, reached via direct save() (clean() never runs); must abort, not hang.
  393. child_bay_1 = ModuleBay.objects.create(device=self.device, module=self.module_1, name='Child Bay 1')
  394. child_bay_2 = ModuleBay.objects.create(device=self.device, module=self.module_2, name='Child Bay 2')
  395. Module.objects.filter(pk=self.module_1.pk).update(module_bay=child_bay_2)
  396. Module.objects.filter(pk=self.module_2.pk).update(module_bay=child_bay_1)
  397. self.module_1.module_bay = self.bay_c
  398. with fail_after(15):
  399. with self.assertRaises(AbortRequest):
  400. self.module_1.save()
  401. def test_create_in_cyclic_hierarchy_with_token_template_raises_abort_request(self):
  402. # _save_new()'s template resolution walks the target bay's ancestry; a cycle must abort, not hang.
  403. child_bay_1 = ModuleBay.objects.create(device=self.device, module=self.module_1, name='Child Bay 1')
  404. child_bay_2 = ModuleBay.objects.create(device=self.device, module=self.module_2, name='Child Bay 2')
  405. Module.objects.filter(pk=self.module_1.pk).update(module_bay=child_bay_2)
  406. Module.objects.filter(pk=self.module_2.pk).update(module_bay=child_bay_1)
  407. token_type = ModuleType.objects.create(
  408. manufacturer=self.module_type.manufacturer, model='Module Type Token'
  409. )
  410. InterfaceTemplate.objects.create(
  411. module_type=token_type, name='eth{module}/0', type=InterfaceTypeChoices.TYPE_1GE_FIXED
  412. )
  413. target_bay = ModuleBay.objects.create(
  414. device=self.device, module=self.module_1, name='Child Bay 3', position='3'
  415. )
  416. # Fresh fetch so the ancestry walk reads database state, not cached relations
  417. target_bay = ModuleBay.objects.get(pk=target_bay.pk)
  418. module = Module(device=self.device, module_bay=target_bay, module_type=token_type)
  419. with fail_after(15):
  420. with self.assertRaises(AbortRequest) as cm:
  421. module.save()
  422. self.assertIn('contains a cycle', cm.exception.message)
  423. def test_lock_rediscovers_membership_added_after_planning(self):
  424. old = Module.objects.get(pk=self.module_1.pk)
  425. new = Module.objects.get(pk=self.module_1.pk)
  426. new.module_bay = ModuleBay.objects.create(device=self.device, name='Bay C')
  427. plan = ModuleMovePlan.from_module(old_module=old, new_module=new)
  428. late_interface = Interface.objects.create(
  429. device=self.device, module=self.module_1, name='late0', type=InterfaceTypeChoices.TYPE_1GE_FIXED
  430. )
  431. with transaction.atomic():
  432. plan.lock()
  433. self.assertIn(late_interface.pk, [obj.pk for obj in plan.components[Interface]])
  434. def test_locked_device_get_does_not_exist_raises_abort_request(self):
  435. # Same TOCTOU, at the device row lock acquired by ModuleMovePlan.lock(): the
  436. # single-statement filter(pk__in=...) form returns fewer rows than expected pks.
  437. self.module_1.module_bay = self.bay_c
  438. with patch.object(Device.objects, 'select_for_update') as mock_sfu:
  439. mock_sfu.return_value.filter.return_value.order_by.return_value = []
  440. with self.assertRaises(AbortRequest):
  441. self.module_1.save()
  442. class ModuleMoveSaveContractTestCase(TestCase):
  443. @classmethod
  444. def setUpTestData(cls):
  445. cls.device = create_test_device('Device A')
  446. cls.bay_a = ModuleBay.objects.create(device=cls.device, name='Bay A')
  447. cls.bay_b = ModuleBay.objects.create(device=cls.device, name='Bay B')
  448. manufacturer = Manufacturer.objects.create(name='Manufacturer M', slug='manufacturer-m')
  449. cls.module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type M')
  450. cls.module = Module.objects.create(
  451. device=cls.device, module_bay=cls.bay_a, module_type=cls.module_type
  452. )
  453. def test_update_fields_excluding_module_bay_saves_without_moving(self):
  454. # A changed placement field left out of update_fields is never persisted, so no move happens.
  455. self.module.module_bay = self.bay_b
  456. self.module.serial = 'ABC123'
  457. self.module.save(update_fields=['serial'])
  458. self.module.refresh_from_db()
  459. self.assertEqual(self.module.serial, 'ABC123')
  460. self.assertEqual(self.module.module_bay, self.bay_a)
  461. def test_update_fields_including_placement_moves(self):
  462. self.module.module_bay = self.bay_b
  463. self.module.save(update_fields=['device', 'module_bay'])
  464. self.module.refresh_from_db()
  465. self.assertEqual(self.module.module_bay, self.bay_b)
  466. def test_generator_update_fields_excluding_module_bay_saves_without_moving(self):
  467. self.module.module_bay = self.bay_b
  468. self.module.serial = 'ABC123'
  469. self.module.save(update_fields=(field for field in ('serial',)))
  470. self.module.refresh_from_db()
  471. self.assertEqual(self.module.serial, 'ABC123')
  472. self.assertEqual(self.module.module_bay, self.bay_a)
  473. def test_generator_update_fields_including_placement_moves(self):
  474. self.module.module_bay = self.bay_b
  475. self.module.save(update_fields=(field for field in ('device', 'module_bay')))
  476. self.module.refresh_from_db()
  477. self.assertEqual(self.module.module_bay, self.bay_b)
  478. def test_deadlock_is_translated_to_abort_request(self):
  479. deadlock = OperationalError('Simulated deadlock error')
  480. deadlock.__cause__ = type('FakeDeadlock', (Exception,), {'sqlstate': '40P01'})()
  481. self.module.module_bay = self.bay_b
  482. with patch.object(ModuleMovePlan, 'lock', side_effect=deadlock):
  483. with self.assertRaises(AbortRequest):
  484. self.module.save()
  485. def test_non_deadlock_operational_error_propagates(self):
  486. error = OperationalError('Simulated connection error')
  487. self.module.module_bay = self.bay_b
  488. with patch.object(ModuleMovePlan, 'lock', side_effect=error):
  489. with self.assertRaises(OperationalError):
  490. self.module.save()
  491. def test_post_save_signals_use_write_alias(self):
  492. ModuleBay.objects.create(device=self.device, module=self.module, name='Child Bay 1')
  493. self.module.module_bay = self.bay_b
  494. with patch('dcim.models.module_moves.post_save') as mock_signal:
  495. self.module.save()
  496. self.assertTrue(mock_signal.send.called)
  497. for call in mock_signal.send.call_args_list:
  498. self.assertEqual(call.kwargs['using'], router.db_for_write(ModuleBay))
  499. def test_move_to_deleted_bay_raises_abort_request(self):
  500. target = ModuleBay.objects.create(device=self.device, name='Bay C')
  501. self.module.module_bay = target
  502. ModuleBay.objects.filter(pk=target.pk).delete()
  503. with self.assertRaises(AbortRequest):
  504. self.module.save()
  505. def test_partial_update_fields_after_completed_move_succeeds(self):
  506. self.module.module_bay = self.bay_b
  507. self.module.save()
  508. self.module.serial = 'A1'
  509. self.module.save(update_fields=['serial'])
  510. self.module.refresh_from_db()
  511. self.assertEqual(self.module.serial, 'A1')
  512. self.assertEqual(self.module.module_bay, self.bay_b)
  513. def test_update_fields_with_module_bay_name_moves_same_device(self):
  514. child_bay = ModuleBay.objects.create(device=self.device, module=self.module, name='Child Bay 1')
  515. self.module.module_bay = self.bay_b
  516. self.module.save(update_fields=['module_bay'])
  517. self.module.refresh_from_db()
  518. child_bay.refresh_from_db()
  519. self.assertEqual(self.module.module_bay, self.bay_b)
  520. self.assertEqual(child_bay.parent_id, self.bay_b.pk)
  521. def test_update_fields_with_attnames_moves_cross_device(self):
  522. device_b = create_test_device('Device B')
  523. bay_c = ModuleBay.objects.create(device=device_b, name='Bay C')
  524. self.module.device = device_b
  525. self.module.module_bay = bay_c
  526. self.module.save(update_fields=['device_id', 'module_bay_id'])
  527. self.module.refresh_from_db()
  528. self.assertEqual(self.module.device, device_b)
  529. self.assertEqual(self.module.module_bay, bay_c)
  530. def test_update_fields_missing_device_rejects_cross_device_delta(self):
  531. device_b = create_test_device('Device B')
  532. bay_c = ModuleBay.objects.create(device=device_b, name='Bay C')
  533. self.module.device = device_b
  534. self.module.module_bay = bay_c
  535. with self.assertRaises(AbortRequest):
  536. self.module.save(update_fields=['module_bay'])
  537. def test_update_fields_incomplete_against_current_placement_rejects(self):
  538. """
  539. update_fields completeness is judged against the locked database placement,
  540. not the placement the saving instance last observed.
  541. """
  542. device_b = create_test_device('Device B')
  543. bay_c = ModuleBay.objects.create(device=device_b, name='Bay C')
  544. stale = Module.objects.get(pk=self.module.pk)
  545. mover = Module.objects.get(pk=self.module.pk)
  546. mover.device = device_b
  547. mover.module_bay = bay_c
  548. mover.save()
  549. stale.module_bay = self.bay_b
  550. with self.assertRaises(AbortRequest):
  551. stale.save(update_fields=['module_bay'])
  552. self.module.refresh_from_db()
  553. self.assertEqual(self.module.device, device_b)
  554. self.assertEqual(self.module.module_bay, bay_c)
  555. def test_update_fields_with_unchanged_placement_field_saves_without_moving(self):
  556. """
  557. A placement field listed in update_fields with an unchanged value is written as-is;
  558. a diverged placement field left out of update_fields never triggers a move.
  559. """
  560. self.module.module_bay = self.bay_b
  561. self.module.serial = 'NOMOVE1'
  562. with patch.object(ModuleMovePlan, 'from_module') as mock_plan:
  563. self.module.save(update_fields=['device', 'serial'])
  564. mock_plan.assert_not_called()
  565. self.module.refresh_from_db()
  566. self.assertEqual(self.module.serial, 'NOMOVE1')
  567. self.assertEqual(self.module.module_bay, self.bay_a)
  568. class ModuleMoveRenameTestCase(TestCase):
  569. @classmethod
  570. def setUpTestData(cls):
  571. manufacturer = Manufacturer.objects.create(name='Manufacturer M', slug='manufacturer-m')
  572. role = DeviceRole.objects.create(name='Role 1', slug='role-1')
  573. site = Site.objects.create(name='Site 1', slug='site-1')
  574. device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Chassis', slug='chassis')
  575. ModuleBayTemplate.objects.create(device_type=device_type, name='Slot 1', position='1')
  576. ModuleBayTemplate.objects.create(device_type=device_type, name='Slot 2', position='2')
  577. cls.line_card_type = ModuleType.objects.create(manufacturer=manufacturer, model='Line Card')
  578. InterfaceTemplate.objects.create(
  579. module_type=cls.line_card_type,
  580. name='Ethernet{module}/1',
  581. label='Port {module}',
  582. type=InterfaceTypeChoices.TYPE_1GE_FIXED,
  583. )
  584. ModuleBayTemplate.objects.create(
  585. module_type=cls.line_card_type, name='SFP bay {module}/1', position='{module}/1'
  586. )
  587. cls.sfp_type = ModuleType.objects.create(manufacturer=manufacturer, model='SFP')
  588. InterfaceTemplate.objects.create(
  589. module_type=cls.sfp_type, name='SFP {module}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
  590. )
  591. cls.device = Device.objects.create(name='Chassis A', device_type=device_type, role=role, site=site)
  592. cls.slot_1 = cls.device.modulebays.get(name='Slot 1')
  593. cls.slot_2 = cls.device.modulebays.get(name='Slot 2')
  594. cls.line_card = Module.objects.create(
  595. device=cls.device, module_bay=cls.slot_1, module_type=cls.line_card_type
  596. )
  597. cls.sfp_bay = cls.line_card.modulebays.get(name='SFP bay 1/1')
  598. cls.sfp_module = Module.objects.create(
  599. device=cls.device, module_bay=cls.sfp_bay, module_type=cls.sfp_type
  600. )
  601. def _move_line_card_to_slot_2(self):
  602. self.line_card.module_bay = self.slot_2
  603. self.line_card.full_clean()
  604. self.line_card.save()
  605. def test_same_device_move_renames_templated_interface(self):
  606. interface = self.line_card.interfaces.get(name='Ethernet1/1')
  607. self._move_line_card_to_slot_2()
  608. interface.refresh_from_db()
  609. self.assertEqual(interface.name, 'Ethernet2/1')
  610. self.assertEqual(interface.label, 'Port 2')
  611. self.assertEqual(interface._name, naturalize_interface('Ethernet2/1', max_length=100))
  612. def test_move_renames_nested_bay_and_grandchild_components(self):
  613. sfp_interface = self.sfp_module.interfaces.get(name='SFP 1/1')
  614. self._move_line_card_to_slot_2()
  615. self.sfp_bay.refresh_from_db()
  616. self.assertEqual(self.sfp_bay.name, 'SFP bay 2/1')
  617. self.assertEqual(self.sfp_bay.position, '2/1')
  618. sfp_interface.refresh_from_db()
  619. self.assertEqual(sfp_interface.name, 'SFP 2/1')
  620. def test_manually_renamed_component_is_preserved(self):
  621. interface = self.line_card.interfaces.get(name='Ethernet1/1')
  622. interface.name = 'uplink-core'
  623. interface.save()
  624. self._move_line_card_to_slot_2()
  625. interface.refresh_from_db()
  626. self.assertEqual(interface.name, 'uplink-core')
  627. def test_manually_changed_label_is_preserved_while_name_renames(self):
  628. interface = self.line_card.interfaces.get(name='Ethernet1/1')
  629. interface.label = 'Uplink'
  630. interface.save()
  631. self._move_line_card_to_slot_2()
  632. interface.refresh_from_db()
  633. self.assertEqual(interface.name, 'Ethernet2/1')
  634. self.assertEqual(interface.label, 'Uplink')
  635. def test_ambiguous_template_match_is_preserved(self):
  636. # A second template resolving to the same old name makes the match ambiguous
  637. InterfaceTemplate.objects.create(
  638. module_type=self.line_card_type, name='Ethernet1/1', type=InterfaceTypeChoices.TYPE_1GE_FIXED
  639. )
  640. interface = self.line_card.interfaces.get(name='Ethernet1/1')
  641. self._move_line_card_to_slot_2()
  642. interface.refresh_from_db()
  643. self.assertEqual(interface.name, 'Ethernet1/1')
  644. def test_move_after_module_type_change_preserves_names(self):
  645. other_type = ModuleType.objects.create(
  646. manufacturer=self.line_card_type.manufacturer, model='Other Card'
  647. )
  648. self.line_card.module_type = other_type
  649. self.line_card.save()
  650. interface = self.line_card.interfaces.get(name='Ethernet1/1')
  651. self._move_line_card_to_slot_2()
  652. interface.refresh_from_db()
  653. self.assertEqual(interface.name, 'Ethernet1/1')
  654. def test_manually_changed_bay_position_stops_rename_cascade(self):
  655. self.sfp_bay.position = 'X'
  656. self.sfp_bay.save()
  657. sfp_interface = self.sfp_module.interfaces.get(name='SFP 1/1')
  658. self._move_line_card_to_slot_2()
  659. self.sfp_bay.refresh_from_db()
  660. self.assertEqual(self.sfp_bay.position, 'X')
  661. sfp_interface.refresh_from_db()
  662. self.assertEqual(sfp_interface.name, 'SFP 1/1')
  663. def test_duplicate_final_names_within_moved_set_fail(self):
  664. Interface.objects.create(
  665. device=self.device, module=self.line_card, name='Ethernet2/1',
  666. type=InterfaceTypeChoices.TYPE_1GE_FIXED,
  667. )
  668. self.line_card.module_bay = self.slot_2
  669. with self.assertRaises(ValidationError):
  670. self.line_card.full_clean()
  671. def test_conflict_with_existing_destination_component_fails(self):
  672. Interface.objects.create(
  673. device=self.device, name='Ethernet2/1', type=InterfaceTypeChoices.TYPE_1GE_FIXED
  674. )
  675. self.line_card.module_bay = self.slot_2
  676. with self.assertRaises(ValidationError):
  677. self.line_card.full_clean()
  678. def test_rename_chain_collision_is_rejected(self):
  679. # 'E{module}/1' resolves to E1/1 in slot 1 and E2/1 in slot 2, while 'E2/{module}'
  680. # resolves to E2/1 in slot 1 and E2/2 in slot 2: E1/1 -> E2/1 while E2/1 -> E2/2.
  681. # Applying both renames on the same device in one statement is order-dependent.
  682. InterfaceTemplate.objects.create(
  683. module_type=self.line_card_type, name='E2/{module}', type=InterfaceTypeChoices.TYPE_1GE_FIXED
  684. )
  685. InterfaceTemplate.objects.create(
  686. module_type=self.line_card_type, name='E{module}/1', type=InterfaceTypeChoices.TYPE_1GE_FIXED
  687. )
  688. Interface.objects.create(
  689. device=self.device, module=self.line_card, name='E2/1', type=InterfaceTypeChoices.TYPE_1GE_FIXED
  690. )
  691. Interface.objects.create(
  692. device=self.device, module=self.line_card, name='E1/1', type=InterfaceTypeChoices.TYPE_1GE_FIXED
  693. )
  694. self.line_card.module_bay = self.slot_2
  695. with self.assertRaises(ValidationError) as cm:
  696. self.line_card.full_clean()
  697. self.assertIn('current name of another moved', str(cm.exception))
  698. def test_bay_rename_chain_collision_is_rejected(self):
  699. # 'Bay E2/{module}' resolves to Bay E2/1 in slot 1 and Bay E2/2 in slot 2, while
  700. # 'Bay E{module}/1' resolves to Bay E1/1 in slot 1 and Bay E2/1 in slot 2: Bay E1/1
  701. # -> Bay E2/1 while Bay E2/1 -> Bay E2/2. Applying both renames in one same-device
  702. # bulk statement would collide on the destination namespace depending on row order.
  703. ModuleBayTemplate.objects.create(module_type=self.line_card_type, name='Bay E2/{module}')
  704. ModuleBayTemplate.objects.create(module_type=self.line_card_type, name='Bay E{module}/1')
  705. ModuleBay.objects.create(device=self.device, module=self.line_card, name='Bay E2/1')
  706. ModuleBay.objects.create(device=self.device, module=self.line_card, name='Bay E1/1')
  707. self.line_card.module_bay = self.slot_2
  708. with self.assertRaises(ValidationError) as cm:
  709. self.line_card.full_clean()
  710. self.assertIn('current name of another moved module bay', str(cm.exception))
  711. def test_rename_exceeding_name_max_length_is_rejected(self):
  712. name_limit = Interface._meta.get_field('name').max_length
  713. template_name_limit = InterfaceTemplate._meta.get_field('name').max_length
  714. position_limit = ModuleBay._meta.get_field('position').max_length
  715. prefix = 'A' * (template_name_limit - len('{module}'))
  716. long_type = ModuleType.objects.create(
  717. manufacturer=self.line_card_type.manufacturer, model='Oversized Name Card'
  718. )
  719. InterfaceTemplate.objects.create(
  720. module_type=long_type, name=f'{prefix}{{module}}', type=InterfaceTypeChoices.TYPE_1GE_FIXED
  721. )
  722. long_bay = ModuleBay.objects.create(device=self.device, name='Long Bay', position='X' * position_limit)
  723. module = Module.objects.create(device=self.device, module_bay=self.slot_2, module_type=long_type)
  724. interface = module.interfaces.get()
  725. self.assertLessEqual(len(interface.name), name_limit)
  726. module.module_bay = long_bay
  727. with self.assertRaises(ValidationError) as cm:
  728. module.full_clean()
  729. self.assertIn(str(interface), str(cm.exception))
  730. def test_rename_exceeding_position_max_length_is_rejected(self):
  731. position_limit = ModuleBay._meta.get_field('position').max_length
  732. template_position_limit = ModuleBayTemplate._meta.get_field('position').max_length
  733. prefix = 'B' * (template_position_limit - len('{module}'))
  734. long_type = ModuleType.objects.create(
  735. manufacturer=self.line_card_type.manufacturer, model='Oversized Position Card'
  736. )
  737. ModuleBayTemplate.objects.create(
  738. module_type=long_type, name='Nested Bay {module}', position=f'{prefix}{{module}}'
  739. )
  740. long_bay = ModuleBay.objects.create(device=self.device, name='Long Bay', position='Y' * position_limit)
  741. module = Module.objects.create(device=self.device, module_bay=self.slot_2, module_type=long_type)
  742. bay = module.modulebays.get()
  743. module.module_bay = long_bay
  744. with self.assertRaises(ValidationError) as cm:
  745. module.full_clean()
  746. self.assertIn(str(bay), str(cm.exception))
  747. def test_leaf_token_left_raw_matches_fresh_walker_resolution(self):
  748. # A bay whose stored position literally contains an unresolved {module} token
  749. # (bypassing normal template-driven creation) must resolve its own children's
  750. # names identically to a fresh get_module_bay_positions() call, both before and
  751. # after a move, so a later move can still recognize the template match.
  752. odd_bay = ModuleBay.objects.create(
  753. device=self.device, module=self.line_card, name='Odd Bay', position='{module}A'
  754. )
  755. child_module = Module.objects.create(device=self.device, module_bay=odd_bay, module_type=self.sfp_type)
  756. child_interface = child_module.interfaces.get()
  757. self._move_line_card_to_slot_2()
  758. child_interface.refresh_from_db()
  759. odd_bay.refresh_from_db()
  760. expected_name = resolve_module_placeholder('SFP {module}', get_module_bay_positions(odd_bay))
  761. self.assertEqual(child_interface.name, expected_name)
  762. def test_second_move_still_renames_child_of_raw_token_bay(self):
  763. # Regression pin: a second move of the same module must still template-match and
  764. # rename a child nested under a raw-token bay, not silently stop renaming.
  765. two_token_type = ModuleType.objects.create(
  766. manufacturer=self.line_card_type.manufacturer, model='Two Token SFP'
  767. )
  768. InterfaceTemplate.objects.create(
  769. module_type=two_token_type, name='SFP {module}/{module}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
  770. )
  771. odd_bay = ModuleBay.objects.create(
  772. device=self.device, module=self.line_card, name='Odd Bay', position='{module}A'
  773. )
  774. child_module = Module.objects.create(device=self.device, module_bay=odd_bay, module_type=two_token_type)
  775. child_interface = child_module.interfaces.get()
  776. self.assertEqual(child_interface.name, 'SFP 1/{module}A')
  777. self._move_line_card_to_slot_2()
  778. child_interface.refresh_from_db()
  779. self.assertEqual(child_interface.name, 'SFP 2/{module}A')
  780. self.line_card.module_bay = self.slot_1
  781. self.line_card.full_clean()
  782. self.line_card.save()
  783. child_interface.refresh_from_db()
  784. self.assertEqual(child_interface.name, 'SFP 1/{module}A')
  785. def test_move_into_raw_token_bay_resolves_ancestor_from_child(self):
  786. """
  787. A raw {module} token on the DESTINATION bay resolves from the planned child
  788. position below it, matching a fresh post-move walker resolution exactly.
  789. """
  790. raw_slot = ModuleBay.objects.create(device=self.device, name='Raw Slot', position='{module}R')
  791. b_bay = ModuleBay.objects.create(device=self.device, module=self.line_card, name='B Bay', position='B')
  792. two_token_type = ModuleType.objects.create(
  793. manufacturer=self.line_card_type.manufacturer, model='Two Token'
  794. )
  795. InterfaceTemplate.objects.create(
  796. module_type=two_token_type, name='SFP {module}/{module}',
  797. type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS,
  798. )
  799. child_module = Module.objects.create(
  800. device=self.device, module_bay=b_bay, module_type=two_token_type
  801. )
  802. child_interface = child_module.interfaces.get(name='SFP 1/B')
  803. self.line_card.module_bay = raw_slot
  804. self.line_card.full_clean()
  805. self.line_card.save()
  806. child_interface.refresh_from_db()
  807. self.assertEqual(child_interface.name, 'SFP BR/B')
  808. self.assertNotIn('{module}', child_interface.name)
  809. fresh_chain = get_module_bay_positions(ModuleBay.objects.get(pk=b_bay.pk))
  810. self.assertEqual(child_interface.name, resolve_module_placeholder('SFP {module}/{module}', fresh_chain))
  811. # Leaf-raw parity: the moved module's own component sees the destination
  812. # bay's token unresolved, exactly as an install into that bay would
  813. root_interface = self.line_card.interfaces.get(name__startswith='Ethernet')
  814. self.assertEqual(root_interface.name, 'Ethernet{module}R/1')
  815. def test_move_under_multiple_raw_token_ancestors_resolves_full_chain(self):
  816. """
  817. Every ancestor level carrying a raw {module} token inherits from the resolved
  818. position below it, across more than one level.
  819. """
  820. bare_type = ModuleType.objects.create(
  821. manufacturer=self.line_card_type.manufacturer, model='Bare Carrier'
  822. )
  823. three_token_type = ModuleType.objects.create(
  824. manufacturer=self.line_card_type.manufacturer, model='Three Token'
  825. )
  826. InterfaceTemplate.objects.create(
  827. module_type=three_token_type, name='T{module}.{module}.{module}',
  828. type=InterfaceTypeChoices.TYPE_1GE_FIXED,
  829. )
  830. # Source: depth-3 chain ['7', '8', 'B'] so the three-token template resolves
  831. s1 = ModuleBay.objects.create(device=self.device, name='S1', position='7')
  832. mid_src = Module.objects.create(device=self.device, module_bay=s1, module_type=bare_type)
  833. s2 = ModuleBay.objects.create(device=self.device, module=mid_src, name='S2', position='8')
  834. carrier = Module.objects.create(device=self.device, module_bay=s2, module_type=bare_type)
  835. c_bay = ModuleBay.objects.create(device=self.device, module=carrier, name='C Bay', position='B')
  836. child_module = Module.objects.create(
  837. device=self.device, module_bay=c_bay, module_type=three_token_type
  838. )
  839. child_interface = child_module.interfaces.get(name='T7.8.B')
  840. # Destination: two stacked raw-token ancestors
  841. g_bay = ModuleBay.objects.create(device=self.device, name='G Bay', position='{module}X')
  842. mid_dst = Module.objects.create(device=self.device, module_bay=g_bay, module_type=bare_type)
  843. r_bay = ModuleBay.objects.create(device=self.device, module=mid_dst, name='R Bay', position='{module}R')
  844. carrier.module_bay = r_bay
  845. carrier.full_clean()
  846. carrier.save()
  847. child_interface.refresh_from_db()
  848. self.assertEqual(child_interface.name, 'TBRX.BR.B')
  849. self.assertNotIn('{module}', child_interface.name)
  850. fresh_chain = get_module_bay_positions(ModuleBay.objects.get(pk=c_bay.pk))
  851. self.assertEqual(fresh_chain, ['BRX', 'BR', 'B'])
  852. self.assertEqual(
  853. child_interface.name, resolve_module_placeholder('T{module}.{module}.{module}', fresh_chain)
  854. )
  855. def test_move_to_shallower_bay_with_unresolvable_template_is_rejected(self):
  856. """
  857. A component whose name matched a source template that cannot resolve at the
  858. destination depth rejects the move instead of silently keeping the stale name.
  859. """
  860. two_token_type = ModuleType.objects.create(
  861. manufacturer=self.line_card_type.manufacturer, model='Two Token'
  862. )
  863. InterfaceTemplate.objects.create(
  864. module_type=two_token_type, name='SFP {module}/{module}',
  865. type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS,
  866. )
  867. b_bay = ModuleBay.objects.create(device=self.device, module=self.line_card, name='B Bay', position='B')
  868. child_module = Module.objects.create(
  869. device=self.device, module_bay=b_bay, module_type=two_token_type
  870. )
  871. shallow = ModuleBay.objects.create(device=self.device, name='Shallow', position='X')
  872. child_module.module_bay = shallow
  873. with self.assertRaises(ValidationError) as cm:
  874. child_module.full_clean()
  875. self.assertIn('cannot be resolved', str(cm.exception))
  876. with self.assertRaises(AbortRequest):
  877. child_module.save()
  878. child_module.refresh_from_db()
  879. self.assertEqual(child_module.module_bay_id, b_bay.pk)
  880. self.assertTrue(child_module.interfaces.filter(name='SFP 1/B').exists())
  881. def test_matched_label_unresolvable_at_destination_rejects_move(self):
  882. """
  883. A label matching its source template resolution rejects the move when the
  884. label template cannot resolve at the destination depth.
  885. """
  886. label_type = ModuleType.objects.create(
  887. manufacturer=self.line_card_type.manufacturer, model='Label Type'
  888. )
  889. InterfaceTemplate.objects.create(
  890. module_type=label_type, name='N{module}', label='L{module}/{module}',
  891. type=InterfaceTypeChoices.TYPE_1GE_FIXED,
  892. )
  893. b_bay = ModuleBay.objects.create(device=self.device, module=self.line_card, name='B Bay', position='B')
  894. child_module = Module.objects.create(device=self.device, module_bay=b_bay, module_type=label_type)
  895. interface = child_module.interfaces.get(name='NB')
  896. self.assertEqual(interface.label, 'L1/B')
  897. shallow = ModuleBay.objects.create(device=self.device, name='Shallow', position='X')
  898. child_module.module_bay = shallow
  899. with self.assertRaises(ValidationError):
  900. child_module.full_clean()
  901. def test_unmatched_label_with_unresolvable_template_still_moves(self):
  902. """
  903. A manually customized label never source-matches, so an unresolvable label
  904. template does not block the move and the label is preserved.
  905. """
  906. label_type = ModuleType.objects.create(
  907. manufacturer=self.line_card_type.manufacturer, model='Label Type'
  908. )
  909. InterfaceTemplate.objects.create(
  910. module_type=label_type, name='N{module}', label='L{module}/{module}',
  911. type=InterfaceTypeChoices.TYPE_1GE_FIXED,
  912. )
  913. b_bay = ModuleBay.objects.create(device=self.device, module=self.line_card, name='B Bay', position='B')
  914. child_module = Module.objects.create(device=self.device, module_bay=b_bay, module_type=label_type)
  915. interface = child_module.interfaces.get(name='NB')
  916. interface.label = 'Custom'
  917. interface.save()
  918. shallow = ModuleBay.objects.create(device=self.device, name='Shallow', position='X')
  919. child_module.module_bay = shallow
  920. child_module.full_clean()
  921. child_module.save()
  922. interface.refresh_from_db()
  923. self.assertEqual(interface.name, 'NX')
  924. self.assertEqual(interface.label, 'Custom')
  925. def test_matched_bay_position_unresolvable_at_destination_rejects_move(self):
  926. """
  927. A module bay position matching its source template resolution rejects the move
  928. when the position template cannot resolve at the destination depth.
  929. """
  930. pos_type = ModuleType.objects.create(
  931. manufacturer=self.line_card_type.manufacturer, model='Pos Type'
  932. )
  933. ModuleBayTemplate.objects.create(module_type=pos_type, name='PB', position='{module}/{module}')
  934. b_bay = ModuleBay.objects.create(device=self.device, module=self.line_card, name='B Bay', position='B')
  935. pos_module = Module.objects.create(device=self.device, module_bay=b_bay, module_type=pos_type)
  936. self.assertEqual(pos_module.modulebays.get(name='PB').position, '1/B')
  937. shallow = ModuleBay.objects.create(device=self.device, name='Shallow', position='X')
  938. pos_module.module_bay = shallow
  939. with self.assertRaises(ValidationError):
  940. pos_module.full_clean()
  941. class ModuleCrossDeviceBlockerTestCase(TestCase):
  942. @classmethod
  943. def setUpTestData(cls):
  944. cls.device_a = create_test_device('Device A')
  945. cls.device_b = create_test_device('Device B')
  946. cls.bay_a = ModuleBay.objects.create(device=cls.device_a, name='Bay A')
  947. cls.bay_b = ModuleBay.objects.create(device=cls.device_b, name='Bay B')
  948. manufacturer = Manufacturer.objects.create(name='Manufacturer M', slug='manufacturer-m')
  949. cls.module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type M')
  950. cls.module = Module.objects.create(
  951. device=cls.device_a, module_bay=cls.bay_a, module_type=cls.module_type
  952. )
  953. cls.interface = Interface.objects.create(
  954. device=cls.device_a, module=cls.module, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED
  955. )
  956. def _assert_move_blocked(self, token):
  957. self.module.device = self.device_b
  958. self.module.module_bay = self.bay_b
  959. with self.assertRaises(ValidationError) as cm:
  960. self.module.full_clean()
  961. self.assertIn('cannot be moved to a different device', str(cm.exception))
  962. self.assertIn(token, str(cm.exception))
  963. def _assert_move_allowed(self):
  964. self.module.device = self.device_b
  965. self.module.module_bay = self.bay_b
  966. self.module.full_clean()
  967. def test_cable_blocks(self):
  968. peer = Interface.objects.create(
  969. device=self.device_a, name='peer0', type=InterfaceTypeChoices.TYPE_1GE_FIXED
  970. )
  971. Cable(a_terminations=[self.interface], b_terminations=[peer]).save()
  972. self._assert_move_blocked('cabled')
  973. def test_mark_connected_blocks(self):
  974. self.interface.mark_connected = True
  975. self.interface.save()
  976. self._assert_move_blocked('cabled or connection-marked')
  977. def test_ip_address_blocks(self):
  978. IPAddress.objects.create(address='192.0.2.1/32', assigned_object=self.interface)
  979. self._assert_move_blocked('IP addresses')
  980. def test_fhrp_group_assignment_blocks(self):
  981. group = FHRPGroup.objects.create(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=1)
  982. FHRPGroupAssignment.objects.create(group=group, interface=self.interface, priority=100)
  983. self._assert_move_blocked('FHRP')
  984. def test_tunnel_termination_blocks(self):
  985. tunnel = Tunnel.objects.create(name='Tunnel 1', encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP)
  986. TunnelTermination.objects.create(tunnel=tunnel, termination=self.interface)
  987. self._assert_move_blocked('tunnel')
  988. def test_l2vpn_termination_blocks(self):
  989. l2vpn = L2VPN.objects.create(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN)
  990. L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=self.interface)
  991. self._assert_move_blocked('L2VPN')
  992. def test_virtual_circuit_termination_blocks(self):
  993. provider = Provider.objects.create(name='Provider 1', slug='provider-1')
  994. provider_network = ProviderNetwork.objects.create(provider=provider, name='Provider Network 1')
  995. vc_type = VirtualCircuitType.objects.create(name='VC Type 1', slug='vc-type-1')
  996. vc = VirtualCircuit.objects.create(provider_network=provider_network, cid='VC 1', type=vc_type)
  997. virtual_interface = Interface.objects.create(
  998. device=self.device_a, module=self.module, name='vc0', type=InterfaceTypeChoices.TYPE_VIRTUAL
  999. )
  1000. VirtualCircuitTermination.objects.create(virtual_circuit=vc, interface=virtual_interface)
  1001. self._assert_move_blocked('virtual circuit')
  1002. def test_wireless_link_blocks(self):
  1003. radio_a = Interface.objects.create(
  1004. device=self.device_a, module=self.module, name='radio0', type=InterfaceTypeChoices.TYPE_80211AC
  1005. )
  1006. radio_b = Interface.objects.create(
  1007. device=self.device_a, name='radio1', type=InterfaceTypeChoices.TYPE_80211AC
  1008. )
  1009. WirelessLink(interface_a=radio_a, interface_b=radio_b, ssid='LINK1').save()
  1010. self._assert_move_blocked('wireless links')
  1011. def test_wireless_lan_blocks(self):
  1012. wlan = WirelessLAN.objects.create(ssid='SSID1')
  1013. self.interface.wireless_lans.add(wlan)
  1014. self._assert_move_blocked('wireless LAN')
  1015. def test_untagged_vlan_blocks(self):
  1016. vlan = VLAN.objects.create(vid=100, name='VLAN 100')
  1017. self.interface.mode = InterfaceModeChoices.MODE_ACCESS
  1018. self.interface.untagged_vlan = vlan
  1019. self.interface.save()
  1020. self._assert_move_blocked('untagged VLAN')
  1021. def test_tagged_vlan_blocks(self):
  1022. vlan = VLAN.objects.create(vid=200, name='VLAN 200')
  1023. self.interface.mode = InterfaceModeChoices.MODE_TAGGED
  1024. self.interface.save()
  1025. self.interface.tagged_vlans.add(vlan)
  1026. self._assert_move_blocked('tagged VLANs')
  1027. def test_qinq_svlan_blocks(self):
  1028. svlan = VLAN.objects.create(vid=999, name='SVLAN 999')
  1029. self.interface.mode = InterfaceModeChoices.MODE_Q_IN_Q
  1030. self.interface.qinq_svlan = svlan
  1031. self.interface.save()
  1032. self._assert_move_blocked('Q-in-Q')
  1033. def test_vlan_translation_policy_blocks(self):
  1034. policy = VLANTranslationPolicy.objects.create(name='Policy 1')
  1035. self.interface.vlan_translation_policy = policy
  1036. self.interface.save()
  1037. self._assert_move_blocked('VLAN translation')
  1038. def test_vdc_assignment_blocks(self):
  1039. vdc = VirtualDeviceContext.objects.create(device=self.device_a, name='VDC 1', status='active')
  1040. self.interface.vdcs.add(vdc)
  1041. self._assert_move_blocked('VDC')
  1042. def test_vrf_blocks(self):
  1043. vrf = VRF.objects.create(name='VRF 1')
  1044. self.interface.vrf = vrf
  1045. self.interface.save()
  1046. self._assert_move_blocked('VRF')
  1047. def test_parent_outside_moved_set_blocks(self):
  1048. parent = Interface.objects.create(
  1049. device=self.device_a, name='parent0', type=InterfaceTypeChoices.TYPE_VIRTUAL
  1050. )
  1051. Interface.objects.create(
  1052. device=self.device_a, module=self.module, name='child0',
  1053. type=InterfaceTypeChoices.TYPE_VIRTUAL, parent=parent,
  1054. )
  1055. self._assert_move_blocked('boundary')
  1056. def test_bridge_outside_moved_set_blocks(self):
  1057. bridge = Interface.objects.create(
  1058. device=self.device_a, name='bridge0', type=InterfaceTypeChoices.TYPE_BRIDGE
  1059. )
  1060. self.interface.bridge = bridge
  1061. self.interface.save()
  1062. self._assert_move_blocked('boundary')
  1063. def test_lag_outside_moved_set_blocks(self):
  1064. lag = Interface.objects.create(
  1065. device=self.device_a, name='lag0', type=InterfaceTypeChoices.TYPE_LAG
  1066. )
  1067. self.interface.lag = lag
  1068. self.interface.save()
  1069. self._assert_move_blocked('boundary')
  1070. def test_nonmoved_member_of_moved_lag_blocks(self):
  1071. lag = Interface.objects.create(
  1072. device=self.device_a, module=self.module, name='lag0', type=InterfaceTypeChoices.TYPE_LAG
  1073. )
  1074. Interface.objects.create(
  1075. device=self.device_a, name='member0', type=InterfaceTypeChoices.TYPE_1GE_FIXED, lag=lag
  1076. )
  1077. self._assert_move_blocked('boundary')
  1078. def test_split_port_mapping_blocks(self):
  1079. front_port = FrontPort.objects.create(
  1080. device=self.device_a, module=self.module, name='Front 1', type=PortTypeChoices.TYPE_LC
  1081. )
  1082. rear_port = RearPort.objects.create(
  1083. device=self.device_a, name='Rear 1', type=PortTypeChoices.TYPE_LC, positions=1
  1084. )
  1085. PortMapping.objects.create(
  1086. front_port=front_port, front_port_position=1, rear_port=rear_port, rear_port_position=1
  1087. )
  1088. self._assert_move_blocked('port mappings')
  1089. def test_split_power_outlet_blocks(self):
  1090. power_port = PowerPort.objects.create(device=self.device_a, name='PP 1')
  1091. PowerOutlet.objects.create(
  1092. device=self.device_a, module=self.module, name='Outlet 1', power_port=power_port
  1093. )
  1094. self._assert_move_blocked('power outlet')
  1095. def test_attached_inventory_item_blocks(self):
  1096. InventoryItem.objects.create(device=self.device_a, name='Item 1', component=self.interface)
  1097. self._assert_move_blocked('inventory items')
  1098. def test_intra_module_bridge_pair_is_allowed(self):
  1099. other = Interface.objects.create(
  1100. device=self.device_a, module=self.module, name='eth1', type=InterfaceTypeChoices.TYPE_1GE_FIXED
  1101. )
  1102. self.interface.bridge = other
  1103. self.interface.save()
  1104. self._assert_move_allowed()
  1105. def test_intra_module_power_pair_is_allowed(self):
  1106. power_port = PowerPort.objects.create(device=self.device_a, module=self.module, name='PP 1')
  1107. PowerOutlet.objects.create(
  1108. device=self.device_a, module=self.module, name='Outlet 1', power_port=power_port
  1109. )
  1110. self._assert_move_allowed()
  1111. #
  1112. # Blockers name the offending components, not just how many there are
  1113. #
  1114. def _blocked_message(self):
  1115. self.module.device = self.device_b
  1116. self.module.module_bay = self.bay_b
  1117. with self.assertRaises(ValidationError) as cm:
  1118. self.module.full_clean()
  1119. return str(cm.exception)
  1120. @staticmethod
  1121. def _samples(message):
  1122. """Every parenthesized "e.g." list in a blocker message, as a list of name lists."""
  1123. return [
  1124. [name.strip() for name in group.split(',')]
  1125. for group in re.findall(r'\(e\.g\. ([^)]*)\)', message)
  1126. ]
  1127. def test_cable_blocker_names_offending_component(self):
  1128. peer = Interface.objects.create(
  1129. device=self.device_a, name='peer0', type=InterfaceTypeChoices.TYPE_1GE_FIXED
  1130. )
  1131. Cable(a_terminations=[self.interface], b_terminations=[peer]).save()
  1132. # A second moved interface with no cable must not be named
  1133. Interface.objects.create(
  1134. device=self.device_a, module=self.module, name='quiet0',
  1135. type=InterfaceTypeChoices.TYPE_1GE_FIXED,
  1136. )
  1137. message = self._blocked_message()
  1138. self.assertIn('eth0', message)
  1139. self.assertNotIn('quiet0', message)
  1140. def test_interface_state_blocker_names_offending_interface(self):
  1141. IPAddress.objects.create(address='192.0.2.1/24', assigned_object=self.interface)
  1142. self.assertEqual(self._samples(self._blocked_message()), [['eth0']])
  1143. def test_boundary_blocker_names_both_directions(self):
  1144. outsider = Interface.objects.create(
  1145. device=self.device_a, name='outsider0', type=InterfaceTypeChoices.TYPE_1GE_FIXED
  1146. )
  1147. self.interface.bridge = outsider
  1148. self.interface.save()
  1149. lag = Interface.objects.create(
  1150. device=self.device_a, module=self.module, name='lag0', type=InterfaceTypeChoices.TYPE_LAG
  1151. )
  1152. Interface.objects.create(
  1153. device=self.device_a, name='member0', type=InterfaceTypeChoices.TYPE_1GE_FIXED, lag=lag
  1154. )
  1155. message = self._blocked_message()
  1156. self.assertIn('2 parent, bridge, or LAG interface relations', message)
  1157. self.assertEqual(self._samples(message), [['eth0', 'member0']])
  1158. def test_split_power_outlet_blocker_names_outlet(self):
  1159. power_port = PowerPort.objects.create(device=self.device_a, name='PP 1')
  1160. PowerOutlet.objects.create(
  1161. device=self.device_a, module=self.module, name='Outlet 1', power_port=power_port
  1162. )
  1163. self.assertEqual(self._samples(self._blocked_message()), [['Outlet 1']])
  1164. def test_split_port_mapping_blocker_names_front_port(self):
  1165. front_port = FrontPort.objects.create(
  1166. device=self.device_a, module=self.module, name='Front 1', type=PortTypeChoices.TYPE_LC
  1167. )
  1168. rear_port = RearPort.objects.create(
  1169. device=self.device_a, name='Rear 1', type=PortTypeChoices.TYPE_LC, positions=1
  1170. )
  1171. PortMapping.objects.create(
  1172. front_port=front_port, front_port_position=1, rear_port=rear_port, rear_port_position=1
  1173. )
  1174. self.assertEqual(self._samples(self._blocked_message()), [['Front 1']])
  1175. def test_split_port_mapping_blocker_names_moved_rear_port(self):
  1176. """
  1177. With the rear port moving and the front port staying behind, the sample must name the
  1178. rear port: a user told to look for the front port would not find it on this module.
  1179. """
  1180. rear_port = RearPort.objects.create(
  1181. device=self.device_a, module=self.module, name='Moved Rear 1',
  1182. type=PortTypeChoices.TYPE_LC, positions=1,
  1183. )
  1184. front_port = FrontPort.objects.create(
  1185. device=self.device_a, name='Chassis Front 1', type=PortTypeChoices.TYPE_LC
  1186. )
  1187. PortMapping.objects.create(
  1188. front_port=front_port, front_port_position=1, rear_port=rear_port, rear_port_position=1
  1189. )
  1190. message = self._blocked_message()
  1191. self.assertEqual(self._samples(message), [['Moved Rear 1']])
  1192. self.assertNotIn('Chassis Front 1', message)
  1193. def test_inventory_item_blocker_names_component(self):
  1194. InventoryItem.objects.create(device=self.device_a, name='Item 1', component=self.interface)
  1195. self.assertEqual(self._samples(self._blocked_message()), [['eth0']])
  1196. def test_blocker_sample_is_capped_but_count_is_complete(self):
  1197. peer = Interface.objects.create(
  1198. device=self.device_a, name='peer0', type=InterfaceTypeChoices.TYPE_1GE_FIXED
  1199. )
  1200. Cable(a_terminations=[self.interface], b_terminations=[peer]).save()
  1201. for i in range(1, 8):
  1202. marked = Interface.objects.create(
  1203. device=self.device_a, module=self.module, name=f'eth{i}',
  1204. type=InterfaceTypeChoices.TYPE_1GE_FIXED,
  1205. )
  1206. marked.mark_connected = True
  1207. marked.save()
  1208. message = self._blocked_message()
  1209. self.assertIn('8 cabled or connection-marked interfaces', message)
  1210. sample, = self._samples(message)
  1211. self.assertEqual(len(sample), ModuleMovePlan.SAMPLE_LIMIT)
  1212. self.assertEqual(sample, ['eth0', 'eth1', 'eth2', 'eth3', 'eth4'])
  1213. def test_mac_address_is_allowed(self):
  1214. mac = MACAddress.objects.create(mac_address='00:11:22:33:44:55', assigned_object=self.interface)
  1215. self.interface.primary_mac_address = mac
  1216. self.interface.save()
  1217. self._assert_move_allowed()
  1218. class ModuleCrossDeviceMoveTestCase(TestCase):
  1219. @classmethod
  1220. def setUpTestData(cls):
  1221. manufacturer = Manufacturer.objects.create(name='Manufacturer M', slug='manufacturer-m')
  1222. role = DeviceRole.objects.create(name='Role 1', slug='role-1')
  1223. cls.site_a = Site.objects.create(name='Site A', slug='site-a')
  1224. cls.site_b = Site.objects.create(name='Site B', slug='site-b')
  1225. device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Chassis', slug='chassis')
  1226. ModuleBayTemplate.objects.create(device_type=device_type, name='Slot 1', position='1')
  1227. ModuleBayTemplate.objects.create(device_type=device_type, name='Slot 2', position='2')
  1228. cls.line_card_type = ModuleType.objects.create(manufacturer=manufacturer, model='Line Card')
  1229. InterfaceTemplate.objects.create(
  1230. module_type=cls.line_card_type, name='Ethernet{module}/1', type=InterfaceTypeChoices.TYPE_1GE_FIXED
  1231. )
  1232. power_port_template = PowerPortTemplate.objects.create(module_type=cls.line_card_type, name='PP 1')
  1233. PowerOutletTemplate.objects.create(
  1234. module_type=cls.line_card_type, name='Outlet 1', power_port=power_port_template
  1235. )
  1236. front_port_template = FrontPortTemplate.objects.create(
  1237. module_type=cls.line_card_type, name='Front 1', type=PortTypeChoices.TYPE_LC
  1238. )
  1239. rear_port_template = RearPortTemplate.objects.create(
  1240. module_type=cls.line_card_type, name='Rear 1', type=PortTypeChoices.TYPE_LC, positions=1
  1241. )
  1242. PortTemplateMapping.objects.create(
  1243. module_type=cls.line_card_type,
  1244. front_port=front_port_template, front_port_position=1,
  1245. rear_port=rear_port_template, rear_port_position=1,
  1246. )
  1247. ModuleBayTemplate.objects.create(
  1248. module_type=cls.line_card_type, name='SFP bay {module}/1', position='{module}/1'
  1249. )
  1250. cls.sfp_type = ModuleType.objects.create(manufacturer=manufacturer, model='SFP')
  1251. InterfaceTemplate.objects.create(
  1252. module_type=cls.sfp_type, name='SFP {module}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
  1253. )
  1254. cls.device_a = Device.objects.create(
  1255. name='Chassis A', device_type=device_type, role=role, site=cls.site_a
  1256. )
  1257. cls.device_b = Device.objects.create(
  1258. name='Chassis B', device_type=device_type, role=role, site=cls.site_b
  1259. )
  1260. cls.slot_1_a = cls.device_a.modulebays.get(name='Slot 1')
  1261. cls.slot_2_b = cls.device_b.modulebays.get(name='Slot 2')
  1262. cls.line_card = Module.objects.create(
  1263. device=cls.device_a, module_bay=cls.slot_1_a, module_type=cls.line_card_type
  1264. )
  1265. cls.sfp_bay = cls.line_card.modulebays.get(name='SFP bay 1/1')
  1266. cls.sfp_module = Module.objects.create(
  1267. device=cls.device_a, module_bay=cls.sfp_bay, module_type=cls.sfp_type
  1268. )
  1269. def _move_to_device_b(self):
  1270. self.line_card.device = self.device_b
  1271. self.line_card.module_bay = self.slot_2_b
  1272. self.line_card.full_clean()
  1273. self.line_card.save()
  1274. def test_cross_device_move_updates_subtree(self):
  1275. sfp_interface = self.sfp_module.interfaces.get(name='SFP 1/1')
  1276. previous = sfp_interface.last_updated
  1277. self._move_to_device_b()
  1278. self.line_card.refresh_from_db()
  1279. self.assertEqual(self.line_card.device, self.device_b)
  1280. self.assertEqual(self.line_card.module_bay, self.slot_2_b)
  1281. self.sfp_module.refresh_from_db()
  1282. self.assertEqual(self.sfp_module.device, self.device_b)
  1283. self.sfp_bay.refresh_from_db()
  1284. self.assertEqual(self.sfp_bay.device, self.device_b)
  1285. self.assertEqual(self.sfp_bay._site, self.site_b)
  1286. self.assertEqual(self.sfp_bay.parent_id, self.slot_2_b.pk)
  1287. self.assertTrue(str(self.sfp_bay.path).startswith(f'{self.slot_2_b.path}.'))
  1288. sfp_interface.refresh_from_db()
  1289. self.assertEqual(sfp_interface.device, self.device_b)
  1290. self.assertEqual(sfp_interface._site, self.site_b)
  1291. self.assertGreater(sfp_interface.last_updated, previous)
  1292. def test_cross_device_move_renames_templated_components(self):
  1293. interface = self.line_card.interfaces.get(name='Ethernet1/1')
  1294. sfp_interface = self.sfp_module.interfaces.get(name='SFP 1/1')
  1295. self._move_to_device_b()
  1296. interface.refresh_from_db()
  1297. self.assertEqual(interface.name, 'Ethernet2/1')
  1298. sfp_interface.refresh_from_db()
  1299. self.assertEqual(sfp_interface.name, 'SFP 2/1')
  1300. def test_cross_device_move_applies_swap_chain_bay_renames(self):
  1301. """
  1302. A rename chain where one bay's new name equals another moved bay's current name
  1303. applies cleanly cross-device, with both bays renamed on the destination device.
  1304. """
  1305. # Templates resolve Bay E1/1 -> Bay E2/1 and Bay E2/1 -> Bay E2/2 across slots
  1306. ModuleBayTemplate.objects.create(module_type=self.line_card_type, name='Bay E2/{module}')
  1307. ModuleBayTemplate.objects.create(module_type=self.line_card_type, name='Bay E{module}/1')
  1308. bay_1 = ModuleBay.objects.create(device=self.device_a, module=self.line_card, name='Bay E1/1')
  1309. bay_2 = ModuleBay.objects.create(device=self.device_a, module=self.line_card, name='Bay E2/1')
  1310. self._move_to_device_b()
  1311. bay_1.refresh_from_db()
  1312. bay_2.refresh_from_db()
  1313. self.assertEqual(bay_1.name, 'Bay E2/1')
  1314. self.assertEqual(bay_2.name, 'Bay E2/2')
  1315. self.assertEqual(bay_1.device, self.device_b)
  1316. self.assertEqual(bay_2.device, self.device_b)
  1317. def test_cross_device_move_updates_port_mappings(self):
  1318. mapping = PortMapping.objects.get(front_port__module=self.line_card)
  1319. self._move_to_device_b()
  1320. mapping.refresh_from_db()
  1321. self.assertEqual(mapping.device, self.device_b)
  1322. @override_settings(BULK_UPDATE_CHUNK_SIZE=1)
  1323. def test_cross_device_move_changelogs_port_mappings(self):
  1324. front_port = FrontPort.objects.create(
  1325. device=self.device_a, module=self.line_card, name='Front 2', type=PortTypeChoices.TYPE_LC
  1326. )
  1327. rear_port = RearPort.objects.create(
  1328. device=self.device_a, module=self.line_card, name='Rear 2', type=PortTypeChoices.TYPE_LC, positions=1
  1329. )
  1330. PortMapping.objects.create(
  1331. front_port=front_port, front_port_position=1, rear_port=rear_port, rear_port_position=1
  1332. )
  1333. mappings = PortMapping.objects.filter(front_port__module=self.line_card)
  1334. self.assertEqual(mappings.count(), 2)
  1335. request = RequestFactory().get('/')
  1336. request.id = uuid.uuid4()
  1337. request.user = User.objects.create_user(username='mapping-mover')
  1338. with event_tracking(request):
  1339. self.line_card.snapshot()
  1340. self._move_to_device_b()
  1341. for mapping in mappings:
  1342. self.assertEqual(mapping.device, self.device_b)
  1343. change = ObjectChange.objects.get(
  1344. changed_object_type=ContentType.objects.get_for_model(PortMapping), changed_object_id=mapping.pk
  1345. )
  1346. self.assertEqual(change.prechange_data['device'], self.device_a.pk)
  1347. self.assertEqual(change.postchange_data['device'], self.device_b.pk)
  1348. def test_cross_device_move_keeps_intra_module_power_link(self):
  1349. outlet = self.line_card.poweroutlets.get(name='Outlet 1')
  1350. self._move_to_device_b()
  1351. outlet.refresh_from_db()
  1352. self.assertEqual(outlet.device, self.device_b)
  1353. self.assertEqual(outlet.power_port.device_id, self.device_b.pk)
  1354. def test_cross_device_move_moves_mac_address(self):
  1355. interface = self.line_card.interfaces.get(name='Ethernet1/1')
  1356. mac = MACAddress.objects.create(mac_address='00:11:22:33:44:55', assigned_object=interface)
  1357. self._move_to_device_b()
  1358. mac.refresh_from_db()
  1359. interface.refresh_from_db()
  1360. self.assertEqual(mac.assigned_object, interface)
  1361. self.assertEqual(interface.device, self.device_b)
  1362. def test_cross_device_move_recomputes_counters(self):
  1363. self._move_to_device_b()
  1364. self.device_a.refresh_from_db()
  1365. self.device_b.refresh_from_db()
  1366. self.assertEqual(self.device_a.interface_count, 0)
  1367. self.assertEqual(self.device_b.interface_count, 2)
  1368. self.assertEqual(self.device_a.module_bay_count, 2)
  1369. self.assertEqual(self.device_b.module_bay_count, 3)
  1370. def test_move_query_count_independent_of_destination_size(self):
  1371. def move_and_count(destination_device, destination_bay):
  1372. module = Module.objects.create(
  1373. device=self.device_a, module_bay=self.device_a.modulebays.get(name='Slot 2'),
  1374. module_type=self.sfp_type,
  1375. )
  1376. module.device = destination_device
  1377. module.module_bay = destination_bay
  1378. module.full_clean()
  1379. with CaptureQueriesContext(connection) as ctx:
  1380. module.save()
  1381. return len(ctx.captured_queries)
  1382. small_count = move_and_count(self.device_b, self.slot_2_b)
  1383. big_device = Device.objects.create(
  1384. name='Warehouse', device_type=self.device_b.device_type, role=self.device_b.role,
  1385. site=self.site_b,
  1386. )
  1387. ModuleBay.objects.bulk_create([
  1388. ModuleBay(device=big_device, name=f'Storage Bay {i}') for i in range(1, 201)
  1389. ])
  1390. target_bay = ModuleBay.objects.create(device=big_device, name='Target Bay')
  1391. big_count = move_and_count(big_device, target_bay)
  1392. self.assertEqual(small_count, big_count)
  1393. def test_move_query_count_independent_of_same_type_child_count(self):
  1394. """
  1395. Moving a module with several installed children of the same module_type issues
  1396. the same number of template-table queries as moving one with a single child: the
  1397. per-pass template lookup is cached per module_type, not repeated per module.
  1398. """
  1399. multi_type = ModuleType.objects.create(
  1400. manufacturer=self.line_card_type.manufacturer, model='Multi-Slot Card'
  1401. )
  1402. for i in range(1, 4):
  1403. ModuleBayTemplate.objects.create(module_type=multi_type, name=f'Child Bay {i}', position=str(i))
  1404. def build_and_move(child_count):
  1405. device = create_test_device(f'Card Device {child_count}')
  1406. card_bay = ModuleBay.objects.create(device=device, name='Card Bay')
  1407. target_bay = ModuleBay.objects.create(device=device, name='Target Bay')
  1408. card = Module.objects.create(device=device, module_bay=card_bay, module_type=multi_type)
  1409. for child_bay in card.modulebays.order_by('name')[:child_count]:
  1410. Module.objects.create(device=device, module_bay=child_bay, module_type=self.sfp_type)
  1411. card.module_bay = target_bay
  1412. card.full_clean()
  1413. with CaptureQueriesContext(connection) as ctx:
  1414. card.save()
  1415. return sum(1 for query in ctx.captured_queries if 'template' in query['sql'].lower())
  1416. one_child_queries = build_and_move(1)
  1417. three_children_queries = build_and_move(3)
  1418. self.assertEqual(one_child_queries, three_children_queries)
  1419. def test_bulk_updates_use_configured_chunk_size(self):
  1420. """
  1421. The move path must honour BULK_UPDATE_CHUNK_SIZE rather than a private constant, so
  1422. that an operator bounding rows-per-statement bounds this operation too.
  1423. """
  1424. original_bulk_update = QuerySet.bulk_update
  1425. batch_sizes = []
  1426. def recording_bulk_update(self, objs, fields, batch_size=None, **kwargs):
  1427. batch_sizes.append(batch_size)
  1428. return original_bulk_update(self, objs, fields, batch_size=batch_size, **kwargs)
  1429. with override_settings(BULK_UPDATE_CHUNK_SIZE=7):
  1430. with patch.object(QuerySet, 'bulk_update', recording_bulk_update):
  1431. self._move_to_device_b()
  1432. self.assertTrue(batch_sizes, 'the move issued no bulk_update calls')
  1433. self.assertEqual(set(batch_sizes), {7})
  1434. @override_settings(BULK_UPDATE_CHUNK_SIZE=1)
  1435. def test_move_is_correct_when_updates_are_chunked(self):
  1436. """
  1437. A chunk size small enough to split every statement must not disturb the staged bay
  1438. writes, whose correctness depends on the ltree triggers settling per level.
  1439. """
  1440. sfp_interface = self.sfp_module.interfaces.get(name='SFP 1/1')
  1441. self._move_to_device_b()
  1442. self.line_card.refresh_from_db()
  1443. self.sfp_module.refresh_from_db()
  1444. self.assertEqual(self.line_card.device, self.device_b)
  1445. self.assertEqual(self.sfp_module.device, self.device_b)
  1446. moved_bay = ModuleBay.objects.get(pk=self.sfp_bay.pk)
  1447. dest_bay = ModuleBay.objects.get(pk=self.slot_2_b.pk)
  1448. self.assertEqual(moved_bay.name, 'SFP bay 2/1')
  1449. self.assertEqual(moved_bay.device, self.device_b)
  1450. self.assertTrue(str(moved_bay.path).startswith(f'{dest_bay.path}.'))
  1451. sfp_interface.refresh_from_db()
  1452. self.assertEqual(sfp_interface.name, 'SFP 2/1')
  1453. self.assertEqual(sfp_interface.device, self.device_b)
  1454. self.assertEqual(sfp_interface._site, self.site_b)
  1455. self.assertEqual(
  1456. self.line_card.interfaces.get().name, 'Ethernet2/1'
  1457. )
  1458. def test_cross_device_move_refreshes_bay_sort_path(self):
  1459. """
  1460. The trigger-maintained sort_path of a moved nested bay reflects the
  1461. destination hierarchy and the renamed chain after reparent plus rename.
  1462. """
  1463. old_sort_path = self.sfp_bay.sort_path
  1464. self._move_to_device_b()
  1465. moved_bay = ModuleBay.objects.get(pk=self.sfp_bay.pk)
  1466. dest_bay = ModuleBay.objects.get(pk=self.slot_2_b.pk)
  1467. self.assertEqual(moved_bay.name, 'SFP bay 2/1')
  1468. self.assertTrue(str(moved_bay.path).startswith(f'{dest_bay.path}.'))
  1469. self.assertNotEqual(moved_bay.sort_path, old_sort_path)
  1470. self.assertTrue(str(moved_bay.sort_path).startswith(str(dest_bay.sort_path)))
  1471. self.assertIn('SFP bay 2/1', str(moved_bay.sort_path))
  1472. class ModuleMoveComponentCoverageTestCase(TestCase):
  1473. """
  1474. Guard against a newly introduced modular component model being left out of the move
  1475. planner, which is how cooling intakes and outflows were initially missed.
  1476. """
  1477. def test_every_modular_component_model_is_planned(self):
  1478. # Scoped to dcim: a plugin may define its own ModularComponentModel subclass, which core
  1479. # cannot add to COMPONENT_TEMPLATE_ATTRS, so it must not fail this assertion.
  1480. core_models = {
  1481. model for model in apps.get_models()
  1482. if issubclass(model, ModularComponentModel) and model._meta.app_label == 'dcim'
  1483. }
  1484. # ModuleBay is planned separately (nested hierarchy, distinct uniqueness constraint).
  1485. planned = set(COMPONENT_TEMPLATE_ATTRS) | {ModuleBay}
  1486. self.assertEqual(
  1487. core_models - planned, set(),
  1488. 'Modular component model(s) are not relocated by ModuleMovePlan. '
  1489. 'Add them to COMPONENT_TEMPLATE_ATTRS.'
  1490. )
  1491. def test_planned_template_attrs_exist_on_module_type(self):
  1492. for model, template_attr in COMPONENT_TEMPLATE_ATTRS.items():
  1493. with self.subTest(model=model._meta.label):
  1494. self.assertTrue(
  1495. hasattr(ModuleType, template_attr),
  1496. f'ModuleType has no relation {template_attr!r}'
  1497. )
  1498. def test_device_counters_are_derived_for_every_planned_model(self):
  1499. """
  1500. Counter recomputation is derived from Device's own CounterCacheField declarations, so
  1501. adding a modular component model cannot silently skip it. Assert the derivation still
  1502. resolves a counter for each planned model, and does not reach beyond device-scoped ones.
  1503. """
  1504. counters = ModuleMovePlan.device_counters_by_model(Device)
  1505. planned = set(COMPONENT_TEMPLATE_ATTRS) | {ModuleBay}
  1506. self.assertEqual(
  1507. planned - set(counters), set(),
  1508. 'A planned model has no device-scoped Device counter. If that is intended, this '
  1509. 'assertion needs to record the exception explicitly.'
  1510. )
  1511. self.assertEqual(counters[CoolingIntake], 'cooling_intake_count')
  1512. self.assertEqual(counters[ModuleBay], 'module_bay_count')
  1513. # Models counted by Device but never moved must not gain a delta
  1514. self.assertNotIn(DeviceBay, planned)
  1515. self.assertNotIn(InventoryItem, planned)
  1516. class ModuleMoveCounterTestCase(TestCase):
  1517. """
  1518. A cross-device move must adjust the Device counter of every modular component model the
  1519. planner relocates. Exercises all of them at once, so a broken counter derivation cannot
  1520. pass by covering only the component types other tests happen to create.
  1521. """
  1522. @classmethod
  1523. def setUpTestData(cls):
  1524. manufacturer = Manufacturer.objects.create(name='Manufacturer M', slug='manufacturer-m')
  1525. role = DeviceRole.objects.create(name='Role 1', slug='role-1')
  1526. site = Site.objects.create(name='Site A', slug='site-a')
  1527. device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Chassis', slug='chassis')
  1528. ModuleBayTemplate.objects.create(device_type=device_type, name='Slot 1')
  1529. ModuleBayTemplate.objects.create(device_type=device_type, name='Slot 2')
  1530. # One template of every modular component type, so each planned model contributes a row
  1531. cls.module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Full Card')
  1532. ConsolePortTemplate.objects.create(module_type=cls.module_type, name='Console 1')
  1533. ConsoleServerPortTemplate.objects.create(module_type=cls.module_type, name='Console Server 1')
  1534. CoolingIntakeTemplate.objects.create(module_type=cls.module_type, name='Intake 1')
  1535. CoolingOutflowTemplate.objects.create(module_type=cls.module_type, name='Outflow 1')
  1536. InterfaceTemplate.objects.create(
  1537. module_type=cls.module_type, name='Ethernet 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED
  1538. )
  1539. power_port = PowerPortTemplate.objects.create(module_type=cls.module_type, name='PP 1')
  1540. PowerOutletTemplate.objects.create(
  1541. module_type=cls.module_type, name='Outlet 1', power_port=power_port
  1542. )
  1543. front_port = FrontPortTemplate.objects.create(
  1544. module_type=cls.module_type, name='Front 1', type=PortTypeChoices.TYPE_LC
  1545. )
  1546. rear_port = RearPortTemplate.objects.create(
  1547. module_type=cls.module_type, name='Rear 1', type=PortTypeChoices.TYPE_LC, positions=1
  1548. )
  1549. PortTemplateMapping.objects.create(
  1550. module_type=cls.module_type,
  1551. front_port=front_port, front_port_position=1,
  1552. rear_port=rear_port, rear_port_position=1,
  1553. )
  1554. ModuleBayTemplate.objects.create(module_type=cls.module_type, name='Sub bay 1')
  1555. cls.device_a = Device.objects.create(
  1556. name='Chassis A', device_type=device_type, role=role, site=site
  1557. )
  1558. cls.device_b = Device.objects.create(
  1559. name='Chassis B', device_type=device_type, role=role, site=site
  1560. )
  1561. def test_cross_device_move_adjusts_every_planned_counter(self):
  1562. module = Module.objects.create(
  1563. device=self.device_a, module_bay=self.device_a.modulebays.get(name='Slot 1'),
  1564. module_type=self.module_type,
  1565. )
  1566. # Fail loudly rather than vacuously if the fixture stops covering every planned model
  1567. rows = {model: model.objects.filter(module=module).count() for model in COMPONENT_TEMPLATE_ATTRS}
  1568. rows[ModuleBay] = ModuleBay.objects.filter(module=module).count()
  1569. self.assertEqual(
  1570. set(rows.values()), {1},
  1571. f'the fixture must create exactly one row per planned model, got {rows}'
  1572. )
  1573. counters = ModuleMovePlan.device_counters_by_model(Device)
  1574. planned_counters = sorted(counters[model] for model in rows)
  1575. self.device_a.refresh_from_db()
  1576. self.device_b.refresh_from_db()
  1577. before_a = {counter: getattr(self.device_a, counter) for counter in planned_counters}
  1578. before_b = {counter: getattr(self.device_b, counter) for counter in planned_counters}
  1579. module.device = self.device_b
  1580. module.module_bay = self.device_b.modulebays.get(name='Slot 2')
  1581. module.full_clean()
  1582. module.save()
  1583. self.device_a.refresh_from_db()
  1584. self.device_b.refresh_from_db()
  1585. for counter in planned_counters:
  1586. with self.subTest(counter=counter):
  1587. self.assertEqual(
  1588. getattr(self.device_a, counter), before_a[counter] - 1,
  1589. f'{counter} was not decremented on the source device'
  1590. )
  1591. self.assertEqual(
  1592. getattr(self.device_b, counter), before_b[counter] + 1,
  1593. f'{counter} was not incremented on the destination device'
  1594. )
  1595. class ModuleMoveCoolingTestCase(TestCase):
  1596. """
  1597. Cooling intakes and outflows are modular components and must be relocated, renamed, and
  1598. counted like any other. See netbox#15289.
  1599. """
  1600. @classmethod
  1601. def setUpTestData(cls):
  1602. manufacturer = Manufacturer.objects.create(name='Manufacturer M', slug='manufacturer-m')
  1603. role = DeviceRole.objects.create(name='Role 1', slug='role-1')
  1604. cls.site_a = Site.objects.create(name='Site A', slug='site-a')
  1605. cls.site_b = Site.objects.create(name='Site B', slug='site-b')
  1606. device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Chassis', slug='chassis')
  1607. ModuleBayTemplate.objects.create(device_type=device_type, name='Slot 1', position='1')
  1608. ModuleBayTemplate.objects.create(device_type=device_type, name='Slot 2', position='2')
  1609. cls.card_type = ModuleType.objects.create(manufacturer=manufacturer, model='Cooled Card')
  1610. CoolingIntakeTemplate.objects.create(module_type=cls.card_type, name='Intake {module}/1')
  1611. CoolingOutflowTemplate.objects.create(module_type=cls.card_type, name='Outflow {module}/1')
  1612. ModuleBayTemplate.objects.create(
  1613. module_type=cls.card_type, name='Sub bay {module}/1', position='{module}/1'
  1614. )
  1615. cls.sub_type = ModuleType.objects.create(manufacturer=manufacturer, model='Cooled Sub')
  1616. CoolingIntakeTemplate.objects.create(module_type=cls.sub_type, name='Sub intake {module}')
  1617. cls.device_a = Device.objects.create(
  1618. name='Chassis A', device_type=device_type, role=role, site=cls.site_a
  1619. )
  1620. cls.device_b = Device.objects.create(
  1621. name='Chassis B', device_type=device_type, role=role, site=cls.site_b
  1622. )
  1623. cls.slot_1_a = cls.device_a.modulebays.get(name='Slot 1')
  1624. cls.slot_2_a = cls.device_a.modulebays.get(name='Slot 2')
  1625. cls.slot_2_b = cls.device_b.modulebays.get(name='Slot 2')
  1626. def setUp(self):
  1627. super().setUp()
  1628. self.card = Module.objects.create(
  1629. device=self.device_a, module_bay=self.slot_1_a, module_type=self.card_type
  1630. )
  1631. self.intake = self.card.coolingintakes.get()
  1632. self.outflow = self.card.coolingoutflows.get()
  1633. def _move_to_device_b(self):
  1634. self.card.device = self.device_b
  1635. self.card.module_bay = self.slot_2_b
  1636. self.card.full_clean()
  1637. self.card.save()
  1638. def test_same_device_move_renames_cooling_components(self):
  1639. self.card.module_bay = self.slot_2_a
  1640. self.card.full_clean()
  1641. self.card.save()
  1642. self.intake.refresh_from_db()
  1643. self.outflow.refresh_from_db()
  1644. self.assertEqual(self.intake.name, 'Intake 2/1')
  1645. self.assertEqual(self.outflow.name, 'Outflow 2/1')
  1646. self.assertEqual(self.intake.device, self.device_a)
  1647. def test_cross_device_move_relocates_cooling_components(self):
  1648. self._move_to_device_b()
  1649. self.intake.refresh_from_db()
  1650. self.outflow.refresh_from_db()
  1651. for component in (self.intake, self.outflow):
  1652. self.assertEqual(component.device, self.device_b)
  1653. self.assertEqual(component._site, self.site_b)
  1654. self.assertEqual(component._location, self.device_b.location)
  1655. self.assertEqual(component._rack, self.device_b.rack)
  1656. self.assertEqual(self.intake.name, 'Intake 2/1')
  1657. self.assertEqual(self.outflow.name, 'Outflow 2/1')
  1658. def test_cross_device_move_relocates_nested_cooling_components(self):
  1659. sub_bay = self.card.modulebays.get()
  1660. sub_module = Module.objects.create(
  1661. device=self.device_a, module_bay=sub_bay, module_type=self.sub_type
  1662. )
  1663. sub_intake = sub_module.coolingintakes.get()
  1664. self.assertEqual(sub_intake.name, 'Sub intake 1/1')
  1665. self._move_to_device_b()
  1666. sub_intake.refresh_from_db()
  1667. self.assertEqual(sub_intake.device, self.device_b)
  1668. self.assertEqual(sub_intake.name, 'Sub intake 2/1')
  1669. def test_cross_device_move_recomputes_cooling_counters(self):
  1670. self.device_a.refresh_from_db()
  1671. self.assertEqual(self.device_a.cooling_intake_count, 1)
  1672. self.assertEqual(self.device_a.cooling_outflow_count, 1)
  1673. self._move_to_device_b()
  1674. self.device_a.refresh_from_db()
  1675. self.device_b.refresh_from_db()
  1676. self.assertEqual(self.device_a.cooling_intake_count, 0)
  1677. self.assertEqual(self.device_a.cooling_outflow_count, 0)
  1678. self.assertEqual(self.device_b.cooling_intake_count, 1)
  1679. self.assertEqual(self.device_b.cooling_outflow_count, 1)
  1680. def test_reinstall_into_vacated_bay_after_move(self):
  1681. """
  1682. The vacated bay must be reusable: a stale cooling name left on the source device
  1683. would collide with the replacement module's replicated components.
  1684. """
  1685. self._move_to_device_b()
  1686. replacement = Module(
  1687. device=self.device_a, module_bay=self.slot_1_a, module_type=self.card_type
  1688. )
  1689. replacement.full_clean()
  1690. replacement.save()
  1691. self.assertEqual(replacement.coolingintakes.get().name, 'Intake 1/1')
  1692. def test_cooling_name_conflict_at_destination_is_rejected(self):
  1693. CoolingIntake.objects.create(device=self.device_b, name='Intake 2/1')
  1694. self.card.device = self.device_b
  1695. self.card.module_bay = self.slot_2_b
  1696. with self.assertRaises(ValidationError) as cm:
  1697. self.card.full_clean()
  1698. self.assertIn('would conflict with', str(cm.exception))
  1699. def test_split_cooling_outflow_relation_blocks(self):
  1700. """An outflow's upstream intake must stay on the same device, so it cannot be split."""
  1701. device_intake = CoolingIntake.objects.create(device=self.device_a, name='Chassis Intake')
  1702. self.outflow.cooling_intake = device_intake
  1703. self.outflow.save()
  1704. self.card.device = self.device_b
  1705. self.card.module_bay = self.slot_2_b
  1706. with self.assertRaises(ValidationError) as cm:
  1707. self.card.full_clean()
  1708. self.assertIn('cooling outflow relations crossing', str(cm.exception))
  1709. self.assertIn('Outflow 1/1', str(cm.exception))
  1710. def test_inward_cooling_outflow_relation_blocks(self):
  1711. device_outflow = CoolingOutflow.objects.create(device=self.device_a, name='Chassis Outflow')
  1712. device_outflow.cooling_intake = self.intake
  1713. device_outflow.save()
  1714. self.card.device = self.device_b
  1715. self.card.module_bay = self.slot_2_b
  1716. with self.assertRaises(ValidationError) as cm:
  1717. self.card.full_clean()
  1718. self.assertIn('cooling outflow relations crossing', str(cm.exception))
  1719. self.assertIn('Chassis Outflow', str(cm.exception))
  1720. def test_intra_module_cooling_pair_is_allowed(self):
  1721. self.outflow.cooling_intake = self.intake
  1722. self.outflow.save()
  1723. self._move_to_device_b()
  1724. self.outflow.refresh_from_db()
  1725. self.assertEqual(self.outflow.device, self.device_b)
  1726. self.assertEqual(self.outflow.cooling_intake, self.intake)
  1727. def test_upstream_outflow_on_another_device_is_allowed(self):
  1728. """
  1729. CoolingIntake.cooling_outflow is not device-scoped: an intake is routinely supplied
  1730. by an outflow on another device, such as a CDU. It must not block a move.
  1731. """
  1732. cdu_outflow = CoolingOutflow.objects.create(device=self.device_a, name='CDU Outflow')
  1733. self.intake.cooling_outflow = cdu_outflow
  1734. self.intake.save()
  1735. self._move_to_device_b()
  1736. self.intake.refresh_from_db()
  1737. self.assertEqual(self.intake.device, self.device_b)
  1738. self.assertEqual(self.intake.cooling_outflow, cdu_outflow)
  1739. def test_cooling_components_are_changelogged(self):
  1740. with event_tracking(self._make_request()):
  1741. self.card.snapshot()
  1742. self._move_to_device_b()
  1743. intake_type = ContentType.objects.get_for_model(CoolingIntake)
  1744. change = ObjectChange.objects.get(
  1745. changed_object_type=intake_type, changed_object_id=self.intake.pk
  1746. )
  1747. self.assertEqual(change.prechange_data['name'], 'Intake 1/1')
  1748. self.assertEqual(change.postchange_data['name'], 'Intake 2/1')
  1749. self.assertEqual(change.postchange_data['device'], self.device_b.pk)
  1750. def _make_request(self):
  1751. request = RequestFactory().get('/')
  1752. request.id = uuid.uuid4()
  1753. request.user = User.objects.create_user(username='cooling-mover')
  1754. return request
  1755. def test_attached_inventory_item_on_cooling_component_blocks(self):
  1756. InventoryItem.objects.create(
  1757. device=self.device_a, name='Coolant Sensor', component=self.intake
  1758. )
  1759. self.card.device = self.device_b
  1760. self.card.module_bay = self.slot_2_b
  1761. with self.assertRaises(ValidationError) as cm:
  1762. self.card.full_clean()
  1763. self.assertIn('attached inventory items', str(cm.exception))