test_api.py 42 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078
  1. import logging
  2. from decimal import Decimal
  3. from django.db import connection
  4. from django.db.models.signals import post_init
  5. from django.test import override_settings, tag
  6. from django.test.utils import CaptureQueriesContext
  7. from django.urls import reverse
  8. from netaddr import IPNetwork
  9. from rest_framework import status
  10. from core.models import ObjectType
  11. from dcim.choices import InterfaceModeChoices
  12. from dcim.models import Platform, Site
  13. from extras.choices import CustomFieldTypeChoices
  14. from extras.models import ConfigContext, ConfigTemplate, CustomField
  15. from ipam.choices import VLANQinQRoleChoices
  16. from ipam.models import VLAN, VRF, IPAddress, Prefix
  17. from users.constants import TOKEN_PREFIX
  18. from users.models import Token
  19. from utilities.testing import (
  20. APITestCase,
  21. APIViewTestCases,
  22. create_test_device,
  23. create_test_nat_ip_pair,
  24. create_test_virtualmachine,
  25. disable_logging,
  26. )
  27. from virtualization.choices import *
  28. from virtualization.models import *
  29. class AppTestCase(APITestCase):
  30. def test_root(self):
  31. url = reverse('virtualization-api:api-root')
  32. response = self.client.get('{}?format=api'.format(url), **self.header)
  33. self.assertEqual(response.status_code, 200)
  34. class ClusterTypeTestCase(APIViewTestCases.APIViewTestCase):
  35. model = ClusterType
  36. brief_fields = ['cluster_count', 'description', 'display', 'id', 'name', 'slug', 'url']
  37. create_data = [
  38. {
  39. 'name': 'Cluster Type 4',
  40. 'slug': 'cluster-type-4',
  41. },
  42. {
  43. 'name': 'Cluster Type 5',
  44. 'slug': 'cluster-type-5',
  45. },
  46. {
  47. 'name': 'Cluster Type 6',
  48. 'slug': 'cluster-type-6',
  49. },
  50. ]
  51. bulk_update_data = {
  52. 'description': 'New description',
  53. }
  54. bulk_update_invalid_data = {
  55. 'owner': 99999,
  56. }
  57. @classmethod
  58. def setUpTestData(cls):
  59. cluster_types = (
  60. ClusterType(name='Cluster Type 1', slug='cluster-type-1'),
  61. ClusterType(name='Cluster Type 2', slug='cluster-type-2'),
  62. ClusterType(name='Cluster Type 3', slug='cluster-type-3'),
  63. )
  64. ClusterType.objects.bulk_create(cluster_types)
  65. class ClusterGroupTestCase(APIViewTestCases.APIViewTestCase):
  66. model = ClusterGroup
  67. brief_fields = ['cluster_count', 'description', 'display', 'id', 'name', 'slug', 'url']
  68. create_data = [
  69. {
  70. 'name': 'Cluster Group 4',
  71. 'slug': 'cluster-type-4',
  72. },
  73. {
  74. 'name': 'Cluster Group 5',
  75. 'slug': 'cluster-type-5',
  76. },
  77. {
  78. 'name': 'Cluster Group 6',
  79. 'slug': 'cluster-type-6',
  80. },
  81. ]
  82. bulk_update_data = {
  83. 'description': 'New description',
  84. }
  85. bulk_update_invalid_data = {
  86. 'owner': 99999,
  87. }
  88. @classmethod
  89. def setUpTestData(cls):
  90. cluster_Groups = (
  91. ClusterGroup(name='Cluster Group 1', slug='cluster-type-1'),
  92. ClusterGroup(name='Cluster Group 2', slug='cluster-type-2'),
  93. ClusterGroup(name='Cluster Group 3', slug='cluster-type-3'),
  94. )
  95. ClusterGroup.objects.bulk_create(cluster_Groups)
  96. class ClusterTestCase(APIViewTestCases.APIViewTestCase):
  97. model = Cluster
  98. brief_fields = ['description', 'display', 'id', 'name', 'url', 'virtualmachine_count']
  99. bulk_update_data = {
  100. 'status': 'offline',
  101. 'comments': 'New comment',
  102. }
  103. bulk_update_invalid_data = {
  104. 'status': 'not-a-valid-status',
  105. }
  106. @classmethod
  107. def setUpTestData(cls):
  108. cluster_types = (
  109. ClusterType(name='Cluster Type 1', slug='cluster-type-1'),
  110. ClusterType(name='Cluster Type 2', slug='cluster-type-2'),
  111. )
  112. ClusterType.objects.bulk_create(cluster_types)
  113. cluster_groups = (
  114. ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'),
  115. ClusterGroup(name='Cluster Group 2', slug='cluster-group-2'),
  116. )
  117. ClusterGroup.objects.bulk_create(cluster_groups)
  118. clusters = (
  119. Cluster(
  120. name='Cluster 1',
  121. type=cluster_types[0],
  122. group=cluster_groups[0],
  123. status=ClusterStatusChoices.STATUS_PLANNED,
  124. ),
  125. Cluster(
  126. name='Cluster 2',
  127. type=cluster_types[0],
  128. group=cluster_groups[0],
  129. status=ClusterStatusChoices.STATUS_PLANNED,
  130. ),
  131. Cluster(
  132. name='Cluster 3',
  133. type=cluster_types[0],
  134. group=cluster_groups[0],
  135. status=ClusterStatusChoices.STATUS_PLANNED,
  136. ),
  137. )
  138. for cluster in clusters:
  139. cluster.save()
  140. cls.create_data = [
  141. {
  142. 'name': 'Cluster 4',
  143. 'type': cluster_types[1].pk,
  144. 'group': cluster_groups[1].pk,
  145. 'status': ClusterStatusChoices.STATUS_STAGING,
  146. },
  147. {
  148. 'name': 'Cluster 5',
  149. 'type': cluster_types[1].pk,
  150. 'group': cluster_groups[1].pk,
  151. 'status': ClusterStatusChoices.STATUS_STAGING,
  152. },
  153. {
  154. 'name': 'Cluster 6',
  155. 'type': cluster_types[1].pk,
  156. 'group': cluster_groups[1].pk,
  157. 'status': ClusterStatusChoices.STATUS_STAGING,
  158. },
  159. ]
  160. def test_list_vm_totals_without_loading_virtual_machines(self):
  161. """
  162. VM counts and resource totals come from annotations without loading any virtual machine.
  163. """
  164. cluster1 = Cluster.objects.get(name='Cluster 1')
  165. cluster2 = Cluster.objects.get(name='Cluster 2')
  166. VirtualMachine.objects.bulk_create((
  167. VirtualMachine(name='Virtual Machine 1', cluster=cluster1, vcpus=Decimal('1.5'), memory=1024, disk=10000),
  168. VirtualMachine(name='Virtual Machine 2', cluster=cluster1, vcpus=Decimal('2.25'), memory=2048, disk=20000),
  169. VirtualMachine(name='Virtual Machine 3', cluster=cluster1),
  170. VirtualMachine(name='Virtual Machine 4', cluster=cluster2),
  171. ))
  172. self.add_permissions('virtualization.view_cluster')
  173. loaded = []
  174. def record(sender, instance, **kwargs):
  175. loaded.append(instance)
  176. post_init.connect(record, sender=VirtualMachine)
  177. try:
  178. response = self.client.get(self._get_list_url(), **self.header)
  179. finally:
  180. post_init.disconnect(record, sender=VirtualMachine)
  181. self.assertHttpStatus(response, status.HTTP_200_OK)
  182. fields = ('virtualmachine_count', 'allocated_vcpus', 'allocated_memory', 'allocated_disk')
  183. totals = {row['name']: tuple(row[field] for field in fields) for row in response.data['results']}
  184. self.assertEqual(totals, {
  185. 'Cluster 1': (3, Decimal('3.75'), 3072, 30000),
  186. 'Cluster 2': (1, None, None, None),
  187. 'Cluster 3': (0, None, None, None),
  188. })
  189. self.assertEqual(loaded, [])
  190. class VirtualMachineTypeTestCase(APIViewTestCases.APIViewTestCase):
  191. model = VirtualMachineType
  192. brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url']
  193. user_permissions = ('dcim.view_platform', 'virtualization.view_virtualmachine')
  194. @classmethod
  195. def setUpTestData(cls):
  196. cls.platforms = (
  197. Platform.objects.create(name='Platform 1', slug='platform-1'),
  198. Platform.objects.create(name='Platform 2', slug='platform-2'),
  199. Platform.objects.create(name='Platform 3', slug='platform-3'),
  200. )
  201. cls.virtual_machine_types = (
  202. VirtualMachineType.objects.create(
  203. name='Virtual Machine Type 1',
  204. slug='virtual-machine-type-1',
  205. default_platform=cls.platforms[0],
  206. default_vcpus=1,
  207. default_memory=1024,
  208. ),
  209. VirtualMachineType.objects.create(
  210. name='Virtual Machine Type 2',
  211. slug='virtual-machine-type-2',
  212. default_platform=cls.platforms[1],
  213. default_vcpus=2,
  214. default_memory=2048,
  215. ),
  216. VirtualMachineType.objects.create(
  217. name='Virtual Machine Type 3',
  218. slug='virtual-machine-type-3',
  219. default_platform=cls.platforms[2],
  220. default_vcpus=4,
  221. default_memory=4096,
  222. ),
  223. )
  224. cls.create_data = [
  225. {
  226. 'name': 'Virtual Machine Type 4',
  227. 'slug': 'virtual-machine-type-4',
  228. 'default_platform': cls.platforms[0].pk,
  229. 'default_vcpus': 1,
  230. 'default_memory': 1024,
  231. },
  232. {
  233. 'name': 'Virtual Machine Type 5',
  234. 'slug': 'virtual-machine-type-5',
  235. 'default_platform': cls.platforms[1].pk,
  236. 'default_vcpus': 2,
  237. 'default_memory': 2048,
  238. },
  239. {
  240. 'name': 'Virtual Machine Type 6',
  241. 'slug': 'virtual-machine-type-6',
  242. 'default_platform': cls.platforms[2].pk,
  243. 'default_vcpus': 4,
  244. 'default_memory': 4096,
  245. },
  246. ]
  247. cls.bulk_update_data = {
  248. 'default_platform': cls.platforms[2].pk,
  249. 'default_vcpus': 8,
  250. 'default_memory': 8192,
  251. 'description': 'New description',
  252. }
  253. cls.bulk_update_invalid_data = {
  254. 'owner': 99999,
  255. }
  256. class VirtualMachineTestCase(APIViewTestCases.APIViewTestCase):
  257. model = VirtualMachine
  258. brief_fields = ['description', 'display', 'id', 'name', 'url']
  259. bulk_update_data = {
  260. 'status': 'staged',
  261. }
  262. bulk_update_invalid_data = {
  263. 'status': 'not-a-valid-status',
  264. }
  265. user_permissions = ('dcim.view_platform', 'virtualization.view_virtualmachinetype')
  266. @classmethod
  267. def setUpTestData(cls):
  268. clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
  269. clustergroup = ClusterGroup.objects.create(name='Cluster Group 1', slug='cluster-group-1')
  270. cls.sites = (
  271. Site(name='Site 1', slug='site-1'),
  272. Site(name='Site 2', slug='site-2'),
  273. Site(name='Site 3', slug='site-3'),
  274. )
  275. Site.objects.bulk_create(cls.sites)
  276. cls.clusters = (
  277. Cluster(name='Cluster 1', type=clustertype, scope=cls.sites[0], group=clustergroup),
  278. Cluster(name='Cluster 2', type=clustertype, scope=cls.sites[1], group=clustergroup),
  279. Cluster(name='Cluster 3', type=clustertype),
  280. )
  281. for cluster in cls.clusters:
  282. cluster.save()
  283. cls.devices = (
  284. create_test_device('device1', site=cls.sites[0], cluster=cls.clusters[0]),
  285. create_test_device('device2', site=cls.sites[1], cluster=cls.clusters[1]),
  286. )
  287. cls.platforms = (
  288. Platform.objects.create(name='Platform 1', slug='platform-1'),
  289. Platform.objects.create(name='Platform 2', slug='platform-2'),
  290. Platform.objects.create(name='Platform 3', slug='platform-3'),
  291. )
  292. cls.vm_types = (
  293. VirtualMachineType.objects.create(
  294. name='Virtual Machine Type 1',
  295. slug='virtual-machine-type-1',
  296. default_platform=cls.platforms[0],
  297. default_vcpus=2,
  298. default_memory=4096,
  299. ),
  300. VirtualMachineType.objects.create(
  301. name='Virtual Machine Type 2',
  302. slug='virtual-machine-type-2',
  303. default_platform=cls.platforms[1],
  304. default_vcpus=4,
  305. default_memory=8192,
  306. ),
  307. )
  308. virtual_machines = (
  309. VirtualMachine(
  310. name='Virtual Machine 1',
  311. virtual_machine_type=cls.vm_types[0],
  312. site=cls.sites[0],
  313. cluster=cls.clusters[0],
  314. device=cls.devices[0],
  315. platform=cls.platforms[0],
  316. vcpus=2,
  317. memory=4096,
  318. local_context_data={'A': 1},
  319. ),
  320. VirtualMachine(
  321. name='Virtual Machine 2',
  322. site=cls.sites[0],
  323. cluster=cls.clusters[0],
  324. local_context_data={'B': 2},
  325. ),
  326. VirtualMachine(
  327. name='Virtual Machine 3',
  328. site=cls.sites[0],
  329. cluster=cls.clusters[0],
  330. local_context_data={'C': 3},
  331. start_on_boot=VirtualMachineStartOnBootChoices.STATUS_ON,
  332. ),
  333. )
  334. VirtualMachine.objects.bulk_create(virtual_machines)
  335. cls.create_data = [
  336. {
  337. 'name': 'Virtual Machine 4',
  338. 'site': cls.sites[1].pk,
  339. 'cluster': cls.clusters[1].pk,
  340. 'device': cls.devices[1].pk,
  341. 'virtual_machine_type': cls.vm_types[0].pk,
  342. },
  343. {
  344. 'name': 'Virtual Machine 5',
  345. 'site': cls.sites[1].pk,
  346. 'cluster': cls.clusters[1].pk,
  347. 'virtual_machine_type': cls.vm_types[1].pk,
  348. },
  349. {
  350. 'name': 'Virtual Machine 6',
  351. 'site': cls.sites[1].pk,
  352. },
  353. {
  354. 'name': 'Virtual Machine 7',
  355. 'cluster': cls.clusters[2].pk,
  356. 'virtual_machine_type': cls.vm_types[0].pk,
  357. 'start_on_boot': VirtualMachineStartOnBootChoices.STATUS_ON,
  358. },
  359. ]
  360. def test_virtual_machine_type_defaults_applied_on_create(self):
  361. data = {
  362. 'name': 'Virtual Machine With Defaults',
  363. 'site': self.sites[1].pk,
  364. 'cluster': self.clusters[1].pk,
  365. 'virtual_machine_type': self.vm_types[0].pk,
  366. 'platform': None,
  367. 'vcpus': None,
  368. 'memory': None,
  369. }
  370. self.add_permissions('virtualization.add_virtualmachine')
  371. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  372. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  373. vm = VirtualMachine.objects.get(pk=response.data['id'])
  374. self.assertEqual(vm.virtual_machine_type, self.vm_types[0])
  375. self.assertEqual(vm.platform, self.vm_types[0].default_platform)
  376. self.assertEqual(vm.vcpus, self.vm_types[0].default_vcpus)
  377. self.assertEqual(vm.memory, self.vm_types[0].default_memory)
  378. def test_virtual_machine_type_defaults_do_not_override_explicit_values(self):
  379. data = {
  380. 'name': 'Virtual Machine With Explicit Values',
  381. 'site': self.sites[1].pk,
  382. 'cluster': self.clusters[1].pk,
  383. 'virtual_machine_type': self.vm_types[0].pk,
  384. 'platform': self.platforms[2].pk,
  385. 'vcpus': 6,
  386. 'memory': 12288,
  387. }
  388. self.add_permissions('virtualization.add_virtualmachine')
  389. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  390. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  391. vm = VirtualMachine.objects.get(pk=response.data['id'])
  392. self.assertEqual(vm.virtual_machine_type, self.vm_types[0])
  393. self.assertEqual(vm.platform, self.platforms[2])
  394. self.assertEqual(vm.vcpus, 6)
  395. self.assertEqual(vm.memory, 12288)
  396. def test_setting_virtual_machine_type_on_existing_vm_does_not_backfill_defaults(self):
  397. vm = VirtualMachine.objects.get(name='Virtual Machine 2')
  398. self.add_permissions('virtualization.change_virtualmachine')
  399. response = self.client.patch(
  400. self._get_detail_url(vm),
  401. {'virtual_machine_type': self.vm_types[1].pk},
  402. format='json',
  403. **self.header,
  404. )
  405. self.assertHttpStatus(response, status.HTTP_200_OK)
  406. vm.refresh_from_db()
  407. self.assertEqual(vm.virtual_machine_type, self.vm_types[1])
  408. self.assertIsNone(vm.platform)
  409. self.assertIsNone(vm.vcpus)
  410. self.assertIsNone(vm.memory)
  411. def test_config_context_included_by_default_in_list_view(self):
  412. """
  413. Check that config context data is included by default in the virtual machines list.
  414. """
  415. virtualmachine = VirtualMachine.objects.first()
  416. url = '{}?id={}'.format(reverse('virtualization-api:virtualmachine-list'), virtualmachine.pk)
  417. self.add_permissions('virtualization.view_virtualmachine')
  418. response = self.client.get(url, **self.header)
  419. self.assertEqual(response.data['results'][0].get('config_context', {}).get('A'), 1)
  420. def test_config_context_not_loaded_when_omitted(self):
  421. """
  422. List responses which omit config_context neither annotate it nor load the cached context.
  423. """
  424. self.add_permissions('virtualization.view_virtualmachine')
  425. for params in ({'fields': 'id,name'}, {'omit': 'config_context'}, {'brief': 1}):
  426. with self.subTest(params=params):
  427. with CaptureQueriesContext(connection) as queries:
  428. response = self.client.get(self._get_list_url(), params, **self.header)
  429. self.assertHttpStatus(response, status.HTTP_200_OK)
  430. sql = '\n'.join(query['sql'] for query in queries.captured_queries)
  431. self.assertNotIn('"config_context_data"', sql)
  432. # The nested device still loads its own cache
  433. self.assertNotIn('"virtualization_virtualmachine"."_config_context_data"', sql)
  434. def test_config_context_loaded_when_requested(self):
  435. """
  436. Responses which include config_context read warm virtual machines from the cache and cold ones from the
  437. annotation, without a query per virtual machine.
  438. """
  439. self.add_permissions('virtualization.view_virtualmachine')
  440. ConfigContext.objects.create(name='Config Context 1', weight=100, data={'foo': 123})
  441. VirtualMachine.objects.filter(name='Virtual Machine 1').update(_config_context_data={'foo': 'cached'})
  442. cache_refresh = (
  443. 'SELECT "virtualization_virtualmachine"."id", "virtualization_virtualmachine"."_config_context_data" FROM'
  444. )
  445. # fields takes precedence over omit, and both over brief
  446. for params in (
  447. {},
  448. {'fields': 'name,config_context'},
  449. {'fields': 'name,config_context', 'omit': 'config_context'},
  450. {'brief': 1, 'fields': 'name,config_context'},
  451. {'brief': 1, 'omit': 'comments'},
  452. ):
  453. with self.subTest(params=params):
  454. with CaptureQueriesContext(connection) as queries:
  455. response = self.client.get(self._get_list_url(), params, **self.header)
  456. self.assertHttpStatus(response, status.HTTP_200_OK)
  457. contexts = {row['name']: row['config_context'] for row in response.data['results']}
  458. self.assertEqual(contexts, {
  459. 'Virtual Machine 1': {'foo': 'cached'},
  460. 'Virtual Machine 2': {'foo': 123, 'B': 2},
  461. 'Virtual Machine 3': {'foo': 123, 'C': 3},
  462. })
  463. statements = [
  464. query['sql'] for query in queries.captured_queries if '"extras_configcontext"' in query['sql']
  465. ]
  466. self.assertEqual(len(statements), 1)
  467. self.assertTrue(statements[0].startswith('SELECT "virtualization_virtualmachine"'))
  468. refreshes = [
  469. query['sql'] for query in queries.captured_queries if query['sql'].startswith(cache_refresh)
  470. ]
  471. self.assertEqual(refreshes, [])
  472. def test_unique_name_per_cluster_constraint(self):
  473. """
  474. Check that creating a virtual machine with a duplicate name fails.
  475. """
  476. data = {
  477. 'name': 'Virtual Machine 1',
  478. 'cluster': Cluster.objects.first().pk,
  479. }
  480. url = reverse('virtualization-api:virtualmachine-list')
  481. self.add_permissions('virtualization.add_virtualmachine')
  482. response = self.client.post(url, data, format='json', **self.header)
  483. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  484. def test_render_config(self):
  485. configtemplate = ConfigTemplate.objects.create(
  486. name='Config Template 1',
  487. template_code='Config for virtual machine {{ virtualmachine.name }}'
  488. )
  489. vm = VirtualMachine.objects.first()
  490. vm.config_template = configtemplate
  491. vm.save()
  492. self.add_permissions(
  493. 'virtualization.render_config_virtualmachine', 'virtualization.view_virtualmachine'
  494. )
  495. url = reverse('virtualization-api:virtualmachine-render-config', kwargs={'pk': vm.pk})
  496. response = self.client.post(url, {}, format='json', **self.header)
  497. self.assertHttpStatus(response, status.HTTP_200_OK)
  498. self.assertEqual(response.data['content'], f'Config for virtual machine {vm.name}')
  499. def test_render_config_without_permission(self):
  500. configtemplate = ConfigTemplate.objects.create(
  501. name='Config Template 1',
  502. template_code='Config for virtual machine {{ virtualmachine.name }}'
  503. )
  504. vm = VirtualMachine.objects.first()
  505. vm.config_template = configtemplate
  506. vm.save()
  507. # No permissions added - user has no render_config permission
  508. url = reverse('virtualization-api:virtualmachine-render-config', kwargs={'pk': vm.pk})
  509. response = self.client.post(url, {}, format='json', **self.header)
  510. self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
  511. def test_render_config_token_write_enabled(self):
  512. configtemplate = ConfigTemplate.objects.create(
  513. name='Config Template 1',
  514. template_code='Config for virtual machine {{ virtualmachine.name }}'
  515. )
  516. vm = VirtualMachine.objects.first()
  517. vm.config_template = configtemplate
  518. vm.save()
  519. self.add_permissions('virtualization.render_config_virtualmachine', 'virtualization.view_virtualmachine')
  520. url = reverse('virtualization-api:virtualmachine-render-config', kwargs={'pk': vm.pk})
  521. # Request without token auth should fail with PermissionDenied
  522. response = self.client.post(url, {}, format='json')
  523. self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
  524. # Create token with write_enabled=False
  525. token = Token.objects.create(version=2, user=self.user, write_enabled=False)
  526. token_header = f'Bearer {TOKEN_PREFIX}{token.key}.{token.token}'
  527. # Request with write-disabled token should fail
  528. response = self.client.post(url, {}, format='json', HTTP_AUTHORIZATION=token_header)
  529. self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
  530. # Enable write and retry
  531. token.write_enabled = True
  532. token.save()
  533. response = self.client.post(url, {}, format='json', HTTP_AUTHORIZATION=token_header)
  534. self.assertHttpStatus(response, status.HTTP_200_OK)
  535. def test_list_object_includes_nat_inside_on_primary_ip(self):
  536. virtualmachine = create_test_virtualmachine('natted-vm')
  537. interface = VMInterface.objects.create(virtual_machine=virtualmachine, name='eth0')
  538. real_ip, nat_ip = create_test_nat_ip_pair(
  539. real_address='10.0.1.10/32',
  540. nat_address='198.51.100.20/32',
  541. inside_interface=interface,
  542. )
  543. virtualmachine.primary_ip4 = nat_ip
  544. virtualmachine.save()
  545. self.add_permissions('virtualization.view_virtualmachine', 'ipam.view_ipaddress')
  546. response = self.client.get(f'{self._get_list_url()}?id={virtualmachine.pk}', **self.header)
  547. self.assertHttpStatus(response, status.HTTP_200_OK)
  548. result = response.data['results'][0]
  549. for field in ('primary_ip', 'primary_ip4'):
  550. self.assertEqual(result[field]['address'], str(nat_ip.address))
  551. self.assertEqual(result[field]['nat_inside']['address'], str(real_ip.address))
  552. self.assertEqual(result[field]['nat_outside'], [])
  553. def test_get_object_includes_nat_outside_on_primary_ip(self):
  554. virtualmachine = create_test_virtualmachine('real-ip-vm')
  555. interface = VMInterface.objects.create(virtual_machine=virtualmachine, name='eth0')
  556. real_ip, nat_ip = create_test_nat_ip_pair(
  557. real_address='10.0.1.11/32',
  558. nat_address='198.51.100.21/32',
  559. inside_interface=interface,
  560. )
  561. virtualmachine.primary_ip4 = real_ip
  562. virtualmachine.save()
  563. self.add_permissions('virtualization.view_virtualmachine', 'ipam.view_ipaddress')
  564. response = self.client.get(
  565. self._get_detail_url(virtualmachine),
  566. **self.header,
  567. )
  568. self.assertHttpStatus(response, status.HTTP_200_OK)
  569. for field in ('primary_ip', 'primary_ip4'):
  570. self.assertEqual(response.data[field]['address'], str(real_ip.address))
  571. self.assertIsNone(response.data[field]['nat_inside'])
  572. self.assertCountEqual(
  573. [ip['address'] for ip in response.data[field]['nat_outside']],
  574. [str(nat_ip.address)],
  575. )
  576. def test_get_object_includes_dns_name_on_primary_ip(self):
  577. virtualmachine = create_test_virtualmachine('dns-vm')
  578. interfaces = (
  579. VMInterface.objects.create(virtual_machine=virtualmachine, name='eth0'),
  580. VMInterface.objects.create(virtual_machine=virtualmachine, name='eth1'),
  581. )
  582. ip4 = IPAddress(address='192.0.2.40/32', dns_name='vm4.example.com')
  583. ip4.assigned_object = interfaces[0]
  584. ip4.save()
  585. ip6 = IPAddress(address='2001:db8::40/128', dns_name='vm6.example.com')
  586. ip6.assigned_object = interfaces[1]
  587. ip6.save()
  588. virtualmachine.primary_ip4 = ip4
  589. virtualmachine.primary_ip6 = ip6
  590. virtualmachine.save()
  591. self.add_permissions('virtualization.view_virtualmachine', 'ipam.view_ipaddress')
  592. response = self.client.get(
  593. f'{self._get_detail_url(virtualmachine)}?exclude=config_context',
  594. **self.header,
  595. )
  596. self.assertHttpStatus(response, status.HTTP_200_OK)
  597. self.assertEqual(response.data['primary_ip4']['dns_name'], 'vm4.example.com')
  598. self.assertEqual(response.data['primary_ip6']['dns_name'], 'vm6.example.com')
  599. self.assertIn(
  600. response.data['primary_ip']['dns_name'],
  601. ('vm4.example.com', 'vm6.example.com'),
  602. )
  603. @tag('regression') # Ref: #23278
  604. def test_create_with_unassigned_primary_ip(self):
  605. """Creating a VM with an unassigned primary IP returns HTTP 400."""
  606. ip4 = IPAddress.objects.create(address='192.0.2.10/24')
  607. ip6 = IPAddress.objects.create(address='2001:db8::10/64')
  608. self.add_permissions('virtualization.add_virtualmachine')
  609. for field, ip in (('primary_ip4', ip4), ('primary_ip6', ip6)):
  610. with self.subTest(field=field):
  611. data = {
  612. 'name': 'vm-new',
  613. 'site': self.sites[0].pk,
  614. field: ip.pk,
  615. }
  616. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  617. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  618. self.assertIn(field, response.data)
  619. def test_render_config_with_config_template_id(self):
  620. default_template = ConfigTemplate.objects.create(
  621. name='Default Template',
  622. template_code='Default config for {{ virtualmachine.name }}'
  623. )
  624. override_template = ConfigTemplate.objects.create(
  625. name='Override Template',
  626. template_code='Override config for {{ virtualmachine.name }}'
  627. )
  628. vm = VirtualMachine.objects.first()
  629. vm.config_template = default_template
  630. vm.save()
  631. self.add_permissions(
  632. 'virtualization.render_config_virtualmachine', 'virtualization.view_virtualmachine',
  633. 'extras.view_configtemplate'
  634. )
  635. url = reverse('virtualization-api:virtualmachine-render-config', kwargs={'pk': vm.pk})
  636. # Render with override template
  637. response = self.client.post(url, {'config_template_id': override_template.pk}, format='json', **self.header)
  638. self.assertHttpStatus(response, status.HTTP_200_OK)
  639. self.assertEqual(response.data['content'], f'Override config for {vm.name}')
  640. # Render with nonexistent config_template_id
  641. response = self.client.post(url, {'config_template_id': 999999}, format='json', **self.header)
  642. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  643. # Render with non-integer config_template_id
  644. response = self.client.post(url, {'config_template_id': 'abc'}, format='json', **self.header)
  645. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  646. # Without view_configtemplate permission, override template should not be accessible
  647. self.remove_permissions('extras.view_configtemplate')
  648. response = self.client.post(url, {'config_template_id': override_template.pk}, format='json', **self.header)
  649. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  650. class VMInterfaceTestCase(APIViewTestCases.APIViewTestCase):
  651. model = VMInterface
  652. brief_fields = ['description', 'display', 'id', 'name', 'url', 'virtual_machine']
  653. bulk_update_data = {
  654. 'description': 'New description',
  655. }
  656. bulk_update_invalid_data = {
  657. 'mode': 'not-a-valid-mode',
  658. }
  659. graphql_base_name = 'vm_interface'
  660. user_permissions = ('virtualization.view_virtualmachine', )
  661. @classmethod
  662. def setUpTestData(cls):
  663. virtualmachine = create_test_virtualmachine('Virtual Machine 1')
  664. interfaces = (
  665. VMInterface(virtual_machine=virtualmachine, name='Interface 1'),
  666. VMInterface(virtual_machine=virtualmachine, name='Interface 2'),
  667. VMInterface(virtual_machine=virtualmachine, name='Interface 3'),
  668. )
  669. VMInterface.objects.bulk_create(interfaces)
  670. vlans = (
  671. VLAN(name='VLAN 1', vid=1),
  672. VLAN(name='VLAN 2', vid=2),
  673. VLAN(name='VLAN 3', vid=3),
  674. VLAN(name='SVLAN 1', vid=1001, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
  675. )
  676. VLAN.objects.bulk_create(vlans)
  677. vrfs = (
  678. VRF(name='VRF 1'),
  679. VRF(name='VRF 2'),
  680. VRF(name='VRF 3'),
  681. )
  682. VRF.objects.bulk_create(vrfs)
  683. cls.create_data = [
  684. {
  685. 'virtual_machine': virtualmachine.pk,
  686. 'name': 'Interface 4',
  687. 'mode': InterfaceModeChoices.MODE_TAGGED,
  688. 'tagged_vlans': [vlans[0].pk, vlans[1].pk],
  689. 'untagged_vlan': vlans[2].pk,
  690. 'vrf': vrfs[0].pk,
  691. },
  692. {
  693. 'virtual_machine': virtualmachine.pk,
  694. 'name': 'Interface 5',
  695. 'mode': InterfaceModeChoices.MODE_TAGGED,
  696. 'bridge': interfaces[0].pk,
  697. 'tagged_vlans': [vlans[0].pk, vlans[1].pk],
  698. 'untagged_vlan': vlans[2].pk,
  699. 'vrf': vrfs[1].pk,
  700. },
  701. {
  702. 'virtual_machine': virtualmachine.pk,
  703. 'name': 'Interface 6',
  704. 'mode': InterfaceModeChoices.MODE_TAGGED,
  705. 'parent': interfaces[1].pk,
  706. 'tagged_vlans': [vlans[0].pk, vlans[1].pk],
  707. 'untagged_vlan': vlans[2].pk,
  708. 'vrf': vrfs[2].pk,
  709. },
  710. {
  711. 'virtual_machine': virtualmachine.pk,
  712. 'name': 'Interface 7',
  713. 'mode': InterfaceModeChoices.MODE_Q_IN_Q,
  714. 'qinq_svlan': vlans[3].pk,
  715. },
  716. ]
  717. @tag('regression')
  718. def test_set_vminterface_as_object_in_custom_field(self):
  719. cf = CustomField.objects.create(
  720. name='associated_interface',
  721. type=CustomFieldTypeChoices.TYPE_OBJECT,
  722. related_object_type=ObjectType.objects.get_for_model(VMInterface),
  723. required=False
  724. )
  725. cf.object_types.set([ObjectType.objects.get_for_model(Prefix)])
  726. cf.save()
  727. prefix = Prefix.objects.create(prefix=IPNetwork('10.0.0.0/12'))
  728. vmi = VMInterface.objects.first()
  729. url = reverse('ipam-api:prefix-detail', kwargs={'pk': prefix.pk})
  730. data = {
  731. 'custom_fields': {
  732. 'associated_interface': vmi.id,
  733. },
  734. }
  735. self.add_permissions('ipam.change_prefix')
  736. response = self.client.patch(url, data, format='json', **self.header)
  737. self.assertEqual(response.status_code, 200)
  738. prefix_data = response.json()
  739. self.assertEqual(prefix_data['custom_fields']['associated_interface']['id'], vmi.id)
  740. reloaded_prefix = Prefix.objects.get(pk=prefix.pk)
  741. self.assertEqual(prefix.pk, reloaded_prefix.pk)
  742. self.assertNotEqual(reloaded_prefix.cf['associated_interface'], None)
  743. def test_bulk_delete_child_interfaces(self):
  744. interface1 = VMInterface.objects.get(name='Interface 1')
  745. virtual_machine = interface1.virtual_machine
  746. self.add_permissions('virtualization.delete_vminterface')
  747. # Create a child interface
  748. child = VMInterface.objects.create(
  749. virtual_machine=virtual_machine,
  750. name='Interface 1A',
  751. parent=interface1
  752. )
  753. self.assertEqual(virtual_machine.interfaces.count(), 4)
  754. # Attempt to delete only the parent interface
  755. url = self._get_detail_url(interface1)
  756. with disable_logging(level=logging.WARNING):
  757. self.client.delete(url, **self.header)
  758. self.assertEqual(virtual_machine.interfaces.count(), 4) # Parent was not deleted
  759. # Attempt to bulk delete parent & child together
  760. data = [
  761. {"id": interface1.pk},
  762. {"id": child.pk},
  763. ]
  764. self.client.delete(self._get_list_url(), data, format='json', **self.header)
  765. self.assertEqual(virtual_machine.interfaces.count(), 2) # Child & parent were both deleted
  766. def test_mac_address_create(self):
  767. """
  768. Creating a VMInterface with mac_address creates the primary MACAddress in one request.
  769. """
  770. self.add_permissions('virtualization.add_vminterface', 'dcim.add_macaddress')
  771. vm = VMInterface.objects.first().virtual_machine
  772. data = {
  773. 'virtual_machine': vm.pk,
  774. 'name': 'Interface MAC Create',
  775. 'mac_address': 'AA:BB:CC:DD:EE:FF',
  776. }
  777. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  778. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  779. iface = VMInterface.objects.get(pk=response.data['id'])
  780. self.assertIsNotNone(iface.primary_mac_address)
  781. self.assertEqual(str(iface.primary_mac_address.mac_address).upper(), 'AA:BB:CC:DD:EE:FF')
  782. self.assertEqual(iface.primary_mac_address.assigned_object, iface)
  783. def test_mac_address_update(self):
  784. """
  785. Patching mac_address creates/updates the primary MACAddress in one request.
  786. """
  787. self.add_permissions('virtualization.change_vminterface', 'dcim.add_macaddress', 'dcim.change_macaddress')
  788. iface = VMInterface.objects.first()
  789. url = self._get_detail_url(iface)
  790. # Set a new primary MAC via mac_address shortcut
  791. response = self.client.patch(url, {'mac_address': '11:22:33:44:55:66'}, format='json', **self.header)
  792. self.assertHttpStatus(response, status.HTTP_200_OK)
  793. iface.refresh_from_db()
  794. self.assertIsNotNone(iface.primary_mac_address)
  795. self.assertEqual(str(iface.primary_mac_address.mac_address).upper(), '11:22:33:44:55:66')
  796. # Update the MAC to a new value
  797. response = self.client.patch(url, {'mac_address': 'AA:BB:CC:DD:EE:FF'}, format='json', **self.header)
  798. self.assertHttpStatus(response, status.HTTP_200_OK)
  799. iface.refresh_from_db()
  800. self.assertEqual(str(iface.primary_mac_address.mac_address).upper(), 'AA:BB:CC:DD:EE:FF')
  801. # Clear the primary MAC by sending null
  802. response = self.client.patch(url, {'mac_address': None}, format='json', **self.header)
  803. self.assertHttpStatus(response, status.HTTP_200_OK)
  804. iface.refresh_from_db()
  805. self.assertIsNone(iface.primary_mac_address)
  806. def test_mac_address_invalid(self):
  807. """
  808. Sending an invalid MAC address string returns a 400 error.
  809. """
  810. self.add_permissions('virtualization.add_vminterface', 'dcim.add_macaddress')
  811. vm = VMInterface.objects.first().virtual_machine
  812. data = {
  813. 'virtual_machine': vm.pk,
  814. 'name': 'Interface MAC Bad',
  815. 'mac_address': 'not-a-mac',
  816. }
  817. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  818. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  819. self.assertIn('mac_address', response.data)
  820. def test_mac_address_find_or_create(self):
  821. """
  822. Patching mac_address with a MAC that already exists on the VMInterface promotes it to
  823. primary without creating a duplicate MACAddress record.
  824. """
  825. from dcim.models import MACAddress
  826. self.add_permissions('virtualization.change_vminterface', 'dcim.add_macaddress', 'dcim.change_macaddress')
  827. iface = VMInterface.objects.first()
  828. mac1 = MACAddress.objects.create(mac_address='CC:DD:EE:FF:00:01', assigned_object=iface)
  829. mac2 = MACAddress.objects.create(mac_address='CC:DD:EE:FF:00:02', assigned_object=iface)
  830. iface.primary_mac_address = mac1
  831. iface.save()
  832. mac_count_before = iface.mac_addresses.count()
  833. url = self._get_detail_url(iface)
  834. response = self.client.patch(url, {'mac_address': 'CC:DD:EE:FF:00:02'}, format='json', **self.header)
  835. self.assertHttpStatus(response, status.HTTP_200_OK)
  836. iface.refresh_from_db()
  837. self.assertEqual(iface.primary_mac_address.pk, mac2.pk)
  838. self.assertEqual(iface.mac_addresses.count(), mac_count_before)
  839. def test_mac_address_conflicts_with_primary_mac_address(self):
  840. """
  841. Supplying both mac_address and primary_mac_address in one request is rejected on VMInterface
  842. too (the shared shortcut mixin applies to both interface types).
  843. """
  844. from dcim.models import MACAddress
  845. self.add_permissions(
  846. 'virtualization.change_vminterface', 'dcim.add_macaddress', 'dcim.change_macaddress'
  847. )
  848. iface = VMInterface.objects.first()
  849. mac = MACAddress.objects.create(mac_address='DD:EE:FF:00:11:22', assigned_object=iface)
  850. url = self._get_detail_url(iface)
  851. response = self.client.patch(
  852. url,
  853. {'mac_address': 'DD:EE:FF:00:11:33', 'primary_mac_address': {'mac_address': str(mac.mac_address)}},
  854. format='json',
  855. **self.header
  856. )
  857. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  858. def test_mac_address_conflicts_with_explicit_null_primary(self):
  859. """
  860. The presence-based conflict guard applies to VMInterface too: mac_address shortcut plus an
  861. explicit primary_mac_address=null is rejected.
  862. """
  863. self.add_permissions('virtualization.change_vminterface', 'dcim.add_macaddress')
  864. iface = VMInterface.objects.first()
  865. url = self._get_detail_url(iface)
  866. response = self.client.patch(
  867. url,
  868. {'mac_address': 'DD:EE:FF:00:11:44', 'primary_mac_address': None},
  869. format='json',
  870. **self.header
  871. )
  872. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  873. def test_primary_mac_address_must_belong_to_interface(self):
  874. """
  875. Setting primary_mac_address to a MAC not assigned to this VMInterface is rejected on update,
  876. so the primary MAC can't dangle outside the interface's own MAC set.
  877. """
  878. from dcim.models import MACAddress
  879. self.add_permissions('virtualization.change_vminterface', 'dcim.change_macaddress')
  880. iface = VMInterface.objects.first()
  881. unassigned = MACAddress.objects.create(mac_address='DD:EE:FF:00:11:55')
  882. url = self._get_detail_url(iface)
  883. response = self.client.patch(
  884. url,
  885. {'primary_mac_address': {'mac_address': str(unassigned.mac_address)}},
  886. format='json',
  887. **self.header
  888. )
  889. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  890. iface.refresh_from_db()
  891. self.assertIsNone(iface.primary_mac_address)
  892. @override_settings(CUSTOM_VALIDATORS={'dcim.macaddress': [{'mac_address': {'regex': '^AA:'}}]})
  893. def test_mac_address_custom_validation_returns_400(self):
  894. """
  895. A MAC that fails a custom validator on VMInterface creation returns 400, not 500.
  896. """
  897. self.add_permissions('virtualization.add_vminterface', 'dcim.add_macaddress')
  898. vm = VirtualMachine.objects.first()
  899. data = {
  900. 'virtual_machine': vm.pk,
  901. 'name': 'VMInterface Custom Validation',
  902. 'mac_address': 'BB:CC:DD:EE:FF:00',
  903. }
  904. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  905. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  906. class VirtualDiskTestCase(APIViewTestCases.APIViewTestCase):
  907. model = VirtualDisk
  908. brief_fields = ['description', 'display', 'id', 'name', 'size', 'url', 'virtual_machine']
  909. bulk_update_data = {
  910. 'size': 888,
  911. }
  912. bulk_update_invalid_data = {
  913. 'virtual_machine': 99999,
  914. }
  915. graphql_base_name = 'virtual_disk'
  916. user_permissions = ('virtualization.view_virtualmachine', )
  917. @classmethod
  918. def setUpTestData(cls):
  919. virtualmachine = create_test_virtualmachine('Virtual Machine 1')
  920. disks = (
  921. VirtualDisk(virtual_machine=virtualmachine, name='Disk 1', size=10),
  922. VirtualDisk(virtual_machine=virtualmachine, name='Disk 2', size=20),
  923. VirtualDisk(virtual_machine=virtualmachine, name='Disk 3', size=30),
  924. )
  925. VirtualDisk.objects.bulk_create(disks)
  926. cls.create_data = [
  927. {
  928. 'virtual_machine': virtualmachine.pk,
  929. 'name': 'Disk 4',
  930. 'size': 10,
  931. },
  932. {
  933. 'virtual_machine': virtualmachine.pk,
  934. 'name': 'Disk 5',
  935. 'size': 20,
  936. },
  937. {
  938. 'virtual_machine': virtualmachine.pk,
  939. 'name': 'Disk 6',
  940. 'size': 30,
  941. },
  942. ]