5d8743076d32f2169208572aa50cb22341ff7fda
[laas.git] / src / resource_inventory / models.py
1 ##############################################################################
2 # Copyright (c) 2018 Sawyer Bergeron, Parker Berberian, and others.
3 # Copyright (c) 2020 Sawyer Bergeron, Sean Smith, others.
4 #
5 # All rights reserved. This program and the accompanying materials
6 # are made available under the terms of the Apache License, Version 2.0
7 # which accompanies this distribution, and is available at
8 # http://www.apache.org/licenses/LICENSE-2.0
9 ##############################################################################
10
11 from django.contrib.auth.models import User
12
13 from django.core.exceptions import ValidationError
14 from django.db import models
15 from django.db.models import Q
16 import traceback
17 import json
18
19 import re
20 from collections import Counter
21
22 from account.models import Lab
23 from dashboard.utils import AbstractModelQuery
24
25 """
26 Profiles of resources hosted by labs.
27
28 These describe hardware attributes of the different Resources a lab hosts.
29 A single Resource subclass (e.g. Server) may have instances that point to different
30 Profile models (e.g. an x86 server profile and armv8 server profile.
31 """
32
33
34 class ResourceProfile(models.Model):
35     id = models.AutoField(primary_key=True)
36     name = models.CharField(max_length=200, unique=True)
37     architecture = models.CharField(max_length=50, choices=[
38         ("x86_64", "x86_64"),
39         ("aarch64", "aarch64")
40     ])
41     description = models.TextField()
42     labs = models.ManyToManyField(Lab, related_name="resourceprofiles")
43
44     def validate(self):
45         validname = re.compile(r"^[A-Za-z0-9\-\_\.\/\, ]+$")
46         if not validname.match(self.name):
47             return "Invalid host profile name given. Name must only use A-Z, a-z, 0-9, hyphens, underscores, dots, commas, or spaces."
48         else:
49             return None
50
51     def __str__(self):
52         return self.name
53
54     def get_resources(self, lab=None, working=True, unreserved=False):
55         """
56         Return a list of Resource objects which have this profile.
57
58         If lab is provided, only resources at that lab will be returned.
59         If working=True, will only return working hosts
60         """
61         resources = []
62         query = Q(profile=self)
63         if lab:
64             query = query & Q(lab=lab)
65         if working:
66             query = query & Q(working=True)
67
68         resources = ResourceQuery.filter(query)
69
70         if unreserved:
71             resources = [r for r in resources if not r.is_reserved()]
72
73         return resources
74
75
76 class InterfaceProfile(models.Model):
77     id = models.AutoField(primary_key=True)
78     speed = models.IntegerField()
79     name = models.CharField(max_length=100)
80     host = models.ForeignKey(ResourceProfile, on_delete=models.CASCADE, related_name='interfaceprofile')
81     nic_type = models.CharField(
82         max_length=50,
83         choices=[
84             ("onboard", "onboard"),
85             ("pcie", "pcie")
86         ],
87         default="onboard"
88     )
89     order = models.IntegerField(default=-1)
90
91     def __str__(self):
92         return self.name + " for " + str(self.host)
93
94
95 class DiskProfile(models.Model):
96     id = models.AutoField(primary_key=True)
97     size = models.IntegerField()
98     media_type = models.CharField(max_length=50, choices=[
99         ("SSD", "SSD"),
100         ("HDD", "HDD")
101     ])
102     name = models.CharField(max_length=50)
103     host = models.ForeignKey(ResourceProfile, on_delete=models.CASCADE, related_name='storageprofile')
104     rotation = models.IntegerField(default=0)
105     interface = models.CharField(
106         max_length=50,
107         choices=[
108             ("sata", "sata"),
109             ("sas", "sas"),
110             ("ssd", "ssd"),
111             ("nvme", "nvme"),
112             ("scsi", "scsi"),
113             ("iscsi", "iscsi"),
114         ],
115         default="sata"
116     )
117
118     def __str__(self):
119         return self.name + " for " + str(self.host)
120
121
122 class CpuProfile(models.Model):
123     id = models.AutoField(primary_key=True)
124     cores = models.IntegerField()
125     architecture = models.CharField(max_length=50, choices=[
126         ("x86_64", "x86_64"),
127         ("aarch64", "aarch64")
128     ])
129     cpus = models.IntegerField()
130     host = models.ForeignKey(ResourceProfile, on_delete=models.CASCADE, related_name='cpuprofile')
131     cflags = models.TextField(null=True, blank=True)
132
133     def __str__(self):
134         return str(self.architecture) + " " + str(self.cpus) + "S" + str(self.cores) + " C for " + str(self.host)
135
136
137 class RamProfile(models.Model):
138     id = models.AutoField(primary_key=True)
139     amount = models.IntegerField()
140     channels = models.IntegerField()
141     host = models.ForeignKey(ResourceProfile, on_delete=models.CASCADE, related_name='ramprofile')
142
143     def __str__(self):
144         return str(self.amount) + "G for " + str(self.host)
145
146
147 """
148 Resource Models
149
150 These models represent actual hardware resources
151 with varying degrees of abstraction.
152 """
153
154
155 class CloudInitFile(models.Model):
156     text = models.TextField()
157
158     # higher priority is applied later, so "on top" of existing files
159     priority = models.IntegerField()
160     generated = models.BooleanField(default=False)
161
162     @classmethod
163     def merge_strategy(cls):
164         return [
165             {'name': 'list', 'settings': ['append']},
166             {'name': 'dict', 'settings': ['recurse_list', 'replace']},
167         ]
168
169     @classmethod
170     def create(cls, text="", priority=0):
171         return CloudInitFile.objects.create(priority=priority, text=text)
172
173
174 class ResourceTemplate(models.Model):
175     """
176     Models a "template" of a complete, configured collection of resources that can be booked.
177
178     For example, this may represent a Pharos POD. This model is a template of the actual
179     resources that will be booked. This model can be "instantiated" into real resource models
180     across multiple different bookings.
181     """
182
183     # TODO: template might not be a good name because this is a collection of lots of configured resources
184     id = models.AutoField(primary_key=True)
185     name = models.CharField(max_length=300)
186     xml = models.TextField()
187     owner = models.ForeignKey(User, null=True, on_delete=models.SET_NULL)
188     lab = models.ForeignKey(Lab, null=True, on_delete=models.SET_NULL, related_name="resourcetemplates")
189     description = models.CharField(max_length=1000, default="")
190     public = models.BooleanField(default=False)
191     temporary = models.BooleanField(default=False)
192     copy_of = models.ForeignKey("ResourceTemplate", blank=True, null=True, on_delete=models.SET_NULL)
193
194     # if these fields are empty ("") then they are implicitly "every vlan",
195     # otherwise we filter any allocations we try to instantiate against this list
196     # they should be represented as a json list of integers
197     private_vlan_pool = models.TextField(default="")
198     public_vlan_pool = models.TextField(default="")
199
200     def private_vlan_pool_set(self):
201         if self.private_vlan_pool != "":
202             return set(json.loads(self.private_vlan_pool))
203         else:
204             return None
205
206     def public_vlan_pool_set(self):
207         if self.private_vlan_pool != "":
208             return set(json.loads(self.public_vlan_pool))
209         else:
210             return None
211
212     def getConfigs(self):
213         configs = self.resourceConfigurations.all()
214         return list(configs)
215
216     def get_required_resources(self):
217         profiles = Counter([str(config.profile) for config in self.getConfigs()])
218         return dict(profiles)
219
220     def __str__(self):
221         return self.name
222
223
224 class ResourceBundle(models.Model):
225     """
226     Collection of Resource objects.
227
228     This is just a way of aggregating all the resources in a booking into a single model.
229     """
230
231     template = models.ForeignKey(ResourceTemplate, on_delete=models.SET_NULL, null=True)
232
233     def __str__(self):
234         if self.template is None:
235             return "Resource bundle " + str(self.id) + " with no template"
236         return "instance of " + str(self.template)
237
238     def get_resources(self):
239         return ResourceQuery.filter(bundle=self)
240
241     def get_resource_with_role(self, role):
242         # TODO
243         pass
244
245     def release(self):
246         for pn in PhysicalNetwork.objects.filter(bundle=self).all():
247             try:
248                 pn.release()
249             except Exception as e:
250                 print("Exception occurred while trying to release resource ", pn.vlan_id)
251                 print(e)
252                 traceback.print_exc()
253
254         for resource in self.get_resources():
255             try:
256                 resource.release()
257             except Exception as e:
258                 print("Exception occurred while trying to release resource ", resource)
259                 print(e)
260                 traceback.print_exc()
261
262     def get_template_name(self):
263         if not self.template:
264             return ""
265         if not self.template.temporary:
266             return self.template.name
267         return self.template.copy_of.name
268
269
270 class ResourceConfiguration(models.Model):
271     """Model to represent a complete configuration for a single physical Resource."""
272
273     id = models.AutoField(primary_key=True)
274     profile = models.ForeignKey(ResourceProfile, on_delete=models.CASCADE)
275     image = models.ForeignKey("Image", on_delete=models.PROTECT)
276     template = models.ForeignKey(ResourceTemplate, related_name="resourceConfigurations", null=True, on_delete=models.CASCADE)
277     is_head_node = models.BooleanField(default=False)
278     name = models.CharField(max_length=3000, default="opnfv_host")
279
280     cloud_init_files = models.ManyToManyField(CloudInitFile, blank=True)
281
282     def __str__(self):
283         return str(self.name)
284
285     def ci_file_list(self):
286         return list(self.cloud_init_files.order_by("priority").all())
287
288
289 def get_default_remote_info():
290     return RemoteInfo.objects.get_or_create(
291         address="default",
292         mac_address="default",
293         password="default",
294         user="default",
295         management_type="default",
296         versions="[default]"
297     )[0].pk
298
299
300 class Resource(models.Model):
301     """
302     Super class for all hardware resource models.
303
304     Defines methods that must be implemented and common database fields.
305     Any new kind of Resource a lab wants to host (White box switch, traffic generator, etc)
306     should inherit from this class and fulfill the functional interface
307     """
308
309     class Meta:
310         abstract = True
311
312     bundle = models.ForeignKey(ResourceBundle, on_delete=models.SET_NULL, blank=True, null=True)
313     profile = models.ForeignKey(ResourceProfile, on_delete=models.CASCADE)
314     config = models.ForeignKey(ResourceConfiguration, on_delete=models.SET_NULL, blank=True, null=True)
315     working = models.BooleanField(default=True)
316     vendor = models.CharField(max_length=100, default="unknown")
317     model = models.CharField(max_length=150, default="unknown")
318     interfaces = models.ManyToManyField("Interface")
319     remote_management = models.ForeignKey("RemoteInfo", default=get_default_remote_info, on_delete=models.SET(get_default_remote_info))
320     labid = models.CharField(max_length=200, default="default_id", unique=True)
321     lab = models.ForeignKey(Lab, on_delete=models.CASCADE)
322
323     def get_configuration(self, state):
324         """
325         Get configuration of Resource.
326
327         Returns the desired configuration for this host as a
328         JSON object as defined in the rest api spec.
329         state is a ConfigState
330         """
331         raise NotImplementedError("Must implement in concrete Resource classes")
332
333     def reserve(self):
334         """Reserve this resource for its currently assigned booking."""
335         raise NotImplementedError("Must implement in concrete Resource classes")
336
337     def release(self):
338         """Make this resource available again for new boookings."""
339         raise NotImplementedError("Must implement in concrete Resource classes")
340
341     def get_interfaces(self):
342         """
343         Return a list of interfaces on this resource.
344
345         The ordering of interfaces should be consistent.
346         """
347         raise NotImplementedError("Must implement in concrete Resource classes")
348
349     def is_reserved(self):
350         """Return True if this Resource is reserved."""
351         raise NotImplementedError("Must implement in concrete Resource classes")
352
353     def same_instance(self, other):
354         """Return True if this Resource is the same instance as other."""
355         raise NotImplementedError("Must implement in concrete Resource classes")
356
357     def save(self, *args, **kwargs):
358         """Assert that labid is unique across all Resource models."""
359         res = ResourceQuery.filter(labid=self.labid)
360         if len(res) > 1:
361             raise ValidationError("Too many resources with labid " + str(self.labid))
362
363         if len(res) == 1:
364             if not self.same_instance(res[0]):
365                 raise ValidationError("Too many resources with labid " + str(self.labid))
366         super().save(*args, **kwargs)
367
368
369 class RemoteInfo(models.Model):
370     address = models.CharField(max_length=15)
371     mac_address = models.CharField(max_length=17)
372     password = models.CharField(max_length=100)
373     user = models.CharField(max_length=100)
374     management_type = models.CharField(max_length=50, default="ipmi")
375     versions = models.CharField(max_length=100)  # json serialized list of floats
376
377
378 class Server(Resource):
379     """Resource subclass - a basic baremetal server."""
380
381     booked = models.BooleanField(default=False)
382     name = models.CharField(max_length=200, unique=True)
383
384     def __str__(self):
385         return self.name
386
387     def get_configuration(self, state):
388         ipmi = state == ConfigState.NEW
389         power = "off" if state == ConfigState.CLEAN else "on"
390         image = self.config.image.lab_id if self.config else "unknown"
391
392         return {
393             "id": self.labid,
394             "image": image,
395             "hostname": self.config.name,
396             "power": power,
397             "ipmi_create": str(ipmi)
398         }
399
400     def get_interfaces(self):
401         return list(self.interfaces.all().order_by('bus_address'))
402
403     def release(self):
404         self.bundle = None
405         self.booked = False
406         self.save()
407
408     def reserve(self):
409         self.booked = True
410         self.save()
411
412     def is_reserved(self):
413         return self.booked
414
415     def same_instance(self, other):
416         return isinstance(other, Server) and other.name == self.name
417
418
419 def is_serializable(data):
420     try:
421         json.dumps(data)
422         return True
423     except Exception:
424         return False
425
426
427 class Opsys(models.Model):
428     id = models.AutoField(primary_key=True)
429     name = models.CharField(max_length=100)
430     lab_id = models.CharField(max_length=100)
431     obsolete = models.BooleanField(default=False)
432     available = models.BooleanField(default=True)  # marked true by Cobbler if it exists there
433     from_lab = models.ForeignKey(Lab, on_delete=models.CASCADE)
434
435     indexes = [
436         models.Index(fields=['cobbler_id'])
437     ]
438
439     def new_from_data(data):
440         opsys = Opsys()
441         opsys.update(data)
442         return opsys
443
444     def serialize(self):
445         d = {}
446         for field in vars(self):
447             attr = getattr(self, field)
448             if is_serializable(attr):
449                 d[field] = attr
450         return d
451
452     def update(self, data):
453         for field in vars(self):
454             if field in data:
455                 setattr(self, field, data[field] if data[field] else getattr(self, field))
456
457     def __str__(self):
458         return self.name
459
460
461 class Image(models.Model):
462     """Model for representing OS images / snapshots of hosts."""
463
464     id = models.AutoField(primary_key=True)
465     from_lab = models.ForeignKey(Lab, on_delete=models.CASCADE)
466     architecture = models.CharField(max_length=50, choices=[
467         ("x86_64", "x86_64"),
468         ("aarch64", "aarch64"),
469         ("unknown", "unknown"),
470     ])
471     lab_id = models.CharField(max_length=100)
472     name = models.CharField(max_length=100)
473     owner = models.ForeignKey(User, null=True, on_delete=models.SET_NULL)
474     public = models.BooleanField(default=True)
475     description = models.TextField()
476     os = models.ForeignKey(Opsys, null=True, on_delete=models.CASCADE)
477
478     available = models.BooleanField(default=True)  # marked True by cobbler if it exists there
479     obsolete = models.BooleanField(default=False)
480
481     indexes = [
482         models.Index(fields=['architecture']),
483         models.Index(fields=['cobbler_id'])
484     ]
485
486     def __str__(self):
487         return self.name
488
489     def is_obsolete(self):
490         return self.obsolete or self.os.obsolete
491
492     def serialize(self):
493         d = {}
494         for field in vars(self):
495             attr = getattr(self, field)
496             if is_serializable(attr):
497                 d[field] = attr
498         return d
499
500     def update(self, data):
501         for field in vars(self):
502             if field in data:
503                 setattr(self, field, data[field] if data[field] else getattr(self, field))
504
505     def new_from_data(data):
506         img = Image()
507         img.update(data)
508         return img
509
510     def in_use(self):
511         for resource in ResourceQuery.filter(config__image=self):
512             if resource.is_reserved():
513                 return True
514
515         return False
516
517
518 """
519 Networking configuration models
520 """
521
522
523 class Network(models.Model):
524     id = models.AutoField(primary_key=True)
525     name = models.CharField(max_length=200)
526     bundle = models.ForeignKey(ResourceTemplate, on_delete=models.CASCADE, related_name="networks")
527     is_public = models.BooleanField()
528
529     def __str__(self):
530         return self.name
531
532
533 class PhysicalNetwork(models.Model):
534     vlan_id = models.IntegerField()
535     generic_network = models.ForeignKey(Network, on_delete=models.CASCADE)
536     bundle = models.ForeignKey(ResourceBundle, null=True, blank=True, on_delete=models.CASCADE)
537
538     def get_configuration(self, state):
539         """
540         Get the network configuration.
541
542         Collects info about each attached network interface and vlan, etc
543         """
544         return {}
545
546     def reserve(self):
547         """Reserve vlan(s) associated with this network."""
548         return False
549
550     def release(self):
551         from booking.models import Booking
552
553         booking = Booking.objects.get(resource=self.bundle)
554         lab = booking.lab
555         vlan_manager = lab.vlan_manager
556
557         if self.generic_network.is_public:
558             vlan_manager.release_public_vlan(self.vlan_id)
559         else:
560             vlan_manager.release_vlans([self.vlan_id])
561         return False
562
563     def __str__(self):
564         return 'Physical Network for ' + self.generic_network.name
565
566
567 class NetworkConnection(models.Model):
568     network = models.ForeignKey(Network, on_delete=models.CASCADE)
569     vlan_is_tagged = models.BooleanField()
570
571     def __str__(self):
572         return 'Connection to ' + self.network.name
573
574
575 class Vlan(models.Model):
576     id = models.AutoField(primary_key=True)
577     vlan_id = models.IntegerField()
578     tagged = models.BooleanField()
579     public = models.BooleanField(default=False)
580     network = models.ForeignKey(PhysicalNetwork, on_delete=models.DO_NOTHING, null=True)
581
582     def __str__(self):
583         return str(self.vlan_id) + ("_T" if self.tagged else "")
584
585
586 class InterfaceConfiguration(models.Model):
587     id = models.AutoField(primary_key=True)
588     profile = models.ForeignKey(InterfaceProfile, on_delete=models.CASCADE)
589     resource_config = models.ForeignKey(ResourceConfiguration, on_delete=models.CASCADE, related_name='interface_configs')
590     connections = models.ManyToManyField(NetworkConnection, blank=True)
591
592     def __str__(self):
593         return "type " + str(self.profile) + " on host " + str(self.resource_config)
594
595
596 """
597 OPNFV / Software configuration models
598 """
599
600
601 class Scenario(models.Model):
602     id = models.AutoField(primary_key=True)
603     name = models.CharField(max_length=300)
604
605     def __str__(self):
606         return self.name
607
608
609 class Installer(models.Model):
610     id = models.AutoField(primary_key=True)
611     name = models.CharField(max_length=200)
612     sup_scenarios = models.ManyToManyField(Scenario, blank=True)
613
614     def __str__(self):
615         return self.name
616
617
618 class NetworkRole(models.Model):
619     name = models.CharField(max_length=100)
620     network = models.ForeignKey(Network, on_delete=models.CASCADE)
621
622
623 def create_resource_ref_string(for_hosts: [str]) -> str:
624     # need to sort the list, then do dump
625     for_hosts.sort()
626
627     return json.dumps(for_hosts)
628
629
630 class OPNFVConfig(models.Model):
631     id = models.AutoField(primary_key=True)
632     installer = models.ForeignKey(Installer, on_delete=models.CASCADE)
633     scenario = models.ForeignKey(Scenario, on_delete=models.CASCADE)
634     template = models.ForeignKey(ResourceTemplate, related_name="opnfv_config", on_delete=models.CASCADE)
635     networks = models.ManyToManyField(NetworkRole)
636     name = models.CharField(max_length=300, blank=True, default="")
637     description = models.CharField(max_length=600, blank=True, default="")
638
639     def __str__(self):
640         return "OPNFV job with " + str(self.installer) + " and " + str(self.scenario)
641
642
643 class OPNFVRole(models.Model):
644     id = models.AutoField(primary_key=True)
645     name = models.CharField(max_length=200)
646     description = models.TextField()
647
648     def __str__(self):
649         return self.name
650
651
652 def get_sentinal_opnfv_role():
653     return OPNFVRole.objects.get_or_create(name="deleted", description="Role was deleted.")
654
655
656 class ResourceOPNFVConfig(models.Model):
657     role = models.ForeignKey(OPNFVRole, related_name="resource_opnfv_configs", on_delete=models.CASCADE)
658     resource_config = models.ForeignKey(ResourceConfiguration, related_name="resource_opnfv_config", on_delete=models.CASCADE)
659     opnfv_config = models.ForeignKey(OPNFVConfig, related_name="resource_opnfv_config", on_delete=models.CASCADE)
660
661
662 class Interface(models.Model):
663     id = models.AutoField(primary_key=True)
664     mac_address = models.CharField(max_length=17)
665     bus_address = models.CharField(max_length=50)
666     config = models.ManyToManyField(Vlan)
667     acts_as = models.OneToOneField(InterfaceConfiguration, blank=True, null=True, on_delete=models.CASCADE)
668     profile = models.ForeignKey(InterfaceProfile, on_delete=models.CASCADE)
669
670     def __str__(self):
671         return self.mac_address + " on host " + str(self.profile.host.name)
672
673     def clean(self, *args, **kwargs):
674         if self.acts_as and self.acts_as.profile != self.profile:
675             raise ValidationError("Interface Configuration's Interface Profile does not match Interface Profile chosen for Interface.")
676         super().clean(*args, **kwargs)
677
678     def save(self, *args, **kwargs):
679         self.full_clean()
680         super().save(*args, **kwargs)
681
682
683 """
684 Some Enums for dealing with global constants.
685 """
686
687
688 class OPNFV_SETTINGS():
689     """This is a static configuration class."""
690
691     # all the required network types in PDF/IDF spec
692     NETWORK_ROLES = ["public", "private", "admin", "mgmt"]
693
694
695 class ConfigState:
696     NEW = 0
697     RESET = 100
698     CLEAN = 200
699
700
701 RESOURCE_TYPES = [Server]
702
703
704 class ResourceQuery(AbstractModelQuery):
705     model_list = [Server]