test_api.py 52 KB

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