aboutsummaryrefslogtreecommitdiffstats
path: root/src/api
diff options
context:
space:
mode:
Diffstat (limited to 'src/api')
-rw-r--r--src/api/admin.py2
-rw-r--r--src/api/migrations/0017_apilog.py27
-rw-r--r--src/api/migrations/0017_auto_20210630_1629.py18
-rw-r--r--src/api/migrations/0018_apilog_ip_addr.py18
-rw-r--r--src/api/migrations/0018_cloudinitfile.py25
-rw-r--r--src/api/migrations/0019_auto_20210322_1823.py19
-rw-r--r--src/api/migrations/0019_auto_20210907_1448.py29
-rw-r--r--src/api/migrations/0020_auto_20210322_2218.py23
-rw-r--r--src/api/migrations/0021_auto_20210405_1943.py18
-rw-r--r--src/api/migrations/0022_merge_20211102_2136.py14
-rw-r--r--src/api/migrations/0023_add_cifile_generated_field.py14
-rw-r--r--src/api/models.py287
-rw-r--r--src/api/urls.py39
-rw-r--r--src/api/views.py424
14 files changed, 947 insertions, 10 deletions
diff --git a/src/api/admin.py b/src/api/admin.py
index 8b2fcb3..1e243a0 100644
--- a/src/api/admin.py
+++ b/src/api/admin.py
@@ -22,6 +22,7 @@ from api.models import (
SoftwareRelation,
HostHardwareRelation,
HostNetworkRelation,
+ APILog
)
@@ -39,3 +40,4 @@ admin.site.register(AccessRelation)
admin.site.register(SoftwareRelation)
admin.site.register(HostHardwareRelation)
admin.site.register(HostNetworkRelation)
+admin.site.register(APILog)
diff --git a/src/api/migrations/0017_apilog.py b/src/api/migrations/0017_apilog.py
new file mode 100644
index 0000000..d209aef
--- /dev/null
+++ b/src/api/migrations/0017_apilog.py
@@ -0,0 +1,27 @@
+# Generated by Django 2.2 on 2021-03-19 20:45
+
+from django.conf import settings
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('api', '0016_auto_20201109_2149'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='APILog',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('call_time', models.DateTimeField(auto_now=True)),
+ ('endpoint', models.CharField(max_length=300)),
+ ('body', django.contrib.postgres.fields.jsonb.JSONField()),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ ]
diff --git a/src/api/migrations/0017_auto_20210630_1629.py b/src/api/migrations/0017_auto_20210630_1629.py
new file mode 100644
index 0000000..643ff5f
--- /dev/null
+++ b/src/api/migrations/0017_auto_20210630_1629.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.2 on 2021-06-30 16:29
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0016_auto_20201109_2149'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='snapshotconfig',
+ name='image',
+ field=models.CharField(max_length=200, null=True),
+ ),
+ ]
diff --git a/src/api/migrations/0018_apilog_ip_addr.py b/src/api/migrations/0018_apilog_ip_addr.py
new file mode 100644
index 0000000..4b7ce39
--- /dev/null
+++ b/src/api/migrations/0018_apilog_ip_addr.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.2 on 2021-03-22 18:12
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0017_apilog'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='apilog',
+ name='ip_addr',
+ field=models.GenericIPAddressField(null=True),
+ ),
+ ]
diff --git a/src/api/migrations/0018_cloudinitfile.py b/src/api/migrations/0018_cloudinitfile.py
new file mode 100644
index 0000000..4e41b39
--- /dev/null
+++ b/src/api/migrations/0018_cloudinitfile.py
@@ -0,0 +1,25 @@
+# Generated by Django 2.2 on 2021-07-01 20:45
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('resource_inventory', '0019_auto_20210701_1947'),
+ ('booking', '0008_auto_20201109_1947'),
+ ('api', '0017_auto_20210630_1629'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='CloudInitFile',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('resource_id', models.CharField(max_length=200)),
+ ('booking', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='booking.Booking')),
+ ('rconfig', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='resource_inventory.ResourceConfiguration')),
+ ],
+ ),
+ ]
diff --git a/src/api/migrations/0019_auto_20210322_1823.py b/src/api/migrations/0019_auto_20210322_1823.py
new file mode 100644
index 0000000..b3c4cdf
--- /dev/null
+++ b/src/api/migrations/0019_auto_20210322_1823.py
@@ -0,0 +1,19 @@
+# Generated by Django 2.2 on 2021-03-22 18:23
+
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0018_apilog_ip_addr'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='apilog',
+ name='body',
+ field=django.contrib.postgres.fields.jsonb.JSONField(null=True),
+ ),
+ ]
diff --git a/src/api/migrations/0019_auto_20210907_1448.py b/src/api/migrations/0019_auto_20210907_1448.py
new file mode 100644
index 0000000..92140fb
--- /dev/null
+++ b/src/api/migrations/0019_auto_20210907_1448.py
@@ -0,0 +1,29 @@
+# Generated by Django 2.2 on 2021-09-07 14:48
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('booking', '0008_auto_20201109_1947'),
+ ('resource_inventory', '0020_cloudinitfile'),
+ ('api', '0018_cloudinitfile'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='GeneratedCloudConfig',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('resource_id', models.CharField(max_length=200)),
+ ('text', models.TextField(blank=True, null=True)),
+ ('booking', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='booking.Booking')),
+ ('rconfig', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='resource_inventory.ResourceConfiguration')),
+ ],
+ ),
+ migrations.DeleteModel(
+ name='CloudInitFile',
+ ),
+ ]
diff --git a/src/api/migrations/0020_auto_20210322_2218.py b/src/api/migrations/0020_auto_20210322_2218.py
new file mode 100644
index 0000000..0252c79
--- /dev/null
+++ b/src/api/migrations/0020_auto_20210322_2218.py
@@ -0,0 +1,23 @@
+# Generated by Django 2.2 on 2021-03-22 22:18
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0019_auto_20210322_1823'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='apilog',
+ name='method',
+ field=models.CharField(max_length=4, null=True),
+ ),
+ migrations.AlterField(
+ model_name='apilog',
+ name='endpoint',
+ field=models.CharField(max_length=300, null=True),
+ ),
+ ]
diff --git a/src/api/migrations/0021_auto_20210405_1943.py b/src/api/migrations/0021_auto_20210405_1943.py
new file mode 100644
index 0000000..ca6e741
--- /dev/null
+++ b/src/api/migrations/0021_auto_20210405_1943.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.2 on 2021-04-05 19:43
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0020_auto_20210322_2218'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='apilog',
+ name='method',
+ field=models.CharField(max_length=6, null=True),
+ ),
+ ]
diff --git a/src/api/migrations/0022_merge_20211102_2136.py b/src/api/migrations/0022_merge_20211102_2136.py
new file mode 100644
index 0000000..bb27ae4
--- /dev/null
+++ b/src/api/migrations/0022_merge_20211102_2136.py
@@ -0,0 +1,14 @@
+# Generated by Django 2.2 on 2021-11-02 21:36
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0019_auto_20210907_1448'),
+ ('api', '0021_auto_20210405_1943'),
+ ]
+
+ operations = [
+ ]
diff --git a/src/api/migrations/0023_add_cifile_generated_field.py b/src/api/migrations/0023_add_cifile_generated_field.py
new file mode 100644
index 0000000..df2b6d7
--- /dev/null
+++ b/src/api/migrations/0023_add_cifile_generated_field.py
@@ -0,0 +1,14 @@
+from django.db import migrations, models
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('api', '0022_merge_20211102_2136'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="CloudInitFile",
+ name="generated",
+ field=models.BooleanField(default=False)
+ ),
+ ]
diff --git a/src/api/models.py b/src/api/models.py
index d1bb692..93168f5 100644
--- a/src/api/models.py
+++ b/src/api/models.py
@@ -12,24 +12,30 @@ from django.contrib.auth.models import User
from django.db import models
from django.core.exceptions import PermissionDenied, ValidationError
from django.shortcuts import get_object_or_404
+from django.contrib.postgres.fields import JSONField
from django.http import HttpResponseNotFound
from django.urls import reverse
from django.utils import timezone
import json
import uuid
+import yaml
+import re
from booking.models import Booking
from resource_inventory.models import (
Lab,
ResourceProfile,
Image,
+ Opsys,
Interface,
ResourceOPNFVConfig,
RemoteInfo,
OPNFVConfig,
ConfigState,
- ResourceQuery
+ ResourceQuery,
+ ResourceConfiguration,
+ CloudInitFile
)
from resource_inventory.idf_templater import IDFTemplater
from resource_inventory.pdf_templater import PDFTemplater
@@ -37,7 +43,7 @@ from account.models import Downtime, UserProfile
from dashboard.utils import AbstractModelQuery
-class JobStatus(object):
+class JobStatus:
"""
A poor man's enum for a job's status.
@@ -52,7 +58,7 @@ class JobStatus(object):
ERROR = 300
-class LabManagerTracker(object):
+class LabManagerTracker:
@classmethod
def get(cls, lab_name, token):
@@ -72,7 +78,7 @@ class LabManagerTracker(object):
raise PermissionDenied("Lab not authorized")
-class LabManager(object):
+class LabManager:
"""
Handles all lab REST calls.
@@ -83,6 +89,18 @@ class LabManager(object):
def __init__(self, lab):
self.lab = lab
+ def get_opsyss(self):
+ return Opsys.objects.filter(from_lab=self.lab)
+
+ def get_images(self):
+ return Image.objects.filter(from_lab=self.lab)
+
+ def get_image(self, image_id):
+ return Image.objects.filter(from_lab=self.lab, lab_id=image_id)
+
+ def get_opsys(self, opsys_id):
+ return Opsys.objects.filter(from_lab=self.lab, lab_id=opsys_id)
+
def get_downtime(self):
return Downtime.objects.filter(start__lt=timezone.now(), end__gt=timezone.now(), lab=self.lab)
@@ -337,6 +355,248 @@ class LabManager(object):
return profile_ser
+class GeneratedCloudConfig(models.Model):
+ resource_id = models.CharField(max_length=200)
+ booking = models.ForeignKey(Booking, on_delete=models.CASCADE)
+ rconfig = models.ForeignKey(ResourceConfiguration, on_delete=models.CASCADE)
+ text = models.TextField(null=True, blank=True)
+
+ def _normalize_username(self, username: str) -> str:
+ # TODO: make usernames posix compliant
+ s = re.sub(r'\W+', '', username)
+ return s
+
+ def _get_ssh_string(self, username: str) -> str:
+ user = User.objects.get(username=username)
+ uprofile = user.userprofile
+
+ ssh_file = uprofile.ssh_public_key
+
+ escaped_file = ssh_file.open().read().decode(encoding="UTF-8").replace("\n", " ")
+
+ return escaped_file
+
+ def _serialize_users(self):
+ """
+ returns the dictionary to be placed behind the `users` field of the toplevel c-i dict
+ """
+ # conserves distro default user
+ user_array = ["default"]
+
+ users = list(self.booking.collaborators.all())
+ users.append(self.booking.owner)
+ for collaborator in users:
+ userdict = {}
+
+ # TODO: validate if usernames are valid as linux usernames (and provide an override potentially)
+ userdict['name'] = self._normalize_username(collaborator.username)
+
+ userdict['groups'] = "sudo"
+ userdict['sudo'] = "ALL=(ALL) NOPASSWD:ALL"
+
+ userdict['ssh_authorized_keys'] = [self._get_ssh_string(collaborator.username)]
+
+ user_array.append(userdict)
+
+ # user_array.append({
+ # "name": "opnfv",
+ # "passwd": "$6$k54L.vim1cLaEc4$5AyUIrufGlbtVBzuCWOlA1yV6QdD7Gr2MzwIs/WhuYR9ebSfh3Qlb7djkqzjwjxpnSAonK1YOabPP6NxUDccu.",
+ # "ssh_redirect_user": True,
+ # "sudo": "ALL=(ALL) NOPASSWD:ALL",
+ # "groups": "sudo",
+ # })
+
+ return user_array
+
+ # TODO: make this configurable
+ def _serialize_sysinfo(self):
+ defuser = {}
+ defuser['name'] = 'opnfv'
+ defuser['plain_text_passwd'] = 'OPNFV_HOST'
+ defuser['home'] = '/home/opnfv'
+ defuser['shell'] = '/bin/bash'
+ defuser['lock_passwd'] = True
+ defuser['gecos'] = 'Lab Manager User'
+ defuser['groups'] = 'sudo'
+
+ return {'default_user': defuser}
+
+ # TODO: make this configurable
+ def _serialize_runcmds(self):
+ cmdlist = []
+
+ # have hosts run dhcp on boot
+ cmdlist.append(['sudo', 'dhclient', '-r'])
+ cmdlist.append(['sudo', 'dhclient'])
+
+ return cmdlist
+
+ def _serialize_netconf_v1(self):
+ # interfaces = {} # map from iface_name => dhcp_config
+ # vlans = {} # map from vlan_id => dhcp_config
+
+ config_arr = []
+
+ for interface in self._resource().interfaces.all():
+ interface_name = interface.profile.name
+ interface_mac = interface.mac_address
+
+ iface_dict_entry = {
+ "type": "physical",
+ "name": interface_name,
+ "mac_address": interface_mac,
+ }
+
+ for vlan in interface.config.all():
+ if vlan.tagged:
+ vlan_dict_entry = {'type': 'vlan'}
+ vlan_dict_entry['name'] = str(interface_name) + "." + str(vlan.vlan_id)
+ vlan_dict_entry['vlan_link'] = str(interface_name)
+ vlan_dict_entry['vlan_id'] = int(vlan.vlan_id)
+ vlan_dict_entry['mac_address'] = str(interface_mac)
+ if vlan.public:
+ vlan_dict_entry["subnets"] = [{"type": "dhcp"}]
+ config_arr.append(vlan_dict_entry)
+ if (not vlan.tagged) and vlan.public:
+ iface_dict_entry["subnets"] = [{"type": "dhcp"}]
+
+ # vlan_dict_entry['mtu'] = # TODO, determine override MTU if needed
+
+ config_arr.append(iface_dict_entry)
+
+ ns_dict = {
+ 'type': 'nameserver',
+ 'address': ['10.64.0.1', '8.8.8.8']
+ }
+
+ config_arr.append(ns_dict)
+
+ full_dict = {'version': 1, 'config': config_arr}
+
+ return full_dict
+
+ @classmethod
+ def get(cls, booking_id: int, resource_lab_id: str, file_id: int):
+ return GeneratedCloudConfig.objects.get(resource_id=resource_lab_id, booking__id=booking_id, file_id=file_id)
+
+ def _resource(self):
+ return ResourceQuery.get(labid=self.resource_id, lab=self.booking.lab)
+
+ # def _get_facts(self):
+ # resource = self._resource()
+
+ # hostname = self.rconfig.name
+ # iface_configs = for_config.interface_configs.all()
+
+ def _to_dict(self):
+ main_dict = {}
+
+ main_dict['users'] = self._serialize_users()
+ main_dict['network'] = self._serialize_netconf_v1()
+ main_dict['hostname'] = self.rconfig.name
+
+ # add first startup commands
+ main_dict['runcmd'] = self._serialize_runcmds()
+
+ # configure distro default user
+ main_dict['system_info'] = self._serialize_sysinfo()
+
+ return main_dict
+
+ def serialize(self) -> str:
+ return yaml.dump(self._to_dict(), width=float("inf"))
+
+
+class APILog(models.Model):
+ user = models.ForeignKey(User, on_delete=models.PROTECT)
+ call_time = models.DateTimeField(auto_now=True)
+ method = models.CharField(null=True, max_length=6)
+ endpoint = models.CharField(null=True, max_length=300)
+ ip_addr = models.GenericIPAddressField(protocol="both", null=True, unpack_ipv4=False)
+ body = JSONField(null=True)
+
+ def __str__(self):
+ return "Call to {} at {} by {}".format(
+ self.endpoint,
+ self.call_time,
+ self.user.username
+ )
+
+
+class AutomationAPIManager:
+ @staticmethod
+ def serialize_booking(booking):
+ sbook = {}
+ sbook['id'] = booking.pk
+ sbook['owner'] = booking.owner.username
+ sbook['collaborators'] = [user.username for user in booking.collaborators.all()]
+ sbook['start'] = booking.start
+ sbook['end'] = booking.end
+ sbook['lab'] = AutomationAPIManager.serialize_lab(booking.lab)
+ sbook['purpose'] = booking.purpose
+ sbook['resourceBundle'] = AutomationAPIManager.serialize_bundle(booking.resource)
+ return sbook
+
+ @staticmethod
+ def serialize_lab(lab):
+ slab = {}
+ slab['id'] = lab.pk
+ slab['name'] = lab.name
+ return slab
+
+ @staticmethod
+ def serialize_bundle(bundle):
+ sbundle = {}
+ sbundle['id'] = bundle.pk
+ sbundle['resources'] = [
+ AutomationAPIManager.serialize_server(server)
+ for server in bundle.get_resources()]
+ return sbundle
+
+ @staticmethod
+ def serialize_server(server):
+ sserver = {}
+ sserver['id'] = server.pk
+ sserver['name'] = server.name
+ return sserver
+
+ @staticmethod
+ def serialize_resource_profile(profile):
+ sprofile = {}
+ sprofile['id'] = profile.pk
+ sprofile['name'] = profile.name
+ return sprofile
+
+ @staticmethod
+ def serialize_template(rec_temp_and_count):
+ template = rec_temp_and_count[0]
+ count = rec_temp_and_count[1]
+
+ stemplate = {}
+ stemplate['id'] = template.pk
+ stemplate['name'] = template.name
+ stemplate['count_available'] = count
+ stemplate['resourceProfiles'] = [
+ AutomationAPIManager.serialize_resource_profile(config.profile)
+ for config in template.getConfigs()
+ ]
+ return stemplate
+
+ @staticmethod
+ def serialize_image(image):
+ simage = {}
+ simage['id'] = image.pk
+ simage['name'] = image.name
+ return simage
+
+ @staticmethod
+ def serialize_userprofile(up):
+ sup = {}
+ sup['id'] = up.pk
+ sup['username'] = up.user.username
+ return sup
+
+
class Job(models.Model):
"""
A Job to be performed by the Lab.
@@ -670,6 +930,7 @@ class HardwareConfig(TaskConfig):
return self.get_delta()
def get_delta(self):
+ # TODO: grab the GeneratedCloudConfig urls from self.hosthardwarerelation.get_resource()
return self.format_delta(
self.hosthardwarerelation.get_resource().get_configuration(self.state),
self.hosthardwarerelation.lab_token)
@@ -722,7 +983,7 @@ class NetworkConfig(TaskConfig):
class SnapshotConfig(TaskConfig):
resource_id = models.CharField(max_length=200, default="default_id")
- image = models.IntegerField(null=True)
+ image = models.CharField(max_length=200, null=True) # cobbler ID
dashboard_id = models.IntegerField()
delta = models.TextField(default="{}")
@@ -1013,6 +1274,10 @@ class JobFactory(object):
booking=booking,
job=job
)
+ cls.makeGeneratedCloudConfigs(
+ resources=resources,
+ job=job
+ )
all_users = list(booking.collaborators.all())
all_users.append(booking.owner)
cls.makeAccessConfig(
@@ -1037,6 +1302,18 @@ class JobFactory(object):
continue
@classmethod
+ def makeGeneratedCloudConfigs(cls, resources=[], job=Job()):
+ for res in resources:
+ cif = GeneratedCloudConfig.objects.create(resource_id=res.labid, booking=job.booking, 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()
+
+ @classmethod
def makeHardwareConfigs(cls, resources=[], job=Job()):
"""
Create and save HardwareConfig.
diff --git a/src/api/urls.py b/src/api/urls.py
index bae86ea..acef947 100644
--- a/src/api/urls.py
+++ b/src/api/urls.py
@@ -45,10 +45,30 @@ from api.views import (
lab_users,
lab_user,
GenerateTokenView,
- analytics_job
+ analytics_job,
+ user_bookings,
+ specific_booking,
+ extend_booking,
+ make_booking,
+ list_labs,
+ all_users,
+ images_for_template,
+ available_templates,
+ resource_ci_metadata,
+ resource_ci_userdata,
+ resource_ci_userdata_directory,
+ all_images,
+ all_opsyss,
+ single_image,
+ single_opsys,
+ create_ci_file,
)
urlpatterns = [
+ path('labs/<slug:lab_name>/opsys/<slug:opsys_id>', single_opsys),
+ path('labs/<slug:lab_name>/image/<slug:image_id>', single_image),
+ path('labs/<slug:lab_name>/opsys', all_opsyss),
+ path('labs/<slug:lab_name>/image', all_images),
path('labs/<slug:lab_name>/profile', lab_profile),
path('labs/<slug:lab_name>/status', lab_status),
path('labs/<slug:lab_name>/inventory', lab_inventory),
@@ -59,11 +79,28 @@ urlpatterns = [
path('labs/<slug:lab_name>/booking/<int:booking_id>/idf', get_idf, name="get-idf"),
path('labs/<slug:lab_name>/jobs/<int:job_id>', specific_job),
path('labs/<slug:lab_name>/jobs/<int:job_id>/<slug:task_id>', specific_task),
+ path('labs/<slug:lab_name>/jobs/<int:job_id>/cidata/<slug:resource_id>/user-data', resource_ci_userdata_directory, name="specific-user-data"),
+ path('labs/<slug:lab_name>/jobs/<int:job_id>/cidata/<slug:resource_id>/meta-data', resource_ci_metadata, name="specific-meta-data"),
+ path('labs/<slug:lab_name>/jobs/<int:job_id>/cidata/<slug:resource_id>/<int:file_id>/user-data', resource_ci_userdata, name="user-data-dir"),
path('labs/<slug:lab_name>/jobs/new', new_jobs),
path('labs/<slug:lab_name>/jobs/current', current_jobs),
path('labs/<slug:lab_name>/jobs/done', done_jobs),
path('labs/<slug:lab_name>/jobs/getByType/DATA', analytics_job),
path('labs/<slug:lab_name>/users', lab_users),
path('labs/<slug:lab_name>/users/<int:user_id>', lab_user),
+
+ path('booking', user_bookings),
+ path('booking/<int:booking_id>', specific_booking),
+ path('booking/<int:booking_id>/extendBooking/<int:days>', extend_booking),
+ path('booking/makeBooking', make_booking),
+
+ path('resource_inventory/availableTemplates', available_templates),
+ path('resource_inventory/<int:template_id>/images', images_for_template),
+
+ path('resource_inventory/cloud/create', create_ci_file),
+
+ path('users', all_users),
+ path('labs', list_labs),
+
url(r'^token$', GenerateTokenView.as_view(), name='generate_token'),
]
diff --git a/src/api/views.py b/src/api/views.py
index 2e5f33f..1516374 100644
--- a/src/api/views.py
+++ b/src/api/views.py
@@ -8,27 +8,45 @@
# http://www.apache.org/licenses/LICENSE-2.0
##############################################################################
+import json
+import math
+import traceback
+import sys
+from datetime import timedelta
from django.contrib.auth.decorators import login_required
-from django.shortcuts import redirect
+from django.shortcuts import redirect, get_object_or_404
from django.utils.decorators import method_decorator
from django.utils import timezone
from django.views import View
+from django.http import HttpResponseNotFound
from django.http.response import JsonResponse, HttpResponse
from rest_framework import viewsets
from rest_framework.authtoken.models import Token
from django.views.decorators.csrf import csrf_exempt
from django.core.exceptions import ObjectDoesNotExist
+from django.db.models import Q
from api.serializers.booking_serializer import BookingSerializer
from api.serializers.old_serializers import UserSerializer
from api.forms import DowntimeForm
-from account.models import UserProfile
+from account.models import UserProfile, Lab
from booking.models import Booking
-from api.models import LabManagerTracker, get_task
+from booking.quick_deployer import create_from_API
+from api.models import LabManagerTracker, get_task, Job, AutomationAPIManager, APILog
from notifier.manager import NotificationHandler
from analytics.models import ActiveVPNUser
-import json
+from resource_inventory.models import (
+ Image,
+ Opsys,
+ CloudInitFile,
+ ResourceQuery,
+ ResourceTemplate,
+)
+
+import yaml
+import uuid
+from deepmerge import Merger
"""
API views.
@@ -80,6 +98,83 @@ def lab_host(request, lab_name="", host_id=""):
if request.method == "POST":
return JsonResponse(lab_manager.update_host(host_id, request.POST), safe=False)
+# API extension for Cobbler integration
+
+
+def all_images(request, lab_name=""):
+ a = []
+ for i in Image.objects.all():
+ a.append(i.serialize())
+ return JsonResponse(a, safe=False)
+
+
+def all_opsyss(request, lab_name=""):
+ a = []
+ for opsys in Opsys.objects.all():
+ a.append(opsys.serialize())
+
+ return JsonResponse(a, safe=False)
+
+
+@csrf_exempt
+def single_image(request, lab_name="", image_id=""):
+ lab_token = request.META.get('HTTP_AUTH_TOKEN')
+ lab_manager = LabManagerTracker.get(lab_name, lab_token)
+ img = lab_manager.get_image(image_id).first()
+
+ if request.method == "GET":
+ if not img:
+ return HttpResponse(status=404)
+ return JsonResponse(img.serialize(), safe=False)
+
+ if request.method == "POST":
+ # get POST data
+ data = json.loads(request.body.decode('utf-8'))
+ if img:
+ img.update(data)
+ else:
+ # append lab name and the ID from the URL
+ data['from_lab_id'] = lab_name
+ data['lab_id'] = image_id
+
+ # create and save a new Image object
+ img = Image.new_from_data(data)
+
+ img.save()
+
+ # indicate success in response
+ return HttpResponse(status=200)
+ return HttpResponse(status=405)
+
+
+@csrf_exempt
+def single_opsys(request, lab_name="", opsys_id=""):
+ lab_token = request.META.get('HTTP_AUTH_TOKEN')
+ lab_manager = LabManagerTracker.get(lab_name, lab_token)
+ opsys = lab_manager.get_opsys(opsys_id).first()
+
+ if request.method == "GET":
+ if not opsys:
+ return HttpResponse(status=404)
+ return JsonResponse(opsys.serialize(), safe=False)
+
+ if request.method == "POST":
+ data = json.loads(request.body.decode('utf-8'))
+ if opsys:
+ opsys.update(data)
+ else:
+ # only name, available, and obsolete are needed to create an Opsys
+ # other fields are derived from the URL parameters
+ data['from_lab_id'] = lab_name
+ data['lab_id'] = opsys_id
+ opsys = Opsys.new_from_data(data)
+
+ opsys.save()
+ return HttpResponse(status=200)
+ return HttpResponse(status=405)
+
+# end API extension
+
def get_pdf(request, lab_name="", booking_id=""):
lab_token = request.META.get('HTTP_AUTH_TOKEN')
@@ -167,6 +262,89 @@ def specific_job(request, lab_name="", job_id=""):
return JsonResponse(lab_manager.get_job(job_id), safe=False)
+@csrf_exempt
+def resource_ci_userdata(request, lab_name="", job_id="", resource_id="", file_id=0):
+ # lab_token = request.META.get('HTTP_AUTH_TOKEN')
+ # lab_manager = LabManagerTracker.get(lab_name, lab_token)
+
+ # job = lab_manager.get_job(job_id)
+ Job.objects.get(id=job_id) # verify a valid job was given, even if we don't use it
+
+ cifile = None
+ try:
+ cifile = CloudInitFile.objects.get(id=file_id)
+ except ObjectDoesNotExist:
+ return HttpResponseNotFound("Could not find a matching resource by id " + str(resource_id))
+
+ text = cifile.text
+
+ prepended_text = "#cloud-config\n"
+ # mstrat = CloudInitFile.merge_strategy()
+ # prepended_text = prepended_text + yaml.dump({"merge_strategy": mstrat}) + "\n"
+ # print("in cloudinitfile create")
+ text = prepended_text + text
+ cloud_dict = {
+ "datasource": {
+ "None": {
+ "metadata": {
+ "instance-id": str(uuid.uuid4())
+ },
+ "userdata_raw": text,
+ },
+ },
+ "datasource_list": ["None"],
+ }
+
+ return HttpResponse(yaml.dump(cloud_dict, width=float("inf")), status=200)
+
+
+@csrf_exempt
+def resource_ci_metadata(request, lab_name="", job_id="", resource_id="", file_id=0):
+ return HttpResponse("#cloud-config", status=200)
+
+
+@csrf_exempt
+def resource_ci_userdata_directory(request, lab_name="", job_id="", resource_id=""):
+ # files = [{"id": file.file_id, "priority": file.priority} for file in CloudInitFile.objects.filter(job__id=job_id, resource_id=resource_id).order_by("priority").all()]
+ resource = ResourceQuery.get(labid=resource_id, lab=Lab.objects.get(name=lab_name))
+ files = resource.config.cloud_init_files
+ files = [{"id": file.id, "priority": file.priority} for file in files.order_by("priority").all()]
+
+ d = {}
+
+ merge_failures = []
+
+ merger = Merger(
+ [
+ (list, ["append"]),
+ (dict, ["merge"]),
+ ],
+ ["override"], # fallback
+ ["override"], # if types conflict (shouldn't happen in CI, but handle case)
+ )
+
+ for f in resource.config.cloud_init_files.order_by("priority").all():
+ try:
+ other_dict = yaml.safe_load(f.text)
+ if not (type(d) is dict):
+ raise Exception("CI file was valid yaml but was not a dict")
+
+ merger.merge(d, other_dict)
+ except Exception as e:
+ # if fail to merge, then just skip
+ print("Failed to merge file in, as it had invalid content:", f.id)
+ print("File text was:")
+ print(f.text)
+ merge_failures.append({f.id: str(e)})
+
+ if len(merge_failures) > 0:
+ d['merge_failures'] = merge_failures
+
+ file = CloudInitFile.create(text=yaml.dump(d, width=float("inf")), priority=0)
+
+ return HttpResponse(json.dumps([{"id": file.id, "priority": file.priority}]), status=200)
+
+
def new_jobs(request, lab_name=""):
lab_token = request.META.get('HTTP_AUTH_TOKEN')
lab_manager = LabManagerTracker.get(lab_name, lab_token)
@@ -234,3 +412,241 @@ def done_jobs(request, lab_name=""):
lab_token = request.META.get('HTTP_AUTH_TOKEN')
lab_manager = LabManagerTracker.get(lab_name, lab_token)
return JsonResponse(lab_manager.get_done_jobs(), safe=False)
+
+
+def auth_and_log(request, endpoint):
+ """
+ Function to authenticate an API user and log info
+ in the API log model. This is to keep record of
+ all calls to the dashboard
+ """
+ user_token = request.META.get('HTTP_AUTH_TOKEN')
+ response = None
+
+ if user_token is None:
+ return HttpResponse('Unauthorized', status=401)
+
+ try:
+ token = Token.objects.get(key=user_token)
+ except Token.DoesNotExist:
+ token = None
+ response = HttpResponse('Unauthorized', status=401)
+
+ x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
+ if x_forwarded_for:
+ ip = x_forwarded_for.split(',')[0]
+ else:
+ ip = request.META.get('REMOTE_ADDR')
+
+ body = None
+
+ if request.method in ['POST', 'PUT']:
+ try:
+ body = json.loads(request.body.decode('utf-8')),
+ except Exception:
+ response = HttpResponse('Invalid Request Body', status=400)
+
+ APILog.objects.create(
+ user=token.user,
+ call_time=timezone.now(),
+ method=request.method,
+ endpoint=endpoint,
+ body=body,
+ ip_addr=ip
+ )
+
+ if response:
+ return response
+ else:
+ return token
+
+
+"""
+Booking API Views
+"""
+
+
+def user_bookings(request):
+ token = auth_and_log(request, 'booking')
+
+ if isinstance(token, HttpResponse):
+ return token
+
+ bookings = Booking.objects.filter(owner=token.user, end__gte=timezone.now())
+ output = [AutomationAPIManager.serialize_booking(booking)
+ for booking in bookings]
+ return JsonResponse(output, safe=False)
+
+
+@csrf_exempt
+def specific_booking(request, booking_id=""):
+ token = auth_and_log(request, 'booking/{}'.format(booking_id))
+
+ if isinstance(token, HttpResponse):
+ return token
+
+ booking = get_object_or_404(Booking, pk=booking_id, owner=token.user)
+ if request.method == "GET":
+ sbooking = AutomationAPIManager.serialize_booking(booking)
+ return JsonResponse(sbooking, safe=False)
+
+ if request.method == "DELETE":
+
+ if booking.end < timezone.now():
+ return HttpResponse("Booking already over", status=400)
+
+ booking.end = timezone.now()
+ booking.save()
+ return HttpResponse("Booking successfully cancelled")
+
+
+@csrf_exempt
+def extend_booking(request, booking_id="", days=""):
+ token = auth_and_log(request, 'booking/{}/extendBooking/{}'.format(booking_id, days))
+
+ if isinstance(token, HttpResponse):
+ return token
+
+ booking = get_object_or_404(Booking, pk=booking_id, owner=token.user)
+
+ if booking.end < timezone.now():
+ return HttpResponse("This booking is already over, cannot extend")
+
+ if days > 30:
+ return HttpResponse("Cannot extend a booking longer than 30 days")
+
+ if booking.ext_count == 0:
+ return HttpResponse("Booking has already been extended 2 times, cannot extend again")
+
+ booking.end += timedelta(days=days)
+ booking.ext_count -= 1
+ booking.save()
+
+ return HttpResponse("Booking successfully extended")
+
+
+@csrf_exempt
+def make_booking(request):
+ token = auth_and_log(request, 'booking/makeBooking')
+
+ if isinstance(token, HttpResponse):
+ return token
+
+ try:
+ booking = create_from_API(request.body, token.user)
+
+ except Exception:
+ finalTrace = ''
+ exc_type, exc_value, exc_traceback = sys.exc_info()
+ for i in traceback.format_exception(exc_type, exc_value, exc_traceback):
+ finalTrace += '<br>' + i.strip()
+ return HttpResponse(finalTrace, status=400)
+
+ sbooking = AutomationAPIManager.serialize_booking(booking)
+ return JsonResponse(sbooking, safe=False)
+
+
+"""
+Resource Inventory API Views
+"""
+
+
+def available_templates(request):
+ token = auth_and_log(request, 'resource_inventory/availableTemplates')
+
+ if isinstance(token, HttpResponse):
+ return token
+
+ # get available templates
+ # mirrors MultipleSelectFilter Widget
+ avt = []
+ for lab in Lab.objects.all():
+ for template in ResourceTemplate.objects.filter(Q(owner=token.user) | Q(public=True), lab=lab, temporary=False):
+ available_resources = lab.get_available_resources()
+ required_resources = template.get_required_resources()
+ least_available = 100
+
+ for resource, count_required in required_resources.items():
+ try:
+ curr_count = math.floor(available_resources[str(resource)] / count_required)
+ if curr_count < least_available:
+ least_available = curr_count
+ except KeyError:
+ least_available = 0
+
+ if least_available > 0:
+ avt.append((template, least_available))
+
+ savt = [AutomationAPIManager.serialize_template(temp)
+ for temp in avt]
+
+ return JsonResponse(savt, safe=False)
+
+
+def images_for_template(request, template_id=""):
+ _ = auth_and_log(request, 'resource_inventory/{}/images'.format(template_id))
+
+ template = get_object_or_404(ResourceTemplate, pk=template_id)
+ images = [AutomationAPIManager.serialize_image(config.image)
+ for config in template.getConfigs()]
+ return JsonResponse(images, safe=False)
+
+
+"""
+User API Views
+"""
+
+
+def all_users(request):
+ token = auth_and_log(request, 'users')
+
+ if token is None:
+ return HttpResponse('Unauthorized', status=401)
+
+ users = [AutomationAPIManager.serialize_userprofile(up)
+ for up in UserProfile.objects.filter(public_user=True)]
+
+ return JsonResponse(users, safe=False)
+
+
+def create_ci_file(request):
+ token = auth_and_log(request, 'booking/makeCloudConfig')
+
+ if isinstance(token, HttpResponse):
+ return token
+
+ try:
+ cconf = request.body
+ d = yaml.load(cconf)
+ if not (type(d) is dict):
+ raise Exception()
+
+ cconf = CloudInitFile.create(text=cconf, priority=CloudInitFile.objects.count())
+
+ return JsonResponse({"id": cconf.id})
+ except Exception:
+ return JsonResponse({"error": "Provided config file was not valid yaml or was not a dict at the top level"})
+
+
+"""
+Lab API Views
+"""
+
+
+def list_labs(request):
+ lab_list = []
+ for lab in Lab.objects.all():
+ lab_info = {
+ 'name': lab.name,
+ 'username': lab.lab_user.username,
+ 'status': lab.status,
+ 'project': lab.project,
+ 'description': lab.description,
+ 'location': lab.location,
+ 'info': lab.lab_info_link,
+ 'email': lab.contact_email,
+ 'phone': lab.contact_phone
+ }
+ lab_list.append(lab_info)
+
+ return JsonResponse(lab_list, safe=False)