aboutsummaryrefslogtreecommitdiffstats
path: root/src/dashboard/admin_utils.py
blob: 75e4f3edadd3429caf7c22a26087215cdf8e16cc (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
##############################################################################
# Copyright (c) 2021 Sawyer Bergeron and others.
#
# All rights reserved. This program and the accompanying materials
# are made available under the terms of the Apache License, Version 2.0
# which accompanies this distribution, and is available at
# http://www.apache.org/licenses/LICENSE-2.0
##############################################################################

from resource_inventory.models import (
    ResourceTemplate,
    Image,
    Server,
    ResourceBundle,
    ResourceProfile,
    InterfaceProfile,
    PhysicalNetwork,
    ResourceConfiguration,
    NetworkConnection,
    InterfaceConfiguration,
    Network,
    DiskProfile,
    CpuProfile,
    RamProfile,
    Interface,
    CloudInitFile,
)

import json
import yaml
import sys
import inspect
import pydoc
import csv

from django.contrib.auth.models import User

from account.models import (
    Lab,
    PublicNetwork
)

from resource_inventory.resource_manager import ResourceManager
from resource_inventory.pdf_templater import PDFTemplater

from booking.quick_deployer import update_template

from datetime import timedelta, date, datetime, timezone

from booking.models import Booking
from notifier.manager import NotificationHandler
from api.models import JobFactory

from api.models import JobStatus, Job, GeneratedCloudConfig


def print_div():
    """
    Utility function for printing dividers, does nothing directly useful as a utility
    """
    print("=" * 68)


def book_host(owner_username, host_labid, lab_username, hostname, image_id, template_name, length_days=21, collaborator_usernames=[], purpose="internal", project="LaaS"):
    """
    creates a quick booking using the given host

    @owner_username is the simple username for the user who will own the resulting booking.
    Do not set this to a lab username!

    @image_id is the django id of the image in question, NOT the labid of the image.
    Query Image objects by their public status and compatible host types

    @host_labid is usually of the form `hpe3` or similar, is the labid of the Server (subtype of Resource) object

    @lab_username for iol is `unh_iol`, other labs will be documented here

    @hostname the hostname that the resulting host should have set

    @template_name the name of the (public, or user accessible) template to use for this booking

    @length_days how long the booking should be, no hard limit currently

    @collaborator_usernames a list of usernames for collaborators to the booking

    @purpose what this booking will be used for

    @project what project/group this booking is on behalf of or the owner represents
    """
    lab = Lab.objects.get(lab_user__username=lab_username)
    host = Server.objects.filter(lab=lab).get(labid=host_labid)
    if host.booked:
        print("Can't book host, already marked as booked")
        return
    else:
        host.booked = True
        host.save()

    template = ResourceTemplate.objects.filter(public=True).get(name=template_name)
    image = Image.objects.get(id=image_id)

    owner = User.objects.get(username=owner_username)

    new_template = update_template(template, image, hostname, owner)

    rmanager = ResourceManager.getInstance()

    vlan_map = rmanager.get_vlans(new_template)

    # only a single host so can reuse var for iter here
    resource_bundle = ResourceBundle.objects.create(template=new_template)
    res_configs = new_template.getConfigs()

    for config in res_configs:
        try:
            host.bundle = resource_bundle
            host.config = config
            rmanager.configureNetworking(resource_bundle, host, vlan_map)
            host.save()
        except Exception:
            host.booked = False
            host.save()
            print("Failed to book host due to error configuring it")
            return

    new_template.save()

    booking = Booking.objects.create(
        purpose=purpose,
        project=project,
        lab=lab,
        owner=owner,
        start=timezone.now(),
        end=timezone.now() + timedelta(days=int(length_days)),
        resource=resource_bundle,
        opnfv_config=None
    )

    booking.pdf = PDFTemplater.makePDF(booking)

    booking.save()

    for collaborator_username in collaborator_usernames:
        try:
            user = User.objects.get(username=collaborator_username)
            booking.collaborators.add(user)
        except Exception:
            print("couldn't add user with username ", collaborator_username)

    booking.save()

    JobFactory.makeCompleteJob(booking)
    NotificationHandler.notify_new_booking(booking)


def mark_working(host_labid, lab_username, working=True):
    """
    Mark a host working/not working so that it is either bookable or hidden in the dashboard.

    @host_labid is usually of the form `hpe3` or similar, is the labid of the Server (subtype of Resource) object

    @lab_username: param of the form `unh_iol` or similar

    @working: bool, whether by the end of execution the host should be considered working or not working
    """

    lab = Lab.objects.get(lab_user__username=lab_username)
    server = Server.objects.filter(lab=lab).get(labid=host_labid)
    print("changing server working status from ", server.working, "to", working)
    server.working = working
    server.save()


def mark_booked(host_labid, lab_username, booked=True):
    """
    Mark a host as booked/unbooked

    @host_labid is usually of the form `hpe3` or similar, is the labid of the Server (subtype of Resource) object

    @lab_username: param of the form `unh_iol` or similar

    @working: bool, whether by the end of execution the host should be considered booked or not booked
    """

    lab = Lab.objects.get(lab_user__username=lab_username)
    server = Server.objects.filter(lab=lab).get(labid=host_labid)
    print("changing server booked status from ", server.booked, "to", booked)
    server.booked = booked
    server.save()


def get_host(host_labid, lab_username):
    """
    Returns host filtered by lab and then unique id within lab

    @host_labid is usually of the form `hpe3` or similar, is the labid of the Server (subtype of Resource) object

    @lab_username: param of the form `unh_iol` or similar
    """
    lab = Lab.objects.get(lab_user__username=lab_username)
    return Server.objects.filter(lab=lab).get(labid=host_labid)


def get_info(host_labid, lab_username):
    """
    Returns various information on the host queried by the given parameters

    @host_labid is usually of the form `hpe3` or similar, is the labid of the Server (subtype of Resource) object

    @lab_username: param of the form `unh_iol` or similar
    """
    info = {}
    host = get_host(host_labid, lab_username)
    info['host_labid'] = host_labid
    info['booked'] = host.booked
    info['working'] = host.working
    info['profile'] = str(host.profile)
    if host.bundle:
        binfo = {}
        info['bundle'] = binfo
    if host.config:
        cinfo = {}
        info['config'] = cinfo

    return info


class CumulativeData:
    use_days = 0
    count_bookings = 0
    count_extensions = 0

    def __init__(self, file_writer):
        self.file_writer = file_writer

    def account(self, booking, usage_days):
        self.count_bookings += 1
        self.count_extensions += booking.ext_count
        self.use_days += usage_days

    def write_cumulative(self):
        self.file_writer.writerow([])
        self.file_writer.writerow([])
        self.file_writer.writerow(['Lab Use Days', 'Count of Bookings', 'Total Extensions Used'])
        self.file_writer.writerow([self.use_days, self.count_bookings, (self.count_bookings * 2) - self.count_extensions])


def get_years_booking_data(start_year=None, end_year=None):
    """
    Outputs yearly booking information from the past 'start_year' years (default: current year)
    until the last day of the end year (default current year) as a csv file.
    """
    if start_year is None and end_year is None:
        start = datetime.combine(date(datetime.now().year, 1, 1), datetime.min.time()).replace(tzinfo=timezone.utc)
        end = datetime.combine(date(start.year + 1, 1, 1), datetime.min.time()).replace(tzinfo=timezone.utc)
    elif end_year is None:
        start = datetime.combine(date(start_year, 1, 1), datetime.min.time()).replace(tzinfo=timezone.utc)
        end = datetime.combine(date(datetime.now().year, 1, 1), datetime.min.time()).replace(tzinfo=timezone.utc)
    else:
        start = datetime.combine(date(start_year, 1, 1), datetime.min.time()).replace(tzinfo=timezone.utc)
        end = datetime.combine(date(end_year + 1, 1, 1), datetime.min.time()).replace(tzinfo=timezone.utc)

    if (start.year == end.year - 1):
        file_name = "yearly_booking_data_" + str(start.year) + ".csv"
    else:
        file_name = "yearly_booking_data_" + str(start.year) + "-" + str(end.year - 1) + ".csv"

    with open(file_name, "w", newline="") as file:
        file_writer = csv.writer(file)
        cumulative_data = CumulativeData(file_writer)
        file_writer.writerow(
            [
                'ID',
                'Project',
                'Purpose',
                'User',
                'Collaborators',
                'Extensions Left',
                'Usage Days',
                'Start',
                'End'
            ]
        )

        for booking in Booking.objects.filter(start__gte=start, start__lte=end):
            filtered = False
            booking_filter = [279]
            user_filter = ["ParkerBerberian", "ssmith", "ahassick", "sbergeron", "jhodgdon", "rhodgdon", "aburch", "jspewock"]
            user = booking.owner.username if booking.owner.username is not None else "None"

            for b in booking_filter:
                if b == booking.id:
                    filtered = True

            for u in user_filter:
                if u == user:
                    filtered = True
            # trims time delta to the the specified year(s) if between years
            usage_days = ((end if booking.end > end else booking.end) - (start if booking.start < start else booking.start)).days
            collaborators = []

            for c in booking.collaborators.all():
                collaborators.append(c.username)

            if (not filtered):
                cumulative_data.account(booking, usage_days)
                file_writer.writerow([
                    str(booking.id),
                    str(booking.project),
                    str(booking.purpose),
                    str(booking.owner.username),
                    ','.join(collaborators),
                    str(booking.ext_count),
                    str(usage_days),
                    str(booking.start),
                    str(booking.end)
                ])
        cumulative_data.write_cumulative()


def map_cntt_interfaces(labid: str):
    """
    Use this during cntt migrations, call it with a host labid and it will change profiles for this host
    as well as mapping its interfaces across. interface ens1f2 should have the mac address of interface eno50
    as an invariant before calling this function
    """
    host = get_host(labid, "unh_iol")
    host.profile = ResourceProfile.objects.get(name="HPE x86 CNTT")
    host.save()
    host = get_host(labid, "unh_iol")

    for iface in host.interfaces.all():
        new_ifprofile = None
        if iface.profile.name == "ens1f2":
            new_ifprofile = InterfaceProfile.objects.get(host=host.profile, name="eno50")
        else:
            new_ifprofile = InterfaceProfile.objects.get(host=host.profile, name=iface.profile.name)

        iface.profile = new_ifprofile

        iface.save()


def detect_leaked_hosts(labid="unh_iol"):
    """
    Use this to try to detect leaked hosts.
    These hosts may still be in the process of unprovisioning,
    but if they are not (or unprovisioning is frozen) then
    these hosts are instead leaked
    """
    working_servers = Server.objects.filter(working=True, lab__lab_user__username=labid)
    booked = working_servers.filter(booked=True)
    filtered = booked
    print_div()
    print("In use now:")
    for booking in Booking.objects.filter(end__gte=timezone.now()):
        res_for_booking = booking.resource.get_resources()
        print(res_for_booking)
        for resource in res_for_booking:
            filtered = filtered.exclude(id=resource.id)
    print_div()
    print("Possibly leaked:")
    for host in filtered:
        print(host)
    print_div()
    return filtered


def booking_for_host(host_labid: str, lab_username="unh_iol"):
    """
    Returns the booking that this server is a part of, if any.
    Fails with an exception if no such booking exists

    @host_labid is usually of the form `hpe3` or similar, is the labid of the Server (subtype of Resource) object

    @lab_username: param of the form `unh_iol` or similar
    """
    server = Server.objects.get(lab__lab_user__username=lab_username, labid=host_labid)
    booking = server.bundle.booking_set.first()
    print_div()
    print(booking)
    print("id:", booking.id)
    print("owner:", booking.owner)
    print("job (id):", booking.job, "(" + str(booking.job.id) + ")")
    print_div()
    return booking


def force_release_booking(booking_id: int):
    """
    Takes a booking id and forces the booking to end whether or not the tasks have
    completed normally.

    Use with caution! Hosts may or may not be released depending on other underlying issues

    @booking_id: the id of the Booking object to be released
    """
    booking = Booking.objects.get(id=booking_id)
    job = booking.job
    tasks = job.get_tasklist()
    for task in tasks:
        task.status = JobStatus.DONE
        task.save()


def free_leaked_public_vlans(safety_buffer_days=2):
    for lab in Lab.objects.all():
        current_booking_set = Booking.objects.filter(end__gte=timezone.now() + timedelta(days=safety_buffer_days))

        marked_nets = set()

        for booking in current_booking_set:
            for network in get_network_metadata(booking.id):
                marked_nets.add(network["vlan_id"])

        for net in PublicNetwork.objects.filter(lab=lab).filter(in_use=True):
            if net.vlan not in marked_nets:
                lab.vlan_manager.release_public_vlan(net.vlan)


def get_network_metadata(booking_id: int):
    """
    Takes a booking id and prints all (known) networks that are owned by it.
    Returns an object of the form {<network name>: {"vlan_id": int, "netname": str <network name>, "public": bool <whether network is public/routable}}

    @booking_id: the id of the Booking object to be queried
    """
    booking = Booking.objects.get(id=booking_id)
    bundle = booking.resource
    pnets = PhysicalNetwork.objects.filter(bundle=bundle).all()
    metadata = {}
    for pnet in pnets:
        net = pnet.generic_network
        mdata = {"vlan_id": pnet.vlan_id, "netname": net.name, "public": net.is_public}
        metadata[net.name] = mdata
    return metadata


def print_dict_pretty(a_dict):
    """
    admin_utils internal function
    """

    print(json.dumps(a_dict, sort_keys=True, indent=4))


def import_host(filenames):
    """
    Imports host from an array of converted inspection files and if needed creates a new profile for the host.
    NOTE: CONVERT INSPECTION FILES USING convert_inspect_results(["file", "file"])
    (original file names not including "-import.yaml" i.e. hpe44) AND FILL IN <NEEDED FIELDS> BEFORE THIS
    @filenames: array of host import file names to import
    """

    for filename in filenames:

        # open import file
        file = open("dashboard/" + filename + "-import.yaml", "r")
        data = yaml.safe_load(file)

        # if a new profile is needed create one and a matching template
        if (data["new_profile"]):
            add_profile(data)
            print("Profile: " + data["name"] + " created!")
            make_default_template(
                ResourceProfile.objects.get(name=data["name"]),
                Image.objects.get(lab_id=data["image"]).id,
                None,
                None,
                False,
                False,
                data["owner"],
                "unh_iol",
                True,
                False,
                data["temp_desc"]
            )

            print(" Template: " + data["temp_name"] + " created!")

        # add the server
        add_server(
            ResourceProfile.objects.get(name=data["name"]),
            data["hostname"],
            data["interfaces"],
            data["lab"],
            data["vendor"],
            data["model"]
        )

        print(data["hostname"] + " imported!")


def convert_inspect_results(files):
    """
    Converts an array of inspection result files into templates (filename-import.yaml) to be filled out for importing the servers into the dashboard
    @files an array of file names (not including the file type. i.e hpe44). Default: []
    """
    for filename in files:
        # open host inspect file
        file = open("dashboard/" + filename + ".yaml")
        output = open("dashboard/" + filename + "-import.yaml", "w")
        data = json.load(file)

        # gather data about disks
        disk_data = {}
        for i in data["disk"]:

            # don't include loops in disks
            if "loop" not in i:
                disk_data[i["name"]] = {
                    "capacity": i["size"][:-3],
                    "media_type": "<\"SSD\" or \"HDD\">",
                    "interface": "<\"sata\", \"sas\", \"ssd\", \"nvme\", \"scsi\", or \"iscsi\">",
                }

        # gather interface data
        interface_data = {}
        for i in data["interfaces"]:
            interface_data[data["interfaces"][i]["name"]] = {
                "speed": data["interfaces"][i]["speed"],
                "nic_type": "<\"onboard\" or \"pcie\">",
                "order": "<order in switch>",
                "mac_address": data["interfaces"][i]["mac"],
                "bus_addr": data["interfaces"][i]["busaddr"],
            }

        # gather cpu data
        cpu_data = {
            "cores": data["cpu"]["cores"],
            "architecture": data["cpu"]["arch"],
            "cpus": data["cpu"]["cpus"],
            "cflags": "<cflags string>",
        }

        # gather ram data
        ram_data = {
            "amount": data["memory"][:-1],
            "channels": "<int of ram channels used>",
        }

        # assemble data for host import file
        import_data = {
            "new_profile": "<True or False> (Set to True to create a new profile for the host's type)",
            "name": "<profile name> (Used to set the profile of a host and for creating a new profile)",
            "description": "<profile description>",
            "labs": "<labs using profile>",
            "temp_name": "<Template name>",
            "temp_desc": "<template description>",
            "image": "<image lab_id>",
            "owner": "<template owner>",
            "hostname": data["hostname"],
            "lab": "<lab server is in> (i.e. \"unh_iol\")",
            "disks": disk_data,
            "interfaces": interface_data,
            "cpus": cpu_data,
            "ram": ram_data,
            "vendor": "<host vendor>",
            "model": "<host model>",
        }

        # export data as yaml
        yaml.dump(import_data, output)


def add_profile(data):
    """
    Used for adding a host profile to the dashboard

    schema (of dict passed as "data" param):
    {
        "name": str
        "description": str
        "labs": [
            str (lab username)
        ]
        "disks": {
            <diskname> : {
                capacity: int (GiB)
                media_type: str ("SSD" or "HDD")
                interface: str ("sata", "sas", "ssd", "nvme", "scsi", or "iscsi")
            }
        }
        interfaces: {
            <intname>: {
                "speed": int (mbit)
                "nic_type": str ("onboard" or "pcie")
                "order": int (compared to the other interfaces, indicates the "order" that the ports are laid out)
            }
        }
        cpus: {
            cores: int (hardware threads count)
            architecture: str (x86_64" or "aarch64")
            cpus: int (number of sockets)
            cflags: str
        }
        ram: {
            amount: int (GiB)
            channels: int
        }
    }
    """
    base_profile = ResourceProfile.objects.create(name=data['name'], description=data['description'])
    base_profile.save()

    for lab_username in data['labs']:
        lab = Lab.objects.get(lab_user__username=lab_username)

        base_profile.labs.add(lab)
        base_profile.save()

    for diskname in data['disks'].keys():
        disk = data['disks'][diskname]

        disk_profile = DiskProfile.objects.create(name=diskname, size=disk['capacity'], media_type=disk['media_type'], interface=disk['interface'], host=base_profile)
        disk_profile.save()

    for ifacename in data['interfaces'].keys():
        iface = data['interfaces'][ifacename]

        iface_profile = InterfaceProfile.objects.create(name=ifacename, speed=iface['speed'], nic_type=iface['nic_type'], order=iface['order'], host=base_profile)
        iface_profile.save()

    cpu = data['cpus']
    cpu_prof = CpuProfile.objects.create(cores=cpu['cores'], architecture=cpu['architecture'], cpus=cpu['cpus'], cflags=cpu['cflags'], host=base_profile)
    cpu_prof.save()

    ram_prof = RamProfile.objects.create(amount=data['ram']['amount'], channels=data['ram']['channels'], host=base_profile)
    ram_prof.save()


def make_default_template(resource_profile, image_id=None, template_name=None, connected_interface_names=None, interfaces_tagged=False, connected_interface_tagged=False, owner_username="root", lab_username="unh_iol", public=True, temporary=False, description=""):
    """
    Do not call this function without reading the related source code, it may have unintended effects.

    Used for creating a default template from some host profile
    """

    if not resource_profile:
        raise Exception("No viable continuation from none resource_profile")

    if not template_name:
        template_name = resource_profile.name

    if not connected_interface_names:
        connected_interface_names = [InterfaceProfile.objects.filter(host=resource_profile).first().name]
        print("setting connected interface names to", connected_interface_names)

    if not image_id:
        image_id = Image.objects.filter(host_type=resource_profile).first().id

    image = Image.objects.get(id=image_id)

    base = ResourceTemplate.objects.create(
        name=template_name,
        xml="",
        owner=User.objects.get(username=owner_username),
        lab=Lab.objects.get(lab_user__username=lab_username), description=description,
        public=public, temporary=temporary, copy_of=None)

    rconf = ResourceConfiguration.objects.create(profile=resource_profile, image=image, template=base, is_head_node=True, name="opnfv_host")
    rconf.save()

    connected_interfaces = []

    for iface_prof in InterfaceProfile.objects.filter(host=resource_profile).all():
        iface_conf = InterfaceConfiguration.objects.create(profile=iface_prof, resource_config=rconf)

        if iface_prof.name in connected_interface_names:
            connected_interfaces.append(iface_conf)

    network = Network.objects.create(name="public", bundle=base, is_public=True)

    for iface in connected_interfaces:
        connection = NetworkConnection.objects.create(network=network, vlan_is_tagged=interfaces_tagged)
        connection.save()

        iface.connections.add(connection)
        print("adding connection to iface ", iface)
        iface.save()
        connection.save()


def add_server(profile, name, interfaces, lab_username="unh_iol", vendor="unknown", model="unknown"):
    """
    Used to enroll a new host of some profile

    @profile: the ResourceProfile in question (by reference to a model object)

    @name: the unique name of the server, currently indistinct from labid

    @interfaces: interfaces should be dict from interface name (eg ens1f0) to dict of schema:
        {
            mac_address: <mac addr>,
            bus_addr: <bus addr>, //this field is optional, "" is default
        }

    @lab_username: username of the lab to be added to

    @vendor: vendor name of the host, such as "HPE" or "Gigabyte"

    @model: specific model of the host, such as "DL380 Gen 9"

    """
    server = Server.objects.create(
        bundle=None,
        profile=profile,
        config=None,
        working=True,
        vendor=vendor,
        model=model,
        labid=name,
        lab=Lab.objects.get(lab_user__username=lab_username),
        name=name,
        booked=False)

    for iface_prof in InterfaceProfile.objects.filter(host=profile).all():
        mac_addr = interfaces[iface_prof.name]["mac_address"]
        bus_addr = "unknown"
        if "bus_addr" in interfaces[iface_prof.name].keys():
            bus_addr = interfaces[iface_prof.name]["bus_addr"]

        iface = Interface.objects.create(acts_as=None, profile=iface_prof, mac_address=mac_addr, bus_address=bus_addr)
        iface.save()

        server.interfaces.add(iface)
        server.save()


def extend_booking(booking_id, days=0, hours=0, minutes=0, weeks=0):
    """
    Extend a booking by n <days, hours, minutes, weeks>

    @booking_id: id of the booking

    @days/@hours/@minutes/@weeks: the cumulative amount of delta to add to the length of the booking
    """

    booking = Booking.objects.get(id=booking_id)
    booking.end = booking.end + timedelta(days=days, hours=hours, minutes=minutes, weeks=weeks)
    booking.save()


def regenerate_cloud_configs(booking_id):
    b = Booking.objects.get(id=booking_id)
    for res in b.resource.get_resources():
        res.config.cloud_init_files.set(res.config.cloud_init_files.filter(generated=False))  # careful!
        res.config.save()
        cif = GeneratedCloudConfig.objects.create(resource_id=res.labid, booking=b, rconfig=res.config)
        cif.save()
        cif = CloudInitFile.create(priority=0, text=cif.serialize())
        cif.save()
        res.config.cloud_init_files.add(cif)
        res.config.save()


def set_job_new(job_id):
    j = Job.objects.get(id=job_id)
    b = j.booking
    regenerate_cloud_configs(b.id)
    for task in j.get_tasklist():
        task.status = JobStatus.NEW
        task.save()
    j.status = JobStatus.NEW
    j.save()


def docs(function=None, fulltext=False):
    """
    Print documentation for a given function in admin_utils.
    Call without arguments for more information
    """

    fn = None

    if isinstance(function, str):
        try:
            fn = globals()[function]
        except KeyError:
            print("Couldn't find a function by the given name")
            return
    elif callable(function):
        fn = function
    else:
        print("docs(function: callable | str, fulltext: bool) was called with a 'function' that was neither callable nor a string name of a function")
        print("usage: docs('some_function_in_admin_utils', fulltext=True)")
        print("The 'fulltext' argument is used to choose if you want the complete source of the function printed. If this argument is false then you will only see the pydoc rendered documentation for the function")
        return

    if not fn:
        print("couldn't find a function by that name")

    if not fulltext:
        print("Pydoc documents the function as such:")
        print(pydoc.render_doc(fn))
    else:
        print("The full source of the function is this:")
        print(inspect.getsource(fn))


def admin_functions():
    """
    List functions available to call within admin_utils
    """

    return [name for name, func in inspect.getmembers(sys.modules[__name__]) if (inspect.isfunction(func) and func.__module__ == __name__)]


print("Hint: call `docs(<function name>)` or `admin_functions()` for help on using the admin utils")
print("docs(<function name>) displays documentation on a given function")
print("admin_functions() lists all functions available to call within this module")