From 104470ee008817df4a3b78d24e524e8c92eac9a0 Mon Sep 17 00:00:00 2001 From: Sawyer Bergeron Date: Fri, 15 Oct 2021 17:03:58 -0400 Subject: WIP merge of master into cobbler Squashed commit of the following: commit 35b9f39178cc502a5283a1b37a65f7dd0838ae05 Author: Sawyer Bergeron Date: Fri Oct 15 14:14:46 2021 -0400 Merge User Booking API Rev 1 (Try 3) Signed-off-by: Sawyer Bergeron Change-Id: Ie1eee0a59929f8da39f16bb6bc17ae3de4f1cba9 Signed-off-by: Sawyer Bergeron commit b3ed8ebcf536c021330e7ccbc0376f6b89189348 Author: Jacob Hodgdon Date: Tue Jul 20 09:55:04 2021 -0400 Additional changes for rebrand Signed-off-by: Jacob Hodgdon Change-Id: Ibd525f966b3ed3aebdbe688e5ee5daa4ea1cf294 Signed-off-by: Jacob Hodgdon Signed-off-by: Raven Hodgdon commit 17b6c7809771ac8bf3879add033d2169f671591a Author: Jacob Hodgdon Date: Fri May 14 15:42:56 2021 -0400 Color fixes for rebrand Signed-off-by: Jacob Hodgdon Change-Id: I5cf4ede598afa377db7ecec17d8dfef085e130ac Change-Id: I55494d24bcef74def85236b09168508e428d368e Signed-off-by: Sawyer Bergeron --- config.env.sample | 11 +- config/rabbitmq/rabbitmq.conf | 2 + docker-compose.yml | 4 +- laas_api_documentation.yaml | 401 +++++++++++++++++++++ requirements.txt | 23 +- src/account/jira_util.py | 65 ---- src/account/models.py | 1 + src/account/tasks.py | 37 -- src/account/urls.py | 36 +- src/account/views.py | 148 +------- src/api/admin.py | 2 + src/api/migrations/0020_apilog.py | 29 ++ src/api/models.py | 97 ++++- src/api/urls.py | 20 + src/api/views.py | 236 +++++++++++- src/booking/quick_deployer.py | 120 +++--- src/booking/stats.py | 2 +- src/booking/urls.py | 2 +- src/booking/views.py | 1 - src/dashboard/templatetags/jira_filters.py | 17 - src/dashboard/urls.py | 2 +- src/laas_dashboard/settings.py | 75 ++-- src/notifier/urls.py | 2 +- src/resource_inventory/resource_manager.py | 37 +- src/resource_inventory/urls.py | 2 +- src/static/css/anuket.css | 115 ++++++ src/static/css/lfedge.css | 14 + src/static/package-lock.json | 91 ++++- src/templates/base/account/configuration_list.html | 85 ----- src/templates/base/account/details.html | 1 - src/templates/base/base.html | 8 +- src/templates/base/booking/booking_delete.html | 1 - src/templates/base/booking/booking_table.html | 2 +- src/templates/laas/base.html | 78 +--- src/templates/lfedge/base.html | 18 +- src/templates/lfedge/booking/booking_table.html | 2 +- src/workflow/models.py | 2 +- web/Dockerfile | 2 +- worker/Dockerfile | 2 +- 39 files changed, 1188 insertions(+), 605 deletions(-) create mode 100644 config/rabbitmq/rabbitmq.conf create mode 100644 laas_api_documentation.yaml delete mode 100644 src/account/jira_util.py delete mode 100644 src/account/tasks.py create mode 100644 src/api/migrations/0020_apilog.py delete mode 100644 src/dashboard/templatetags/jira_filters.py create mode 100644 src/static/css/anuket.css create mode 100644 src/static/css/lfedge.css delete mode 100644 src/templates/base/account/configuration_list.html diff --git a/config.env.sample b/config.env.sample index 5b34217..c47f2bf 100644 --- a/config.env.sample +++ b/config.env.sample @@ -34,13 +34,6 @@ SECRET_KEY=http://www.miniwebtool.com/django-secret-key-generator/ OAUTH_CONSUMER_KEY=sample_key OAUTH_CONSUMER_SECRET=sample_secret -# access information for Jira -# In addition to this, the rsa keys from your jira admin -# need to go into src/account -JIRA_URL=sample_url -JIRA_USER_NAME=sample_jira_user -JIRA_USER_PASSWORD=sample_jira_pass - # LFID OIDC_CLIENT_ID=sample_id OIDC_CLIENT_SECRET=sample_secret @@ -55,8 +48,8 @@ OIDC_RP_SIGN_ALGO=RS256 OIDC_OP_JWKS_ENDPOINT=https://sso.linuxfoundation.org/.well-known/jwks.json # Rabbitmq -RABBITMQ_DEFAULT_USER=opnfv -RABBITMQ_DEFAULT_PASS=opnfvopnfv +DEFAULT_USER=opnfv +DEFAULT_PASS=opnfvopnfv # Jenkins Build Server JENKINS_URL=https://build.opnfv.org/ci diff --git a/config/rabbitmq/rabbitmq.conf b/config/rabbitmq/rabbitmq.conf new file mode 100644 index 0000000..39c222c --- /dev/null +++ b/config/rabbitmq/rabbitmq.conf @@ -0,0 +1,2 @@ +default_user=opnfv +default_pass=opnfvopnfv diff --git a/docker-compose.yml b/docker-compose.yml index ee8de2c..f0de7b2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,7 +50,9 @@ services: restart: always image: rabbitmq container_name: rm01 - env_file: config.env + #env_file: config.env + volumes: + - ./config/rabbitmq:/etc/rabbitmq ports: - "5672:5672" diff --git a/laas_api_documentation.yaml b/laas_api_documentation.yaml new file mode 100644 index 0000000..ee967b0 --- /dev/null +++ b/laas_api_documentation.yaml @@ -0,0 +1,401 @@ +swagger: '2.0' +info: + description: |- + Details for all endpoints for LaaS automation API. This serves to allow users + to create bookings outside of the web UI hosted at labs.lfnetworking.org. + All included setup is referencing the development server hosted while in + beta testing for the API. + version: 1.0.0 + title: LaaS Automation API + termsOfService: 'http://labs.lfnetworking.org' + contact: + email: opnfv@iol.unh.edu + license: + name: MIT License +host: 10.10.30.55 +basePath: /api +tags: + - name: Bookings + description: View and edit existing bookings + - name: Resource Inventory + description: Examine and manage resources in a lab + - name: Users + description: All actions for referencing +schemes: + - http +security: + - AutomationAPI: [] +paths: + /booking: + get: + tags: + - Bookings + summary: Get all bookings belonging to user + description: Get all bookings belonging to the user authenticated by API key. + operationId: retrieveBookings + produces: + - application/json + responses: + '200': + description: successful operation + schema: + type: array + items: + $ref: '#/definitions/Booking' + '401': + description: Unauthorized API key + /booking/makeBooking: + put: + tags: + - Bookings + summary: Make booking by specifying information + description: Exposes same functionality as quick booking form from dashboard + operationId: makeBooking + consumes: + - application/json + produces: + - application/json + parameters: + - in: body + name: booking + description: the booking to create + schema: + $ref: '#/definitions/MakeBookingTemplate' + responses: + '200': + description: successful operation + schema: + $ref: '#/definitions/Booking' + '400': + description: Error in booking info + '401': + description: Unauthorized API key + '/booking/{bookingID}': + get: + tags: + - Bookings + summary: See all info for specific booking + description: '' + operationId: specificBooking + parameters: + - in: path + name: bookingID + required: true + type: integer + produces: + - application/json + responses: + '200': + description: successful operation + schema: + $ref: '#/definitions/Booking' + '404': + description: Booking does not exist + '401': + description: Unauthorized API key + delete: + tags: + - Bookings + summary: Cancel booking + description: '' + operationId: cancelBooking + parameters: + - in: path + name: bookingID + required: true + type: integer + produces: + - application/json + responses: + '200': + description: successfully canceled booking + '404': + description: Booking does not exist + '400': + description: Cannnot cancel booking + '401': + description: Unauthorized API key + '/booking/{bookingID}/extendBooking/{days}': + post: + tags: + - Bookings + summary: Extend end date of booking + description: '' + operationId: extendBooking + parameters: + - in: path + name: bookingID + required: true + type: integer + - in: path + name: days + required: true + type: integer + responses: + '200': + description: successful operation + schema: + $ref: '#/definitions/Booking' + '404': + description: Booking to extend does not exist + '400': + description: Cannot extend Booking + '401': + description: Unauthorized API key + '/resource_inventory/{templateLabID}/images': + get: + tags: + - Resource Inventory + summary: See valid images for a resource template + description: '' + operationId: viewImages + parameters: + - in: path + name: templateLabID + required: true + type: integer + produces: + - application/json + responses: + '200': + description: successful operation + schema: + $ref: '#/definitions/Image' + '404': + description: Resource Template does not exist + '401': + description: Unauthorized API key + /resource_inventory/availableTemplates: + get: + tags: + - Resource Inventory + summary: All Resource Templates currently available + description: '' + operationId: listTemplates + produces: + - application/json + responses: + '200': + description: successful operation + schema: + $ref: '#/definitions/ResourceTemplate' + '401': + description: Unauthorized API key + /users: + get: + tags: + - Users + summary: See all public users that can be added to a booking + description: '' + operationId: getUsers + produces: + - application/json + responses: + '200': + description: successful operation + schema: + type: array + items: + $ref: '#/definitions/UserProfile' + '401': + description: Unauthorized API key + /labs: + get: + tags: + - Lab + summary: List all labs and some of their info + description: '' + operationId: listLabs + produces: + - application/json + responses: + '200': + description: successful operation + schema: + type: array + items: + $ref: '#/definitions/Lab' + '401': + description: Unauthorized API Key + /labs/{labID}/users: + get: + tags: + - Lab + summary: Get all users that are visible to a lab for operational purposes + description: '' + operationId: labUsers + consumes: + - application/json + produces: + - application/json + parameters: + - in: path + name: labID + required: true + type: string + responses: + '200': + description: successful + schema: array + items: + $ref: '#/definitions/UserProfile' + '400': + description: invalid lab id +securityDefinitions: + AutomationAPI: + type: apiKey + in: header + name: auth-token +definitions: + Lab: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + MakeBookingTemplate: + type: object + required: + - templateID + - purpose + - project + - collaborators + - hostname + - length + - imageLabID + properties: + templateID: + type: integer + purpose: + type: string + project: + type: string + collaborators: + type: array + items: + type: string + description: username of the referred user + hostname: + type: string + length: + type: integer + description: length of the booking in days (max 21, min 1) + imageLabID: + type: integer + Booking: + type: object + required: + - id + - owner + - collaborators + - start + - end + - lab + - purpose + - project + - resourceBundle + properties: + id: + type: integer + format: int64 + owner: + type: string + collaborators: + type: array + items: + $ref: '#/definitions/UserProfile' + start: + type: string + format: date-time + end: + type: string + format: date-time + lab: + $ref: '#/definitions/Lab' + purpose: + type: string + resourceBundle: + $ref: '#/definitions/ResourceBundle' + project: + type: string + Image: + type: object + required: + - labID + - resources + properties: + labID: + type: integer + format: int64 + name: + type: string + ResourceBundle: + type: object + required: + - id + - resources + properties: + id: + type: integer + format: int64 + resources: + type: array + items: + $ref: '#/definitions/Server' + ResourceProfile: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + UserProfile: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + ResourceTemplate: + type: object + required: + - id + - name + - resourceProfiles + properties: + id: + type: integer + format: int64 + name: + type: string + resourceProfiles: + type: array + items: + $ref: '#/definitions/ResourceProfile' + Server: + type: object + required: + - id + - labid + - profile + properties: + id: + type: integer + format: int64 + profile: + $ref: '#/definitions/ResourceProfile' + labid: + type: string diff --git a/requirements.txt b/requirements.txt index d93ac7c..4f4c6f7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,19 +1,18 @@ -celery==3.1.23 -cryptography==2.6.1 +celery==5.1.2 +cryptography==3.4.7 Django==2.2 django-bootstrap4==0.0.8 django-filter==2.0.0 djangorestframework==3.8.2 -gunicorn==19.6.0 -jira==1.0.7 +gunicorn==20.1.0 oauth2==1.9.0.post1 -oauthlib==1.1.2 -pika==0.10.0 -psycopg2==2.8.4 -PyJWT==1.4.2 -requests==2.22.0 +oauthlib==3.1.1 +pika==1.2.0 +psycopg2==2.8.6 +PyJWT==2.1.0 +requests==2.26.0 django-fernet-fields==0.6 -pyyaml==3.13 -pytz==2018.5 -mozilla-django-oidc==1.2.3 deepmerge==0.3 +pyyaml==5.4.1 +pytz==2021.1 +mozilla-django-oidc==2.0.0 diff --git a/src/account/jira_util.py b/src/account/jira_util.py deleted file mode 100644 index a522594..0000000 --- a/src/account/jira_util.py +++ /dev/null @@ -1,65 +0,0 @@ -############################################################################## -# Copyright (c) 2016 Max Breitenfeldt 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 -############################################################################## - - -import base64 -import os - -import oauth2 as oauth -from django.conf import settings -from jira import JIRA -from tlslite.utils import keyfactory - - -class SignatureMethod_RSA_SHA1(oauth.SignatureMethod): - name = 'RSA-SHA1' - - def signing_base(self, request, consumer, token): - if not hasattr(request, 'normalized_url') or request.normalized_url is None: - raise ValueError("Base URL for request is not set.") - - sig = ( - oauth.escape(request.method), - oauth.escape(request.normalized_url), - oauth.escape(request.get_normalized_parameters()), - ) - - key = '%s&' % oauth.escape(consumer.secret) - if token: - key += oauth.escape(token.secret) - raw = '&'.join(sig) - return key, raw - - def sign(self, request, consumer, token): - """Build the base signature string.""" - key, raw = self.signing_base(request, consumer, token) - - module_dir = os.path.dirname(__file__) # get current directory - with open(module_dir + '/rsa.pem', 'r') as f: - data = f.read() - privateKeyString = data.strip() - privatekey = keyfactory.parsePrivateKey(privateKeyString) - raw = str.encode(raw) - signature = privatekey.hashAndSign(raw) - return base64.b64encode(signature) - - -def get_jira(user): - module_dir = os.path.dirname(__file__) # get current directory - with open(module_dir + '/rsa.pem', 'r') as f: - key_cert = f.read() - - oauth_dict = { - 'access_token': user.userprofile.oauth_token, - 'access_token_secret': user.userprofile.oauth_secret, - 'consumer_key': settings.OAUTH_CONSUMER_KEY, - 'key_cert': key_cert - } - - return JIRA(server=settings.JIRA_URL, oauth=oauth_dict) diff --git a/src/account/models.py b/src/account/models.py index 2501be6..32229b1 100644 --- a/src/account/models.py +++ b/src/account/models.py @@ -51,6 +51,7 @@ class UserProfile(models.Model): oauth_secret = models.CharField(max_length=1024, blank=False) jira_url = models.CharField(max_length=100, null=True, blank=True, default='') + full_name = models.CharField(max_length=100, null=True, blank=True, default='') booking_privledge = models.BooleanField(default=False) diff --git a/src/account/tasks.py b/src/account/tasks.py deleted file mode 100644 index df98c73..0000000 --- a/src/account/tasks.py +++ /dev/null @@ -1,37 +0,0 @@ -############################################################################## -# Copyright (c) 2016 Max Breitenfeldt and others. -# Copyright (c) 2018 Parker Berberian, 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 celery import shared_task -from django.contrib.auth.models import User -from jira import JIRAError - -from account.jira_util import get_jira - - -@shared_task -def sync_jira_accounts(): - users = User.objects.all() - for user in users: - jira = get_jira(user) - try: - user_dict = jira.myself() - except JIRAError: - # User can be anonymous (local django admin account) - continue - try: - user.email = user_dict['emailAddress'] - except KeyError: - pass - user.userprofile.url = user_dict['self'] - user.userprofile.full_name = user_dict['displayName'] - - user.userprofile.save() - user.save() diff --git a/src/account/urls.py b/src/account/urls.py index 97d8c77..6d4ef2f 100644 --- a/src/account/urls.py +++ b/src/account/urls.py @@ -30,46 +30,30 @@ from django.urls import path from account.views import ( AccountSettingsView, - JiraAuthenticatedView, - JiraLoginView, OIDCLoginView, - JiraLogoutView, + LogoutView, UserListView, account_resource_view, account_booking_view, account_images_view, - account_configuration_view, account_detail_view, - resource_delete_view, + template_delete_view, booking_cancel_view, image_delete_view, - configuration_delete_view ) -from laas_dashboard import settings +app_name = 'account' - -def get_login_view(): - if (settings.AUTH_SETTING == 'LFID'): - return OIDCLoginView.as_view() - else: - return JiraLoginView.as_view() - - -app_name = "account" urlpatterns = [ url(r'^settings/', AccountSettingsView.as_view(), name='settings'), - url(r'^authenticated/$', JiraAuthenticatedView.as_view(), name='authenticated'), - url(r'^login/$', get_login_view(), name='login'), - url(r'^logout/$', JiraLogoutView.as_view(), name='logout'), + url(r'^login/$', OIDCLoginView.as_view(), name='login'), + url(r'^logout/$', LogoutView.as_view(), name='logout'), url(r'^users/$', UserListView.as_view(), name='users'), - url(r'^my/resources/$', account_resource_view, name="my-resources"), - path('my/resources/delete/', resource_delete_view), - url(r'^my/bookings/$', account_booking_view, name="my-bookings"), + url(r'^my/resources/$', account_resource_view, name='my-resources'), + path('my/resources/delete/', template_delete_view), + url(r'^my/bookings/$', account_booking_view, name='my-bookings'), path('my/bookings/cancel/', booking_cancel_view), - url(r'^my/images/$', account_images_view, name="my-images"), + url(r'^my/images/$', account_images_view, name='my-images'), path('my/images/delete/', image_delete_view), - url(r'^my/configurations/$', account_configuration_view, name="my-configurations"), - path('my/configurations/delete/', configuration_delete_view), - url(r'^my/$', account_detail_view, name="my-account"), + url(r'^my/$', account_detail_view, name='my-account'), ] diff --git a/src/account/views.py b/src/account/views.py index b74126e..8976ff9 100644 --- a/src/account/views.py +++ b/src/account/views.py @@ -10,13 +10,10 @@ import os -import urllib -import oauth2 as oauth -from django.conf import settings from django.utils import timezone from django.contrib import messages -from django.contrib.auth import logout, authenticate, login +from django.contrib.auth import logout from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.models import User @@ -26,13 +23,11 @@ from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator from django.views.generic import RedirectView, TemplateView, UpdateView from django.shortcuts import render -from jira import JIRA from rest_framework.authtoken.models import Token from mozilla_django_oidc.auth import OIDCAuthenticationBackend from account.forms import AccountSettingsForm -from account.jira_util import SignatureMethod_RSA_SHA1 from account.models import UserProfile from booking.models import Booking from resource_inventory.models import ResourceTemplate, Image @@ -69,7 +64,7 @@ class MyOIDCAB(OIDCAuthenticationBackend): If this changes we will need to match users based on some other criterea. """ - username = claims.get(os.environ['CLAIMS_ENDPOINT'] + 'username') + username = claims.get(os.environ.get('CLAIMS_ENDPOINT') + 'username') if not username: return HttpResponse('No username provided, contact support.') @@ -101,109 +96,17 @@ class MyOIDCAB(OIDCAuthenticationBackend): return user -class JiraLoginView(RedirectView): - def get_redirect_url(self, *args, **kwargs): - consumer = oauth.Consumer(settings.OAUTH_CONSUMER_KEY, settings.OAUTH_CONSUMER_SECRET) - client = oauth.Client(consumer) - client.set_signature_method(SignatureMethod_RSA_SHA1()) - - # Step 1. Get a request token from Jira. - try: - resp, content = client.request(settings.OAUTH_REQUEST_TOKEN_URL, "POST") - except Exception: - messages.add_message(self.request, messages.ERROR, - 'Error: Connection to Jira failed. Please contact an Administrator') - return '/' - if resp['status'] != '200': - messages.add_message(self.request, messages.ERROR, - 'Error: Connection to Jira failed. Please contact an Administrator') - return '/' - - # Step 2. Store the request token in a session for later use. - self.request.session['request_token'] = dict(urllib.parse.parse_qsl(content.decode())) - # Step 3. Redirect the user to the authentication URL. - url = settings.OAUTH_AUTHORIZE_URL + '?oauth_token=' + \ - self.request.session['request_token']['oauth_token'] + \ - '&oauth_callback=' + settings.OAUTH_CALLBACK_URL - return url - - class OIDCLoginView(RedirectView): def get_redirect_url(self, *args, **kwargs): return reverse('oidc_authentication_init') -class JiraLogoutView(LoginRequiredMixin, RedirectView): +class LogoutView(LoginRequiredMixin, RedirectView): def get_redirect_url(self, *args, **kwargs): logout(self.request) return '/' -class JiraAuthenticatedView(RedirectView): - def get_redirect_url(self, *args, **kwargs): - # Step 1. Use the request token in the session to build a new client. - consumer = oauth.Consumer(settings.OAUTH_CONSUMER_KEY, settings.OAUTH_CONSUMER_SECRET) - token = oauth.Token(self.request.session['request_token']['oauth_token'], - self.request.session['request_token']['oauth_token_secret']) - client = oauth.Client(consumer, token) - client.set_signature_method(SignatureMethod_RSA_SHA1()) - - # Step 2. Request the authorized access token from Jira. - try: - resp, content = client.request(settings.OAUTH_ACCESS_TOKEN_URL, "POST") - except Exception: - messages.add_message(self.request, messages.ERROR, - 'Error: Connection to Jira failed. Please contact an Administrator') - return '/' - if resp['status'] != '200': - messages.add_message(self.request, messages.ERROR, - 'Error: Connection to Jira failed. Please contact an Administrator') - return '/' - - access_token = dict(urllib.parse.parse_qsl(content.decode())) - - module_dir = os.path.dirname(__file__) # get current directory - with open(module_dir + '/rsa.pem', 'r') as f: - key_cert = f.read() - - oauth_dict = { - 'access_token': access_token['oauth_token'], - 'access_token_secret': access_token['oauth_token_secret'], - 'consumer_key': settings.OAUTH_CONSUMER_KEY, - 'key_cert': key_cert - } - - jira = JIRA(server=settings.JIRA_URL, oauth=oauth_dict) - username = jira.current_user() - email = "" - try: - email = jira.user(username).emailAddress - except AttributeError: - email = "" - url = '/' - # Step 3. Lookup the user or create them if they don't exist. - try: - user = User.objects.get(username=username) - except User.DoesNotExist: - # Save our permanent token and secret for later. - user = User.objects.create_user(username=username, - password=access_token['oauth_token_secret']) - profile = UserProfile() - profile.user = user - profile.save() - user.userprofile.email_addr = email - url = reverse('account:settings') - user.userprofile.oauth_token = access_token['oauth_token'] - user.userprofile.oauth_secret = access_token['oauth_token_secret'] - user.userprofile.save() - user.set_password(access_token['oauth_token_secret']) - user.save() - user = authenticate(username=username, password=access_token['oauth_token_secret']) - login(self.request, user) - # redirect user to settings page to complete profile - return url - - @method_decorator(login_required, name='dispatch') class UserListView(TemplateView): template_name = "account/user_list.html" @@ -232,9 +135,9 @@ def account_resource_view(request): template = "account/resource_list.html" active_bundles = [book.resource for book in Booking.objects.filter( - owner=request.user, end__gte=timezone.now())] + owner=request.user, end__gte=timezone.now(), resource__template__temporary=False)] active_resources = [bundle.template.id for bundle in active_bundles] - resource_list = list(ResourceTemplate.objects.filter(owner=request.user)) + resource_list = list(ResourceTemplate.objects.filter(owner=request.user, temporary=False)) context = { "resources": resource_list, @@ -262,15 +165,6 @@ def account_booking_view(request): return render(request, template, context=context) -def account_configuration_view(request): - if not request.user.is_authenticated: - return render(request, "dashboard/login.html", {'title': 'Authentication Required'}) - template = "account/configuration_list.html" - configs = list(ResourceTemplate.objects.filter(owner=request.user)) - context = {"title": "Configuration List", "configurations": configs} - return render(request, template, context=context) - - def account_images_view(request): if not request.user.is_authenticated: return render(request, "dashboard/login.html", {'title': 'Authentication Required'}) @@ -290,28 +184,18 @@ def account_images_view(request): return render(request, template, context=context) -def resource_delete_view(request, resource_id=None): - if not request.user.is_authenticated: - return HttpResponse('no') # 403? - grb = get_object_or_404(ResourceTemplate, pk=resource_id) - if not request.user.id == grb.owner.id: - return HttpResponse('no') # 403? - if Booking.objects.filter(resource__template=grb, end__gt=timezone.now()).exists(): - return HttpResponse('no') # 403? - grb.delete() - return HttpResponse('') - - -def configuration_delete_view(request, config_id=None): +def template_delete_view(request, resource_id=None): if not request.user.is_authenticated: - return HttpResponse('no') # 403? - config = get_object_or_404(ResourceTemplate, pk=config_id) - if not request.user.id == config.owner.id: - return HttpResponse('no') # 403? - if Booking.objects.filter(resource__template=config, end__gt=timezone.now()).exists(): - return HttpResponse('no') - config.delete() - return HttpResponse('') + return HttpResponse(status=403) + template = get_object_or_404(ResourceTemplate, pk=resource_id) + if not request.user.id == template.owner.id: + return HttpResponse(status=403) + if Booking.objects.filter(resource__template=template, end__gt=timezone.now()).exists(): + return HttpResponse(status=403) + template.public = False + template.temporary = True + template.save() + return HttpResponse(status=200) def booking_cancel_view(request, booking_id=None): 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/0020_apilog.py b/src/api/migrations/0020_apilog.py new file mode 100644 index 0000000..6acf352 --- /dev/null +++ b/src/api/migrations/0020_apilog.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2 on 2021-10-22 20:00 + +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', '0019_auto_20210907_1448'), + ] + + 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)), + ('method', models.CharField(max_length=6, null=True)), + ('endpoint', models.CharField(max_length=300, null=True)), + ('ip_addr', models.GenericIPAddressField(null=True)), + ('body', django.contrib.postgres.fields.jsonb.JSONField(null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/src/api/models.py b/src/api/models.py index 3b36b68..d4a7bb7 100644 --- a/src/api/models.py +++ b/src/api/models.py @@ -12,6 +12,7 @@ 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 @@ -41,7 +42,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. @@ -56,7 +57,7 @@ class JobStatus(object): ERROR = 300 -class LabManagerTracker(object): +class LabManagerTracker: @classmethod def get(cls, lab_name, token): @@ -76,7 +77,7 @@ class LabManagerTracker(object): raise PermissionDenied("Lab not authorized") -class LabManager(object): +class LabManager: """ Handles all lab REST calls. @@ -504,6 +505,96 @@ class GeneratedCloudConfig(models.Model): def serialize(self) -> str: return yaml.dump(self._to_dict()) +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. diff --git a/src/api/urls.py b/src/api/urls.py index 8dcfafe..5ffcc51 100644 --- a/src/api/urls.py +++ b/src/api/urls.py @@ -53,6 +53,14 @@ from api.views import ( all_opsyss, single_image, single_opsys, + user_bookings, + make_booking, + available_templates, + images_for_template, + specific_booking, + extend_booking, + all_users, + list_labs ) urlpatterns = [ @@ -79,5 +87,17 @@ urlpatterns = [ path('labs//jobs/getByType/DATA', analytics_job), path('labs//users', lab_users), path('labs//users/', lab_user), + + path('booking', user_bookings), + path('booking/', specific_booking), + path('booking//extendBooking/', extend_booking), + path('booking/makeBooking', make_booking), + + path('resource_inventory/availableTemplates', available_templates), + path('resource_inventory//images', images_for_template), + + 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 13bf1dd..fa96087 100644 --- a/src/api/views.py +++ b/src/api/views.py @@ -8,9 +8,14 @@ # 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 @@ -26,20 +31,24 @@ from api.serializers.old_serializers import UserSerializer from api.forms import DowntimeForm from account.models import UserProfile, Lab from booking.models import Booking -from api.models import LabManagerTracker, get_task, Job -from notifier.manager import NotificationHandler -from analytics.models import ActiveVPNUser from resource_inventory.models import ( Image, Opsys, CloudInitFile, ResourceQuery, + ResourceTemplate, ) import json import yaml import uuid from deepmerge import Merger +from api.models import LabManagerTracker, AutomationAPIManager, get_task, APILog, Job +from notifier.manager import NotificationHandler +from analytics.models import ActiveVPNUser +from booking.quick_deployer import create_from_API +from django.db.models import Q + """ API views. @@ -397,3 +406,222 @@ 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 += '
' + 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) + + +""" +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) diff --git a/src/booking/quick_deployer.py b/src/booking/quick_deployer.py index 2eb9fba..944d161 100644 --- a/src/booking/quick_deployer.py +++ b/src/booking/quick_deployer.py @@ -11,10 +11,11 @@ import json import yaml from django.db.models import Q +from django.db import transaction from datetime import timedelta from django.utils import timezone from django.core.exceptions import ValidationError -from account.models import Lab +from account.models import Lab, UserProfile from resource_inventory.models import ( ResourceTemplate, @@ -114,7 +115,7 @@ def update_template(old_template, image, hostname, user, global_cloud_config=Non image=image_to_set, template=template, is_head_node=old_config.is_head_node, - name=hostname if len(old_template.getConfigs()) == 1 else old_config.name, + name=hostname if (hostname and len(old_template.getConfigs()) == 1) else old_config.name, #cloud_init_files=old_config.cloud_init_files.set() ) @@ -177,13 +178,21 @@ def generate_resource_bundle(template): return resource_bundle -def check_invariants(request, **kwargs): +def check_invariants(**kwargs): # TODO: This should really happen in the BookingForm validation methods installer = kwargs['installer'] image = kwargs['image'] scenario = kwargs['scenario'] lab = kwargs['lab'] length = kwargs['length'] + + user = kwargs['owner'] + + if not user.userprofile: + raise ValidationError("The given owner did not have a userprofile, owner should be an enrolled user") + if not user.userprofile.ssh_public_key: + raise ValidationError("User has no uploaded ssh key. User should upload a key in settings") + # check that image os is compatible with installer if image: if image.from_lab != lab: @@ -191,7 +200,7 @@ def check_invariants(request, **kwargs): # TODO # if image.host_type != host_profile: # raise ValidationError("The chosen image is not available for the chosen host type") - if not image.public and image.owner != request.user: + if not image.public and image.owner != kwargs['owner']: raise ValidationError("You are not the owner of the chosen private image") if length < 1 or length > 21: raise BookingLengthException("Booking must be between 1 and 21 days long") @@ -206,76 +215,89 @@ def generate_cloud_configs(resource_bundle, global_cloud_config): def create_from_form(form, request): """ - Create a Booking from the user's form. - - Large, nasty method to create a booking or return a useful error - based on the form from the frontend + Parse data from QuickBookingForm to create booking """ resource_field = form.cleaned_data['filter_field'] - purpose_field = form.cleaned_data['purpose'] - project_field = form.cleaned_data['project'] - users_field = form.cleaned_data['users'] - hostname = 'opnfv_host' if not form.cleaned_data['hostname'] else form.cleaned_data['hostname'] - length = form.cleaned_data['length'] - global_cloud_config = None if not form.cleaned_data['global_cloud_config'] else form.cleaned_data['global_cloud_config'] - - if global_cloud_config: - try: - d = yaml.load(global_cloud_config) - if not (type(d) is dict): - raise Exception("CI file was valid yaml but was not a dict") - except Exception as e: - raise ValidationError("The provided Cloud Config is not valid yaml, please refer to the Cloud Init documentation for expected structure") - print("about to create global cloud config") - global_cloud_config = CloudInitFile.create(text=global_cloud_config, priority=CloudInitFile.objects.count()) - print("made global cloud config") - - image = form.cleaned_data['image'] - scenario = form.cleaned_data['scenario'] - installer = form.cleaned_data['installer'] lab, resource_template = parse_resource_field(resource_field) data = form.cleaned_data data['lab'] = lab data['resource_template'] = resource_template - check_invariants(request, **data) + data['owner'] = request.user + + return _create_booking(data) + + +def create_from_API(body, user): + """ + Parse data from Automation API to create booking + """ + booking_info = json.loads(body.decode('utf-8')) + + data = {} + data['purpose'] = booking_info['purpose'] + data['project'] = booking_info['project'] + data['users'] = [UserProfile.objects.get(user__username=username) + for username in booking_info['collaborators']] + data['hostname'] = booking_info['hostname'] + data['length'] = booking_info['length'] + data['installer'] = None + data['scenario'] = None + + data['image'] = Image.objects.get(pk=booking_info['imageLabID']) + + data['resource_template'] = ResourceTemplate.objects.get(pk=booking_info['templateID']) + data['lab'] = data['resource_template'].lab + data['owner'] = user + + return _create_booking(data) + +@transaction.atomic +def _create_booking(data): + check_invariants(**data) # check booking privileges # TODO: use the canonical booking_allowed method because now template might have multiple # machines - if Booking.objects.filter(owner=request.user, end__gt=timezone.now()).count() >= 3 and not request.user.userprofile.booking_privledge: + if Booking.objects.filter(owner=data['owner'], end__gt=timezone.now()).count() >= 3 and not data['owner'].userprofile.booking_privledge: raise PermissionError("You do not have permission to have more than 3 bookings at a time.") - ResourceManager.getInstance().templateIsReservable(resource_template) + global_cloud_config = None if not data['global_cloud_config'] else data['global_cloud_config'] - resource_template = update_template(resource_template, image, hostname, request.user, global_cloud_config=global_cloud_config) + reservable = ResourceManager.getInstance().templateIsReservable(data['resource_template']) + if not reservable: + raise ValidationError("The given template is not currently possible to reserve, try again later or try a different host") - # if no installer provided, just create blank host - opnfv_config = None - if installer: - hconf = resource_template.getConfigs()[0] - opnfv_config = generate_opnfvconfig(scenario, installer, resource_template) - generate_hostopnfv(hconf, opnfv_config) + if global_cloud_config: + try: + d = yaml.load(global_cloud_config) + if not (type(d) is dict): + raise Exception("CI file was valid yaml but was not a dict") + except Exception as e: + raise ValidationError("The provided Cloud Config is not valid yaml, please refer to the Cloud Init documentation for expected structure") + global_cloud_config = CloudInitFile.create(text=global_cloud_config, priority=CloudInitFile.objects.count()) - # generate resource bundle - resource_bundle = generate_resource_bundle(resource_template) + updated_template = update_template(data['resource_template'], data['image'], 'opnfv_host' if not data['hostname'] else data['hostname'], data['owner'], global_cloud_config) - #generate_cloud_configs(resource_bundle) + + # allocate hosts (as a resource bundle) according to template spec + resource_bundle = generate_resource_bundle(updated_template) # generate booking booking = Booking.objects.create( - purpose=purpose_field, - project=project_field, - lab=lab, - owner=request.user, + purpose=data['purpose'], + project=data['project'], + lab=data['lab'], + owner=data['owner'], start=timezone.now(), - end=timezone.now() + timedelta(days=int(length)), + end=timezone.now() + timedelta(days=int(data['length'])), resource=resource_bundle, - opnfv_config=opnfv_config + opnfv_config=None ) + booking.pdf = PDFTemplater.makePDF(booking) - for collaborator in users_field: # list of Users (not UserProfile) + for collaborator in data['users']: # list of UserProfiles booking.collaborators.add(collaborator.user) booking.save() diff --git a/src/booking/stats.py b/src/booking/stats.py index 626ed79..70f91fa 100644 --- a/src/booking/stats.py +++ b/src/booking/stats.py @@ -104,5 +104,5 @@ class StatisticsManager(object): "user": [x, users], "utils": [in_use, not_in_use, maintenance], "projects": [project_keys, project_counts], - "colors": anuket_colors if os.environ['TEMPLATE_OVERRIDE_DIR'] == 'laas' else lfedge_colors + "colors": anuket_colors if os.environ.get('TEMPLATE_OVERRIDE_DIR') == 'laas' else lfedge_colors } diff --git a/src/booking/urls.py b/src/booking/urls.py index cdf18ae..0b60351 100644 --- a/src/booking/urls.py +++ b/src/booking/urls.py @@ -38,7 +38,7 @@ from booking.views import ( booking_modify_image ) -app_name = "booking" +app_name = 'booking' urlpatterns = [ url(r'^detail/(?P[0-9]+)/$', booking_detail_view, name='detail'), url(r'^(?P[0-9]+)/$', booking_detail_view, name='booking_detail'), diff --git a/src/booking/views.py b/src/booking/views.py index e7ffc42..940428b 100644 --- a/src/booking/views.py +++ b/src/booking/views.py @@ -131,7 +131,6 @@ class ResourceBookingsJSON(View): 'start', 'end', 'purpose', - 'jira_issue_status', 'config_bundle__name' ) return JsonResponse({'bookings': list(bookings)}) diff --git a/src/dashboard/templatetags/jira_filters.py b/src/dashboard/templatetags/jira_filters.py deleted file mode 100644 index 9a97c1d..0000000 --- a/src/dashboard/templatetags/jira_filters.py +++ /dev/null @@ -1,17 +0,0 @@ -############################################################################## -# Copyright (c) 2016 Max Breitenfeldt 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 django.conf import settings -from django.template.defaultfilters import register - - -@register.filter -def jira_issue_url(issue): - return settings.JIRA_URL + '/browse/' + str(issue) diff --git a/src/dashboard/urls.py b/src/dashboard/urls.py index d5dad57..c87dacc 100644 --- a/src/dashboard/urls.py +++ b/src/dashboard/urls.py @@ -33,7 +33,7 @@ from dashboard.views import ( host_profile_detail_view ) -app_name = "dashboard" +app_name = 'dashboard' urlpatterns = [ url(r'^$', landing_view, name='index'), url(r'^lab/$', lab_list_view, name='all_labs'), diff --git a/src/laas_dashboard/settings.py b/src/laas_dashboard/settings.py index 6b3ed09..f253fa0 100644 --- a/src/laas_dashboard/settings.py +++ b/src/laas_dashboard/settings.py @@ -15,8 +15,8 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # SECURITY WARNING: don't run with debug turned on in production! # NOTE: os.environ only returns strings, so making a comparison to # 'True' here will convert it to the correct Boolean value. -DEBUG = os.environ['DEBUG'] == 'True' -TESTING = os.environ['TEST'] == 'True' +DEBUG = os.environ.get('DEBUG') == 'True' +TESTING = os.environ.get('TEST') == 'True' # Application definition @@ -53,29 +53,34 @@ MIDDLEWARE = [ 'account.middleware.TimezoneMiddleware', ] -AUTH_SETTING = os.environ.get('AUTH_SETTING', 'JIRA') -if AUTH_SETTING == 'LFID': - AUTHENTICATION_BACKENDS = ['account.views.MyOIDCAB'] +# AUTHENTICATION_BACKENDS = ['django.contrib.auth.backends.ModelBackend', 'account.views.MyOIDCAB'] +AUTHENTICATION_BACKENDS = ['django.contrib.auth.backends.ModelBackend'] + +AUTH_SETTING = os.environ.get('AUTH_SETTING') +if AUTH_SETTING == 'LFID': # OpenID Authentications - OIDC_RP_CLIENT_ID = os.environ['OIDC_CLIENT_ID'] - OIDC_RP_CLIENT_SECRET = os.environ['OIDC_CLIENT_SECRET'] + AUTHENTICATION_BACKENDS.append('account.views.MyOIDCAB') + OIDC_RP_CLIENT_ID = os.environ.get('OIDC_CLIENT_ID') + OIDC_RP_CLIENT_SECRET = os.environ.get('OIDC_CLIENT_SECRET') - OIDC_OP_AUTHORIZATION_ENDPOINT = os.environ['OIDC_AUTHORIZATION_ENDPOINT'] - OIDC_OP_TOKEN_ENDPOINT = os.environ['OIDC_TOKEN_ENDPOINT'] - OIDC_OP_USER_ENDPOINT = os.environ['OIDC_USER_ENDPOINT'] + OIDC_OP_AUTHORIZATION_ENDPOINT = os.environ.get('OIDC_AUTHORIZATION_ENDPOINT') + OIDC_OP_TOKEN_ENDPOINT = os.environ.get('OIDC_TOKEN_ENDPOINT') + OIDC_OP_USER_ENDPOINT = os.environ.get('OIDC_USER_ENDPOINT') - LOGIN_REDIRECT_URL = os.environ['DASHBOARD_URL'] - LOGOUT_REDIRECT_URL = os.environ['DASHBOARD_URL'] + LOGIN_REDIRECT_URL = os.environ.get('DASHBOARD_URL') + LOGOUT_REDIRECT_URL = os.environ.get('DASHBOARD_URL') - OIDC_RP_SIGN_ALGO = os.environ["OIDC_RP_SIGN_ALGO"] + OIDC_RP_SIGN_ALGO = os.environ.get("OIDC_RP_SIGN_ALGO") if OIDC_RP_SIGN_ALGO == "RS256": - OIDC_OP_JWKS_ENDPOINT = os.environ["OIDC_OP_JWKS_ENDPOINT"] + OIDC_OP_JWKS_ENDPOINT = os.environ.get("OIDC_OP_JWKS_ENDPOINT") +else: + raise Exception('AUTH_SETTING set to invalid value') # This is for LFID auth setups w/ an HTTPS proxy -if os.environ['EXPECT_HOST_FORWARDING'] == 'True': +if os.environ.get('EXPECT_HOST_FORWARDING') == 'True': SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', "https") USE_X_FORWARDED_HOST = True @@ -162,7 +167,7 @@ STATICFILES_DIRS = [ LOGIN_REDIRECT_URL = '/' # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = os.environ['SECRET_KEY'] +SECRET_KEY = os.environ.get('SECRET_KEY') BOOTSTRAP3 = { 'set_placeholder': False, @@ -175,11 +180,11 @@ ALLOWED_HOSTS = ['*'] DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql', - 'NAME': os.environ['DB_NAME'], - 'USER': os.environ['DB_USER'], - 'PASSWORD': os.environ['DB_PASS'], - 'HOST': os.environ['DB_SERVICE'], - 'PORT': os.environ['DB_PORT'] + 'NAME': os.environ.get('DB_NAME'), + 'USER': os.environ.get('DB_USER'), + 'PASSWORD': os.environ.get('DB_PASS'), + 'HOST': os.environ.get('DB_SERVICE'), + 'PORT': os.environ.get('DB_PORT') } } @@ -198,27 +203,17 @@ REST_FRAMEWORK = { MEDIA_ROOT = '/media' STATIC_ROOT = '/static' -# Jira Settings -CREATE_JIRA_TICKET = False - -JIRA_URL = os.environ['JIRA_URL'] - -JIRA_USER_NAME = os.environ['JIRA_USER_NAME'] -JIRA_USER_PASSWORD = os.environ['JIRA_USER_PASSWORD'] - -OAUTH_CONSUMER_KEY = os.environ['OAUTH_CONSUMER_KEY'] -OAUTH_CONSUMER_SECRET = os.environ['OAUTH_CONSUMER_SECRET'] - -OAUTH_REQUEST_TOKEN_URL = JIRA_URL + '/plugins/servlet/oauth/request-token' -OAUTH_ACCESS_TOKEN_URL = JIRA_URL + '/plugins/servlet/oauth/access-token' -OAUTH_AUTHORIZE_URL = JIRA_URL + '/plugins/servlet/oauth/authorize' +OAUTH_CONSUMER_KEY = os.environ.get('OAUTH_CONSUMER_KEY') +OAUTH_CONSUMER_SECRET = os.environ.get('OAUTH_CONSUMER_SECRET') -OAUTH_CALLBACK_URL = os.environ['DASHBOARD_URL'] + '/accounts/authenticated' +OAUTH_CALLBACK_URL = os.environ.get('DASHBOARD_URL') + '/accounts/authenticated' # Celery Settings CELERY_TIMEZONE = 'UTC' RABBITMQ_URL = 'rabbitmq' +# RABBITMQ_DEFAULT_USER = os.environ['DEFAULT_USER'] +# RABBITMQ_DEFAULT_PASS = os.environ['DEFAULT_PASS'] RABBITMQ_DEFAULT_USER = os.environ['RABBITMQ_DEFAULT_USER'] RABBITMQ_DEFAULT_PASS = os.environ['RABBITMQ_DEFAULT_PASS'] @@ -248,10 +243,10 @@ CELERYBEAT_SCHEDULE = { } # Notifier Settings -EMAIL_HOST = os.environ['EMAIL_HOST'] -EMAIL_PORT = os.environ['EMAIL_PORT'] -EMAIL_HOST_USER = os.environ['EMAIL_HOST_USER'] -EMAIL_HOST_PASSWORD = os.environ['EMAIL_HOST_PASSWORD'] +EMAIL_HOST = os.environ.get('EMAIL_HOST') +EMAIL_PORT = os.environ.get('EMAIL_PORT') +EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER') +EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD') EMAIL_USE_TLS = True DEFAULT_EMAIL_FROM = os.environ.get('DEFAULT_EMAIL_FROM', 'webmaster@localhost') SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies" diff --git a/src/notifier/urls.py b/src/notifier/urls.py index fedb9e8..923cc33 100644 --- a/src/notifier/urls.py +++ b/src/notifier/urls.py @@ -12,7 +12,7 @@ from django.conf.urls import url from notifier.views import InboxView, NotificationView -app_name = "notifier" +app_name = 'notifier' urlpatterns = [ url(r'^$', InboxView, name='messages'), url(r'^notification/(?P[0-9]+)/$', NotificationView, name='notifier_single') diff --git a/src/resource_inventory/resource_manager.py b/src/resource_inventory/resource_manager.py index 14a118c..77fc3f8 100644 --- a/src/resource_inventory/resource_manager.py +++ b/src/resource_inventory/resource_manager.py @@ -6,20 +6,28 @@ # which accompanies this distribution, and is available at # http://www.apache.org/licenses/LICENSE-2.0 ############################################################################## +from __future__ import annotations + import re +from typing import Optional from django.db.models import Q from dashboard.exceptions import ResourceAvailabilityException from resource_inventory.models import ( + Resource, ResourceBundle, ResourceTemplate, + ResourceConfiguration, Network, Vlan, PhysicalNetwork, InterfaceConfiguration, ) +from account.models import Lab +from django.contrib.auth.models import User + class ResourceManager: @@ -29,19 +37,19 @@ class ResourceManager: pass @staticmethod - def getInstance(): + def getInstance() -> ResourceManager: if ResourceManager.instance is None: ResourceManager.instance = ResourceManager() return ResourceManager.instance - def getAvailableResourceTemplates(self, lab, user=None): + def getAvailableResourceTemplates(self, lab: Lab, user: Optional[User] = None) -> list[ResourceTemplate]: filter = Q(public=True) if user: filter = filter | Q(owner=user) filter = filter & Q(temporary=False) & Q(lab=lab) return ResourceTemplate.objects.filter(filter) - def templateIsReservable(self, resource_template): + def templateIsReservable(self, resource_template: ResourceTemplate): """ Check if the required resources to reserve this template is available. @@ -63,13 +71,16 @@ class ResourceManager: return True # public interface - def deleteResourceBundle(self, resourceBundle): + def deleteResourceBundle(self, resourceBundle: ResourceBundle): raise NotImplementedError("Resource Bundle Deletion Not Implemented") - def releaseResourceBundle(self, resourceBundle): + def releaseResourceBundle(self, resourceBundle: ResourceBundle): resourceBundle.release() - def get_vlans(self, resourceTemplate): + def get_vlans(self, resourceTemplate: ResourceTemplate) -> dict[str, int]: + """ + returns: dict from network name to the associated vlan number (backend vlan id) + """ networks = {} vlan_manager = resourceTemplate.lab.vlan_manager for network in resourceTemplate.networks.all(): @@ -85,7 +96,7 @@ class ResourceManager: networks[network.name] = vlans[0] return networks - def instantiateTemplate(self, resource_template): + def instantiateTemplate(self, resource_template: ResourceTemplate): """ Convert a ResourceTemplate into a ResourceBundle. @@ -114,16 +125,18 @@ class ResourceManager: return resource_bundle - def configureNetworking(self, resource_bundle, resource, vlan_map): + def configureNetworking(self, resource_bundle: ResourceBundle, resource: Resource, vlan_map: dict[str, int]): + """ + @vlan_map: dict from network name to the associated vlan number (backend vlan id) + """ for physical_interface in resource.interfaces.all(): - # assign interface configs - iface_configs = InterfaceConfiguration.objects.filter( + # assign interface configs + iface_config = InterfaceConfiguration.objects.get( profile=physical_interface.profile, resource_config=resource.config ) - iface_config = iface_configs.first() physical_interface.acts_as = iface_config physical_interface.acts_as.save() @@ -144,7 +157,7 @@ class ResourceManager: ) # private interface - def acquireHost(self, resource_config): + def acquireHost(self, resource_config: ResourceConfiguration) -> Resource: resources = resource_config.profile.get_resources( lab=resource_config.template.lab, unreserved=True diff --git a/src/resource_inventory/urls.py b/src/resource_inventory/urls.py index a008176..a9a4d43 100644 --- a/src/resource_inventory/urls.py +++ b/src/resource_inventory/urls.py @@ -29,7 +29,7 @@ from django.conf.urls import url from resource_inventory.views import HostView, hostprofile_detail_view -app_name = "resource" +app_name = 'resource' urlpatterns = [ url(r'^hosts$', HostView.as_view(), name='hosts'), url(r'^profiles/(?P.+)/$', hostprofile_detail_view, name='host_detail'), diff --git a/src/static/css/anuket.css b/src/static/css/anuket.css new file mode 100644 index 0000000..6bbdb3f --- /dev/null +++ b/src/static/css/anuket.css @@ -0,0 +1,115 @@ +nav ,body{ + background-color:#fff !important; + color:#343a40 !important; + } + + header{ + background-color:#f8f9fa !important; + color:#343a40 !important; + } + + p, h1, h2, h3, h4, h5{ + color:#343a40 !important; + } + + a, .page-link { + color: #007473 !important; + } + + .page-item.active .page-link{ + color: #f8f9fa !important; + background-color: #007473 !important; + } + + .topcrumb.active > span { + background: #007473 !important; + } + + .nav-bg{ + background-color:#fff !important; + color:#343a40 !important; + } + + .nav-bg:hover{ + background-color:#f8f9fa !important; + transition-duration:0.2s; + } + + .dropDown-bg{ + background-color:#d6d8db !important; + color:#343a40 !important; + } + + .btn-primary{ + color: #f8f9fa !important; + background-color: #007473 !important; + border:0px !important; + transition-duration:0.2s !important; + } + + .btn-primary:hover{ + color: #343a40 !important; + background-color: #6BDAD5 !important; + border:0px !important; + } + + .btn-primary:focus{ + color: #343a40 !important; + background-color: #6BDAD5 !important; + border:0px !important; + } + + .btn-success{ + color: #f8f9fa; + background-color: #008852; + border:0px !important; + } + + .btn-success:hover{ + color: #343a40; + background-color: #00CE7C; + border:0px !important; + } + + .btn-success:focus{ + color: #343a40; + background-color: #00CE7C; + border:0px !important; + } + + .btn-danger { + color: #f8f9fa; + background-color: #af2b38; + border:0px !important; + } + + .btn-danger:hover { + color: #f8f9fa; + background-color: #dc3545; + border:0px !important; + } + + .alert-danger{ + background-color: #e6b3c1 !important; + color:#820c2c !important; + border:0px !important; + } + + .Anuket-Text{ + color:#343a40 !important; + } + + .selected_node { + border-color: #008852; + box-shadow: 0 1px 1px rgb(0 0 0 / 8%), 0 0 8px rgb(109 243 76 / 60%); + transition: border-color ease-in-out .1s,box-shadow ease-in-out .1s; + } + + ::selection { + background: #BCE194; + color:#343a40; + } + ::-moz-selection { + background: #BCE194; + color:#343a40; + } \ No newline at end of file diff --git a/src/static/css/lfedge.css b/src/static/css/lfedge.css new file mode 100644 index 0000000..328d71a --- /dev/null +++ b/src/static/css/lfedge.css @@ -0,0 +1,14 @@ +.LFEdge { + background: #0049b0; + margin-left: -25px; +} + +.wtext { + font-size: 18px; + color: #FFFFFF; +} + +.wtext:hover { + color: #FFFFFF; + text-decoration: none; +} \ No newline at end of file diff --git a/src/static/package-lock.json b/src/static/package-lock.json index f8eabe4..89a26db 100644 --- a/src/static/package-lock.json +++ b/src/static/package-lock.json @@ -1,8 +1,97 @@ { "name": "laas", "version": "1.0.0", - "lockfileVersion": 1, + "lockfileVersion": 2, "requires": true, + "packages": { + "": { + "name": "laas", + "version": "1.0.0", + "license": "Apache-2.0", + "dependencies": { + "@fortawesome/fontawesome-free": "^5.12.0", + "bootstrap": "^4.4.1", + "datatables.net-bs4": "^1.10.20", + "datatables.net-responsive-bs4": "^2.2.3", + "jquery": "^3.4.1", + "mxgraph": "^4.0.6", + "plotly.js-dist": "^1.51.3", + "popper.js": "^1.16.0" + } + }, + "node_modules/@fortawesome/fontawesome-free": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.12.0.tgz", + "integrity": "sha512-vKDJUuE2GAdBERaQWmmtsciAMzjwNrROXA5KTGSZvayAsmuTGjam5z6QNqNPCwDfVljLWuov1nEC3mEQf/n6fQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/bootstrap": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.4.1.tgz", + "integrity": "sha512-tbx5cHubwE6e2ZG7nqM3g/FZ5PQEDMWmMGNrCUBVRPHXTJaH7CBDdsLeu3eCh3B1tzAxTnAbtmrzvWEvT2NNEA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/datatables.net": { + "version": "1.10.20", + "resolved": "https://registry.npmjs.org/datatables.net/-/datatables.net-1.10.20.tgz", + "integrity": "sha512-4E4S7tTU607N3h0fZPkGmAtr9mwy462u+VJ6gxYZ8MxcRIjZqHy3Dv1GNry7i3zQCktTdWbULVKBbkAJkuHEnQ==", + "dependencies": { + "jquery": "3.4.1" + } + }, + "node_modules/datatables.net-bs4": { + "version": "1.10.20", + "resolved": "https://registry.npmjs.org/datatables.net-bs4/-/datatables.net-bs4-1.10.20.tgz", + "integrity": "sha512-kQmMUMsHMOlAW96ztdoFqjSbLnlGZQ63iIM82kHbmldsfYdzuyhbb4hTx6YNBi481WCO3iPSvI6YodNec46ZAw==", + "dependencies": { + "datatables.net": "1.10.20", + "jquery": "3.4.1" + } + }, + "node_modules/datatables.net-responsive": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/datatables.net-responsive/-/datatables.net-responsive-2.2.3.tgz", + "integrity": "sha512-8D6VtZcyuH3FG0Hn5A4LPZQEOX3+HrRFM7HjpmsQc/nQDBbdeBLkJX4Sh/o1nzFTSneuT1Wh/lYZHVPpjcN+Sw==", + "dependencies": { + "datatables.net": "1.10.20", + "jquery": "3.4.1" + } + }, + "node_modules/datatables.net-responsive-bs4": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/datatables.net-responsive-bs4/-/datatables.net-responsive-bs4-2.2.3.tgz", + "integrity": "sha512-SQaWI0uLuPcaiBBin9zX+MuQfTSIkK1bYxbXqUV6NLkHCVa6PMQK7Rvftj0ywG4R7uOtjbzY8nSVqxEKvQI0Vg==", + "dependencies": { + "datatables.net-bs4": "1.10.20", + "datatables.net-responsive": "2.2.3", + "jquery": "3.4.1" + } + }, + "node_modules/jquery": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.4.1.tgz", + "integrity": "sha512-36+AdBzCL+y6qjw5Tx7HgzeGCzC81MDDgaUP8ld2zhx58HdqXGoBd+tHdrBMiyjGQs0Hxs/MLZTu/eHNJJuWPw==" + }, + "node_modules/mxgraph": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/mxgraph/-/mxgraph-4.0.6.tgz", + "integrity": "sha512-5XZXeAkA4k6n4BS05Fxd2cNhMw+3dnlRqAaLtsuXdT0g8BvvEa1VT4jjuGtUW4QTt38Q+I2Dr/3EWiAaGRfAXw==" + }, + "node_modules/plotly.js-dist": { + "version": "1.51.3", + "resolved": "https://registry.npmjs.org/plotly.js-dist/-/plotly.js-dist-1.51.3.tgz", + "integrity": "sha512-Bxz0XBg963gpnbt7FVPEhYvT33JsaKa0hEozXBnQZkiKtsiM2M1lZN6tkEHmq6o1N2K6qJXFtdzCXbZ/hLGV0Q==" + }, + "node_modules/popper.js": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.0.tgz", + "integrity": "sha512-+G+EkOPoE5S/zChTpmBSSDYmhXJ5PsW8eMhH8cP/CQHMFPBG/kC9Y5IIw6qNYgdJ+/COf0ddY2li28iHaZRSjw==" + } + }, "dependencies": { "@fortawesome/fontawesome-free": { "version": "5.12.0", diff --git a/src/templates/base/account/configuration_list.html b/src/templates/base/account/configuration_list.html deleted file mode 100644 index fee6e83..0000000 --- a/src/templates/base/account/configuration_list.html +++ /dev/null @@ -1,85 +0,0 @@ -{% extends "base.html" %} -{% block content %} -
-{% for config in configurations %} -
-
-
-

