Coverage for virtualization/models/virtualmachines.py: 47%

181 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 18:35 +0000

1import decimal 

2 

3from django.contrib.contenttypes.fields import GenericRelation 

4from django.core.exceptions import ValidationError 

5from django.core.validators import MinValueValidator 

6from django.db import models 

7from django.db.models import Q, Sum 

8from django.db.models.functions import Lower 

9from django.utils.translation import gettext_lazy as _ 

10 

11from dcim.models import BaseInterface 

12from dcim.models.mixins import RenderConfigMixin 

13from extras.models import ConfigContextModel 

14from extras.querysets import ConfigContextModelQuerySet 

15from netbox.config import get_config 

16from netbox.models import NetBoxModel, PrimaryModel 

17from netbox.models.features import ContactsMixin, ImageAttachmentsMixin 

18from netbox.models.mixins import OwnerMixin 

19from utilities.fields import CounterCacheField, NaturalOrderingField 

20from utilities.ordering import naturalize_interface 

21from utilities.query_functions import CollateAsChar 

22from utilities.tracking import TrackingModelMixin 

23 

24from ..choices import * 

25 

26__all__ = ( 

27 'VMInterface', 

28 'VirtualDisk', 

29 'VirtualMachine', 

30 'VirtualMachineType', 

31) 

32 

33 

34class VirtualMachineType(ImageAttachmentsMixin, PrimaryModel): 

35 """ 

36 A type defining default attributes (platform, vCPUs, memory, etc.) for virtual machines. 

37 """ 

38 

39 name = models.CharField( 

40 verbose_name=_('name'), 

41 max_length=100, 

42 ) 

43 slug = models.SlugField( 

44 verbose_name=_('slug'), 

45 max_length=100, 

46 unique=True, 

47 ) 

48 default_platform = models.ForeignKey( 

49 to='dcim.Platform', 

50 on_delete=models.SET_NULL, 

51 related_name='+', 

52 blank=True, 

53 null=True, 

54 verbose_name=_('default platform'), 

55 ) 

56 default_vcpus = models.DecimalField( 

57 verbose_name=_('default vCPUs'), 

58 max_digits=6, 

59 decimal_places=2, 

60 blank=True, 

61 null=True, 

62 validators=(MinValueValidator(decimal.Decimal('0.01')),), 

63 ) 

64 default_memory = models.PositiveIntegerField( 

65 verbose_name=_('default memory'), 

66 blank=True, 

67 null=True, 

68 ) 

69 

70 # Counter fields 

71 virtual_machine_count = CounterCacheField( 

72 to_model='virtualization.VirtualMachine', 

73 to_field='virtual_machine_type', 

74 ) 

75 

76 clone_fields = ( 

77 'default_platform', 

78 'default_vcpus', 

79 'default_memory', 

80 ) 

81 

82 class Meta: 

83 ordering = ('name',) 

84 constraints = ( 

85 models.UniqueConstraint( 

86 Lower('name'), 

87 name='%(app_label)s_%(class)s_unique_name', 

88 violation_error_message=_('Virtual machine type name must be unique.'), 

89 ), 

90 ) 

91 indexes = ( 

92 models.Index(fields=('name',)), # Default ordering 

93 ) 

94 verbose_name = _('virtual machine type') 

95 verbose_name_plural = _('virtual machine types') 

96 

97 def __str__(self): 

98 return self.name 

99 

100 

101class VirtualMachine( 

102 ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, ConfigContextModel, TrackingModelMixin, PrimaryModel 

103): 

104 """ 

105 A virtual machine which runs on a Cluster or a standalone Device. 

106 

107 Each VM must be placed in at least one of three ways: 

108 

109 1. Assigned to a Site alone (e.g. for logical grouping without a specific host). 

110 2. Assigned to a Cluster and optionally pinned to a host Device within that cluster. 

111 3. Assigned directly to a standalone Device (one that does not belong to any cluster). 

112 

113 When a Cluster or Device is set, the Site is automatically inherited if not explicitly provided. 

114 If a Device belongs to a Cluster, the Cluster must also be specified on the VM. 

115 """ 

116 

