Commit 3b66d6d6 by Guba Sándor

Merge branch 'feature-base_template'

Conflicts:
	circle/dashboard/static/dashboard/dashboard.css
	circle/dashboard/urls.py
parents 4d5d7287 f4067fef
......@@ -42,6 +42,23 @@
}
},
{
"pk": 1,
"model": "storage.diskactivity",
"fields":{
"activity_code": "storage.Disk.create",
"succeeded": true,
"parent": null,
"created": "2014-03-18T15:44:37.671Z",
"started": "2014-03-18T15:44:37.671Z",
"finished": "2014-03-18T15:44:37.677Z",
"modified": "2014-03-18T15:44:37.679Z",
"task_uuid": null,
"user": 1,
"disk": 1,
"result":null
}
},
{
"pk": 1,
"model": "auth.permission",
"fields": {
......@@ -1497,7 +1514,7 @@
"boot_menu": false,
"ram_size": 1024,
"modified": "2014-01-24T00:58:19.654Z",
"system": "",
"system": "bubuntu",
"priority": 20,
"access_method": "ssh",
"raw_data": "",
......
......@@ -445,15 +445,12 @@ class NodeForm(forms.ModelForm):
class TemplateForm(forms.ModelForm):
networks = forms.ModelMultipleChoiceField(
queryset=VLANS, required=False)
system = forms.CharField(widget=forms.TextInput)
def __init__(self, *args, **kwargs):
parent = kwargs.pop("parent", None)
self.user = kwargs.pop("user", None)
super(TemplateForm, self).__init__(*args, **kwargs)
self.fields['disks'] = forms.ModelMultipleChoiceField(
queryset=Disk.get_objects_with_level(
'user', self.user).exclude(type="qcow2-snap")
)
data = self.data.copy()
data['owner'] = self.user.pk
......@@ -468,7 +465,6 @@ class TemplateForm(forms.ModelForm):
for f in fields:
self.initial[f] = parent[f]
self.initial['lease'] = parent['lease_id']
self.initial['disks'] = template.disks.all()
self.initial['parent'] = template
self.initial['name'] = "Clone of %s" % self.initial['name']
self.for_networks = template
......@@ -506,8 +502,6 @@ class TemplateForm(forms.ModelForm):
if commit:
instance.save()
self.instance.disks = data['disks'] # TODO why do I need this
# create and/or delete InterfaceTemplates
networks = InterfaceTemplate.objects.filter(
template=self.instance).values_list("vlan", flat=True)
......@@ -526,6 +520,7 @@ class TemplateForm(forms.ModelForm):
kwargs_raw_data = {}
if not self.user.is_superuser:
kwargs_raw_data['readonly'] = None
helper = FormHelper()
helper.layout = Layout(
Field("name"),
......@@ -585,7 +580,6 @@ class TemplateForm(forms.ModelForm):
),
Fieldset(
_("External"),
Field("disks"),
Field("networks"),
Field("lease"),
Field("tags"),
......@@ -596,7 +590,7 @@ class TemplateForm(forms.ModelForm):
class Meta:
model = InstanceTemplate
exclude = ('state', )
exclude = ('state', 'disks', )
class LeaseForm(forms.ModelForm):
......
......@@ -409,4 +409,11 @@ footer {
footer a, footer a:hover, footer a:visited {
color: white;
text-decoration: underline;
.template-disk-list {
list-style: none;
padding-left: 0;
}
.template-disk-list li {
padding-bottom: 5px;
}
......@@ -122,6 +122,18 @@ $(function () {
'redirect': dir});
return false;
});
/* for disk remove buttons */
$('.disk-remove').click(function() {
var disk_pk = $(this).data('disk-pk');
addModalConfirmation(deleteObject,
{ 'url': '/dashboard/disk/' + disk_pk + '/remove/',
'data': [],
'pk': disk_pk,
'type': "disk",
});
return false;
});
/* for Node removes buttons */
$('.node-delete').click(function() {
......@@ -273,9 +285,15 @@ function deleteObject(data) {
if(!data['redirect']) {
selected = [];
addMessage(re['message'], 'success');
$('a[data-'+data['type']+'-pk="' + data['pk'] + '"]').closest('tr').fadeOut(function() {
$(this).remove();
});
if(data.type === "disk") {
// no need to remove them from DOM
$('a[data-disk-pk="' + data.pk + '"]').parent("li").fadeOut();
$('a[data-disk-pk="' + data.pk + '"]').parent("h4").fadeOut();
} else {
$('a[data-'+data['type']+'-pk="' + data['pk'] + '"]').closest('tr').fadeOut(function() {
$(this).remove();
});
}
} else {
window.location.replace('/dashboard');
}
......
$(function() {
$(".disk-list-disk-percentage").each(function() {
var disk = $(this).data("disk-pk");
var element = $(this);
refreshDisk(disk, element);
});
});
function refreshDisk(disk, element) {
$.get("/dashboard/disk/" + disk + "/status/", function(result) {
if(result.percentage == null || result.failed == "True") {
location.reload();
} else {
var diff = result.percentage - parseInt(element.html());
var refresh = 5 - diff;
refresh = refresh < 1 ? 1 : (result.percentage == 0 ? 1 : refresh);
if(isNaN(refresh)) refresh = 2; // this should not happen
element.html(result.percentage);
setTimeout(function() {refreshDisk(disk, element)}, refresh * 1000);
}
});
}
{% load i18n %}
{% load sizefieldtags %}
<i class="{% if d.is_downloading %}icon-refresh icon-spin{% else %}icon-file{% endif %}"></i>
<i class="{% if d.is_downloading %}icon-refresh icon-spin{% else %}icon-file{% if d.failed %}" style="color: #d9534f;{% endif %}{% endif %}"></i>
{{ d.name }} (#{{ d.id }}) -
{% if not d.is_downloading %}
{% if d.ready %}
{{ d.size|filesize }}
{% if not d.failed %}
{% if d.size %}{{ d.size|filesize }}{% endif %}
{% else %}
<div class="label label-danger">failed</div>
<div class="label label-danger"{% if user.is_superuser %} title="{{ d.get_latest_activity_result }}"{% endif %}>failed</div>
{% endif %}
{% else %}<span class="disk-list-disk-percentage" data-disk-pk="{{ d.pk }}">{{ d.get_download_percentage }}</span>%{% endif %}
<div class="btn btn-xs btn-danger pull-right"><i class="icon-remove"></i> Remove</div>
<a href="{% url "dashboard.views.disk-remove" pk=d.pk %}?next={{ request.path }}"
data-disk-pk="{{ d.pk }}" class="btn btn-xs btn-danger pull-right disk-remove">
<i class="icon-remove"></i>{% if long_remove %} Remove{% endif %}
</a>
<div style="clear: both;"></div>
......@@ -32,7 +32,7 @@
<li>
<i class="icon-file"></i> {% trans "Disks" %}
<span style="float: right; text-align: right;">
{% for d in t.disks.all %}{{ d.name }} ({{ d.size|filesize }}){% if not forloop.last %}, {% endif %}{% endfor %}
{% for d in t.disks.all %}{{ d.name }} ({% if d.size %}{{ d.size|filesize }}{% endif %}){% if not forloop.last %}, {% endif %}{% endfor %}
</span>
<div style="clear: both;"></div>
</li>
......
......@@ -4,7 +4,7 @@
<div class="modal-content">
<div class="modal-body">
{% if text %}
{{ text }}
{{ text|safe }}
{% else %}
{%blocktrans with object=object%}
Are you sure you want to delete <strong>{{ object }}</strong>?
......
......@@ -15,7 +15,7 @@
</div>
<div class="panel-body">
{% if text %}
{{ text }}
{{ text|safe }}
{% else %}
{%blocktrans with object=object%}
Are you sure you want to delete <strong>{{ object }}</strong>?
......@@ -26,7 +26,7 @@
{% csrf_token %}
<a class="btn btn-default">Back</a>
<input type="hidden" name="next" value="{{ request.GET.next }}"/>
<button class="btn btn-danger">Yes, delete</button>
<button class="btn btn-danger">Yes</button>
</form>
</div>
</div>
......
......@@ -8,7 +8,7 @@
<div class="panel panel-default">
<div class="panel-heading">
<a class="pull-right btn btn-default btn-xs" href="{% url "dashboard.views.template-list" %}">{% trans "Back" %}</a>
<h3 class="no-margin"><i class="icon-desktop"></i> {% trans "Create template" %}</h3>
<h3 class="no-margin"><i class="icon-desktop"></i> {% trans "Create base VM" %}</h3>
</div>
<div class="panel-body">
{% with form=form %}
......
......@@ -72,7 +72,10 @@
<h4 class="no-margin"><i class="icon-file"></i> {% trans "Disk list" %}</h4>
</div>
<div class="panel-body">
<ul style="list-style: none; padding-left: 0;">
<ul class="template-disk-list">
{% if not disks %}
{% trans "No disks are added!" %}
{% endif %}
{% for d in disks %}
<li>
{% include "dashboard/_disk-list-element.html" %}
......@@ -104,10 +107,14 @@
font-weight: bold;
}
</style>
<script>
$(function() {
$("#hint_id_num_cores, #hint_id_priority, #hint_id_ram_size").hide();
});
</script>
{% endblock %}
{% block extra_js %}
<script>
$(function() {
$("#hint_id_num_cores, #hint_id_priority, #hint_id_ram_size").hide();
});
</script>
<script src="{{ STATIC_URL }}dashboard/disk-list.js"></script>
{% endblock %}
......@@ -8,7 +8,7 @@
<div class="panel panel-default">
<div class="panel-heading">
<a href="{% url "dashboard.views.template-create" %}" class="pull-right btn btn-success btn-xs">
<i class="icon-plus"></i> new template
<i class="icon-plus"></i> {% trans "new base vm" %}
</a>
<h3 class="no-margin"><i class="icon-puzzle-piece"></i> {% trans "Templates" %}</h3>
</div>
......@@ -24,7 +24,7 @@
<div class="panel panel-default">
<div class="panel-heading">
<a href="{% url "dashboard.views.lease-create" %}" class="pull-right btn btn-success btn-xs" style="margin-right: 10px;">
<i class="icon-plus"></i> new lease
<i class="icon-plus"></i> {% trans "new lease" %}
</a>
<h3 class="no-margin"><i class="icon-time"></i> {% trans "Leases" %}</h3>
</div>
......
......@@ -205,4 +205,5 @@
<script src="{{ STATIC_URL }}dashboard/vm-details.js"></script>
<script src="{{ STATIC_URL }}dashboard/vm-common.js"></script>
<script src="{{ STATIC_URL }}dashboard/vm-console.js"></script>
<script src="{{ STATIC_URL }}dashboard/disk-list.js"></script>
{% endblock %}
......@@ -60,7 +60,9 @@
{% endif %}
{% for d in instance.disks.all %}
<h4 class="list-group-item-heading dashboard-vm-details-network-h3">
{% include "dashboard/_disk-list-element.html" %}
{% with long_remove=True %}
{% include "dashboard/_disk-list-element.html" %}
{% endwith %}
</h4>
{% endfor %}
</div>
......
......@@ -187,6 +187,7 @@ class VmDetailTest(LoginMixin, TestCase):
Vlan.objects.get(id=1).set_level(self.u1, 'user')
response = c.post('/dashboard/vm/create/',
{'template': 1,
'system': "bubi",
'cpu_priority': 1, 'cpu_count': 1,
'ram_size': 1000})
self.assertEqual(response.status_code, 403)
......@@ -199,6 +200,7 @@ class VmDetailTest(LoginMixin, TestCase):
Vlan.objects.get(id=1).set_level(self.u1, 'user')
response = c.post('/dashboard/vm/create/',
{'template': 1,
'system': "bubi",
'cpu_priority': 1, 'cpu_count': 1,
'ram_size': 1000})
self.assertEqual(response.status_code, 302)
......@@ -208,6 +210,7 @@ class VmDetailTest(LoginMixin, TestCase):
self.login(c, 'superuser')
response = c.post('/dashboard/vm/create/',
{'template': 1,
'system': "bubi",
'cpu_priority': 1, 'cpu_count': 1,
'ram_size': 1000})
self.assertEqual(response.status_code, 302)
......
......@@ -10,7 +10,8 @@ from .views import (
TemplateCreate, TemplateDelete, TemplateDetail, TemplateList,
TransferOwnershipConfirmView, TransferOwnershipView, vm_activity, VmCreate,
VmDelete, VmDetailView, VmDetailVncTokenView, VmGraphView, VmList,
VmMassDelete, VmMigrateView, VmRenewView,
VmMassDelete, VmMigrateView, VmRenewView, DiskRemoveView,
get_disk_download_status,
)
urlpatterns = patterns(
......@@ -99,6 +100,11 @@ urlpatterns = patterns(
url(r'^disk/add/$', DiskAddView.as_view(),
name="dashboard.views.disk-add"),
url(r'^disk/(?P<pk>\d+)/remove/$', DiskRemoveView.as_view(),
name="dashboard.views.disk-remove"),
url(r'^disk/(?P<pk>\d+)/status/$', get_disk_download_status,
name="dashboard.views.disk-status"),
url(r'^profile/$', MyPreferencesView.as_view(),
name="dashboard.views.profile"),
)
from __future__ import unicode_literals
from os import getenv
import json
import logging
......@@ -43,6 +45,7 @@ from vm.models import (
Instance, instance_activity, InstanceActivity, InstanceTemplate, Interface,
InterfaceTemplate, Lease, Node, NodeActivity, Trait,
)
from storage.models import Disk
from firewall.models import Vlan, Host, Rule
from dashboard.models import Favourite, Profile
......@@ -275,6 +278,7 @@ class VmDetailView(CheckedDetailView):
resources = {
'num_cores': request.POST.get('cpu-count'),
'ram_size': request.POST.get('ram-size'),
'max_ram_size': request.POST.get('ram-size'), # TODO: max_ram
'priority': request.POST.get('cpu-priority')
}
Instance.objects.filter(pk=self.object.pk).update(**resources)
......@@ -419,12 +423,10 @@ class VmDetailView(CheckedDetailView):
new_name = "Saved from %s (#%d) at %s" % (
self.object.name, self.object.pk, date
)
template = self.object.save_as_template(name=new_name,
owner=request.user)
messages.success(request, _("Instance successfully saved as template, "
"please rename it!"))
return redirect(reverse_lazy("dashboard.views.template-detail",
kwargs={'pk': template.pk}))
self.object.save_as_template_async(name=new_name,
user=request.user)
messages.success(request, _("Saving instance as template!"))
return redirect("%s#activity" % self.object.get_absolute_url())
def __shut_down(self, request):
self.object = self.get_object()
......@@ -765,13 +767,29 @@ class TemplateCreate(SuccessMessageMixin, CreateView):
form = self.form_class(request.POST, user=request.user)
if not form.is_valid():
return self.get(request, form, *args, **kwargs)
post = form.cleaned_data
for disk in post['disks']:
if not disk.has_level(request.user, 'user'):
raise PermissionDenied()
else:
post = form.cleaned_data
networks = self.__create_networks(post.pop("networks"))
req_traits = post.pop("req_traits")
tags = post.pop("tags")
post['pw'] = User.objects.make_random_password()
post.pop("parent")
post['max_ram_size'] = post['ram_size']
inst = Instance.create(params=post, disks=[], networks=networks,
tags=tags, req_traits=req_traits)
messages.success(request, _("Your disk has been created, "
"you can now add disks to it!"))
return redirect("%s#resources" % inst.get_absolute_url())
return super(TemplateCreate, self).post(self, request, args, kwargs)
def __create_networks(self, vlans):
networks = []
for v in vlans:
networks.append(InterfaceTemplate(vlan=v, managed=v.managed))
return networks
def get_success_url(self):
return reverse_lazy("dashboard.views.template-list")
......@@ -2115,3 +2133,64 @@ def set_language_cookie(request, response, lang=None):
cname = getattr(settings, 'LANGUAGE_COOKIE_NAME', 'django_language')
response.set_cookie(cname, lang, 365 * 86400)
class DiskRemoveView(DeleteView):
model = Disk
def get_template_names(self):
if self.request.is_ajax():
return ['dashboard/confirm/ajax-delete.html']
else:
return ['dashboard/confirm/base-delete.html']
def get_context_data(self, **kwargs):
context = super(DiskRemoveView, self).get_context_data(**kwargs)
disk = self.get_object()
app = disk.get_appliance()
context['title'] = _("Disk remove confirmation")
context['text'] = _("Are you sure you want to remove "
"<strong>%(disk)s</strong> from "
"<strong>%(app)s</strong>?" % {'disk': disk,
'app': app}
)
return context
def delete(self, request, *args, **kwargs):
disk = self.get_object()
if not disk.has_level(request.user, 'owner'):
raise PermissionDenied()
disk = self.get_object()
app = disk.get_appliance()
app.disks.remove(disk)
disk.destroy()
next_url = request.POST.get("next")
success_url = next_url if next_url else app.get_absolute_url()
success_message = _("Disk successfully removed!")
if request.is_ajax():
return HttpResponse(
json.dumps({'message': success_message}),
content_type="application/json",
)
else:
messages.success(request, success_message)
return HttpResponseRedirect("%s#resources" % success_url)
@require_GET
def get_disk_download_status(request, pk):
disk = Disk.objects.get(pk=pk)
if not disk.has_level(request.user, 'owner'):
raise PermissionDenied()
return HttpResponse(
json.dumps({
'percentage': disk.get_download_percentage(),
'failed': disk.failed
}),
content_type="application/json",
)
# coding=utf-8
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from contextlib import contextmanager
import logging
......@@ -107,10 +108,35 @@ class Disk(AclBase, TimeStampedModel):
self.disk = disk
class DiskIsNotReady(Exception):
""" Exception for operations that need a deployed disk.
"""
def __init__(self, disk, message=None):
if message is None:
message = ("The requested operation can't be performed on "
"disk '%s (%s)' because it has never been"
"deployed." % (disk.name, disk.filename))
Exception.__init__(self, message)
self.disk = disk
@property
def ready(self):
""" Returns True if the disk is physically ready on the storage.
It needs at least 1 successfull deploy action.
"""
return self.activity_log.filter(activity_code__endswith="deploy",
succeeded__isnull=False)
succeeded=True)
@property
def failed(self):
""" Returns True if the last activity on the disk is failed.
"""
result = self.activity_log.all().order_by('-id')[0].succeeded
return not (result is None) and not result
@property
def path(self):
......@@ -155,18 +181,22 @@ class Disk(AclBase, TimeStampedModel):
}[self.type]
def is_downloading(self):
return self.activity_log.filter(
activity_code__endswith="downloading_disk",
succeeded__isnull=True)
return self.size is None and not self.failed
def get_download_percentage(self):
if not self.is_downloading():
return None
task = self.activity_log.filter(
activity_code__endswith="deploy",
succeeded__isnull=True)[0].task_uuid
result = celery.AsyncResult(id=task)
return result.info.get("percent")
try:
task = self.activity_log.filter(
activity_code__endswith="deploy",
succeeded__isnull=True)[0].task_uuid
result = celery.AsyncResult(id=task)
return result.info.get("percent")
except:
return 0
def get_latest_activity_result(self):
return self.activity_log.latest("pk").result
@property
def is_deletable(self):
......@@ -192,6 +222,17 @@ class Disk(AclBase, TimeStampedModel):
"""
return any(i.state != 'STOPPED' for i in self.instance_set.all())
def get_appliance(self):
"""Return an Instance or InstanceTemplate object where the disk is used
"""
instance = self.instance_set.all()
template = self.template_set.all()
app = list(instance) + list(template)
if len(app) > 0:
return app[0]
else:
return None
def get_exclusive(self):
"""Get an instance of the disk for exclusive usage.
......@@ -247,7 +288,7 @@ class Disk(AclBase, TimeStampedModel):
return u"%s (#%d)" % (self.name, self.id or 0)
def clean(self, *args, **kwargs):
if self.size == "" and self.base:
if (self.size is None or "") and self.base:
self.size = self.base.size
super(Disk, self).clean(*args, **kwargs)
......@@ -305,7 +346,9 @@ class Disk(AclBase, TimeStampedModel):
"""
datastore = params.pop('datastore', DataStore.objects.get())
disk = cls(filename=str(uuid.uuid4()), datastore=datastore, **params)
disk.clean()
disk.save()
logger.debug("Disk created: %s", params)
with disk_activity(code_suffix="create",
user=user,
disk=disk):
......@@ -366,8 +409,6 @@ class Disk(AclBase, TimeStampedModel):
kwargs.setdefault('name', url.split('/')[-1])
disk = Disk.create(type="iso", instance=instance, user=user,
size=None, **kwargs)
# TODO get proper datastore
disk.datastore = DataStore.objects.get()
queue_name = disk.get_remote_queue_name('storage')
def __on_abort(activity, error):
......@@ -439,6 +480,7 @@ class Disk(AclBase, TimeStampedModel):
"""
mapping = {
'qcow2-snap': ('qcow2-norm', self.base),
'qcow2-norm': ('qcow2-norm', self),
}
if self.type not in mapping.keys():
raise self.WrongDiskTypeError(self.type)
......@@ -446,6 +488,9 @@ class Disk(AclBase, TimeStampedModel):
if self.is_in_use:
raise self.DiskInUseError(self)
if not self.ready:
raise self.DiskIsNotReady(self)
# from this point on, the caller has to guarantee that the disk is not
# going to be used until the operation is complete
......@@ -455,7 +500,6 @@ class Disk(AclBase, TimeStampedModel):
name=self.name, size=self.size,
type=new_type)
disk.save()
with disk_activity(code_suffix="save_as", disk=self,
user=user, task_uuid=task_uuid):
with disk_activity(code_suffix="deploy", disk=disk,
......
from storage.models import DataStore
import os
from manager.mancelery import celery
import logging
from storage.tasks import remote_tasks
......@@ -16,13 +15,15 @@ def garbage_collector(timeout=15):
deletes oldest images from trash.
:param timeout: Seconds before TimeOut exception
:type timeoit: int
:type timeout: int
"""
for ds in DataStore.objects.all():
file_list = os.listdir(ds.path)
disk_list = ds.get_deletable_disks()
queue_name = ds.get_remote_queue_name('storage')
for i in set(file_list).intersection(disk_list):
files = set(remote_tasks.list_files.apply_async(
args=[ds.path], queue=queue_name).get(timeout=timeout))
disks = set(ds.get_deletable_disks())
queue_name = ds.get_remote_queue_name('storage')
for i in disks & files:
logger.info("Image: %s at Datastore: %s moved to trash folder." %
(i, ds.path))
remote_tasks.move_to_trash.apply_async(
......
......@@ -2,9 +2,11 @@ from datetime import timedelta
from django.test import TestCase
from django.utils import timezone
from mock import MagicMock, Mock
from ..models import Disk, DataStore
old = timezone.now() - timedelta(days=2)
new = timezone.now() - timedelta(hours=2)
......@@ -46,3 +48,41 @@ class DiskTestCase(TestCase):
self._disk(base=d, destroyed=new)
self._disk(base=d)
assert not d.is_deletable
def test_save_as_disk_in_use_error(self):
class MockException(Exception):
pass
d = MagicMock(spec=Disk)
d.DiskInUseError = MockException
d.type = "qcow2-norm"
d.is_in_use = True
with self.assertRaises(MockException):
Disk.save_as(d)
def test_save_as_wrong_type(self):
class MockException(Exception):
pass
d = MagicMock(spec=Disk)
d.WrongDiskTypeError = MockException
d.type = "wrong"
with self.assertRaises(MockException):
Disk.save_as(d)
def test_save_as_disk_not_ready(self):
class MockException(Exception):
pass
d = MagicMock(spec=Disk)
d.DiskIsNotReady = MockException
d.type = "qcow2-norm"
d.is_in_use = False
d.ready = False
with self.assertRaises(MockException):
Disk.save_as(d)
def test_download_percentage_no_download(self):
d = MagicMock(spec=Disk)
d.is_downloading = Mock(return_value=False)
assert Disk.get_download_percentage(d) is None
......@@ -93,7 +93,6 @@ class VirtualMachineDescModel(BaseResourceConfigModel):
"for hosting the VM."),
verbose_name=_("required traits"))
system = TextField(verbose_name=_('operating system'),
blank=True,
help_text=(_('Name of operating system in '
'format like "%s".') %
'Ubuntu 12.04 LTS Desktop amd64'))
......@@ -250,7 +249,7 @@ class Instance(AclBase, VirtualMachineDescModel, StatusModel,
if message is None:
message = ("The instance's current state (%s) is "
"inappropriate for the invoked operation."
% instance.state)
% instance.status)
Exception.__init__(self, message)
......@@ -266,7 +265,9 @@ class Instance(AclBase, VirtualMachineDescModel, StatusModel,
@property
def is_running(self):
return self.state == 'RUNNING'
"""Check if VM is in running state.
"""
return self.status == 'RUNNI