Merge pull request #2 from ungleich/opennebula-integration
Opennebula integration
This commit is contained in:
commit
56e6e43742
9 changed files with 374 additions and 23 deletions
11
.travis.yml
Normal file
11
.travis.yml
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
language: python
|
||||||
|
python:
|
||||||
|
- "3.5"
|
||||||
|
- "3.6"
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Set a dummy secret key
|
||||||
|
- DJANGO_SECRET_KEY=0
|
||||||
|
# install dependencies
|
||||||
|
install: "pip install -r requirements.txt"
|
||||||
|
script: python manage.py test
|
|
@ -474,3 +474,30 @@ else:
|
||||||
|
|
||||||
ANONYMOUS_USER_NAME = 'anonymous@ungleich.ch'
|
ANONYMOUS_USER_NAME = 'anonymous@ungleich.ch'
|
||||||
GUARDIAN_GET_INIT_ANONYMOUS_USER = 'membership.models.get_anonymous_user_instance'
|
GUARDIAN_GET_INIT_ANONYMOUS_USER = 'membership.models.get_anonymous_user_instance'
|
||||||
|
|
||||||
|
|
||||||
|
#############################################
|
||||||
|
# configurations for opennebula-integration #
|
||||||
|
#############################################
|
||||||
|
|
||||||
|
# The oneadmin user name of the OpenNebula infrastructure
|
||||||
|
OPENNEBULA_USERNAME = env('OPENNEBULA_USERNAME')
|
||||||
|
|
||||||
|
# The oneadmin password of the OpenNebula infrastructure
|
||||||
|
# The default credentials of the Sandbox OpenNebula VM is
|
||||||
|
# oneadmin:opennebula
|
||||||
|
OPENNEBULA_PASSWORD = env('OPENNEBULA_PASSWORD')
|
||||||
|
|
||||||
|
# The protocol is generally http or https
|
||||||
|
OPENNEBULA_PROTOCOL = env('OPENNEBULA_PROTOCOL')
|
||||||
|
|
||||||
|
# The ip address or the domain name of the opennebula infrastructure
|
||||||
|
OPENNEBULA_DOMAIN = env('OPENNEBULA_DOMAIN')
|
||||||
|
|
||||||
|
# The port to connect in order to send an xmlrpc request. The default
|
||||||
|
# port is 2633
|
||||||
|
OPENNEBULA_PORT = env('OPENNEBULA_PORT')
|
||||||
|
|
||||||
|
# The endpoint to which the XML RPC request needs to be sent to. The
|
||||||
|
# default value is /RPC2
|
||||||
|
OPENNEBULA_ENDPOINT = env('OPENNEBULA_ENDPOINT')
|
||||||
|
|
41
hosting/README-opennebula-integration.md
Normal file
41
hosting/README-opennebula-integration.md
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
Here are the steps to follow for running opennebula-integration correctly.
|
||||||
|
|
||||||
|
1. Install [python-oca](https://github.com/python-oca/python-oca)
|
||||||
|
This is the library that allows sending XMLRPC commands to OpenNebula. Unfortunately, the latest version of oca available in Python package index is not compatible with python 3.5. Hence, one would need to download the latest version from the above github link and install it from there.
|
||||||
|
Assuming virtualenv is located at ~/python/env
|
||||||
|
|
||||||
|
```
|
||||||
|
~/python/env/bin/python setup.py build
|
||||||
|
sudo ~/python/env/bin/python setup.py install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Setup opennebula parameters in the `.env` file.
|
||||||
|
|
||||||
|
```
|
||||||
|
#############################################
|
||||||
|
# configurations for opennebula-integration #
|
||||||
|
#############################################
|
||||||
|
|
||||||
|
# The oneadmin user name of the OpenNebula infrastructure
|
||||||
|
OPENNEBULA_USERNAME='oneadmin'
|
||||||
|
|
||||||
|
# The oneadmin password of the OpenNebula infrastructure
|
||||||
|
# The default credentials of the Sandbox OpenNebula VM is
|
||||||
|
# oneadmin:opennebula
|
||||||
|
OPENNEBULA_PASSWORD='opennebula'
|
||||||
|
|
||||||
|
# The protocol is generally http or https
|
||||||
|
OPENNEBULA_PROTOCOL='http'
|
||||||
|
|
||||||
|
# The ip address or the domain name of the opennebula infrastructure
|
||||||
|
OPENNEBULA_DOMAIN='192.168.182.124'
|
||||||
|
|
||||||
|
# The port to connect in order to send an xmlrpc request. The default
|
||||||
|
# port is 2633
|
||||||
|
OPENNEBULA_PORT='2633'
|
||||||
|
|
||||||
|
# The endpoint to which the XML RPC request needs to be sent to. The
|
||||||
|
# default value is /RPC2
|
||||||
|
OPENNEBULA_ENDPOINT='/RPC2'
|
||||||
|
```
|
||||||
|
|
|
@ -5,7 +5,8 @@ from django.core.urlresolvers import reverse
|
||||||
from utils.mailer import BaseEmail
|
from utils.mailer import BaseEmail
|
||||||
|
|
||||||
from .forms import HostingOrderAdminForm
|
from .forms import HostingOrderAdminForm
|
||||||
from .models import VirtualMachineType, VirtualMachinePlan, HostingOrder
|
from .models import VirtualMachineType, VirtualMachinePlan, HostingOrder, ManageVM
|
||||||
|
from .opennebula_functions import HostingManageVMAdmin
|
||||||
|
|
||||||
|
|
||||||
class HostingOrderAdmin(admin.ModelAdmin):
|
class HostingOrderAdmin(admin.ModelAdmin):
|
||||||
|
@ -96,3 +97,4 @@ class VirtualMachinePlanAdmin(admin.ModelAdmin):
|
||||||
admin.site.register(HostingOrder, HostingOrderAdmin)
|
admin.site.register(HostingOrder, HostingOrderAdmin)
|
||||||
admin.site.register(VirtualMachineType)
|
admin.site.register(VirtualMachineType)
|
||||||
admin.site.register(VirtualMachinePlan, VirtualMachinePlanAdmin)
|
admin.site.register(VirtualMachinePlan, VirtualMachinePlanAdmin)
|
||||||
|
admin.site.register(ManageVM, HostingManageVMAdmin)
|
||||||
|
|
|
@ -224,13 +224,12 @@ class HostingOrder(AssignPermissionsMixin, models.Model):
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
|
class ManageVM(models.Model):
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
managed = False
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
230
hosting/opennebula_functions.py
Normal file
230
hosting/opennebula_functions.py
Normal file
|
@ -0,0 +1,230 @@
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import socket
|
||||||
|
import string
|
||||||
|
|
||||||
|
import oca
|
||||||
|
from django.conf import settings
|
||||||
|
from django.conf.urls import url
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.template.response import TemplateResponse
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
from oca.exceptions import OpenNebulaException
|
||||||
|
from oca.pool import WrongNameError
|
||||||
|
|
||||||
|
# Get an instance of a logger
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class HostingManageVMAdmin(admin.ModelAdmin):
|
||||||
|
client = None
|
||||||
|
oneadmin_client = None
|
||||||
|
|
||||||
|
def get_urls(self):
|
||||||
|
urls = super().get_urls()
|
||||||
|
my_urls = [
|
||||||
|
url(r'^$', self.admin_site.admin_view(self.show_vms, cacheable=True), name='showvms'),
|
||||||
|
url(r'^create_vm/$', self.admin_site.admin_view(self.create_vm, cacheable=True), name='createvm'),
|
||||||
|
url(r'^delete_vm/(?P<vmid>\d+)/$', self.admin_site.admin_view(self.delete_vm, cacheable=True),
|
||||||
|
name='deletevm'),
|
||||||
|
url(r'^stop_vm/(?P<vmid>\d+)/$', self.admin_site.admin_view(self.stop_vm, cacheable=True), name='stopvm'),
|
||||||
|
url(r'^start_vm/(?P<vmid>\d+)/$', self.admin_site.admin_view(self.start_vm, cacheable=True),
|
||||||
|
name='startvm'),
|
||||||
|
]
|
||||||
|
return my_urls + urls
|
||||||
|
|
||||||
|
# Function to initialize opennebula client based on the logged in
|
||||||
|
# user
|
||||||
|
def init_opennebula_client(self, request):
|
||||||
|
if self.oneadmin_client is None:
|
||||||
|
self.oneadmin_client = oca.Client("{0}:{1}".format(settings.OPENNEBULA_USERNAME,
|
||||||
|
settings.OPENNEBULA_PASSWORD),
|
||||||
|
"{protocol}://{domain}:{port}{endpoint}".format(
|
||||||
|
protocol=settings.OPENNEBULA_PROTOCOL,
|
||||||
|
domain=settings.OPENNEBULA_DOMAIN,
|
||||||
|
port=settings.OPENNEBULA_PORT,
|
||||||
|
endpoint=settings.OPENNEBULA_ENDPOINT
|
||||||
|
))
|
||||||
|
print("{0}:{1}".format(settings.OPENNEBULA_USERNAME,
|
||||||
|
settings.OPENNEBULA_PASSWORD))
|
||||||
|
print("{protocol}://{domain}:{port}{endpoint}".format(
|
||||||
|
protocol=settings.OPENNEBULA_PROTOCOL,
|
||||||
|
domain=settings.OPENNEBULA_DOMAIN,
|
||||||
|
port=settings.OPENNEBULA_PORT,
|
||||||
|
endpoint=settings.OPENNEBULA_ENDPOINT
|
||||||
|
))
|
||||||
|
self.create_opennebula_user(request)
|
||||||
|
if self.client is None:
|
||||||
|
opennebula_user = request.user.email
|
||||||
|
opennebula_user_password = get_random_password()
|
||||||
|
self.client = oca.Client("{0}:{1}".format(opennebula_user, opennebula_user_password),
|
||||||
|
"{protocol}://{domain}:{port}{endpoint}".format(
|
||||||
|
protocol=settings.OPENNEBULA_PROTOCOL,
|
||||||
|
domain=settings.OPENNEBULA_DOMAIN,
|
||||||
|
port=settings.OPENNEBULA_PORT,
|
||||||
|
endpoint=settings.OPENNEBULA_ENDPOINT
|
||||||
|
))
|
||||||
|
|
||||||
|
# Function that shows the VMs of the current user
|
||||||
|
def show_vms(self, request):
|
||||||
|
vm_pool = None
|
||||||
|
try:
|
||||||
|
self.init_opennebula_client(request)
|
||||||
|
vm_pool = oca.VirtualMachinePool(self.client)
|
||||||
|
vm_pool.info()
|
||||||
|
except socket.timeout:
|
||||||
|
messages.add_message(request, messages.ERROR, _("Socket timeout error."))
|
||||||
|
except OpenNebulaException as opennebula_err:
|
||||||
|
messages.add_message(request, messages.ERROR, _("OpenNebulaException occurred. {0}".format(opennebula_err)))
|
||||||
|
except OSError as err:
|
||||||
|
messages.add_message(request, messages.ERROR, "OS error: {0}".format(err))
|
||||||
|
context = dict(
|
||||||
|
# Include common variables for rendering the admin template.
|
||||||
|
self.admin_site.each_context(request),
|
||||||
|
vms=vm_pool,
|
||||||
|
)
|
||||||
|
return TemplateResponse(request, "hosting/managevms.html", context)
|
||||||
|
|
||||||
|
# Creating VM by using method allocate(client, template)
|
||||||
|
def create_vm(self, request):
|
||||||
|
# check if the request contains the template parameter, if it is
|
||||||
|
# not set warn the user of setting this.
|
||||||
|
vm_template = request.POST.get('vm_template')
|
||||||
|
if vm_template == 'select':
|
||||||
|
messages.add_message(request, messages.ERROR, "Please select a vm template")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
# We do have the vm_template param set. Get and parse it
|
||||||
|
# and check it to be in the desired range.
|
||||||
|
# We have 8 possible VM templates for the moment which are 1x, 2x, 4x ...
|
||||||
|
# the basic template of 10GB disk, 1GB ram, 1 vcpu, 0.1 cpu
|
||||||
|
vm_template_int = int(vm_template)
|
||||||
|
if 1 <= vm_template_int <= 8:
|
||||||
|
vm_string_formatter = """<VM>
|
||||||
|
<MEMORY>{memory}</MEMORY>
|
||||||
|
<VCPU>{vcpu}</VCPU>
|
||||||
|
<CPU>{cpu}</CPU>
|
||||||
|
<DISK>
|
||||||
|
<TYPE>{disk_type}</TYPE>
|
||||||
|
<SIZE>{size}</SIZE>
|
||||||
|
</DISK>
|
||||||
|
</VM>
|
||||||
|
"""
|
||||||
|
vm_id = oca.VirtualMachine.allocate(self.client,
|
||||||
|
vm_string_formatter.format(
|
||||||
|
memory=1024 * vm_template_int,
|
||||||
|
vcpu=vm_template_int,
|
||||||
|
cpu=0.1 * vm_template_int,
|
||||||
|
disk_type='fs',
|
||||||
|
size=10000 * vm_template_int))
|
||||||
|
message = _("Created with id = " + str(vm_id))
|
||||||
|
messages.add_message(request, messages.SUCCESS, message)
|
||||||
|
else:
|
||||||
|
messages.add_message(request, messages.ERROR,
|
||||||
|
_("Please select an appropriate value for vm template."))
|
||||||
|
except socket.timeout as socket_err:
|
||||||
|
messages.add_message(request, messages.ERROR, _("Socket timeout error."))
|
||||||
|
logger.error("Socket timeout error: {0}".format(socket_err))
|
||||||
|
except OpenNebulaException as opennebula_err:
|
||||||
|
messages.add_message(request, messages.ERROR,
|
||||||
|
_("OpenNebulaException occurred. {0}".format(opennebula_err)))
|
||||||
|
logger.error("OpenNebulaException error: {0}".format(opennebula_err))
|
||||||
|
except OSError as os_err:
|
||||||
|
messages.add_message(request, messages.ERROR, _("OS error: {0}".format(os_err)))
|
||||||
|
logger.error("OSError : {0}".format(os_err))
|
||||||
|
except ValueError as value_err:
|
||||||
|
messages.add_message(request, messages.ERROR,
|
||||||
|
_("Please select an appropriate value for vm template."))
|
||||||
|
logger.error("ValueError : {0}".format(value_err))
|
||||||
|
return redirect('admin:showvms')
|
||||||
|
|
||||||
|
# Delete VM from the pool and DB by using method finalize()
|
||||||
|
def delete_vm(self, request, vmid):
|
||||||
|
vm_id = int(vmid)
|
||||||
|
# get the desired vm from the pool
|
||||||
|
logger.debug("Deleting vm with id {0}".format(vm_id))
|
||||||
|
vm = self.get_vm_by_id(vm_id)
|
||||||
|
if vm is None:
|
||||||
|
messages.add_message(request, messages.ERROR, _("Did not find a vm with id = {0}".format(vm_id)))
|
||||||
|
else:
|
||||||
|
logger.debug("Deleting vm_id = " + str(vm_id) + " state = " + vm.str_state)
|
||||||
|
if vm.str_state == 'PENDING' or vm.str_state == 'POWEROFF' or vm.str_state == 'ACTIVE':
|
||||||
|
vm.delete()
|
||||||
|
messages.add_message(request, messages.SUCCESS,
|
||||||
|
_("Deleted from {0} state vm with id = {1}".format(vm.str_state, str(vm_id))))
|
||||||
|
else:
|
||||||
|
vm.finalize()
|
||||||
|
messages.add_message(request, messages.SUCCESS,
|
||||||
|
_("Deleted (using finalize()) from {0} state vm with id = {1}".format(vm.str_state,
|
||||||
|
str(vm_id))))
|
||||||
|
return redirect('admin:showvms')
|
||||||
|
|
||||||
|
def stop_vm(self, request, vmid):
|
||||||
|
vm_id = int(vmid)
|
||||||
|
vm = self.get_vm_by_id(vm_id)
|
||||||
|
if vm is None:
|
||||||
|
messages.add_message(request, messages.ERROR, _("Did not find a vm with id = {0}", vm_id))
|
||||||
|
else:
|
||||||
|
vm.stop()
|
||||||
|
messages.add_message(request, messages.SUCCESS, _("Stopped the vm with id = {0}", vm_id))
|
||||||
|
return redirect('admin:showvms')
|
||||||
|
|
||||||
|
def start_vm(self, request, vmid):
|
||||||
|
vm_id = int(vmid)
|
||||||
|
vm = self.get_vm_by_id(vm_id)
|
||||||
|
if vm is None:
|
||||||
|
messages.add_message(request, messages.ERROR, _("Did not find a vm with id = {0}", vm_id))
|
||||||
|
else:
|
||||||
|
vm.resume()
|
||||||
|
messages.add_message(request, messages.SUCCESS, _("Started the vm with id = {0}", vm_id))
|
||||||
|
return redirect('admin:showvms')
|
||||||
|
|
||||||
|
# Retrives virtual machine pool information
|
||||||
|
def get_vm_pool(self):
|
||||||
|
vm_pool = oca.VirtualMachinePool(self.client)
|
||||||
|
vm_pool.info()
|
||||||
|
return vm_pool
|
||||||
|
|
||||||
|
def get_vm_by_id(self, vmid):
|
||||||
|
vm_pool = self.get_vm_pool()
|
||||||
|
return vm_pool.get_by_id(vmid)
|
||||||
|
|
||||||
|
def create_opennebula_user(self, request):
|
||||||
|
# Notes:
|
||||||
|
# 1. python-oca library's oca.User.allocate(client, user, pass)
|
||||||
|
# method does not work with python-oca version oca-4.15.0a1-py3.5
|
||||||
|
# This is because the call is missing a fourth parameter
|
||||||
|
# auth_driver.
|
||||||
|
# To overcome this issue, we make a direct call to xml-rpc method
|
||||||
|
# 'user.allocate' passing this fourth parameter.
|
||||||
|
#
|
||||||
|
# 2. We have a dummy authentication driver in opennebula and we
|
||||||
|
# use this so as to avoid opennebula authentication. However, we
|
||||||
|
# need to supply a dummy password. Without this, we can not
|
||||||
|
# create an OpenNebula user. We use dummy string 'a' as password
|
||||||
|
# for all users.
|
||||||
|
#
|
||||||
|
# 3. We user the user's email as the user name.
|
||||||
|
# 4. If the user's email is not registered with OpenNebula,
|
||||||
|
# WrongNameError is raised. We create an OpenNebula user in
|
||||||
|
# such case.
|
||||||
|
try:
|
||||||
|
user_pool = oca.UserPool(self.oneadmin_client)
|
||||||
|
user_pool.info()
|
||||||
|
opennebula_user = user_pool.get_by_name(request.user.email)
|
||||||
|
logger.debug("User {0} exists. User id = {1}".format(request.user.email, opennebula_user.id))
|
||||||
|
except WrongNameError as wrong_name_err:
|
||||||
|
user_id = self.oneadmin_client.call('user.allocate', request.user.email, get_random_password(),
|
||||||
|
'dummy')
|
||||||
|
logger.debug("User {0} does not exist. Created the user. User id = {1}", request.user.email, user_id)
|
||||||
|
except OpenNebulaException as err:
|
||||||
|
messages.add_message(request, messages.ERROR,
|
||||||
|
"Error : {0}".format(err))
|
||||||
|
logger.error("Error : {0}".format(err))
|
||||||
|
|
||||||
|
|
||||||
|
# Returns random password that is needed by OpenNebula
|
||||||
|
def get_random_password():
|
||||||
|
return ''.join(random.SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(20))
|
52
hosting/templates/hosting/managevms.html
Normal file
52
hosting/templates/hosting/managevms.html
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
{% extends "admin/base_site.html" %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<form action="{% url 'admin:createvm' %}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<select id="vm_template" name="vm_template">
|
||||||
|
<option value="select">Select a template</option>
|
||||||
|
<option value="1">disk = 10GB, vcpu=1, ram=2GB</option>
|
||||||
|
<option value="2">disk = 20GB, vcpu=2, ram=4GB</option>
|
||||||
|
</select>
|
||||||
|
<input type="submit" name="create_vm" value="Create VM" />
|
||||||
|
</form>
|
||||||
|
{% if vms %}
|
||||||
|
<section>
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Memory</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>User Name</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for vm in vms %}
|
||||||
|
<tr>
|
||||||
|
<td>{{vm.id}}</td>
|
||||||
|
<td>{{vm.name}}</td>
|
||||||
|
<td>{{vm.template.memory}}</td>
|
||||||
|
<td>{{vm.str_state}}</td>
|
||||||
|
<td>{{vm.uname}}</td>
|
||||||
|
<td>
|
||||||
|
{% if vm.str_state == 'ACTIVE' %}
|
||||||
|
<a href="{% url 'admin:stopvm' vm.id %}">Stop VM</a>
|
||||||
|
{% elif vm.str_state == 'STOPPED' %}
|
||||||
|
<a href="{% url 'admin:startvm' vm.id %}">Start VM</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{% url 'admin:deletevm' vm.id %}">Delete VM</a>
|
||||||
|
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{% else %}
|
||||||
|
<h4>You do not have any VM.</h4>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
|
@ -82,7 +82,7 @@ stripe==1.33.0
|
||||||
wheel==0.29.0
|
wheel==0.29.0
|
||||||
django-admin-honeypot==1.0.0
|
django-admin-honeypot==1.0.0
|
||||||
coverage==4.3.4
|
coverage==4.3.4
|
||||||
|
git+https://github.com/python-oca/python-oca.git#egg=python-oca
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -16,18 +16,7 @@
|
||||||
</h2>
|
</h2>
|
||||||
</a>
|
</a>
|
||||||
<p class="post-meta" style="font-size:0.9em;">
|
<p class="post-meta" style="font-size:0.9em;">
|
||||||
Posted
|
Posted on {{ post.date_published|date:"DATE_FORMAT" }}
|
||||||
{% if post.author %}
|
|
||||||
by
|
|
||||||
<!-- <a href="{% url 'djangocms_blog:posts-author' post.author.get_username %}"> -->
|
|
||||||
{% if post.author.get_full_name %}
|
|
||||||
{{ post.author.get_full_name }}
|
|
||||||
{% else %}
|
|
||||||
{{ post.author }}
|
|
||||||
{% endif %}
|
|
||||||
<!-- </a> -->
|
|
||||||
{% endif %}
|
|
||||||
on {{ post.date_published|date:"DATE_FORMAT" }}
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="post-subtitle">
|
<p class="post-subtitle">
|
||||||
|
|
Loading…
Reference in a new issue