117 virtual_machine_type = models.ForeignKey( 

118 to='virtualization.VirtualMachineType', 

119 on_delete=models.PROTECT, 

120 related_name='instances', 

121 verbose_name=_('type'), 

122 blank=True, 

123 null=True, 

124 ) 

125 site = models.ForeignKey( 

126 to='dcim.Site', 

127 on_delete=models.PROTECT, 

128 related_name='virtual_machines', 

129 blank=True, 

130 null=True 

131 ) 

132 cluster = models.ForeignKey( 

133 to='virtualization.Cluster', 

134 on_delete=models.PROTECT, 

135 related_name='virtual_machines', 

136 blank=True, 

137 null=True 

138 ) 

139 device = models.ForeignKey( 

140 to='dcim.Device', 

141 on_delete=models.PROTECT, 

142 related_name='virtual_machines', 

143 blank=True, 

144 null=True 

145 ) 

146 tenant = models.ForeignKey( 

147 to='tenancy.Tenant', 

148 on_delete=models.PROTECT, 

149 related_name='virtual_machines', 

150 blank=True, 

151 null=True 

152 ) 

153 platform = models.ForeignKey( 

154 to='dcim.Platform', 

155 on_delete=models.SET_NULL, 

156 related_name='virtual_machines', 

157 blank=True, 

158 null=True 

159 ) 

160 name = models.CharField( 

161 verbose_name=_('name'), 

162 max_length=64, 

163 db_collation="natural_sort" 

164 ) 

165 status = models.CharField( 

166 max_length=50, 

167 choices=VirtualMachineStatusChoices, 

168 default=VirtualMachineStatusChoices.STATUS_ACTIVE, 

169 verbose_name=_('status') 

170 ) 

171 start_on_boot = models.CharField( 

172 max_length=32, 

173 choices=VirtualMachineStartOnBootChoices, 

174 default=VirtualMachineStartOnBootChoices.STATUS_OFF, 

175 verbose_name=_('start on boot'), 

176 ) 

177 role = models.ForeignKey( 

178 to='dcim.DeviceRole', 

179 on_delete=models.PROTECT, 

180 related_name='virtual_machines', 

181 blank=True, 

182 null=True 

183 ) 

184 primary_ip4 = models.OneToOneField( 

185 to='ipam.IPAddress', 

186 on_delete=models.SET_NULL, 

187 related_name='+', 

188 blank=True, 

189 null=True, 

190 verbose_name=_('primary IPv4') 

191 ) 

192 primary_ip6 = models.OneToOneField( 

193 to='ipam.IPAddress', 

194 on_delete=models.SET_NULL, 

195 related_name='+', 

196 blank=True, 

197 null=True, 

198 verbose_name=_('primary IPv6') 

199 ) 

200 vcpus = models.DecimalField( 

201 max_digits=6, 

202 decimal_places=2, 

203 blank=True, 

204 null=True, 

205 verbose_name=_('vCPUs'), 

206 validators=( 

207 MinValueValidator(decimal.Decimal(0.01)), 

208 ) 

209 ) 

210 memory = models.PositiveIntegerField( 

211 blank=True, 

212 null=True, 

213 verbose_name=_('memory') 

214 ) 

215 disk = models.PositiveIntegerField( 

216 blank=True, 

217 null=True, 

218 verbose_name=_('disk') 

219 ) 

220 serial = models.CharField( 

221 verbose_name=_('serial number'), 

222 blank=True, 

223 max_length=50 

224 ) 

225 services = GenericRelation( 

226 to='ipam.Service', 

227 content_type_field='parent_object_type', 

228 object_id_field='parent_object_id', 

229 related_query_name='virtual_machine', 

230 ) 

231 

232 # Counter fields 

233 interface_count = CounterCacheField( 

234 to_model='virtualization.VMInterface', 

235 to_field='virtual_machine' 

236 ) 

237 virtual_disk_count = CounterCacheField( 

238 to_model='virtualization.VirtualDisk', 

239 to_field='virtual_machine' 

240 ) 

241 

242 objects = ConfigContextModelQuerySet.as_manager() 

243 

