mirror of
https://github.com/retspen/webvirtcloud
synced 2024-12-24 15:15:22 +00:00
Merge branch 'honza801-master'
This commit is contained in:
commit
0ccc366fba
25 changed files with 734 additions and 198 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
@ -3,7 +3,8 @@ venv
|
|||
.idea
|
||||
.DS_*
|
||||
*.pyc
|
||||
db.sqlite3
|
||||
console/cert.pem
|
||||
db.sqlite3*
|
||||
console/cert.pem*
|
||||
tags
|
||||
dhcpd.*
|
||||
webvirtcloud/settings.py
|
||||
|
|
13
README.md
13
README.md
|
@ -19,12 +19,23 @@ sudo service supervisor restart
|
|||
|
||||
WebVirtCloud is a virtualization web interface for admins and users. It can delegate Virtual Machine's to users. A noVNC viewer presents a full graphical console to the guest domain. KVM is currently the only hypervisor supported.
|
||||
|
||||
### Generate secret key
|
||||
You should generate SECRET_KEY after cloning repo. Then put it into webvirtcloud/settings.py.
|
||||
|
||||
```python
|
||||
import random, string
|
||||
haystack = string.ascii_letters + string.digits + string.punctuation
|
||||
print(''.join([random.SystemRandom().choice(haystack) for _ in range(50)]))
|
||||
```
|
||||
|
||||
### Install WebVirtCloud panel (Ubuntu)
|
||||
|
||||
```bash
|
||||
sudo apt-get -y install git python-virtualenv python-dev libxml2-dev libvirt-dev zlib1g-dev nginx supervisor libsasl2-modules gcc pkg-config
|
||||
git clone https://github.com/retspen/webvirtcloud
|
||||
cd webvirtcloud
|
||||
cp webvirtcloud/settings.py.template webvirtcloud/settings.py
|
||||
# now put secret key to webvirtcloud/settings.py
|
||||
sudo cp conf/supervisor/webvirtcloud.conf /etc/supervisor/conf.d
|
||||
sudo cp conf/nginx/webvirtcloud.conf /etc/nginx/conf.d
|
||||
cd ..
|
||||
|
@ -63,6 +74,8 @@ sudo yum -y install python-virtualenv python-devel libvirt-devel glibc gcc nginx
|
|||
```bash
|
||||
sudo mkdir /srv && cd /srv
|
||||
sudo git clone https://github.com/retspen/webvirtcloud && cd webvirtcloud
|
||||
cp webvirtcloud/settings.py.template webvirtcloud/settings.py
|
||||
# now put secret key to webvirtcloud/settings.py
|
||||
```
|
||||
|
||||
#### Start installation webvirtcloud
|
||||
|
|
|
@ -1,7 +1,13 @@
|
|||
from django.contrib.auth.backends import RemoteUserBackend
|
||||
from accounts.models import UserInstance, UserAttributes
|
||||
from instances.models import Instance
|
||||
|
||||
class MyRemoteUserBackend(RemoteUserBackend):
|
||||
|
||||
#create_unknown_user = True
|
||||
|
||||
def configure_user(self, user):
|
||||
user.is_superuser = True
|
||||
#user.is_superuser = True
|
||||
UserAttributes.configure_user(user)
|
||||
return user
|
||||
|
||||
|
|
19
accounts/migrations/0009_auto_20171026_0805.py
Normal file
19
accounts/migrations/0009_auto_20171026_0805.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0008_merge'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='userattributes',
|
||||
name='can_clone_instances',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
|
@ -1,5 +1,6 @@
|
|||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
from django.conf import settings
|
||||
from instances.models import Instance
|
||||
|
||||
|
||||
|
@ -24,11 +25,33 @@ class UserSSHKey(models.Model):
|
|||
|
||||
class UserAttributes(models.Model):
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
||||
can_clone_instances = models.BooleanField(default=False)
|
||||
can_clone_instances = models.BooleanField(default=True)
|
||||
max_instances = models.IntegerField(default=1)
|
||||
max_cpus = models.IntegerField(default=1)
|
||||
max_memory = models.IntegerField(default=2048)
|
||||
max_disk_size = models.IntegerField(default=20)
|
||||
|
||||
@staticmethod
|
||||
def create_missing_userattributes(user):
|
||||
try:
|
||||
userattributes = user.userattributes
|
||||
except UserAttributes.DoesNotExist:
|
||||
userattributes = UserAttributes(user=user)
|
||||
userattributes.save()
|
||||
|
||||
@staticmethod
|
||||
def add_default_instances(user):
|
||||
existing_instances = UserInstance.objects.filter(user=user)
|
||||
if not existing_instances:
|
||||
for instance_name in settings.NEW_USER_DEFAULT_INSTANCES:
|
||||
instance = Instance.objects.get(name=instance_name)
|
||||
user_instance = UserInstance(user=user, instance=instance)
|
||||
user_instance.save()
|
||||
|
||||
@staticmethod
|
||||
def configure_user(user):
|
||||
UserAttributes.create_missing_userattributes(user)
|
||||
UserAttributes.add_default_instances(user)
|
||||
|
||||
def __unicode__(self):
|
||||
return self.user.username
|
||||
|
|
|
@ -13,6 +13,31 @@
|
|||
|
||||
{% include 'errors_block.html' %}
|
||||
|
||||
{% if request.user.is_superuser and publickeys %}
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Key name" %}</th>
|
||||
<th>{% trans "Public key" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for publickey in publickeys %}
|
||||
<tr>
|
||||
<td>{{ publickey.keyname }}</td>
|
||||
<td title="{{ publickey.keypublic }}">{{ publickey.keypublic|truncatechars:64 }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
{% if not user_insts %}
|
||||
|
|
|
@ -41,6 +41,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% if show_profile_edit_password %}
|
||||
<h3 class="page-header">{% trans "Edit Password" %}</h3>
|
||||
<form class="form-horizontal" method="post" action="" role="form">{% csrf_token %}
|
||||
<div class="form-group">
|
||||
|
@ -67,6 +68,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
<h3 class="page-header">{% trans "SSH Keys" %}</h3>
|
||||
{% if publickeys %}
|
||||
<div class="col-lg-12">
|
||||
|
@ -93,20 +95,20 @@
|
|||
{% endif %}
|
||||
<form class="form-horizontal" method="post" action="" role="form">{% csrf_token %}
|
||||
<div class="form-group bridge_name_form_group_dhcp">
|
||||
<label class="col-sm-2 control-label">{% trans "Retry" %}</label>
|
||||
<label class="col-sm-2 control-label">{% trans "Key name" %}</label>
|
||||
<div class="col-sm-4">
|
||||
<input type="text" class="form-control" name="keyname" placeholder="{% trans "Enter Name" %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group bridge_name_form_group_dhcp">
|
||||
<label class="col-sm-2 control-label">{% trans "Retry" %}</label>
|
||||
<label class="col-sm-2 control-label">{% trans "Public key" %}</label>
|
||||
<div class="col-sm-8">
|
||||
<textarea name="keypublic" class="form-control" rows="6" placeholder="{% trans "Enter Public Key" %}"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<button type="submit" class="btn btn-primary">{% trans "Create" %}</button>
|
||||
<button type="submit" class="btn btn-primary">{% trans "Add" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
@ -7,6 +7,9 @@ register = template.Library()
|
|||
|
||||
@register.simple_tag
|
||||
def ssh_to_fingerprint(line):
|
||||
key = base64.b64decode(line.strip().split()[1].encode('ascii'))
|
||||
fp_plain = hashlib.md5(key).hexdigest()
|
||||
return ':'.join(a + b for a, b in zip(fp_plain[::2], fp_plain[1::2]))
|
||||
try:
|
||||
key = base64.b64decode(line.strip().split()[1].encode('ascii'))
|
||||
fp_plain = hashlib.md5(key).hexdigest()
|
||||
return ':'.join(a + b for a, b in zip(fp_plain[::2], fp_plain[1::2]))
|
||||
except Exception:
|
||||
return 'Invalid key'
|
||||
|
|
|
@ -20,6 +20,7 @@ def profile(request):
|
|||
error_messages = []
|
||||
user = User.objects.get(id=request.user.id)
|
||||
publickeys = UserSSHKey.objects.filter(user_id=request.user.id)
|
||||
show_profile_edit_password = settings.SHOW_PROFILE_EDIT_PASSWORD
|
||||
|
||||
if request.method == 'POST':
|
||||
if 'username' in request.POST:
|
||||
|
@ -70,21 +71,11 @@ def accounts(request):
|
|||
:param request:
|
||||
:return:
|
||||
"""
|
||||
|
||||
def create_missing_userattributes(users):
|
||||
for user in users:
|
||||
try:
|
||||
userattributes = user.userattributes
|
||||
except UserAttributes.DoesNotExist:
|
||||
userattributes = UserAttributes(user=user)
|
||||
userattributes.save()
|
||||
|
||||
if not request.user.is_superuser:
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
|
||||
error_messages = []
|
||||
users = User.objects.all().order_by('username')
|
||||
create_missing_userattributes(users)
|
||||
allow_empty_password = settings.ALLOW_EMPTY_PASSWORD
|
||||
|
||||
if request.method == 'POST':
|
||||
|
@ -98,6 +89,7 @@ def accounts(request):
|
|||
if not error_messages:
|
||||
new_user = User.objects.create_user(data['name'], None, data['password'])
|
||||
new_user.save()
|
||||
UserAttributes.configure_user(new_user)
|
||||
return HttpResponseRedirect(request.get_full_path())
|
||||
if 'edit' in request.POST:
|
||||
user_id = request.POST.get('user_id', '')
|
||||
|
@ -155,9 +147,7 @@ def account(request, user_id):
|
|||
user = User.objects.get(id=user_id)
|
||||
user_insts = UserInstance.objects.filter(user_id=user_id)
|
||||
instances = Instance.objects.all().order_by('name')
|
||||
|
||||
if user.username == request.user.username:
|
||||
return HttpResponseRedirect(reverse('profile'))
|
||||
publickeys = UserSSHKey.objects.filter(user_id=user_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
if 'delete' in request.POST:
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
{% load staticfiles %}
|
||||
{% load i18n %}
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="shortcut icon" href="{{ STATIC_URL }}img/favicon.ico">
|
||||
<link rel="shortcut icon" href="{% static "img/favicon.ico" %}">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="{{ STATIC_URL }}css/bootstrap.min.css">
|
||||
<link href="{{ STATIC_URL }}css/webvirtcloud.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{% static "css/bootstrap.min.css" %}">
|
||||
<link href="{% static "css/webvirtcloud.css" %}" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
body {
|
||||
|
@ -92,8 +93,8 @@
|
|||
<div id='main_container' class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
<script src="{{ STATIC_URL }}js/jquery.js"></script>
|
||||
<script src="{{ STATIC_URL }}js/bootstrap.min.js"></script>
|
||||
<script src="{% static "js/jquery.js" %}"></script>
|
||||
<script src="{% static "js/bootstrap.min.js" %}"></script>
|
||||
|
||||
<script>
|
||||
function log_message(msg,type) {
|
||||
|
|
21
instances/migrations/0003_instance_created.py
Normal file
21
instances/migrations/0003_instance_created.py
Normal file
|
@ -0,0 +1,21 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import datetime
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('instances', '0002_instance_is_template'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='instance',
|
||||
name='created',
|
||||
field=models.DateField(default=datetime.datetime(2017, 10, 26, 8, 5, 55, 797326), auto_now_add=True),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
|
@ -7,6 +7,7 @@ class Instance(models.Model):
|
|||
name = models.CharField(max_length=20)
|
||||
uuid = models.CharField(max_length=36)
|
||||
is_template = models.BooleanField(default=False)
|
||||
created = models.DateField(auto_now_add=True)
|
||||
|
||||
def __unicode__(self):
|
||||
return self.name
|
||||
|
|
36
instances/templates/add_instance_owner_block.html
Normal file
36
instances/templates/add_instance_owner_block.html
Normal file
|
@ -0,0 +1,36 @@
|
|||
{% load i18n %}
|
||||
{% if request.user.is_superuser %}
|
||||
<a href="#addInstanceOwner" type="button" class="btn btn-success pull-right" data-toggle="modal">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
|
||||
</a>
|
||||
|
||||
<!-- Modal pool -->
|
||||
<div class="modal fade" id="addInstanceOwner" tabindex="-1" role="dialog" aria-labelledby="addInstanceOwnerLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
|
||||
<h4 class="modal-title">{% trans "Add Instance Owner" %}</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form-horizontal" method="post" action="" role="form">{% csrf_token %}
|
||||
<div class="form-group">
|
||||
<label class="col-sm-4 control-label">{% trans "User" %}</label>
|
||||
<div class="col-sm-6">
|
||||
<select class="form-control" name="user_id">
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}">{{ user.username }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">{% trans "Close" %}</button>
|
||||
<button type="submit" class="btn btn-primary" name="add_owner">{% trans "Add" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div> <!-- /.modal-content -->
|
||||
</div> <!-- /.modal-dialog -->
|
||||
</div> <!-- /.modal -->
|
||||
{% endif %}
|
|
@ -1,48 +1,42 @@
|
|||
{% extends "base.html" %}
|
||||
{% load staticfiles %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "Instance" %} - {{ vname }}{% endblock %}
|
||||
{% block content %}
|
||||
{% include 'pleasewaitdialog.html' %}
|
||||
<!-- Page Heading -->
|
||||
<div class="row">
|
||||
<table>
|
||||
<tr>
|
||||
<td><h3>{{ vname }}</h3></td>
|
||||
<td>
|
||||
{% ifequal status 5 %}
|
||||
<span class="label label-danger">{% trans "Off" %}</span>
|
||||
{% endifequal %}
|
||||
{% ifequal status 1 %}
|
||||
<span class="label label-success">{% trans "Active" %}</span>
|
||||
{% endifequal %}
|
||||
{% ifequal status 3 %}
|
||||
<span class="label label-warning">{% trans "Suspend" %}</span>
|
||||
{% endifequal %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{% url 'instance' compute.id vname %}" type="button" class="btn btn-xs btn-default"><span class="glyphicon glyphicon-refresh"></span></a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table width="65%">
|
||||
<tr>
|
||||
<td>
|
||||
{% if cur_vcpu %}
|
||||
<h4>{{ cur_vcpu }} {% trans "Vcpu" %}</h4>
|
||||
{% else %}
|
||||
<h4>{{ vcpu }} {% trans "Vcpu" %}</h4>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<h4>{{ cur_memory }} {% trans "MB" %} {% trans "Ram" %}</h4>
|
||||
</td>
|
||||
<div>
|
||||
<h3>
|
||||
{{ vname }}{% if title %} ({{ title }}){% endif %}
|
||||
</h3>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
{% ifequal status 5 %}
|
||||
<span class="label label-danger">{% trans "Off" %}</span>
|
||||
{% endifequal %}
|
||||
{% ifequal status 1 %}
|
||||
<span class="label label-success">{% trans "Active" %}</span>
|
||||
{% endifequal %}
|
||||
{% ifequal status 3 %}
|
||||
<span class="label label-warning">{% trans "Suspend" %}</span>
|
||||
{% endifequal %}
|
||||
|
|
||||
{% if cur_vcpu %}
|
||||
{{ cur_vcpu }} {% trans "Vcpu" %}
|
||||
{% else %}
|
||||
{{ vcpu }} {% trans "Vcpu" %}
|
||||
{% endif %}
|
||||
|
|
||||
{{ cur_memory }} {% trans "MB" %} {% trans "Ram" %}
|
||||
|
|
||||
{% for disk in disks %}
|
||||
<td>
|
||||
<h4>{{ disk.size|filesizeformat }} {% trans "Disk" %}</h4>
|
||||
</td>
|
||||
{{ disk.size|filesizeformat }} {% trans "Disk" %} |
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</table>
|
||||
<a href="{% url 'instance' compute.id vname %}" type="button" class="btn btn-xs btn-default"><span class="glyphicon glyphicon-refresh"></span></a>
|
||||
</div>
|
||||
</div>
|
||||
{% if user_quota_msg %}
|
||||
<span class="label label-warning">{{ user_quota_msg|capfirst }} quota reached.</span>
|
||||
{% endif %}
|
||||
|
@ -90,8 +84,8 @@
|
|||
</li>
|
||||
<li role="presentation">
|
||||
<a href="#graphics" id="chartgraphs" class="action-button" aria-controls="graphics" role="tab" data-toggle="tab">
|
||||
<span id="action-block" class="glyphicon glyphicon-signal" aria-hidden="true"></span>
|
||||
{% trans "Graphs" %}
|
||||
<span id="action-block" class="glyphicon glyphicon-stats" aria-hidden="true"></span>
|
||||
{% trans "Stats" %}
|
||||
</a>
|
||||
</li>
|
||||
<li role="presentation">
|
||||
|
@ -233,16 +227,20 @@
|
|||
{% trans "Console" %}
|
||||
</a>
|
||||
</li>
|
||||
{% if show_access_root_password %}
|
||||
<li role="presentation">
|
||||
<a href="#rootpasswd" aria-controls="rootpasswd" role="tab" data-toggle="tab">
|
||||
{% trans "Root Password" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if show_access_ssh_keys %}
|
||||
<li role="presentation">
|
||||
<a href="#sshkeys" aria-controls="sshkeys" role="tab" data-toggle="tab">
|
||||
{% trans "SSH Keys" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<!-- Tab panes -->
|
||||
<div class="tab-content">
|
||||
|
@ -255,6 +253,7 @@
|
|||
{% endifequal %}
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
{% if show_access_root_password %}
|
||||
<div role="tabpanel" class="tab-pane tab-pane-bordered" id="rootpasswd">
|
||||
<p>{% trans "You need shut down your instance and enter a new root password." %}</p>
|
||||
<form class="form-inline" method="post" role="form">{% csrf_token %}
|
||||
|
@ -271,6 +270,8 @@
|
|||
</form>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if show_access_ssh_keys %}
|
||||
<div role="tabpanel" class="tab-pane tab-pane-bordered" id="sshkeys">
|
||||
<p>{% trans "You need shut down your instance and choose your public key." %}</p>
|
||||
<form class="form-inline" method="post" role="form">{% csrf_token %}
|
||||
|
@ -295,6 +296,7 @@
|
|||
</form>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -307,11 +309,16 @@
|
|||
{% trans "Resize Instance" %}
|
||||
</a>
|
||||
</li>
|
||||
<li role="presentation">
|
||||
<a href="#addvolume" aria-controls="addvolume" role="tab" data-toggle="tab">
|
||||
{% trans "Add New Volume" %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- Tab panes -->
|
||||
<div class="tab-content">
|
||||
<div role="tabpanel" class="tab-pane tab-pane-bordered active" id="resizevm">
|
||||
{% if request.user.is_superuser or userinstace.is_change %}
|
||||
{% if request.user.is_superuser or request.user.is_staff or userinstace.is_change %}
|
||||
<form class="form-horizontal" method="post" role="form">{% csrf_token %}
|
||||
<p style="font-weight:bold;">{% trans "Logical host CPUs:" %} {{ vcpu_host }}</p>
|
||||
<div class="form-group">
|
||||
|
@ -388,6 +395,88 @@
|
|||
{% endif %}
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane tab-pane-bordered" id="addvolume">
|
||||
{% if request.user.is_superuser or userinstace.is_change %}
|
||||
<form class="form-horizontal" method="post" role="form">{% csrf_token %}
|
||||
<p style="font-weight:bold;">{% trans "Volume parameters" %}</p>
|
||||
<div class="form-group">
|
||||
<label class="col-sm-3 control-label" style="font-weight:normal;">{% trans "Storage" %}</label>
|
||||
<div class="col-sm-4">
|
||||
<select name="storage" class="form-control image-format">
|
||||
{% for storage in storages %}
|
||||
<option value="{{ storage }}">{{ storage }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-sm-3 control-label" style="font-weight:normal;">{% trans "Name" %}</label>
|
||||
<div class="col-sm-4">
|
||||
<input type="text" class="form-control" name="name" placeholder="{% trans "Name" %}" required pattern="[a-zA-Z0-9\.\-_]+">
|
||||
</div>
|
||||
<div class="col-sm-2">
|
||||
<select name="extension" class="form-control image-format">
|
||||
{% for format in formats %}
|
||||
<option value="{{ format }}" {% if format == default_format %}selected{% endif %}>{% trans format %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-sm-3 control-label" style="font-weight:normal;">{% trans "Format" %}</label>
|
||||
<div class="col-sm-4">
|
||||
<select name="format" class="form-control image-format">
|
||||
{% for format in formats %}
|
||||
<option value="{{ format }}" {% if format == default_format %}selected{% endif %}>{% trans format %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-sm-3 control-label" style="font-weight:normal;">{% trans "Size" %}</label>
|
||||
<div class="col-sm-4">
|
||||
<input type="text" class="form-control" name="size" value="10" maxlength="3" required pattern="[0-9]+">
|
||||
</div>
|
||||
<label class="col-sm-1 control-label">{% trans "GB" %}</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-sm-3 control-label" style="font-weight:normal;">{% trans "Bus" %}</label>
|
||||
<div class="col-sm-4">
|
||||
<select name="bus" class="form-control image-format">
|
||||
{% for bus in busses %}
|
||||
<option value="{{ bus }}" {% if bus == default_bus %}selected{% endif %}>{% trans bus %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-sm-3 control-label" style="font-weight:normal;">{% trans "Cache" %}</label>
|
||||
<div class="col-sm-4">
|
||||
<select name="cache" class="form-control image-format">
|
||||
{% for mode, name in cache_modes %}
|
||||
<option value="{{ mode }}" {% if mode == default_cache %}selected{% endif %}>{% trans name %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group meta-prealloc">
|
||||
<label class="col-sm-3 control-label" style="font-weight:normal;">{% trans "Metadata" %}</label>
|
||||
<div class="col-sm-4">
|
||||
<input type="checkbox" name="meta_prealloc" value="true">
|
||||
</div>
|
||||
</div>
|
||||
{% ifequal status 5 %}
|
||||
<button type="submit" class="btn btn-lg btn-success pull-right" name="addvolume">{% trans "Add volume" %}</button>
|
||||
{% else %}
|
||||
<button class="btn btn-lg btn-success pull-right disabled">{% trans "Add volume" %}</button>
|
||||
{% endifequal %}
|
||||
</form>
|
||||
{% else %}
|
||||
{% trans "You don't have permission for resizing instance" %}
|
||||
<button class="btn btn-lg btn-success pull-right disabled">{% trans "Add volume" %}</button>
|
||||
{% endif %}
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -531,11 +620,15 @@
|
|||
{% trans "XML" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if request.user.is_superuser or request.user.userattributes.can_clone_instances %}
|
||||
<li role="presentation">
|
||||
<a href="#options" aria-controls="options" role="tab" data-toggle="tab">
|
||||
{% trans "Options" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if request.user.is_superuser %}
|
||||
<li role="presentation">
|
||||
<a href="#users" aria-controls="users" role="tab" data-toggle="tab">
|
||||
{% trans "Users" %}
|
||||
|
@ -546,8 +639,8 @@
|
|||
<!-- Tab panes -->
|
||||
<div class="tab-content">
|
||||
<div role="tabpanel" class="tab-pane tab-pane-bordered active" id="media">
|
||||
<form class="form-horizontal" action="" method="post" role="form">{% csrf_token %}
|
||||
{% for cd in media %}
|
||||
{% for cd in media %}
|
||||
<form class="form-horizontal" action="" method="post" role="form">{% csrf_token %}
|
||||
<div class="form-group">
|
||||
<label class="col-sm-2 control-label">{% trans "CDROM" %} {{ forloop.counter }}</label>
|
||||
{% if not cd.image %}
|
||||
|
@ -579,8 +672,8 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</form>
|
||||
</form>
|
||||
{% endfor %}
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
{% if request.user.is_superuser %}
|
||||
|
@ -829,7 +922,7 @@
|
|||
<div class="form-group">
|
||||
<label class="col-sm-3 control-label">{% trans "Live migration" %}</label>
|
||||
<div class="col-sm-6">
|
||||
<input type="checkbox" name="live_migrate" value="true" id="vm_live_migrate" checked>
|
||||
<input type="checkbox" name="live_migrate" value="true" id="vm_live_migrate" {% ifequal status 1 %}checked{% endifequal %}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
|
@ -847,7 +940,7 @@
|
|||
<div class="form-group">
|
||||
<label class="col-sm-3 control-label">{% trans "Offline migration" %}</label>
|
||||
<div class="col-sm-6">
|
||||
<input type="checkbox" name="offline_migrate" value="true" id="offline_migrate">
|
||||
<input type="checkbox" name="offline_migrate" value="true" id="offline_migrate" {% ifequal status 5 %}checked{% endifequal %}>
|
||||
</div>
|
||||
</div>
|
||||
{% if computes_count != 1 %}
|
||||
|
@ -877,6 +970,8 @@
|
|||
</form>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if request.user.is_superuser or request.user.userattributes.can_clone_instances %}
|
||||
<div role="tabpanel" class="tab-pane tab-pane-bordered" id="options">
|
||||
<form class="form-horizontal" action="" method="post" role="form">{% csrf_token %}
|
||||
<div class="form-group">
|
||||
|
@ -894,7 +989,7 @@
|
|||
<div class="form-group">
|
||||
<label class="col-sm-3 control-label">{% trans "Is template" %}</label>
|
||||
<div class="col-sm-6">
|
||||
<input type="checkbox" name="is_template" value="true" id="is_template" {% if instance.is_template %}checked{% endif %}>
|
||||
<input type="checkbox" name="is_template" value="true" id="is_template" {% if instance.is_template %}checked{% endif %} {% if not request.user.is_superuser %}disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
{% ifequal status 5 %}
|
||||
|
@ -905,11 +1000,34 @@
|
|||
</form>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if request.user.is_superuser %}
|
||||
<div role="tabpanel" class="tab-pane tab-pane-bordered" id="users">
|
||||
<p style="font-weight:bold;">{% trans "Instance owners" %}</p>
|
||||
{% for userinstance in userinstances %}
|
||||
<p><a href="{% url 'account' userinstance.user.id %}">{{ userinstance.user }}</a></p>
|
||||
{% endfor %}
|
||||
<div>
|
||||
<p style="font-weight:bold;">
|
||||
{% trans "Instance owners" %}
|
||||
{% include 'add_instance_owner_block.html' %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped sortable-theme-bootstrap" data-sortable>
|
||||
<tbody class="searchable">
|
||||
{% for userinstance in userinstances %}
|
||||
<tr>
|
||||
<td><a href="{% url 'account' userinstance.user.id %}">{{ userinstance.user }}</a></td>
|
||||
<td style="width:30px;">
|
||||
<form action="" method="post" style="height:10px" role="form">{% csrf_token %}
|
||||
<input type="hidden" name="userinstance" value="{{ userinstance.pk }}">
|
||||
<button type="submit" class="btn btn-sm btn-default" name="del_owner" title="{% trans "Delete" %}">
|
||||
<i class="fa fa-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
@ -925,6 +1043,11 @@
|
|||
{% trans "Real Time" %}
|
||||
</a>
|
||||
</li>
|
||||
<li role="presentation">
|
||||
<a href="#logs" aria-controls="logs" role="tab" data-toggle="tab" onclick='update_logs_table("{{ vname }}");'>
|
||||
{% trans "Logs" %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- Tab panes -->
|
||||
<div class="tab-content">
|
||||
|
@ -971,6 +1094,23 @@
|
|||
{% endfor %}
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane tab-pane-bordered" id="logs">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped sortable-theme-bootstrap" id="logs_table" data-sortable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Date" %}</th>
|
||||
<th>{% trans "User" %}</th>
|
||||
<th>{% trans "Message" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="searchable">
|
||||
<tr><td colspan="3"><i>None ...</i></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1016,7 +1156,7 @@
|
|||
</div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script src="{{ STATIC_URL }}/js/ace.js" type="text/javascript" charset="utf-8"></script>
|
||||
<script src="{% static "js/ace.js" %}" type="text/javascript" charset="utf-8"></script>
|
||||
<script>
|
||||
var editor = ace.edit("editor");
|
||||
editor.getSession().setMode("ace/mode/xml");
|
||||
|
@ -1153,7 +1293,7 @@
|
|||
});
|
||||
});
|
||||
</script>
|
||||
<script src="{{ STATIC_URL }}js/Chart.min.js"></script>
|
||||
<script src="{% static "js/Chart.min.js" %}"></script>
|
||||
<script>
|
||||
$('#chartgraphs').on('shown.bs.tab', function (event) {
|
||||
var cpuLineData = {
|
||||
|
@ -1290,13 +1430,14 @@
|
|||
});
|
||||
</script>
|
||||
<script>
|
||||
backgroundJobRunning = false;
|
||||
window.setInterval(function get_status() {
|
||||
var status = {{ status }};
|
||||
$.getJSON('{% url 'inst_status' compute_id vname %}', function (data) {
|
||||
if (data['status'] != status) {
|
||||
if (data['status'] != status && !backgroundJobRunning) {
|
||||
window.location.reload()
|
||||
}
|
||||
})
|
||||
});
|
||||
}, 5000);
|
||||
</script>
|
||||
<script>
|
||||
|
@ -1348,4 +1489,19 @@
|
|||
});
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
function update_logs_table(vname) {
|
||||
$.getJSON('/logs/vm_logs/'+vname+'/', function(data) {
|
||||
var logs = "";
|
||||
$.each(data, function(id) {
|
||||
row = data[id];
|
||||
console.log(row);
|
||||
logs += '<tr><td style="width:150px">'+row['date']+'</td>';
|
||||
logs += '<td>'+row['user']+'</td>';
|
||||
logs += '<td>'+row['message']+'</td></tr>';
|
||||
});
|
||||
$("#logs_table > tbody").html(logs);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
@ -43,7 +43,7 @@
|
|||
<th>Host<br>User</th>
|
||||
<th>Status</th>
|
||||
<th>VCPU</th>
|
||||
<th>Memory</th>
|
||||
<th>Memory<br>({% trans "MB" %})</th>
|
||||
<th data-sortable="false" style="width:205px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
@ -64,7 +64,7 @@
|
|||
{% endifequal %}
|
||||
</td>
|
||||
<td>{{ info.vcpu }}</td>
|
||||
<td>{{ info.memory }} {% trans "MB" %}</td>
|
||||
<td>{{ info.memory }}</td>
|
||||
<td><form action="" method="post" role="form">{% csrf_token %}
|
||||
<input type="hidden" name="name" value="{{ vm }}"/>
|
||||
<input type="hidden" name="compute_id" value="{{ host.0 }}"/>
|
||||
|
|
|
@ -14,4 +14,6 @@ urlpatterns = [
|
|||
views.guess_clone_name, name='guess_clone_name'),
|
||||
url(r'^check_instance/(?P<vname>[\w\-\.]+)/$',
|
||||
views.check_instance, name='check_instance'),
|
||||
url(r'^sshkeys/(?P<vname>[\w\-\.]+)/$',
|
||||
views.sshkeys, name='sshkeys'),
|
||||
]
|
||||
|
|
|
@ -4,7 +4,7 @@ import json
|
|||
import socket
|
||||
import crypt
|
||||
import re
|
||||
from string import letters, digits
|
||||
import string
|
||||
from random import choice
|
||||
from bisect import insort
|
||||
from django.http import HttpResponse, HttpResponseRedirect
|
||||
|
@ -14,10 +14,12 @@ from django.utils.translation import ugettext_lazy as _
|
|||
from django.contrib.auth.decorators import login_required
|
||||
from computes.models import Compute
|
||||
from instances.models import Instance
|
||||
from django.contrib.auth.models import User
|
||||
from accounts.models import UserInstance, UserSSHKey
|
||||
from vrtManager.hostdetails import wvmHostDetails
|
||||
from vrtManager.instance import wvmInstance, wvmInstances
|
||||
from vrtManager.connection import connection_manager
|
||||
from vrtManager.create import wvmCreate
|
||||
from vrtManager.util import randomPasswd
|
||||
from libvirt import libvirtError, VIR_DOMAIN_XML_SECURE
|
||||
from webvirtcloud.settings import QEMU_KEYMAPS, QEMU_CONSOLE_TYPES
|
||||
|
@ -50,13 +52,32 @@ def instances(request):
|
|||
def get_userinstances_info(instance):
|
||||
info = {}
|
||||
uis = UserInstance.objects.filter(instance=instance)
|
||||
info['count'] = len(uis)
|
||||
if len(uis) > 0:
|
||||
info['count'] = uis.count()
|
||||
if info['count'] > 0:
|
||||
info['first_user'] = uis[0]
|
||||
else:
|
||||
info['first_user'] = None
|
||||
return info
|
||||
|
||||
def refresh_instance_database(comp, vm, info):
|
||||
instances = Instance.objects.filter(name=vm)
|
||||
if instances.count() > 1:
|
||||
for i in instances:
|
||||
user_instances_count = UserInstance.objects.filter(instance=i).count()
|
||||
if user_instances_count == 0:
|
||||
addlogmsg(request.user.username, i.name, _("Deleting due to multiple records."))
|
||||
i.delete()
|
||||
|
||||
try:
|
||||
check_uuid = Instance.objects.get(compute_id=comp.id, name=vm)
|
||||
if check_uuid.uuid != info['uuid']:
|
||||
check_uuid.save()
|
||||
all_host_vms[comp.id, comp.name][vm]['is_template'] = check_uuid.is_template
|
||||
all_host_vms[comp.id, comp.name][vm]['userinstances'] = get_userinstances_info(check_uuid)
|
||||
except Instance.DoesNotExist:
|
||||
check_uuid = Instance(compute_id=comp.id, name=vm, uuid=info['uuid'])
|
||||
check_uuid.save()
|
||||
|
||||
if not request.user.is_superuser:
|
||||
user_instances = UserInstance.objects.filter(user_id=request.user.id)
|
||||
for usr_inst in user_instances:
|
||||
|
@ -73,18 +94,12 @@ def instances(request):
|
|||
if connection_manager.host_is_up(comp.type, comp.hostname):
|
||||
try:
|
||||
conn = wvmHostDetails(comp, comp.login, comp.password, comp.type)
|
||||
if conn.get_host_instances():
|
||||
all_host_vms[comp.id, comp.name] = conn.get_host_instances()
|
||||
for vm, info in conn.get_host_instances().items():
|
||||
try:
|
||||
check_uuid = Instance.objects.get(compute_id=comp.id, name=vm)
|
||||
if check_uuid.uuid != info['uuid']:
|
||||
check_uuid.save()
|
||||
all_host_vms[comp.id, comp.name][vm]['is_template'] = check_uuid.is_template
|
||||
all_host_vms[comp.id, comp.name][vm]['userinstances'] = get_userinstances_info(check_uuid)
|
||||
except Instance.DoesNotExist:
|
||||
check_uuid = Instance(compute_id=comp.id, name=vm, uuid=info['uuid'])
|
||||
check_uuid.save()
|
||||
host_instances = conn.get_host_instances()
|
||||
if host_instances:
|
||||
all_host_vms[comp.id, comp.name] = host_instances
|
||||
for vm, info in host_instances.items():
|
||||
refresh_instance_database(comp, vm, info)
|
||||
|
||||
conn.close()
|
||||
except libvirtError as lib_err:
|
||||
error_messages.append(lib_err)
|
||||
|
@ -167,8 +182,9 @@ def instance(request, compute_id, vname):
|
|||
error_messages = []
|
||||
messages = []
|
||||
compute = get_object_or_404(Compute, pk=compute_id)
|
||||
computes = Compute.objects.all()
|
||||
computes_count = len(computes)
|
||||
computes = Compute.objects.all().order_by('name')
|
||||
computes_count = computes.count()
|
||||
users = User.objects.all().order_by('username')
|
||||
publickeys = UserSSHKey.objects.filter(user_id=request.user.id)
|
||||
keymaps = QEMU_KEYMAPS
|
||||
console_types = QEMU_CONSOLE_TYPES
|
||||
|
@ -232,7 +248,7 @@ def instance(request, compute_id, vname):
|
|||
|
||||
def check_user_quota(instance, cpu, memory, disk_size):
|
||||
user_instances = UserInstance.objects.filter(user_id=request.user.id, instance__is_template=False)
|
||||
instance += len(user_instances)
|
||||
instance += user_instances.count()
|
||||
for usr_inst in user_instances:
|
||||
if connection_manager.host_is_up(usr_inst.instance.compute.type,
|
||||
usr_inst.instance.compute.hostname):
|
||||
|
@ -244,7 +260,8 @@ def instance(request, compute_id, vname):
|
|||
cpu += int(conn.get_vcpu())
|
||||
memory += int(conn.get_memory())
|
||||
for disk in conn.get_disk_device():
|
||||
disk_size += int(disk['size'])>>30
|
||||
if disk['size']:
|
||||
disk_size += int(disk['size'])>>30
|
||||
|
||||
ua = request.user.userattributes
|
||||
msg = ""
|
||||
|
@ -266,6 +283,18 @@ def instance(request, compute_id, vname):
|
|||
msg += " (%s > %s)" % (disk_size, ua.max_disk_size)
|
||||
return msg
|
||||
|
||||
def get_new_disk_dev(disks, bus):
|
||||
if bus == "virtio":
|
||||
dev_base = "vd"
|
||||
else:
|
||||
dev_base = "sd"
|
||||
existing_devs = [ disk['dev'] for disk in disks ]
|
||||
for l in string.lowercase:
|
||||
dev = dev_base + l
|
||||
if dev not in existing_devs:
|
||||
return dev
|
||||
raise Exception(_('None available device name'))
|
||||
|
||||
try:
|
||||
conn = wvmInstance(compute.hostname,
|
||||
compute.login,
|
||||
|
@ -285,7 +314,10 @@ def instance(request, compute_id, vname):
|
|||
disks = conn.get_disk_device()
|
||||
media = conn.get_media_device()
|
||||
networks = conn.get_net_device()
|
||||
media_iso = sorted(conn.get_iso_media())
|
||||
if len(media) != 0:
|
||||
media_iso = sorted(conn.get_iso_media())
|
||||
else:
|
||||
media_iso = []
|
||||
vcpu_range = conn.get_max_cpus()
|
||||
memory_range = [256, 512, 768, 1024, 2048, 4096, 6144, 8192, 16384]
|
||||
if memory not in memory_range:
|
||||
|
@ -305,6 +337,16 @@ def instance(request, compute_id, vname):
|
|||
console_passwd = conn.get_console_passwd()
|
||||
clone_free_names = get_clone_free_names()
|
||||
user_quota_msg = check_user_quota(0, 0, 0, 0)
|
||||
storages = sorted(conn.get_storages())
|
||||
cache_modes = sorted(conn.get_cache_modes().items())
|
||||
default_cache = settings.INSTANCE_VOLUME_DEFAULT_CACHE
|
||||
default_format = settings.INSTANCE_VOLUME_DEFAULT_FORMAT
|
||||
formats = conn.get_image_formats()
|
||||
default_bus = settings.INSTANCE_VOLUME_DEFAULT_BUS
|
||||
busses = conn.get_busses()
|
||||
default_bus = settings.INSTANCE_VOLUME_DEFAULT_BUS
|
||||
show_access_root_password = settings.SHOW_ACCESS_ROOT_PASSWORD
|
||||
show_access_ssh_keys = settings.SHOW_ACCESS_SSH_KEYS
|
||||
|
||||
try:
|
||||
instance = Instance.objects.get(compute_id=compute_id, name=vname)
|
||||
|
@ -411,7 +453,7 @@ def instance(request, compute_id, vname):
|
|||
msg = _("Please shutdow down your instance and then try again")
|
||||
error_messages.append(msg)
|
||||
|
||||
if 'resize' in request.POST and (request.user.is_superuser or userinstace.is_change):
|
||||
if 'resize' in request.POST and (request.user.is_superuser or request.user.is_staff or userinstace.is_change):
|
||||
new_vcpu = request.POST.get('vcpu', '')
|
||||
new_cur_vcpu = request.POST.get('cur_vcpu', '')
|
||||
new_memory = request.POST.get('memory', '')
|
||||
|
@ -444,6 +486,27 @@ def instance(request, compute_id, vname):
|
|||
addlogmsg(request.user.username, instance.name, msg)
|
||||
return HttpResponseRedirect(request.get_full_path() + '#resize')
|
||||
|
||||
if 'addvolume' in request.POST and (request.user.is_superuser or userinstace.is_change):
|
||||
connCreate = wvmCreate(compute.hostname,
|
||||
compute.login,
|
||||
compute.password,
|
||||
compute.type)
|
||||
storage = request.POST.get('storage', '')
|
||||
name = request.POST.get('name', '')
|
||||
extension = request.POST.get('extension', '')
|
||||
format = request.POST.get('format', '')
|
||||
size = request.POST.get('size', 0)
|
||||
meta_prealloc = request.POST.get('meta_prealloc', False)
|
||||
bus = request.POST.get('bus', '')
|
||||
cache = request.POST.get('cache', '')
|
||||
target = get_new_disk_dev(disks, bus)
|
||||
|
||||
path = connCreate.create_volume(storage, name, size, format, meta_prealloc, extension)
|
||||
conn.attach_disk(path, target, subdriver=format, cache=cache, targetbus=bus)
|
||||
msg = _('Attach new disk')
|
||||
addlogmsg(request.user.username, instance.name, msg)
|
||||
return HttpResponseRedirect(request.get_full_path() + '#resize')
|
||||
|
||||
if 'umount_iso' in request.POST:
|
||||
image = request.POST.get('path', '')
|
||||
dev = request.POST.get('umount_iso', '')
|
||||
|
@ -595,6 +658,68 @@ def instance(request, compute_id, vname):
|
|||
addlogmsg(request.user.username, instance.name, msg)
|
||||
return HttpResponseRedirect(request.get_full_path() + '#network')
|
||||
|
||||
if 'add_owner' in request.POST:
|
||||
user_id = int(request.POST.get('user_id', ''))
|
||||
|
||||
if settings.ALLOW_INSTANCE_MULTIPLE_OWNER:
|
||||
check_inst = UserInstance.objects.filter(instance=instance, user_id=user_id)
|
||||
else:
|
||||
check_inst = UserInstance.objects.filter(instance=instance)
|
||||
|
||||
if check_inst:
|
||||
msg = _("Owner already added")
|
||||
error_messages.append(msg)
|
||||
else:
|
||||
add_user_inst = UserInstance(instance=instance, user_id=user_id)
|
||||
add_user_inst.save()
|
||||
msg = _("Added owner %d" % user_id)
|
||||
addlogmsg(request.user.username, instance.name, msg)
|
||||
return HttpResponseRedirect(request.get_full_path() + '#users')
|
||||
|
||||
if 'del_owner' in request.POST:
|
||||
userinstance_id = int(request.POST.get('userinstance', ''))
|
||||
userinstance = UserInstance.objects.get(pk=userinstance_id)
|
||||
userinstance.delete()
|
||||
msg = _("Deleted owner %d" % userinstance_id)
|
||||
addlogmsg(request.user.username, instance.name, msg)
|
||||
return HttpResponseRedirect(request.get_full_path() + '#users')
|
||||
|
||||
|
||||
if request.user.is_superuser or request.user.userattributes.can_clone_instances:
|
||||
if 'clone' in request.POST:
|
||||
clone_data = {}
|
||||
clone_data['name'] = request.POST.get('name', '')
|
||||
|
||||
disk_sum = sum([disk['size']>>30 for disk in disks])
|
||||
quota_msg = check_user_quota(1, vcpu, memory, disk_sum)
|
||||
check_instance = Instance.objects.filter(name=clone_data['name'])
|
||||
|
||||
for post in request.POST:
|
||||
clone_data[post] = request.POST.get(post, '').strip()
|
||||
|
||||
if not request.user.is_superuser and quota_msg:
|
||||
msg = _("User %s quota reached, cannot create '%s'!" % (quota_msg, clone_data['name']))
|
||||
error_messages.append(msg)
|
||||
elif check_instance:
|
||||
msg = _("Instance '%s' already exists!" % clone_data['name'])
|
||||
error_messages.append(msg)
|
||||
elif not re.match(r'^[a-zA-Z0-9-]+$', clone_data['name']):
|
||||
msg = _("Instance name '%s' contains invalid characters!" % clone_data['name'])
|
||||
error_messages.append(msg)
|
||||
elif not re.match(r'^([0-9A-F]{2})(\:?[0-9A-F]{2}){5}$', clone_data['clone-net-mac-0'], re.IGNORECASE):
|
||||
msg = _("Instance mac '%s' invalid format!" % clone_data['clone-net-mac-0'])
|
||||
error_messages.append(msg)
|
||||
else:
|
||||
new_uuid = conn.clone_instance(clone_data)
|
||||
new_instance = Instance(compute_id=compute_id, name=clone_data['name'], uuid=new_uuid)
|
||||
new_instance.save()
|
||||
userinstance = UserInstance(instance_id=new_instance.id, user_id=request.user.id, is_delete=True)
|
||||
userinstance.save()
|
||||
|
||||
msg = _("Clone of '%s'" % instance.name)
|
||||
addlogmsg(request.user.username, new_instance.name, msg)
|
||||
return HttpResponseRedirect(reverse('instance', args=[compute_id, clone_data['name']]))
|
||||
|
||||
if 'change_options' in request.POST:
|
||||
instance.is_template = request.POST.get('is_template', False)
|
||||
instance.save()
|
||||
|
@ -609,38 +734,6 @@ def instance(request, compute_id, vname):
|
|||
addlogmsg(request.user.username, instance.name, msg)
|
||||
return HttpResponseRedirect(request.get_full_path() + '#options')
|
||||
|
||||
if request.user.is_superuser or request.user.userattributes.can_clone_instances:
|
||||
if 'clone' in request.POST:
|
||||
clone_data = {}
|
||||
clone_data['name'] = request.POST.get('name', '')
|
||||
|
||||
disk_sum = sum([disk['size']>>30 for disk in disks])
|
||||
quota_msg = check_user_quota(1, vcpu, memory, disk_sum)
|
||||
check_instance = Instance.objects.filter(name=clone_data['name'])
|
||||
|
||||
if not request.user.is_superuser and quota_msg:
|
||||
msg = _("User %s quota reached, cannot create '%s'!" % (quota_msg, clone_data['name']))
|
||||
error_messages.append(msg)
|
||||
elif check_instance:
|
||||
msg = _("Instance '%s' already exists!" % clone_data['name'])
|
||||
error_messages.append(msg)
|
||||
elif not re.match(r'^[a-zA-Z0-9-]+$', clone_data['name']):
|
||||
msg = _("Instance name '%s' contains invalid characters!" % clone_data['name'])
|
||||
error_messages.append(msg)
|
||||
else:
|
||||
for post in request.POST:
|
||||
clone_data[post] = request.POST.get(post, '')
|
||||
|
||||
new_uuid = conn.clone_instance(clone_data)
|
||||
new_instance = Instance(compute_id=compute_id, name=clone_data['name'], uuid=new_uuid)
|
||||
new_instance.save()
|
||||
userinstance = UserInstance(instance_id=new_instance.id, user_id=request.user.id, is_delete=True)
|
||||
userinstance.save()
|
||||
|
||||
msg = _("Clone of '%s'" % instance.name)
|
||||
addlogmsg(request.user.username, new_instance.name, msg)
|
||||
return HttpResponseRedirect(reverse('instance', args=[compute_id, clone_data['name']]))
|
||||
|
||||
conn.close()
|
||||
|
||||
except libvirtError as lib_err:
|
||||
|
@ -795,7 +888,7 @@ def guess_mac_address(request, vname):
|
|||
if name_found and "hardware ethernet" in line:
|
||||
data['mac'] = line.split(' ')[-1].strip().strip(';')
|
||||
break
|
||||
return HttpResponse(json.dumps(data));
|
||||
return HttpResponse(json.dumps(data))
|
||||
|
||||
@login_required
|
||||
def guess_clone_name(request):
|
||||
|
@ -811,7 +904,7 @@ def guess_clone_name(request):
|
|||
hostname = fqdn.split('.')[0]
|
||||
if hostname.startswith(prefix) and hostname not in instance_names:
|
||||
return HttpResponse(json.dumps({'name': hostname}))
|
||||
return HttpResponse(json.dumps({}));
|
||||
return HttpResponse(json.dumps({}))
|
||||
|
||||
@login_required
|
||||
def check_instance(request, vname):
|
||||
|
@ -819,4 +912,62 @@ def check_instance(request, vname):
|
|||
data = { 'vname': vname, 'exists': False }
|
||||
if check_instance:
|
||||
data['exists'] = True
|
||||
return HttpResponse(json.dumps(data));
|
||||
return HttpResponse(json.dumps(data))
|
||||
|
||||
def sshkeys(request, vname):
|
||||
"""
|
||||
:param request:
|
||||
:param vm:
|
||||
:return:
|
||||
"""
|
||||
|
||||
instance_keys = []
|
||||
userinstances = UserInstance.objects.filter(instance__name=vname)
|
||||
|
||||
for ui in userinstances:
|
||||
keys = UserSSHKey.objects.filter(user=ui.user)
|
||||
for k in keys:
|
||||
instance_keys.append(k.keypublic)
|
||||
if request.GET.get('plain', ''):
|
||||
response = '\n'.join(instance_keys)
|
||||
response += '\n'
|
||||
else:
|
||||
response = json.dumps(instance_keys)
|
||||
return HttpResponse(response)
|
||||
|
||||
def delete_instance(instance, delete_disk=False):
|
||||
compute = instance.compute
|
||||
instance_name = instance.name
|
||||
try:
|
||||
conn = wvmInstance(compute.hostname,
|
||||
compute.login,
|
||||
compute.password,
|
||||
compute.type,
|
||||
instance.name)
|
||||
|
||||
del_userinstance = UserInstance.objects.filter(instance=instance)
|
||||
if del_userinstance:
|
||||
print("Deleting UserInstances")
|
||||
print(del_userinstance)
|
||||
del_userinstance.delete()
|
||||
|
||||
if conn.get_status() == 1:
|
||||
print("Forcing shutdown")
|
||||
conn.force_shutdown()
|
||||
if delete_disk:
|
||||
snapshots = sorted(conn.get_snapshot(), reverse=True)
|
||||
for snap in snapshots:
|
||||
print("Deleting snapshot {}".format(snap['name']))
|
||||
conn.snapshot_delete(snap['name'])
|
||||
print("Deleting disks")
|
||||
conn.delete_disk()
|
||||
|
||||
conn.delete()
|
||||
instance.delete()
|
||||
|
||||
print("Instance {} on compute {} sucessfully deleted".format(instance_name, compute.hostname))
|
||||
|
||||
except libvirtError as lib_err:
|
||||
print("Error removing instance {} on compute {}".format(instance_name, compute.hostname))
|
||||
raise lib_err
|
||||
|
||||
|
|
|
@ -4,4 +4,5 @@ from . import views
|
|||
urlpatterns = [
|
||||
url(r'^$', views.showlogs, name='showlogs'),
|
||||
url(r'^(?P<page>[0-9]+)/$', views.showlogs, name='showlogspage'),
|
||||
url(r'^vm_logs/(?P<vname>[\w\-\.]+)/$', views.vm_logs, name='vm_logs'),
|
||||
]
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
from django.shortcuts import render
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.http import HttpResponse, HttpResponseRedirect
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from instances.models import Instance
|
||||
from logs.models import Logs
|
||||
from django.conf import settings
|
||||
import json
|
||||
|
||||
|
||||
def addlogmsg(user, instance, message):
|
||||
|
@ -14,15 +17,13 @@ def addlogmsg(user, instance, message):
|
|||
add_log_msg.save()
|
||||
|
||||
|
||||
@login_required
|
||||
def showlogs(request, page=1):
|
||||
"""
|
||||
:param request:
|
||||
:return:
|
||||
"""
|
||||
|
||||
if not request.user.is_authenticated():
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
|
||||
if not request.user.is_superuser:
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
|
||||
|
@ -34,3 +35,27 @@ def showlogs(request, page=1):
|
|||
# TODO: remove last element from queryset, but do not affect database
|
||||
|
||||
return render(request, 'showlogs.html', locals())
|
||||
|
||||
@login_required
|
||||
def vm_logs(request, vname):
|
||||
"""
|
||||
:param request:
|
||||
:param vm:
|
||||
:return:
|
||||
"""
|
||||
|
||||
if not request.user.is_superuser:
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
|
||||
vm = Instance.objects.get(name=vname)
|
||||
logs_ = Logs.objects.filter(instance=vm.name, date__gte=vm.created).order_by('-date')
|
||||
logs = []
|
||||
for l in logs_:
|
||||
log = {}
|
||||
log['user'] = l.user
|
||||
log['instance'] = l.instance
|
||||
log['message'] = l.message
|
||||
log['date'] = l.date.strftime('%x %X')
|
||||
logs.append(log)
|
||||
|
||||
return HttpResponse(json.dumps(logs))
|
||||
|
|
|
@ -18,8 +18,10 @@
|
|||
<script>
|
||||
function showPleaseWaitDialog() {
|
||||
$('#pleaseWaitDialog').modal();
|
||||
backgroundJobRunning = true;
|
||||
}
|
||||
function hidePleaseWaitDialog() {
|
||||
$('#pleaseWaitDialog').modal('hide');
|
||||
backgroundJobRunning = false;
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -380,6 +380,25 @@ class wvmConnect(object):
|
|||
interface.append(inface)
|
||||
return interface
|
||||
|
||||
def get_cache_modes(self):
|
||||
"""Get cache available modes"""
|
||||
return {
|
||||
'default': 'Default',
|
||||
'none': 'Disabled',
|
||||
'writethrough': 'Write through',
|
||||
'writeback': 'Write back',
|
||||
'directsync': 'Direct sync', # since libvirt 0.9.5
|
||||
'unsafe': 'Unsafe', # since libvirt 0.9.7
|
||||
}
|
||||
|
||||
def get_busses(self):
|
||||
"""Get available busses"""
|
||||
return [ 'ide', 'scsi', 'usb', 'virtio' ]
|
||||
|
||||
def get_image_formats(self):
|
||||
"""Get available image formats"""
|
||||
return [ 'raw', 'qcow', 'qcow2' ]
|
||||
|
||||
def get_iface(self, name):
|
||||
return self.wvm.interfaceLookupByName(name)
|
||||
|
||||
|
@ -424,55 +443,71 @@ class wvmConnect(object):
|
|||
|
||||
def get_net_device(self):
|
||||
netdevice = []
|
||||
def get_info(ctx):
|
||||
dev_type = util.get_xpath(ctx, '/device/capability/@type')
|
||||
interface = util.get_xpath(ctx, '/device/capability/interface')
|
||||
return (dev_type, interface)
|
||||
for dev in self.wvm.listAllDevices(0):
|
||||
xml = dev.XMLDesc(0)
|
||||
dev_type = util.get_xml_path(xml, '/device/capability/@type')
|
||||
(dev_type, interface) = util.get_xml_path(xml, func=get_info)
|
||||
if dev_type == 'net':
|
||||
netdevice.append(util.get_xml_path(xml, '/device/capability/interface'))
|
||||
netdevice.append(interface)
|
||||
return netdevice
|
||||
|
||||
def get_host_instances(self):
|
||||
vname = {}
|
||||
for name in self.get_instances():
|
||||
dom = self.get_instance(name)
|
||||
mem = util.get_xml_path(dom.XMLDesc(0), "/domain/currentMemory")
|
||||
def get_info(ctx):
|
||||
mem = util.get_xpath(ctx, "/domain/currentMemory")
|
||||
mem = int(mem) / 1024
|
||||
cur_vcpu = util.get_xml_path(dom.XMLDesc(0), "/domain/vcpu/@current")
|
||||
cur_vcpu = util.get_xpath(ctx, "/domain/vcpu/@current")
|
||||
if cur_vcpu:
|
||||
vcpu = cur_vcpu
|
||||
else:
|
||||
vcpu = util.get_xml_path(dom.XMLDesc(0), "/domain/vcpu")
|
||||
title = util.get_xml_path(dom.XMLDesc(0), "/domain/title")
|
||||
description = util.get_xml_path(dom.XMLDesc(0), "/domain/description")
|
||||
vcpu = util.get_xpath(ctx, "/domain/vcpu")
|
||||
title = util.get_xpath(ctx, "/domain/title")
|
||||
title = title if title else ''
|
||||
description = util.get_xpath(ctx, "/domain/description")
|
||||
description = description if description else ''
|
||||
return (mem, vcpu, title, description)
|
||||
for name in self.get_instances():
|
||||
dom = self.get_instance(name)
|
||||
xml = dom.XMLDesc(0)
|
||||
(mem, vcpu, title, description) = util.get_xml_path(xml, func=get_info)
|
||||
vname[dom.name()] = {
|
||||
'status': dom.info()[0],
|
||||
'uuid': dom.UUIDString(),
|
||||
'vcpu': vcpu,
|
||||
'memory': mem,
|
||||
'title': title if title else '',
|
||||
'description': description if description else '',
|
||||
'title': title,
|
||||
'description': description,
|
||||
}
|
||||
return vname
|
||||
|
||||
def get_user_instances(self, name):
|
||||
dom = self.get_instance(name)
|
||||
mem = util.get_xml_path(dom.XMLDesc(0), "/domain/currentMemory")
|
||||
mem = int(mem) / 1024
|
||||
cur_vcpu = util.get_xml_path(dom.XMLDesc(0), "/domain/vcpu/@current")
|
||||
if cur_vcpu:
|
||||
vcpu = cur_vcpu
|
||||
else:
|
||||
vcpu = util.get_xml_path(dom.XMLDesc(0), "/domain/vcpu")
|
||||
title = util.get_xml_path(dom.XMLDesc(0), "/domain/title")
|
||||
description = util.get_xml_path(dom.XMLDesc(0), "/domain/description")
|
||||
xml = dom.XMLDesc(0)
|
||||
def get_info(ctx):
|
||||
mem = util.get_xpath(ctx, "/domain/currentMemory")
|
||||
mem = int(mem) / 1024
|
||||
cur_vcpu = util.get_xpath(ctx, "/domain/vcpu/@current")
|
||||
if cur_vcpu:
|
||||
vcpu = cur_vcpu
|
||||
else:
|
||||
vcpu = util.get_xpath(ctx, "/domain/vcpu")
|
||||
title = util.get_xpath(ctx, "/domain/title")
|
||||
title = title if title else ''
|
||||
description = util.get_xpath(ctx, "/domain/description")
|
||||
description = description if description else ''
|
||||
return (mem, vcpu, title, description)
|
||||
(mem, vcpu, title, description) = util.get_xml_path(xml, func=get_info)
|
||||
return {
|
||||
'name': dom.name(),
|
||||
'status': dom.info()[0],
|
||||
'uuid': dom.UUIDString(),
|
||||
'vcpu': vcpu,
|
||||
'memory': mem,
|
||||
'title': title if title else '',
|
||||
'description': description if description else '',
|
||||
'title': title,
|
||||
'description': description,
|
||||
}
|
||||
|
||||
def close(self):
|
||||
|
|
|
@ -48,23 +48,12 @@ class wvmCreate(wvmConnect):
|
|||
"""Get guest capabilities"""
|
||||
return util.get_xml_path(self.get_cap_xml(), "/capabilities/host/cpu/arch")
|
||||
|
||||
def get_cache_modes(self):
|
||||
"""Get cache available modes"""
|
||||
return {
|
||||
'default': 'Default',
|
||||
'none': 'Disabled',
|
||||
'writethrough': 'Write through',
|
||||
'writeback': 'Write back',
|
||||
'directsync': 'Direct sync', # since libvirt 0.9.5
|
||||
'unsafe': 'Unsafe', # since libvirt 0.9.7
|
||||
}
|
||||
|
||||
def create_volume(self, storage, name, size, format='qcow2', metadata=False):
|
||||
def create_volume(self, storage, name, size, format='qcow2', metadata=False, image_extension='img'):
|
||||
size = int(size) * 1073741824
|
||||
stg = self.get_storage(storage)
|
||||
storage_type = util.get_xml_path(stg.XMLDesc(0), "/pool/@type")
|
||||
if storage_type == 'dir':
|
||||
name += '.img'
|
||||
name += '.' + image_extension
|
||||
alloc = 0
|
||||
else:
|
||||
alloc = size
|
||||
|
|
|
@ -340,6 +340,22 @@ class wvmInstance(wvmConnect):
|
|||
xmldom = ElementTree.tostring(tree)
|
||||
self._defineXML(xmldom)
|
||||
|
||||
def attach_disk(self, source, target, sourcetype='file', type='disk', driver='qemu', subdriver='raw', cache='none', targetbus='ide'):
|
||||
tree = ElementTree.fromstring(self._XMLDesc(0))
|
||||
xml_disk = """
|
||||
<disk type='%s' device='%s'>
|
||||
<driver name='%s' type='%s' cache='%s'/>
|
||||
<source file='%s'/>
|
||||
<target dev='%s' bus='%s'/>
|
||||
</disk>
|
||||
""" % (sourcetype, type, driver, subdriver, cache, source, target, targetbus)
|
||||
if self.get_status() == 5:
|
||||
devices = tree.find('devices')
|
||||
elm_disk = ElementTree.fromstring(xml_disk)
|
||||
devices.append(elm_disk)
|
||||
xmldom = ElementTree.tostring(tree)
|
||||
self._defineXML(xmldom)
|
||||
|
||||
def cpu_usage(self):
|
||||
cpu_usage = {}
|
||||
if self.get_status() == 1:
|
||||
|
|
|
@ -94,13 +94,7 @@ def get_xml_path(xml, path=None, func=None):
|
|||
ctx = doc.xpathNewContext()
|
||||
|
||||
if path:
|
||||
ret = ctx.xpathEval(path)
|
||||
if ret is not None:
|
||||
if type(ret) == list:
|
||||
if len(ret) >= 1:
|
||||
result = ret[0].content
|
||||
else:
|
||||
result = ret
|
||||
result = get_xpath(ctx, path)
|
||||
|
||||
elif func:
|
||||
result = func(ctx)
|
||||
|
@ -115,6 +109,19 @@ def get_xml_path(xml, path=None, func=None):
|
|||
return result
|
||||
|
||||
|
||||
def get_xpath(ctx, path):
|
||||
result = None
|
||||
ret = ctx.xpathEval(path)
|
||||
if ret is not None:
|
||||
if type(ret) == list:
|
||||
if len(ret) >= 1:
|
||||
result = ret[0].content
|
||||
else:
|
||||
result = ret
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def pretty_mem(val):
|
||||
val = int(val)
|
||||
if val > (10 * 1024 * 1024):
|
||||
|
|
|
@ -6,7 +6,7 @@ Django settings for webvirtcloud project.
|
|||
import os
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
|
||||
|
||||
SECRET_KEY = '4y(f4rfqc6f2!i8_vfuu)kav6tdv5#sc=n%o451dm+th0&3uci'
|
||||
SECRET_KEY = ''
|
||||
|
||||
DEBUG = True
|
||||
|
||||
|
@ -44,10 +44,10 @@ MIDDLEWARE_CLASSES = (
|
|||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
)
|
||||
|
||||
#AUTHENTICATION_BACKENDS = (
|
||||
# 'django.contrib.auth.backends.RemoteUserBackend',
|
||||
# #'accounts.backends.MyRemoteUserBackend',
|
||||
#)
|
||||
AUTHENTICATION_BACKENDS = (
|
||||
#'django.contrib.auth.backends.RemoteUserBackend',
|
||||
#'accounts.backends.MyRemoteUserBackend',
|
||||
)
|
||||
|
||||
LOGIN_URL = '/accounts/login'
|
||||
|
||||
|
@ -78,9 +78,13 @@ STATICFILES_DIRS = (
|
|||
os.path.join(BASE_DIR, "static"),
|
||||
)
|
||||
|
||||
TEMPLATE_DIRS = (
|
||||
os.path.join(BASE_DIR, 'templates'),
|
||||
)
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [ os.path.join(BASE_DIR, 'templates'), ],
|
||||
'APP_DIRS': True,
|
||||
}
|
||||
]
|
||||
|
||||
## WebVirtCloud settings
|
||||
|
||||
|
@ -113,7 +117,14 @@ LIBVIRT_KEEPALIVE_INTERVAL = 5
|
|||
LIBVIRT_KEEPALIVE_COUNT = 5
|
||||
|
||||
ALLOW_INSTANCE_MULTIPLE_OWNER = True
|
||||
CLONE_INSTANCE_DEFAULT_PREFIX = 'ourea'
|
||||
NEW_USER_DEFAULT_INSTANCES = []
|
||||
CLONE_INSTANCE_DEFAULT_PREFIX = 'instance'
|
||||
LOGS_PER_PAGE = 100
|
||||
QUOTA_DEBUG = True
|
||||
ALLOW_EMPTY_PASSWORD = True
|
||||
SHOW_ACCESS_ROOT_PASSWORD = False
|
||||
SHOW_ACCESS_SSH_KEYS = False
|
||||
SHOW_PROFILE_EDIT_PASSWORD = False
|
||||
INSTANCE_VOLUME_DEFAULT_FORMAT = 'qcow2'
|
||||
INSTANCE_VOLUME_DEFAULT_BUS = 'virtio'
|
||||
INSTANCE_VOLUME_DEFAULT_CACHE = 'directsync'
|
Loading…
Reference in a new issue