Commit bf256930 by Őry Máté

Merge branch 'feature-vm-gc' into 'master'

Feature: destroy&suspend expired VMs

See #80

*  suspend/destroy if expired
*  notification about suspend/destroy happened
*  renew from gui #45
*  notify about expiration coming
*  tests
parents 1ffe10b4 e185ea3e
......@@ -151,9 +151,9 @@ class AclBase(Model):
return True
return False
def get_users_with_level(self):
def get_users_with_level(self, **kwargs):
logger.debug('%s.get_users_with_level() called', unicode(self))
object_levels = (self.object_level_set.select_related(
object_levels = (self.object_level_set.filter(**kwargs).select_related(
'users', 'level').all())
users = []
for object_level in object_levels:
......
......@@ -29,3 +29,12 @@ CACHES = {
'BACKEND': 'django.core.cache.backends.dummy.DummyCache'
}
}
LOGGING['loggers']['djangosaml2'] = {'handlers': ['console'],
'level': 'CRITICAL'}
LOGGING['handlers']['console'] = {'level': 'WARNING',
'class': 'logging.StreamHandler',
'formatter': 'simple'}
for i in LOCAL_APPS:
LOGGING['loggers'][i] = {'handlers': ['console'], 'level': 'CRITICAL'}
......@@ -1372,6 +1372,35 @@
}
},
{
"pk": 12,
"model": "vm.instance",
"fields": {
"destroyed": null,
"disks": [],
"boot_menu": false,
"owner": 1,
"time_of_delete": null,
"max_ram_size": 200,
"pw": "ads",
"time_of_suspend": null,
"ram_size": 200,
"priority": 4,
"active_since": null,
"template": null,
"access_method": "nx",
"lease": 1,
"node": null,
"description": "",
"arch": "x86_64",
"name": "vanneve",
"created": "2013-09-16T09:05:59.991Z",
"raw_data": "",
"vnc_port": 1235,
"num_cores": 2,
"modified": "2013-10-14T07:27:38.192Z"
}
},
{
"pk": 1,
"model": "firewall.domain",
"fields": {
......
......@@ -5,7 +5,8 @@ from django.conf import settings
from django.contrib.auth.models import User, Group
from django.contrib.auth.signals import user_logged_in
from django.db.models import (
Model, ForeignKey, OneToOneField, CharField, IntegerField, TextField
Model, ForeignKey, OneToOneField, CharField, IntegerField, TextField,
DateTimeField,
)
from django.template.loader import render_to_string
from django.utils.translation import ugettext_lazy as _, override
......@@ -34,12 +35,13 @@ class Notification(TimeStampedModel):
to = ForeignKey(User)
subject = CharField(max_length=128)
message = TextField()
valid_until = DateTimeField(null=True, default=None)
class Meta:
ordering = ['-created']
@classmethod
def send(cls, user, subject, template, context={}):
def send(cls, user, subject, template, context={}, valid_until=None):
try:
language = user.profile.preferred_language
except:
......@@ -48,7 +50,8 @@ class Notification(TimeStampedModel):
context['user'] = user
rendered = render_to_string(template, context)
subject = unicode(subject)
return cls.objects.create(to=user, subject=subject, message=rendered)
return cls.objects.create(to=user, subject=subject, message=rendered,
valid_until=valid_until)
class Profile(Model):
......@@ -62,8 +65,9 @@ class Profile(Model):
help_text=_('Unique identifier of the person, e.g. a student number.'))
instance_limit = IntegerField(default=5)
def notify(self, subject, template, context={}):
return Notification.send(self.user, subject, template, context)
def notify(self, subject, template, context={}, valid_until=None):
return Notification.send(self.user, subject, template, context,
valid_until)
class GroupProfile(AclBase):
......
......@@ -3,6 +3,10 @@ $(function() {
if(decideActivityRefresh()) {
checkNewActivity(false, 1);
}
$('a[href="#activity"]').click(function(){
$('a[href="#activity"] i').addClass('icon-spin');
checkNewActivity(false,0);
});
/* save resources */
$('#vm-details-resources-save').click(function() {
......@@ -211,13 +215,13 @@ function checkNewActivity(only_state, runs) {
$("[data-target=#_console]").attr("data-toggle", "_pill").attr("href", "#").parent("li").addClass("disabled");
}
if(decideActivityRefresh()) {
console.log("szia");
if(runs > 0 && decideActivityRefresh()) {
setTimeout(
function() {checkNewActivity(only_state, runs + 1)},
1000 + runs * 250
1000 + Math.exp(runs * 0.05)
);
}
$('a[href="#activity"] i').removeClass('icon-spin');
},
error: function() {
......
{% extends "dashboard/base.html" %}
{% load i18n %}
{% block content %}
<div class="body-content">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="no-margin">
{%blocktrans with instance=instance.name%}
Renewing <em>{{instance}}</em>
{%endblocktrans%}
</h3>
</div>
<div class="panel-body">
{%blocktrans with object=instance.name%}
Do you want to renew <strong>{{ object }}</strong>?
{%endblocktrans%}
{%blocktrans with suspend=time_of_suspend delete=time_of_delete|default:"n/a" %}
The instance will be suspended at <em>{{suspend}}</em>
and removed at <em>{{delete}}</em> if you renew it now.
{%endblocktrans%}
<div class="pull-right">
<form action="" method="POST">
{% csrf_token %}
<a class="btn btn-default"
href="{{instance.get_absolute_path}}">{% trans "Back" %}</a>
<button class="btn btn-danger">{% trans "Renew" %}</button>
</form>
</div>
</div>
</div>
{% endblock %}
{%load i18n%}
{%blocktrans with instance=instance.name url=instance.get_absolute_url %}
Your instance <a href="{{url}}">{{instance}}</a> has been destroyed due to expiration.
{%endblocktrans%}
{%load i18n%}
{%blocktrans with instance=instance.name url=instance.get_absolute_url suspend=instance.time_of_suspend delete=instance.time_of_delete %}
Your instance <a href="{{url}}">{{instance}}</a> is going to expire.
It will be suspended at {{suspend}} and destroyed at {{delete}}.
{%endblocktrans%}
{%blocktrans with token=token url=instance.get_absolute_url %}
Please, either <a href="{{token}}">renew</a> or <a href="{{url}}">destroy</a>
it now.
{%endblocktrans%}
{%load i18n%}
{%blocktrans with instance=instance.name url=instance.get_absolute_url %}
Your instance <a href="{{url}}">{{instance}}</a> has been suspended due to expiration.
{%endblocktrans%}
......@@ -8,6 +8,16 @@
<dd><small>{{ instance.description }}</small></dd>
</dl>
<h4>{% trans "Expiration" %} {% if instance.is_expiring %}<i class="icon-warning-sign text-danger"></i>{% endif %}
<a href="{% url "dashboard.views.vm-renew" instance.pk "" %}" class="btn btn-success btn-xs pull-right">{% trans "renew" %}</a>
</h4>
<dl>
<dt>{% trans "Suspended at:" %}</dt>
<dd><i class="icon-moon"></i> {{ instance.time_of_suspend|timeuntil }}</dd>
<dt>{% trans "Destroyed at:" %}</dt>
<dd><i class="icon-remove"></i> {{ instance.time_of_delete|timeuntil }}</dd>
</dl>
<div style="font-weight: bold;">{% trans "Tags" %}</div>
<div id="vm-details-tags" style="margin-bottom: 20px;">
<div id="vm-details-tags-list">
......
......@@ -3,6 +3,7 @@ from django.contrib.auth.models import User
from django.test import TestCase
from ..models import Profile
from ..views import search_user
class NotificationTestCase(TestCase):
......@@ -25,3 +26,27 @@ class NotificationTestCase(TestCase):
assert 'user1' in msg.message
assert 'testme' in msg.message
assert msg in self.u1.notification_set.all()
class ProfileTestCase(TestCase):
def setUp(self):
self.u1 = User.objects.create(username='user1')
Profile.objects.get_or_create(user=self.u1)
self.u2 = User.objects.create(username='user2',
email='john@example.org')
Profile.objects.get_or_create(user=self.u2, org_id='apple')
def test_search_user_by_name(self):
self.assertEqual(search_user('user1'), self.u1)
self.assertEqual(search_user('user2'), self.u2)
def test_search_user_by_mail(self):
self.assertEqual(search_user('john@example.org'), self.u2)
def test_search_user_by_orgid(self):
self.assertEqual(search_user('apple'), self.u2)
def test_search_user_nonexist(self):
with self.assertRaises(User.DoesNotExist):
search_user('no-such-user')
......@@ -2,9 +2,11 @@ from django.test import TestCase
from django.test.client import Client
from django.contrib.auth.models import User, Group
from django.core.exceptions import SuspiciousOperation
from django.core.urlresolvers import reverse
from vm.models import Instance, InstanceTemplate, Lease, Node
from ..models import Profile
from ..views import VmRenewView
from storage.models import Disk
from firewall.models import Vlan
......@@ -356,3 +358,97 @@ class TransferOwnershipViewTest(LoginMixin, TestCase):
self.login(c, 'user2')
response = c.post(url)
self.assertEquals(Instance.objects.get(pk=1).owner.pk, self.u2.pk)
class RenewViewTest(LoginMixin, TestCase):
fixtures = ['test-vm-fixture.json']
def setUp(self):
self.u1 = User.objects.create(username='user1')
self.u1.set_password('password')
self.u1.save()
Profile.objects.create(user=self.u1)
self.u2 = User.objects.create(username='user2', is_staff=True)
self.u2.set_password('password')
self.u2.save()
Profile.objects.create(user=self.u2)
self.us = User.objects.create(username='superuser', is_superuser=True)
self.us.set_password('password')
self.us.save()
Profile.objects.create(user=self.us)
inst = Instance.objects.get(pk=1)
inst.owner = self.u1
inst.save()
def test_renew_by_owner(self):
c = Client()
ct = Instance.objects.get(pk=1).activity_log.\
filter(activity_code__endswith='renew').count()
self.login(c, 'user1')
response = c.get('/dashboard/vm/1/renew/')
self.assertEquals(response.status_code, 200)
response = c.post('/dashboard/vm/1/renew/')
self.assertEquals(response.status_code, 302)
ct2 = Instance.objects.get(pk=1).activity_log.\
filter(activity_code__endswith='renew').count()
self.assertEquals(ct + 1, ct2)
def test_renew_get_by_nonowner_wo_key(self):
c = Client()
self.login(c, 'user2')
response = c.get('/dashboard/vm/1/renew/')
self.assertEquals(response.status_code, 403)
def test_renew_post_by_nonowner_wo_key(self):
c = Client()
self.login(c, 'user2')
response = c.post('/dashboard/vm/1/renew/')
self.assertEquals(response.status_code, 403)
def test_renew_get_by_nonowner_w_key(self):
key = VmRenewView.get_token_url(Instance.objects.get(pk=1), self.u2)
c = Client()
response = c.get(key)
self.assertEquals(response.status_code, 200)
def test_renew_post_by_anon_w_key(self):
key = VmRenewView.get_token_url(Instance.objects.get(pk=1), self.u2)
ct = Instance.objects.get(pk=1).activity_log.\
filter(activity_code__endswith='renew').count()
c = Client()
response = c.post(key)
self.assertEquals(response.status_code, 302)
ct2 = Instance.objects.get(pk=1).activity_log.\
filter(activity_code__endswith='renew').count()
self.assertEquals(ct + 1, ct2)
def test_renew_post_by_anon_w_invalid_key(self):
class Mockinst(object):
pk = 2
key = VmRenewView.get_token_url(Mockinst(), self.u2)
ct = Instance.objects.get(pk=1).activity_log.\
filter(activity_code__endswith='renew').count()
c = Client()
self.login(c, 'user2')
response = c.get(key)
self.assertEquals(response.status_code, 404)
response = c.post(key)
self.assertEquals(response.status_code, 404)
ct2 = Instance.objects.get(pk=1).activity_log.\
filter(activity_code__endswith='renew').count()
self.assertEquals(ct, ct2)
def test_renew_post_by_anon_w_expired_key(self):
key = reverse(VmRenewView.url_name, args=(
12, 'WzEyLDFd:1WLbSi:2zIb8SUNAIRIOMTmSmKSSit2gpY'))
ct = Instance.objects.get(pk=12).activity_log.\
filter(activity_code__endswith='renew').count()
c = Client()
self.login(c, 'user2')
response = c.get(key)
self.assertEquals(response.status_code, 302)
response = c.post(key)
self.assertEquals(response.status_code, 403)
ct2 = Instance.objects.get(pk=12).activity_log.\
filter(activity_code__endswith='renew').count()
self.assertEquals(ct, ct2)
......@@ -9,7 +9,7 @@ from .views import (
FavouriteView, NodeStatus, GroupList, TemplateDelete, LeaseDelete,
VmGraphView, TemplateAclUpdateView, GroupDetailView, GroupDelete,
GroupAclUpdateView, GroupUserDelete, NotificationView, NodeGraphView,
VmMigrateView, VmDetailVncTokenView,
VmMigrateView, VmDetailVncTokenView, VmRenewView,
)
urlpatterns = patterns(
......@@ -53,6 +53,8 @@ urlpatterns = patterns(
url(r'^vm/(?P<pk>\d+)/activity/$', vm_activity),
url(r'^vm/(?P<pk>\d+)/migrate/$', VmMigrateView.as_view(),
name='dashboard.views.vm-migrate'),
url(r'^vm/(?P<pk>\d+)/renew/((?P<key>.*)/?)$', VmRenewView.as_view(),
name='dashboard.views.vm-renew'),
url(r'^node/list/$', NodeList.as_view(), name='dashboard.views.node-list'),
url(r'^node/(?P<pk>\d+)/$', NodeDetailView.as_view(),
......
......@@ -7,7 +7,7 @@ import requests
from django.conf import settings
from django.contrib.auth.models import User, Group
from django.contrib.auth.views import login
from django.contrib.auth.views import login, redirect_to_login
from django.contrib.messages import warning
from django.core.exceptions import (
PermissionDenied, SuspiciousOperation,
......@@ -15,7 +15,7 @@ from django.core.exceptions import (
from django.core import signing
from django.core.urlresolvers import reverse, reverse_lazy
from django.http import HttpResponse, HttpResponseRedirect, Http404
from django.shortcuts import redirect, render
from django.shortcuts import redirect, render, get_object_or_404
from django.views.decorators.http import require_GET
from django.views.generic.detail import SingleObjectMixin
from django.views.generic import (TemplateView, DetailView, View, DeleteView,
......@@ -27,7 +27,9 @@ from django.template.loader import render_to_string
from django.forms.models import inlineformset_factory
from django_tables2 import SingleTableView
from braces.views import LoginRequiredMixin, SuperuserRequiredMixin
from braces.views import (
LoginRequiredMixin, SuperuserRequiredMixin, AccessMixin
)
from .forms import (
VmCustomizeForm, TemplateForm, LeaseForm, NodeForm, HostForm,
......@@ -48,9 +50,10 @@ def search_user(keyword):
try:
return User.objects.get(username=keyword)
except User.DoesNotExist:
return User.objects.get(email=keyword)
except User.DoesNotExist:
return User.objects.get(profile__org_id=keyword)
try:
return User.objects.get(profile__org_id=keyword)
except User.DoesNotExist:
return User.objects.get(email=keyword)
# github.com/django/django/blob/stable/1.6.x/django/contrib/messages/views.py
......@@ -1533,6 +1536,163 @@ class TransferOwnershipView(LoginRequiredMixin, DetailView):
kwargs={'pk': obj.pk}))
class AbstractVmFunctionView(AccessMixin, View):
"""Abstract instance-action view.
User can do the action with a valid token or if has at least required_level
ACL level for the instance.
Children should at least implement/add template_name, success_message,
url_name, and do_action().
"""
token_max_age = 3 * 24 * 3600
required_level = 'owner'
success_message = _("Failed to perform requested action.")
@classmethod
def check_acl(cls, instance, user):
if not instance.has_level(user, cls.required_level):
raise PermissionDenied()
@classmethod
def get_salt(cls):
return unicode(cls)
@classmethod
def get_token(cls, instance, user, *args):
t = tuple([getattr(i, 'pk', i) for i in [instance, user] + list(args)])
return signing.dumps(t, salt=cls.get_salt())
@classmethod
def get_token_url(cls, instance, user, *args):
key = cls.get_token(instance, user, *args)
args = (instance.pk, key) + args
return reverse(cls.url_name, args=args)
# this wont work, CBVs suck: reverse(cls.as_view(), args=args)
def get_template_names(self):
return [self.template_name]
def get(self, request, pk, key=None, *args, **kwargs):
class LoginNeeded(Exception):
pass
pk = int(pk)
instance = get_object_or_404(Instance, pk=pk)
try:
if key:
logger.debug('Confirm dialog for token %s.', key)
try:
self.validate_key(pk, key)
except signing.SignatureExpired:
messages.error(request, _(
'The token has expired, please log in.'))
raise LoginNeeded()
self.key = key
else:
if not request.user.is_authenticated():
raise LoginNeeded()
self.check_acl(instance, request.user)
except LoginNeeded:
return redirect_to_login(request.get_full_path(),
self.get_login_url(),
self.get_redirect_field_name())
except SuspiciousOperation as e:
messages.error(request, _('This token is invalid.'))
logger.warning('This token %s is invalid. %s', key, unicode(e))
raise PermissionDenied()
return render(request, self.get_template_names(),
self.get_context(instance))
def post(self, request, pk, key=None, *args, **kwargs):
class LoginNeeded(Exception):
pass
pk = int(pk)
instance = get_object_or_404(Instance, pk=pk)
try:
if not request.user.is_authenticated() and key:
try:
user = self.validate_key(pk, key)
except signing.SignatureExpired:
messages.error(request, _(
'The token has expired, please log in.'))
raise LoginNeeded()
self.key = key
else:
user = request.user
self.check_acl(instance, request.user)
except LoginNeeded:
return redirect_to_login(request.get_full_path(),
self.get_login_url(),
self.get_redirect_field_name())
except SuspiciousOperation as e:
messages.error(request, _('This token is invalid.'))
logger.warning('This token %s is invalid. %s', key, unicode(e))
raise PermissionDenied()
if self.do_action(instance, user):
messages.success(request, self.success_message)
else:
messages.error(request, self.fail_message)
return HttpResponseRedirect(instance.get_absolute_url())
def validate_key(self, pk, key):
"""Get object based on signed token.
"""
try:
data = signing.loads(key, salt=self.get_salt())
logger.debug('Token data: %s', unicode(data))
instance, user = data
logger.debug('Extracted token data: instance: %s, user: %s',
unicode(instance), unicode(user))
except (signing.BadSignature, ValueError, TypeError) as e:
logger.warning('Tried invalid token. Token: %s, user: %s. %s',
key, unicode(self.request.user), unicode(e))
raise SuspiciousOperation()
try:
instance, user = signing.loads(key, max_age=self.token_max_age,
salt=self.get_salt())
logger.debug('Extracted non-expired token data: %s, %s',
unicode(instance), unicode(user))
except signing.BadSignature as e:
raise signing.SignatureExpired()
if pk != instance:
logger.debug('pk (%d) != instance (%d)', pk, instance)
raise SuspiciousOperation()
user = User.objects.get(pk=user)
return user
def do_action(self, instance, user): # noqa
raise NotImplementedError('Please override do_action(instance, user)')
def get_context(self, instance):
context = {'instance': instance}
if getattr(self, 'key', None) is not None:
context['key'] = self.key
return context
class VmRenewView(AbstractVmFunctionView):
"""User can renew an instance."""
template_name = 'dashboard/confirm/base-renew.html'
success_message = _("Virtual machine is successfully renewed.")
url_name = 'dashboard.views.vm-renew'
def get_context(self, instance):
context = super(VmRenewView, self).get_context(instance)
(context['time_of_suspend'],
context['time_of_delete']) = instance.get_renew_times()
return context
def do_action(self, instance, user):
instance.renew(user=user)
logger.info('Instance %s renewed by %s.', unicode(instance),
unicode(user))
return True
class TransferOwnershipConfirmView(LoginRequiredMixin, View):
"""User can accept an ownership offer."""
......
......@@ -28,11 +28,16 @@ celery.conf.update(
'schedule': timedelta(seconds=5),
'options': {'queue': 'localhost.man'}
},
'vm.periodic_tasks': {
'vm.update_domain_states': {
'task': 'vm.tasks.local_periodic_tasks.update_domain_states',
'schedule': timedelta(seconds=10),
'options': {'queue': 'localhost.man'}
},
'vm.garbage_collector': {
'task': 'vm.tasks.local_periodic_tasks.garbage_collector',
'schedule': timedelta(minutes=10),
'options': {'queue': 'localhost.man'}
},
'storage.periodic_tasks': {
'task': 'storage.tasks.periodic_tasks.garbage_collector',
'schedule': timedelta(hours=1),
......
......@@ -302,7 +302,7 @@ class Instance(AclBase, VirtualMachineDescModel, TimeStampedModel):
def clean(self, *args, **kwargs):
if self.time_of_delete is None:
self.renew(which='delete')
self._do_renew(which='delete')
super(Instance, self).clean(*args, **kwargs)
@classmethod
......@@ -355,9 +355,16 @@ class Instance(AclBase, VirtualMachineDescModel, TimeStampedModel):
params = dict(template=template, owner=owner, pw=pwgen())
params.update([(f, getattr(template, f)) for f in common_fields])
params.update(kwargs) # override defaults w/ user supplied values
if '%d' not in params['name']:
params['name'] += ' %d'
return [cls.__create_instance(params, disks, networks, req_traits,
tags) for i in xrange(amount)]
instances = []
for i in xrange(amount):
real_params = params
real_params['name'] = real_params['name'].replace('%d', str(i))
instances.append(cls.__create_instance(real_params, disks,
networks, req_traits, tags))
return instances
@classmethod
def __create_instance(cls, params, disks, networks, req_traits, tags):
......@@ -556,16 +563,98 @@ class Instance(AclBase, VirtualMachineDescModel, TimeStampedModel):
else:
raise Node.DoesNotExist()
def renew(self, which='both'):
def _is_notified_about_expiration(self):
renews = self.activity_log.filter(activity_code__endswith='renew')
cond = {'activity_code__endswith': 'notification_about_expiration'}
if len(renews) > 0:
cond['finished__gt'] = renews[0].started
return self.activity_log.filter(**cond).exists()
def notify_owners_about_expiration(self, again=False):
"""Notify owners about vm expiring soon if they aren't already.
:param again: Notify already notified owners.
"""
if not again and self._is_notified_about_expiration():
return False
success, failed = [], []
def on_commit(act):
act.result = {'failed': failed, 'success': success}
with instance_activity('notification_about_expiration', instance=self,
on_commit=on_commit):
from dashboard.views import VmRenewView
level = self.get_level_object("owner")
for u, ulevel in self.get_users_with_level(level__pk=level.pk):
try:
token = VmRenewView.get_token_url(self, u)
u.profile.notify(
_('%s expiring soon') % unicode(self),
'dashboard/notifications/vm-expiring.html',
{'instance': self, 'token': token}, valid_until=min(
self.time_of_delete, self.time_of_suspend))
except Exception as e:
failed.append((u, e))
else:
success.append(u)
return True
def is_expiring(self, threshold=0.1):
"""Returns if an instance will expire soon.
Soon means that the time of suspend or delete comes in 10% of the
interval what the Lease allows. This rate is configurable with the
only parameter, threshold (0.1 = 10% by default).
"""
return (self._is_suspend_expiring(self, threshold) or
self._is_delete_expiring(self, threshold))
def _is_suspend_expiring(self, threshold=0.1):
interval = self.lease.suspend_interval
if interval is not None:
limit = timezone.now() + threshold * self.lease.suspend_interval
return limit > self.time_of_suspend
else:
return False
def _is_delete_expiring(self, threshold=0.1):
interval = self.lease.delete_interval
if interval is not None:
limit = timezone.now() + threshold * self.lease.delete_interval
return limit > self.time_of_delete
else:
return False
def get_renew_times(self):
"""Returns new suspend and delete times if renew would be called.
"""
return (
timezone.now() + self.lease.suspend_interval,
timezone.now() + self.lease.delete_interval)
def _do_renew(self, which='both'):
"""Set expiration times to renewed values.
"""
time_of_suspend, time_of_delete = self.get_renew_times()
if which in ('suspend', 'both'):
self.time_of_suspend = time_of_suspend
if which in ('delete', 'both'):
self.time_of_delete = time_of_delete
def renew(self, which='both', base_activity=None, user=None):
"""Renew virtual machine instance leases.
"""
if which not in ['suspend', 'delete', 'both']:
raise ValueError('No such expiration type.')
if which in ['suspend', 'both']:
self.time_of_suspend = timezone.now() + self.lease.suspend_interval
if which in ['delete', 'both']:
self.time_of_delete