244 clone_fields = ( 

245 'virtual_machine_type', 'site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 

246 'disk', 

247 ) 

248 

249 class Meta: 

250 ordering = ('name', 'pk') # Name may be non-unique 

251 indexes = ( 

252 models.Index(fields=('name', 'id')), # Default ordering 

253 # Partial index supporting the background renderer's scan for objects whose config 

254 # context cache has been invalidated (see extras.jobs.RenderConfigContextJob and 

255 # extras.cache). Indexing only the NULL-cache rows keeps that lookup cheap on large 

256 # tables where the steady state is for nearly every row to be populated. 

257 models.Index( 

258 fields=('id',), 

259 condition=Q(_config_context_data__isnull=True), 

260 name='virtualization_vm_cc_null', 

261 ), 

262 ) 

263 constraints = ( 

264 models.UniqueConstraint( 

265 Lower('name'), 'cluster', 'tenant', 

266 name='%(app_label)s_%(class)s_unique_name_cluster_tenant', 

267 condition=Q(cluster__isnull=False), 

268 nulls_distinct=False, 

269 violation_error_message=_('Virtual machine name must be unique per cluster and tenant.') 

270 ), 

271 models.UniqueConstraint( 

272 Lower('name'), 'device', 'tenant', 

273 name='%(app_label)s_%(class)s_unique_name_device_tenant', 

274 condition=Q(cluster__isnull=True, device__isnull=False), 

275 nulls_distinct=False, 

276 violation_error_message=_('Virtual machine name must be unique per device and tenant.') 

277 ), 

278 ) 

279 verbose_name = _('virtual machine') 

280 verbose_name_plural = _('virtual machines') 

281 permissions = [ 

282 ('render_config', 'Render configuration'), 

283 ] 

284 

285 def __str__(self): 

286 return self.name 

287 

288 def clean(self): 

289 super().clean() 

290 

291 # Must be assigned to a site, cluster, and/or device 

292 if not self.site and not self.cluster and not self.device: 292 ↛ 298line 292 didn't jump to line 298 because the condition on line 292 was always true

293 raise ValidationError( 

294 _('A virtual machine must be assigned to a site, cluster, or device.') 

295 ) 

296 

297 # Validate site for cluster & VM 

298 if self.cluster and self.site and self.cluster._site and self.cluster._site != self.site: 

299 raise ValidationError({ 

300 'cluster': _( 

301 'The selected cluster ({cluster}) is not assigned to this site ({site}).' 

302 ).format(cluster=self.cluster, site=self.site) 

303 }) 

304 

305 # Validate site for the device & VM (when the device is standalone) 

306 if self.device and self.site and self.device.site and self.device.site != self.site: 

307 raise ValidationError({ 

308 'site': _( 

309 'The selected device ({device}) is not assigned to this site ({site}).' 

310 ).format(device=self.device, site=self.site) 

311 }) 

312 

313 # Direct device assignment is only for standalone hosts. If the selected 

314 # device already belongs to a cluster, require that cluster explicitly. 

315 if self.device and not self.cluster and self.device.cluster: 

316 raise ValidationError({ 

317 'cluster': _( 

318 "Must specify the assigned device's cluster ({cluster}) when assigning host device {device}." 

319 ).format(cluster=self.device.cluster, device=self.device) 

320 }) 

321 

322 # Validate assigned cluster device 

323 if self.device and self.cluster and self.device.cluster_id != self.cluster_id: 

324 raise ValidationError({ 

325 'device': _( 

326 "The selected device ({device}) is not assigned to this cluster ({cluster})." 

327 ).format(device=self.device, cluster=self.cluster) 

328 }) 

329 

330 # Validate aggregate disk size 

331 if not self._state.adding: 

332 total_disk = self.virtualdisks.aggregate(Sum('size', default=0))['size__sum'] 

333 if total_disk and self.disk is None: 

334 self.disk = total_disk 

335 elif total_disk and self.disk != total_disk: 

336 raise ValidationError({ 

337 'disk': _( 

338 "The specified disk size ({size}) must match the aggregate size of assigned virtual disks " 

339 "({total_size})." 

340 ).format(size=self.disk, total_size=total_disk) 

341 }) 

