ffa9b3fa5f2aa6574f330594e7f5495479cde99c
[laas.git] / src / api / views.py
1 ##############################################################################
2 # Copyright (c) 2016 Max Breitenfeldt and others.
3 # Copyright (c) 2018 Parker Berberian, Sawyer Bergeron, and 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 import json
12 import math
13 import traceback
14 import sys
15 from datetime import timedelta
16
17 from django.contrib.auth.decorators import login_required
18 from django.shortcuts import redirect, get_object_or_404
19 from django.utils.decorators import method_decorator
20 from django.utils import timezone
21 from django.views import View
22 from django.http import HttpResponseNotFound
23 from django.http.response import JsonResponse, HttpResponse
24 from rest_framework import viewsets
25 from rest_framework.authtoken.models import Token
26 from django.views.decorators.csrf import csrf_exempt
27 from django.core.exceptions import ObjectDoesNotExist
28 from django.db.models import Q
29
30 from api.serializers.booking_serializer import BookingSerializer
31 from api.serializers.old_serializers import UserSerializer
32 from api.forms import DowntimeForm
33 from account.models import UserProfile, Lab
34 from booking.models import Booking
35 from booking.quick_deployer import create_from_API
36 from api.models import LabManagerTracker, get_task, Job, AutomationAPIManager, APILog
37 from notifier.manager import NotificationHandler
38 from analytics.models import ActiveVPNUser
39 from resource_inventory.models import (
40     Image,
41     Opsys,
42     CloudInitFile,
43     ResourceQuery,
44     ResourceTemplate,
45 )
46
47 import yaml
48 import uuid
49 from deepmerge import Merger
50
51 """
52 API views.
53
54 All functions return a Json blob
55 Most functions that deal with info from a specific lab (tasks, host info)
56 requires the Lab auth token.
57     for example, curl -H auth-token:mylabsauthtoken url
58
59 Most functions let you GET or POST to the same endpoint, and
60 the correct thing will happen
61 """
62
63
64 class BookingViewSet(viewsets.ModelViewSet):
65     queryset = Booking.objects.all()
66     serializer_class = BookingSerializer
67     filter_fields = ('resource', 'id')
68
69
70 class UserViewSet(viewsets.ModelViewSet):
71     queryset = UserProfile.objects.all()
72     serializer_class = UserSerializer
73
74
75 @method_decorator(login_required, name='dispatch')
76 class GenerateTokenView(View):
77     def get(self, request, *args, **kwargs):
78         user = self.request.user
79         token, created = Token.objects.get_or_create(user=user)
80         if not created:
81             token.delete()
82             Token.objects.create(user=user)
83         return redirect('account:settings')
84
85
86 def lab_inventory(request, lab_name=""):
87     lab_token = request.META.get('HTTP_AUTH_TOKEN')
88     lab_manager = LabManagerTracker.get(lab_name, lab_token)
89     return JsonResponse(lab_manager.get_inventory(), safe=False)
90
91
92 @csrf_exempt
93 def lab_host(request, lab_name="", host_id=""):
94     lab_token = request.META.get('HTTP_AUTH_TOKEN')
95     lab_manager = LabManagerTracker.get(lab_name, lab_token)
96     if request.method == "GET":
97         return JsonResponse(lab_manager.get_host(host_id), safe=False)
98     if request.method == "POST":
99         return JsonResponse(lab_manager.update_host(host_id, request.POST), safe=False)
100
101 # API extension for Cobbler integration
102
103
104 def all_images(request, lab_name=""):
105     a = []
106     for i in Image.objects.all():
107         a.append(i.serialize())
108     return JsonResponse(a, safe=False)
109
110
111 def all_opsyss(request, lab_name=""):
112     a = []
113     for opsys in Opsys.objects.all():
114         a.append(opsys.serialize())
115
116     return JsonResponse(a, safe=False)
117
118
119 @csrf_exempt
120 def single_image(request, lab_name="", image_id=""):
121     lab_token = request.META.get('HTTP_AUTH_TOKEN')
122     lab_manager = LabManagerTracker.get(lab_name, lab_token)
123     img = lab_manager.get_image(image_id).first()
124
125     if request.method == "GET":
126         if not img:
127             return HttpResponse(status=404)
128         return JsonResponse(img.serialize(), safe=False)
129
130     if request.method == "POST":
131         # get POST data
132         data = json.loads(request.body.decode('utf-8'))
133         if img:
134             img.update(data)
135         else:
136             # append lab name and the ID from the URL
137             data['from_lab_id'] = lab_name
138             data['lab_id'] = image_id
139
140             # create and save a new Image object
141             img = Image.new_from_data(data)
142
143         img.save()
144
145         # indicate success in response
146         return HttpResponse(status=200)
147     return HttpResponse(status=405)
148
149
150 @csrf_exempt
151 def single_opsys(request, lab_name="", opsys_id=""):
152     lab_token = request.META.get('HTTP_AUTH_TOKEN')
153     lab_manager = LabManagerTracker.get(lab_name, lab_token)
154     opsys = lab_manager.get_opsys(opsys_id).first()
155
156     if request.method == "GET":
157         if not opsys:
158             return HttpResponse(status=404)
159         return JsonResponse(opsys.serialize(), safe=False)
160
161     if request.method == "POST":
162         data = json.loads(request.body.decode('utf-8'))
163         if opsys:
164             opsys.update(data)
165         else:
166             # only name, available, and obsolete are needed to create an Opsys
167             # other fields are derived from the URL parameters
168             data['from_lab_id'] = lab_name
169             data['lab_id'] = opsys_id
170             opsys = Opsys.new_from_data(data)
171
172         opsys.save()
173         return HttpResponse(status=200)
174     return HttpResponse(status=405)
175
176 # end API extension
177
178
179 def get_pdf(request, lab_name="", booking_id=""):
180     lab_token = request.META.get('HTTP_AUTH_TOKEN')
181     lab_manager = LabManagerTracker.get(lab_name, lab_token)
182     return HttpResponse(lab_manager.get_pdf(booking_id), content_type="text/plain")
183
184
185 def get_idf(request, lab_name="", booking_id=""):
186     lab_token = request.META.get('HTTP_AUTH_TOKEN')
187     lab_manager = LabManagerTracker.get(lab_name, lab_token)
188     return HttpResponse(lab_manager.get_idf(booking_id), content_type="text/plain")
189
190
191 def lab_status(request, lab_name=""):
192     lab_token = request.META.get('HTTP_AUTH_TOKEN')
193     lab_manager = LabManagerTracker.get(lab_name, lab_token)
194     if request.method == "POST":
195         return JsonResponse(lab_manager.set_status(request.POST), safe=False)
196     return JsonResponse(lab_manager.get_status(), safe=False)
197
198
199 def lab_users(request, lab_name=""):
200     lab_token = request.META.get('HTTP_AUTH_TOKEN')
201     lab_manager = LabManagerTracker.get(lab_name, lab_token)
202     return HttpResponse(lab_manager.get_users(), content_type="text/plain")
203
204
205 def lab_user(request, lab_name="", user_id=-1):
206     lab_token = request.META.get('HTTP_AUTH_TOKEN')
207     lab_manager = LabManagerTracker.get(lab_name, lab_token)
208     return HttpResponse(lab_manager.get_user(user_id), content_type="text/plain")
209
210
211 @csrf_exempt
212 def update_host_bmc(request, lab_name="", host_id=""):
213     lab_token = request.META.get('HTTP_AUTH_TOKEN')
214     lab_manager = LabManagerTracker.get(lab_name, lab_token)
215     if request.method == "POST":
216         # update / create RemoteInfo for host
217         return JsonResponse(
218             lab_manager.update_host_remote_info(request.POST, host_id),
219             safe=False
220         )
221
222
223 def lab_profile(request, lab_name=""):
224     lab_token = request.META.get('HTTP_AUTH_TOKEN')
225     lab_manager = LabManagerTracker.get(lab_name, lab_token)
226     return JsonResponse(lab_manager.get_profile(), safe=False)
227
228
229 @csrf_exempt
230 def specific_task(request, lab_name="", job_id="", task_id=""):
231     lab_token = request.META.get('HTTP_AUTH_TOKEN')
232     LabManagerTracker.get(lab_name, lab_token)  # Authorize caller, but we dont need the result
233
234     if request.method == "POST":
235         task = get_task(task_id)
236         if 'status' in request.POST:
237             task.status = request.POST.get('status')
238         if 'message' in request.POST:
239             task.message = request.POST.get('message')
240         if 'lab_token' in request.POST:
241             task.lab_token = request.POST.get('lab_token')
242         task.save()
243         NotificationHandler.task_updated(task)
244         d = {}
245         d['task'] = task.config.get_delta()
246         m = {}
247         m['status'] = task.status
248         m['job'] = str(task.job)
249         m['message'] = task.message
250         d['meta'] = m
251         return JsonResponse(d, safe=False)
252     elif request.method == "GET":
253         return JsonResponse(get_task(task_id).config.get_delta())
254
255
256 @csrf_exempt
257 def specific_job(request, lab_name="", job_id=""):
258     lab_token = request.META.get('HTTP_AUTH_TOKEN')
259     lab_manager = LabManagerTracker.get(lab_name, lab_token)
260     if request.method == "POST":
261         return JsonResponse(lab_manager.update_job(job_id, request.POST), safe=False)
262     return JsonResponse(lab_manager.get_job(job_id), safe=False)
263
264
265 @csrf_exempt
266 def resource_ci_userdata(request, lab_name="", job_id="", resource_id="", file_id=0):
267     # lab_token = request.META.get('HTTP_AUTH_TOKEN')
268     # lab_manager = LabManagerTracker.get(lab_name, lab_token)
269
270     # job = lab_manager.get_job(job_id)
271     Job.objects.get(id=job_id)  # verify a valid job was given, even if we don't use it
272
273     cifile = None
274     try:
275         cifile = CloudInitFile.objects.get(id=file_id)
276     except ObjectDoesNotExist:
277         return HttpResponseNotFound("Could not find a matching resource by id " + str(resource_id))
278
279     text = cifile.text
280
281     prepended_text = "#cloud-config\n"
282     # mstrat = CloudInitFile.merge_strategy()
283     # prepended_text = prepended_text + yaml.dump({"merge_strategy": mstrat}) + "\n"
284     # print("in cloudinitfile create")
285     text = prepended_text + text
286     cloud_dict = {
287         "datasource": {
288             "None": {
289                 "metadata": {
290                     "instance-id": str(uuid.uuid4())
291                 },
292                 "userdata_raw": text,
293             },
294         },
295         "datasource_list": ["None"],
296     }
297
298     return HttpResponse(yaml.dump(cloud_dict, width=float("inf")), status=200)
299
300
301 @csrf_exempt
302 def resource_ci_metadata(request, lab_name="", job_id="", resource_id="", file_id=0):
303     return HttpResponse("#cloud-config", status=200)
304
305
306 @csrf_exempt
307 def resource_ci_userdata_directory(request, lab_name="", job_id="", resource_id=""):
308     # 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()]
309     resource = ResourceQuery.get(labid=resource_id, lab=Lab.objects.get(name=lab_name))
310     files = resource.config.cloud_init_files
311     files = [{"id": file.id, "priority": file.priority} for file in files.order_by("priority").all()]
312
313     d = {}
314
315     merge_failures = []
316
317     merger = Merger(
318         [
319             (list, ["append"]),
320             (dict, ["merge"]),
321         ],
322         ["override"],  # fallback
323         ["override"],  # if types conflict (shouldn't happen in CI, but handle case)
324     )
325
326     for f in resource.config.cloud_init_files.order_by("priority").all():
327         try:
328             other_dict = yaml.safe_load(f.text)
329             if not (type(d) is dict):
330                 raise Exception("CI file was valid yaml but was not a dict")
331
332             merger.merge(d, other_dict)
333         except Exception as e:
334             # if fail to merge, then just skip
335             print("Failed to merge file in, as it had invalid content:", f.id)
336             print("File text was:")
337             print(f.text)
338             merge_failures.append({f.id: str(e)})
339
340     if len(merge_failures) > 0:
341         d['merge_failures'] = merge_failures
342
343     file = CloudInitFile.create(text=yaml.dump(d, width=float("inf")), priority=0)
344
345     return HttpResponse(json.dumps([{"id": file.id, "priority": file.priority}]), status=200)
346
347
348 def new_jobs(request, lab_name=""):
349     lab_token = request.META.get('HTTP_AUTH_TOKEN')
350     lab_manager = LabManagerTracker.get(lab_name, lab_token)
351     return JsonResponse(lab_manager.get_new_jobs(), safe=False)
352
353
354 def current_jobs(request, lab_name=""):
355     lab_token = request.META.get('HTTP_AUTH_TOKEN')
356     lab_manager = LabManagerTracker.get(lab_name, lab_token)
357     return JsonResponse(lab_manager.get_current_jobs(), safe=False)
358
359
360 @csrf_exempt
361 def analytics_job(request, lab_name=""):
362     """ returns all jobs with type booking"""
363     lab_token = request.META.get('HTTP_AUTH_TOKEN')
364     lab_manager = LabManagerTracker.get(lab_name, lab_token)
365     if request.method == "GET":
366         return JsonResponse(lab_manager.get_analytics_job(), safe=False)
367     if request.method == "POST":
368         users = json.loads(request.body.decode('utf-8'))['active_users']
369         try:
370             ActiveVPNUser.create(lab_name, users)
371         except ObjectDoesNotExist:
372             return JsonResponse('Lab does not exist!', safe=False)
373         return HttpResponse(status=200)
374     return HttpResponse(status=405)
375
376
377 def lab_downtime(request, lab_name=""):
378     lab_token = request.META.get('HTTP_AUTH_TOKEN')
379     lab_manager = LabManagerTracker.get(lab_name, lab_token)
380     if request.method == "GET":
381         return JsonResponse(lab_manager.get_downtime_json())
382     if request.method == "POST":
383         return post_lab_downtime(request, lab_manager)
384     if request.method == "DELETE":
385         return delete_lab_downtime(lab_manager)
386     return HttpResponse(status=405)
387
388
389 def post_lab_downtime(request, lab_manager):
390     current_downtime = lab_manager.get_downtime()
391     if current_downtime.exists():
392         return JsonResponse({"error": "Lab is already in downtime"}, status=422)
393     form = DowntimeForm(request.POST)
394     if form.is_valid():
395         return JsonResponse(lab_manager.create_downtime(form))
396     else:
397         return JsonResponse(form.errors.get_json_data(), status=400)
398
399
400 def delete_lab_downtime(lab_manager):
401     current_downtime = lab_manager.get_downtime()
402     if current_downtime.exists():
403         dt = current_downtime.first()
404         dt.end = timezone.now()
405         dt.save()
406         return JsonResponse(lab_manager.get_downtime_json(), safe=False)
407     else:
408         return JsonResponse({"error": "Lab is not in downtime"}, status=422)
409
410
411 def done_jobs(request, lab_name=""):
412     lab_token = request.META.get('HTTP_AUTH_TOKEN')
413     lab_manager = LabManagerTracker.get(lab_name, lab_token)
414     return JsonResponse(lab_manager.get_done_jobs(), safe=False)
415
416
417 def auth_and_log(request, endpoint):
418     """
419     Function to authenticate an API user and log info
420     in the API log model. This is to keep record of
421     all calls to the dashboard
422     """
423     user_token = request.META.get('HTTP_AUTH_TOKEN')
424     response = None
425
426     if user_token is None:
427         return HttpResponse('Unauthorized', status=401)
428
429     try:
430         token = Token.objects.get(key=user_token)
431     except Token.DoesNotExist:
432         token = None
433         # Added logic to detect malformed token
434         if len(str(user_token)) != 40:
435             response = HttpResponse('Malformed Token', status=401)
436         else:
437             response = HttpResponse('Unauthorized', status=401)
438
439     x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
440     if x_forwarded_for:
441         ip = x_forwarded_for.split(',')[0]
442     else:
443         ip = request.META.get('REMOTE_ADDR')
444
445     body = None
446
447     if request.method in ['POST', 'PUT']:
448         try:
449             body = json.loads(request.body.decode('utf-8')),
450         except Exception:
451             response = HttpResponse('Invalid Request Body', status=400)
452
453     APILog.objects.create(
454         user=token.user,
455         call_time=timezone.now(),
456         method=request.method,
457         endpoint=endpoint,
458         body=body,
459         ip_addr=ip
460     )
461
462     if response:
463         return response
464     else:
465         return token
466
467
468 """
469 Booking API Views
470 """
471
472
473 def user_bookings(request):
474     token = auth_and_log(request, 'booking')
475
476     if isinstance(token, HttpResponse):
477         return token
478
479     bookings = Booking.objects.filter(owner=token.user, end__gte=timezone.now())
480     output = [AutomationAPIManager.serialize_booking(booking)
481               for booking in bookings]
482     return JsonResponse(output, safe=False)
483
484
485 @csrf_exempt
486 def specific_booking(request, booking_id=""):
487     token = auth_and_log(request, 'booking/{}'.format(booking_id))
488
489     if isinstance(token, HttpResponse):
490         return token
491
492     booking = get_object_or_404(Booking, pk=booking_id, owner=token.user)
493     if request.method == "GET":
494         sbooking = AutomationAPIManager.serialize_booking(booking)
495         return JsonResponse(sbooking, safe=False)
496
497     if request.method == "DELETE":
498
499         if booking.end < timezone.now():
500             return HttpResponse("Booking already over", status=400)
501
502         booking.end = timezone.now()
503         booking.save()
504         return HttpResponse("Booking successfully cancelled")
505
506
507 @csrf_exempt
508 def extend_booking(request, booking_id="", days=""):
509     token = auth_and_log(request, 'booking/{}/extendBooking/{}'.format(booking_id, days))
510
511     if isinstance(token, HttpResponse):
512         return token
513
514     booking = get_object_or_404(Booking, pk=booking_id, owner=token.user)
515
516     if booking.end < timezone.now():
517         return HttpResponse("This booking is already over, cannot extend")
518
519     if days > 30:
520         return HttpResponse("Cannot extend a booking longer than 30 days")
521
522     if booking.ext_count == 0:
523         return HttpResponse("Booking has already been extended 2 times, cannot extend again")
524
525     booking.end += timedelta(days=days)
526     booking.ext_count -= 1
527     booking.save()
528
529     return HttpResponse("Booking successfully extended")
530
531
532 @csrf_exempt
533 def make_booking(request):
534     token = auth_and_log(request, 'booking/makeBooking')
535
536     if isinstance(token, HttpResponse):
537         return token
538
539     try:
540         booking = create_from_API(request.body, token.user)
541
542     except Exception:
543         finalTrace = ''
544         exc_type, exc_value, exc_traceback = sys.exc_info()
545         for i in traceback.format_exception(exc_type, exc_value, exc_traceback):
546             finalTrace += '<br>' + i.strip()
547         return HttpResponse(finalTrace, status=400)
548
549     sbooking = AutomationAPIManager.serialize_booking(booking)
550     return JsonResponse(sbooking, safe=False)
551
552
553 """
554 Resource Inventory API Views
555 """
556
557
558 def available_templates(request):
559     token = auth_and_log(request, 'resource_inventory/availableTemplates')
560
561     if isinstance(token, HttpResponse):
562         return token
563
564     # get available templates
565     # mirrors MultipleSelectFilter Widget
566     avt = []
567     for lab in Lab.objects.all():
568         for template in ResourceTemplate.objects.filter(Q(owner=token.user) | Q(public=True), lab=lab, temporary=False):
569             available_resources = lab.get_available_resources()
570             required_resources = template.get_required_resources()
571             least_available = 100
572
573             for resource, count_required in required_resources.items():
574                 try:
575                     curr_count = math.floor(available_resources[str(resource)] / count_required)
576                     if curr_count < least_available:
577                         least_available = curr_count
578                 except KeyError:
579                     least_available = 0
580
581             if least_available > 0:
582                 avt.append((template, least_available))
583
584     savt = [AutomationAPIManager.serialize_template(temp)
585             for temp in avt]
586
587     return JsonResponse(savt, safe=False)
588
589
590 def images_for_template(request, template_id=""):
591     _ = auth_and_log(request, 'resource_inventory/{}/images'.format(template_id))
592
593     template = get_object_or_404(ResourceTemplate, pk=template_id)
594     images = [AutomationAPIManager.serialize_image(config.image)
595               for config in template.getConfigs()]
596     return JsonResponse(images, safe=False)
597
598
599 """
600 User API Views
601 """
602
603
604 def all_users(request):
605     token = auth_and_log(request, 'users')
606
607     if token is None:
608         return HttpResponse('Unauthorized', status=401)
609
610     users = [AutomationAPIManager.serialize_userprofile(up)
611              for up in UserProfile.objects.filter(public_user=True)]
612
613     return JsonResponse(users, safe=False)
614
615
616 def create_ci_file(request):
617     token = auth_and_log(request, 'booking/makeCloudConfig')
618
619     if isinstance(token, HttpResponse):
620         return token
621
622     try:
623         cconf = request.body
624         d = yaml.load(cconf)
625         if not (type(d) is dict):
626             raise Exception()
627
628         cconf = CloudInitFile.create(text=cconf, priority=CloudInitFile.objects.count())
629
630         return JsonResponse({"id": cconf.id})
631     except Exception:
632         return JsonResponse({"error": "Provided config file was not valid yaml or was not a dict at the top level"})
633
634
635 """
636 Lab API Views
637 """
638
639
640 def list_labs(request):
641     lab_list = []
642     for lab in Lab.objects.all():
643         lab_info = {
644             'name': lab.name,
645             'username': lab.lab_user.username,
646             'status': lab.status,
647             'project': lab.project,
648             'description': lab.description,
649             'location': lab.location,
650             'info': lab.lab_info_link,
651             'email': lab.contact_email,
652             'phone': lab.contact_phone
653         }
654         lab_list.append(lab_info)
655
656     return JsonResponse(lab_list, safe=False)