Configuration {{config.id}}

-
-
    -
  • id: {{config.id}}
  • -
  • name: {{config.name}}
  • -
  • description: {{config.description}}
  • -
  • resource: {{config.bundle}}
  • -
- -
-
-{% empty %} -
-

You don't have any configurations. You can create a configuration by configuring a pod.

-
-{% endfor %} -
- - - - -{% endblock %} diff --git a/src/templates/base/account/details.html b/src/templates/base/account/details.html index 3092ad0..ad59c9a 100644 --- a/src/templates/base/account/details.html +++ b/src/templates/base/account/details.html @@ -4,6 +4,5 @@

Account Details

My Resources My Bookings -My Configurations My Snapshots {% endblock content %} diff --git a/src/templates/base/base.html b/src/templates/base/base.html index a628ab4..704bc3b 100644 --- a/src/templates/base/base.html +++ b/src/templates/base/base.html @@ -87,7 +87,7 @@ {% else %} - Login with Jira + Login {% endif %} {% endif %} @@ -156,12 +156,6 @@ My Bookings - - My Configurations - - - My Snapshots - Lab Info diff --git a/src/templates/base/booking/booking_delete.html b/src/templates/base/booking/booking_delete.html index b89eb15..4afa370 100644 --- a/src/templates/base/booking/booking_delete.html +++ b/src/templates/base/booking/booking_delete.html @@ -1,4 +1,3 @@ -{% load jira_filters %} {% load bootstrap4 %}

diff --git a/src/templates/base/booking/booking_table.html b/src/templates/base/booking/booking_table.html index 1b95433..b4a713a 100644 --- a/src/templates/base/booking/booking_table.html +++ b/src/templates/base/booking/booking_table.html @@ -1,4 +1,4 @@ -{% load jira_filters %} + diff --git a/src/templates/laas/base.html b/src/templates/laas/base.html index f980268..b99eb59 100644 --- a/src/templates/laas/base.html +++ b/src/templates/laas/base.html @@ -1,85 +1,11 @@ {% extends "base/base.html" %} {% load staticfiles %} {% block logo %} - - +

- + Anuket logo LaaS Dashboard diff --git a/src/templates/lfedge/base.html b/src/templates/lfedge/base.html index 64c05a4..4413340 100644 --- a/src/templates/lfedge/base.html +++ b/src/templates/lfedge/base.html @@ -1,29 +1,15 @@ {% extends "base/base.html" %} {% load staticfiles %} {% block bgColor %} -