342 

343 # Validate primary IP addresses 

344 interfaces = self.interfaces.all() if self.pk else None 

345 for family in (4, 6): 

346 field = f'primary_ip{family}' 

347 ip = getattr(self, field) 

348 if ip is not None: 

349 if ip.address.version != family: 

350 raise ValidationError({ 

351 field: _( 

352 "Must be an IPv{family} address. ({ip} is an IPv{version} address.)" 

353 ).format(family=family, ip=ip, version=ip.address.version) 

354 }) 

355 if ip.assigned_object in interfaces: 

356 pass 

357 elif ip.nat_inside is not None and ip.nat_inside.assigned_object in interfaces: 

358 pass 

359 else: 

360 raise ValidationError({ 

361 field: _("The specified IP address ({ip}) is not assigned to this VM.").format(ip=ip), 

362 }) 

363 

364 def save(self, *args, **kwargs): 

365 # Assign a site from a cluster or device if not set 

366 if not self.site: 

367 if self.cluster and self.cluster._site: 

368 self.site = self.cluster._site 

369 elif self.device and self.device.site: 

370 self.site = self.device.site 

371 

372 if self._state.adding: 

373 self.apply_type_defaults() 

374 

375 super().save(*args, **kwargs) 

376 

377 def apply_type_defaults(self): 

378 """ 

379 Populate any empty fields with defaults from the assigned VirtualMachineType. 

380 """ 

381 if not self.virtual_machine_type_id: 

382 return 

383 

384 defaults = { 

385 'platform_id': 'default_platform_id', 

386 'vcpus': 'default_vcpus', 

387 'memory': 'default_memory', 

388 } 

389 for field, default_field in defaults.items(): 

390 if getattr(self, field) is None: 

391 default_value = getattr(self.virtual_machine_type, default_field) 

392 if default_value is not None: 

393 setattr(self, field, default_value) 

394 

395 def get_status_color(self): 

396 return VirtualMachineStatusChoices.colors.get(self.status) 

397 

398 def get_start_on_boot_color(self): 

399 return VirtualMachineStartOnBootChoices.colors.get(self.start_on_boot) 

400 

401 @property 

402 def primary_ip(self): 

403 if get_config().PREFER_IPV4 and self.primary_ip4: 

404 return self.primary_ip4 

405 if self.primary_ip6: 

406 return self.primary_ip6 

407 if self.primary_ip4: 

408 return self.primary_ip4 

409 return None 

410 

411 

412# 

413# VM components 

414# 

415 

416 

417class ComponentModel(OwnerMixin, NetBoxModel): 

418 """ 

419 An abstract model inherited by any model which has a parent VirtualMachine. 

420 """ 

421 virtual_machine = models.ForeignKey( 

422 to='virtualization.VirtualMachine', 

423 on_delete=models.CASCADE, 

424 related_name='%(class)ss' 

425 ) 

426 name = models.CharField( 

427 verbose_name=_('name'), 

428 max_length=64, 

429 db_collation="natural_sort" 

430 ) 

431 description = models.CharField( 

432 verbose_name=_('description'), 

433 max_length=200, 

434 blank=True 

435 ) 

436 

437 class Meta: 

438 abstract = True 

439 constraints = ( 

440 models.UniqueConstraint( 

441 fields=('virtual_machine', 'name'), 

442 name='%(app_label)s_%(class)s_unique_virtual_machine_name' 

443 ), 

444 ) 

445 

446 def __str__(self): 

447 return self.name 

448 

449 def to_objectchange(self, action): 

450 objectchange = super().to_objectchange(action) 

451 objectchange.related_object = self.virtual_machine 

452 return objectchange 

453 

454 @property 

455 def parent_object(self): 

456 return self.virtual_machine 

457 

458 

459class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin): 

460 name = models.CharField( 

461 verbose_name=_('name'), 

462 max_length=64, 

463 ) 

464 _name = NaturalOrderingField( 

465 target_field='name', 

466 naturalize_function=naturalize_interface, 

467 max_length=100, 

468 blank=True 

469 ) 

470 virtual_machine = models.ForeignKey( 

471 to='virtualization.VirtualMachine', 

472 on_delete=models.CASCADE, 

473 related_name='interfaces' # Override ComponentModel 

474 ) 

475 ip_addresses = GenericRelation( 

476 to='ipam.IPAddress', 

477 content_type_field='assigned_object_type', 

478 object_id_field='assigned_object_id', 

479 related_query_name='vminterface' 

480 ) 

481 vrf = models.ForeignKey( 

482 to='ipam.VRF', 

483 on_delete=models.SET_NULL, 

484 related_name='vminterfaces', 

485 null=True, 

486 blank=True, 

487 verbose_name=_('VRF') 

488 ) 

489 fhrp_group_assignments = GenericRelation( 

490 to='ipam.FHRPGroupAssignment', 

491 content_type_field='interface_type', 

492 object_id_field='interface_id', 

493 related_query_name='+' 

494 ) 

495 tunnel_terminations = GenericRelation( 

496 to='vpn.TunnelTermination', 

497 content_type_field='termination_type', 

498 object_id_field='termination_id', 

499 related_query_name='vminterface', 

500 ) 

501 l2vpn_terminations = GenericRelation( 

502 to='vpn.L2VPNTermination', 

503 content_type_field='assigned_object_type', 

504 object_id_field='assigned_object_id', 

505 related_query_name='vminterface', 

506 ) 

507 mac_addresses = GenericRelation( 

508 to='dcim.MACAddress', 

509 content_type_field='assigned_object_type', 

510 object_id_field='assigned_object_id', 

511 related_query_name='vminterface' 

512 ) 

513 

514 class Meta(ComponentModel.Meta): 

515 verbose_name = _('interface') 

516 verbose_name_plural = _('interfaces') 

517 ordering = ('virtual_machine', CollateAsChar('_name')) 

518 

519 def clean(self): 

520 super().clean() 

521 

522 # Parent validation 

523 

524 # An interface cannot be its own parent 

525 if self.pk and self.parent_id == self.pk: 

526 raise ValidationError({'parent': _("An interface cannot be its own parent.")}) 

527 

528 # An interface's parent must belong to the same virtual machine 

529 if self.parent and self.parent.virtual_machine != self.virtual_machine: 

530 raise ValidationError({ 

531 'parent': _( 

532 "The selected parent interface ({parent}) belongs to a different virtual machine " 

533 "({virtual_machine})." 

534 ).format(parent=self.parent, virtual_machine=self.parent.virtual_machine) 

535 }) 

536 

537 # Bridge validation 

538 

539 # An interface cannot be bridged to itself 

540 if self.pk and self.bridge_id == self.pk: 

541 raise ValidationError({'bridge': _("An interface cannot be bridged to itself.")}) 

542 

543 # A bridged interface belong to the same virtual machine 

544 if self.bridge and self.bridge.virtual_machine != self.virtual_machine: 

545 raise ValidationError({ 

546 'bridge': _( 

547 "The selected bridge interface ({bridge}) belongs to a different virtual machine " 

548 "({virtual_machine})." 

549 ).format(bridge=self.bridge, virtual_machine=self.bridge.virtual_machine) 

550 }) 

551 

552 # VLAN validation 

553 

554 # Validate untagged VLAN 

555 if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]: 

556 raise ValidationError({ 

557 'untagged_vlan': _( 

558 "The untagged VLAN ({untagged_vlan}) must belong to the same site as the interface's parent " 

559 "virtual machine, or it must be global." 

560 ).format(untagged_vlan=self.untagged_vlan) 

561 }) 

562 

563 @property 

564 def l2vpn_termination(self): 

565 return self.l2vpn_terminations.first() 

566 

567 

568class VirtualDisk(ComponentModel, TrackingModelMixin): 

569 size = models.PositiveIntegerField( 

570 verbose_name=_('size'), 

571 ) 

572 

573 class Meta(ComponentModel.Meta): 

574 verbose_name = _('virtual disk') 

575 verbose_name_plural = _('virtual disks') 

576 ordering = ('virtual_machine', 'name')