Commit 10261b9a by Czémán Arnold

Merge branch 'master' of https://git.ik.bme.hu/circle/cloud into add_rule

parents c7fd2925 dad93220
Pipeline #7 passed with stage
in 0 seconds
......@@ -246,8 +246,8 @@ class AclBase(Model):
def save(self, *args, **kwargs):
super(AclBase, self).save(*args, **kwargs)
if 'owner' in dict(self.ACL_LEVELS) and (hasattr(self, 'owner')
and self.owner):
if 'owner' in dict(self.ACL_LEVELS) and (hasattr(self, 'owner') and
self.owner):
self.set_user_level(self.owner, 'owner')
class Meta:
......
......@@ -505,6 +505,8 @@ if get_env_variable('DJANGO_SAML', 'FALSE') == 'TRUE':
if get_env_variable('DJANGO_SAML_ORG_ID_ATTRIBUTE', False) is not False:
SAML_ORG_ID_ATTRIBUTE = get_env_variable(
'DJANGO_SAML_ORG_ID_ATTRIBUTE')
SAML_MAIN_ATTRIBUTE_MAX_LENGTH = int(get_env_variable(
"DJANGO_SAML_MAIN_ATTRIBUTE_MAX_LENGTH", 0))
LOGIN_REDIRECT_URL = "/"
......
......@@ -71,3 +71,5 @@ STORE_URL = ""
# buildbot doesn't love pipeline
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.StaticFilesStorage'
SAML_MAIN_ATTRIBUTE_MAX_LENGTH=0 # doctest on SAML2 backend runs either way
......@@ -25,7 +25,7 @@ from django.shortcuts import redirect
from circle.settings.base import get_env_variable
from dashboard.views import circle_login, HelpView
from dashboard.views import circle_login, HelpView, ResizeHelpView
from dashboard.forms import CirclePasswordResetForm, CircleSetPasswordForm
from firewall.views import add_blacklist_item
......@@ -65,6 +65,8 @@ urlpatterns = patterns(
url(r'^info/support/$',
TemplateView.as_view(template_name="info/support.html"),
name="info.support"),
url(r'^info/resize-how-to/$', ResizeHelpView.as_view(),
name="info.resize"),
)
......
......@@ -46,7 +46,7 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "circle.settings.production")
# This application object is used by any WSGI server configured to use this
# file. This includes Django's development server, if the WSGI_APPLICATION
# setting points here.
from django.core.wsgi import get_wsgi_application
from django.core.wsgi import get_wsgi_application # noqa
_application = get_wsgi_application()
......
......@@ -17,9 +17,14 @@
# with CIRCLE. If not, see <http://www.gnu.org/licenses/>.
import re
import logging
import sha
from django.conf import settings
from djangosaml2.backends import Saml2Backend as Saml2BackendBase
logger = logging.getLogger(__name__)
class Saml2Backend(Saml2BackendBase):
u"""
......@@ -41,7 +46,14 @@ class Saml2Backend(Saml2BackendBase):
if isinstance(main_attribute, str):
main_attribute = main_attribute.decode('UTF-8')
assert isinstance(main_attribute, unicode)
return re.sub(r'[^\w.@-]', replace, main_attribute)
attr = re.sub(r'[^\w.@-]', replace, main_attribute)
max_length = settings.SAML_MAIN_ATTRIBUTE_MAX_LENGTH
if max_length > 0 and len(attr) > max_length:
logger.info("Main attribute '%s' is too long." % attr)
hashed = sha.new(attr).hexdigest()
attr = hashed[:max_length]
logger.info("New main attribute: %s" % attr)
return attr
def _set_attribute(self, obj, attr, value):
if attr == 'username':
......
......@@ -97,7 +97,7 @@ def has_prefix(activity_code, *prefixes):
>>> assert has_prefix('foo.bar.buz', 'foo', 'bar', 'buz')
>>> assert not has_prefix('foo.bar.buz', 'foo', 'buz')
"""
equal = lambda a, b: a == b
def equal(a, b): return a == b
act_code_parts = split_activity_code(activity_code)
prefixes = chain(*imap(split_activity_code, prefixes))
return all(imap(equal, act_code_parts, prefixes))
......@@ -112,7 +112,7 @@ def has_suffix(activity_code, *suffixes):
>>> assert has_suffix('foo.bar.buz', 'foo', 'bar', 'buz')
>>> assert not has_suffix('foo.bar.buz', 'foo', 'buz')
"""
equal = lambda a, b: a == b
def equal(a, b): return a == b
act_code_parts = split_activity_code(activity_code)
suffixes = list(chain(*imap(split_activity_code, suffixes)))
return all(imap(equal, reversed(act_code_parts), reversed(suffixes)))
......@@ -441,8 +441,8 @@ class HumanReadableObject(object):
@classmethod
def create(cls, user_text_template, admin_text_template=None, **params):
return cls(user_text_template=user_text_template,
admin_text_template=(admin_text_template
or user_text_template), params=params)
admin_text_template=(admin_text_template or
user_text_template), params=params)
def set(self, user_text_template, admin_text_template=None, **params):
self._set_values(user_text_template,
......
# -*- coding: utf-8 -*-
# Copyright 2014 Budapest University of Technology and Economics (BME IK)
#
# This file is part of CIRCLE Cloud.
#
# CIRCLE is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# CIRCLE is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along
# with CIRCLE. If not, see <http://www.gnu.org/licenses/>.
from arrow import locales
class HungarianLocale(locales.Locale):
names = ['hu', 'HU']
past = '{0} ezelőtt'
future = '{0} múlva'
timeframes = {
'now': 'éppen most',
'seconds': {
'past': 'másodpercekkel',
'future': 'pár másodperc'},
'minute': {'past': 'egy perccel', 'future': 'egy perc'},
'minutes': {'past': '{0} perccel', 'future': '{0} perc'},
'hour': {'past': 'egy órával', 'future': 'egy óra'},
'hours': {'past': '{0} órával', 'future': '{0} óra'},
'day': {
'past': 'egy nappal',
'future': 'egy nap'
},
'days': {
'past': '{0} nappal',
'future': '{0} nap'
},
'month': {'past': 'egy hónappal', 'future': 'egy hónap'},
'months': {'past': '{0} hónappal', 'future': '{0} hónap'},
'year': {'past': 'egy évvel', 'future': 'egy év'},
'years': {'past': '{0} évvel', 'future': '{0} év'},
}
month_names = ['', 'Január', 'Február', 'Március', 'Április', 'Május',
'Június', 'Július', 'Augusztus', 'Szeptember',
'Október', 'November', 'December']
month_abbreviations = ['', 'Jan', 'Febr', 'Márc', 'Ápr', 'Máj', 'Jún',
'Júl', 'Aug', 'Szept', 'Okt', 'Nov', 'Dec']
day_names = ['', 'Hétfő', 'Kedd', 'Szerda', 'Csütörtök', 'Péntek',
'Szombat', 'Vasárnap']
day_abbreviations = ['', 'Hét', 'Kedd', 'Szer', 'Csüt', 'Pént',
'Szom', 'Vas']
meridians = {
'am': 'de',
'pm': 'du',
'AM': 'DE',
'PM': 'DU',
}
def _format_timeframe(self, timeframe, delta):
form = self.timeframes[timeframe]
if isinstance(form, dict):
if delta > 0:
form = form['future']
else:
form = form['past']
delta = abs(delta)
return form.format(delta)
......@@ -38,10 +38,10 @@ def highlight(field, q, none_wo_match=True):
match = None
if q and match is not None:
match_end = match + len(q)
return (escape(field[:match])
+ '<span class="autocomplete-hl">'
+ escape(field[match:match_end])
+ '</span>' + escape(field[match_end:]))
return (escape(field[:match]) +
'<span class="autocomplete-hl">' +
escape(field[match:match_end]) +
'</span>' + escape(field[match_end:]))
elif none_wo_match:
return None
else:
......
......@@ -506,8 +506,8 @@ class TemplateForm(forms.ModelForm):
self.allowed_fields = (
'name', 'access_method', 'description', 'system', 'tags',
'arch', 'lease', 'has_agent')
if (self.user.has_perm('vm.change_template_resources')
or not self.instance.pk):
if (self.user.has_perm('vm.change_template_resources') or
not self.instance.pk):
self.allowed_fields += tuple(set(self.fields.keys()) -
set(['raw_data']))
if self.user.is_superuser:
......@@ -523,8 +523,8 @@ class TemplateForm(forms.ModelForm):
self.initial['max_ram_size'] = 512
lease_queryset = (
Lease.get_objects_with_level("operator", self.user).distinct()
| Lease.objects.filter(pk=self.instance.lease_id).distinct())
Lease.get_objects_with_level("operator", self.user).distinct() |
Lease.objects.filter(pk=self.instance.lease_id).distinct())
self.fields["lease"].queryset = lease_queryset
......
......@@ -64,8 +64,8 @@ class Command(BaseCommand):
def handle(self, *args, **options):
self.changed = False
if (DataStore.objects.exists() and Vlan.objects.exists()
and not options['force']):
if (DataStore.objects.exists() and Vlan.objects.exists() and
not options['force']):
return self.print_state()
admin = self.create(User, 'username', username=options['admin_user'],
......
......@@ -1488,3 +1488,38 @@ textarea[name="new_members"] {
.acl-table td:first-child {
text-align: center;
}
#resize-help {
table {
background-color: #f5f5f5;
}
.panel {
padding: 2px 20px;
background-color: #f5f5f5;
margin: 20px 0px;
}
ol li {
margin-top: 15px;
}
img {
display: block;
margin: 15px 0 5px 0;
}
pre {
margin-top: 5px;
}
hr {
margin: 50px 0;
}
}
#vm-details-resize-how-to {
font-size: 1.5em;
text-align: center;
width: 100%;
}
......@@ -4,24 +4,33 @@
<i class="fa fa-file"></i>
{{ d.name }} (#{{ d.id }}) - {{ d.size|filesize }}
{% if op.remove_disk %}
<span class="operation-wrapper">
<span class="operation-wrapper pull-right">
{% if d.is_resizable %}
{% if op.resize_disk %}
<a href="{{ op.resize_disk.get_url }}?disk={{d.pk}}"
class="btn btn-xs btn-{{ op.resize_disk.effect }} operation disk-resize-btn
{% if op.resize_disk.disabled %}disabled{% endif %}">
<i class="fa fa-{{ op.resize_disk.icon }} fa-fw-12"></i> {% trans "Resize" %}
</a>
{% else %}
<a href="{% url "request.views.request-resize" vm_pk=instance.pk disk_pk=d.pk %}" class="btn btn-xs btn-primary operation">
<i class="fa fa-arrows-alt fa-fw-12"></i> {% trans "Request resize" %}
</a>
{% endif %}
{% else %}
<small class="btn-xs">
{% trans "Not resizable" %}
</small>
{% endif %}
{% if op.remove_disk %}
<a href="{{ op.remove_disk.get_url }}?disk={{d.pk}}"
class="btn btn-xs btn-{{ op.remove_disk.effect}} pull-right operation disk-remove-btn
class="btn btn-xs btn-{{ op.remove_disk.effect}} operation disk-remove-btn
{% if op.remove_disk.disabled %}disabled{% endif %}">
<i class="fa fa-{{ op.remove_disk.icon }} fa-fw-12"></i> {% trans "Remove" %}
</a>
</span>
{% endif %}
{% if op.resize_disk %}
<span class="operation-wrapper">
<a href="{{ op.resize_disk.get_url }}?disk={{d.pk}}"
class="btn btn-xs btn-{{ op.resize_disk.effect }} pull-right operation disk-resize-btn
{% if op.resize_disk.disabled %}disabled{% endif %}">
<i class="fa fa-{{ op.resize_disk.icon }} fa-fw-12"></i> {% trans "Resize" %}
</a>
</span>
{% endif %}
{% endif %}
</span>
<div style="clear: both;"></div>
{% if request.user.is_superuser %}
......
......@@ -166,6 +166,28 @@
</ul>
</div>
</div>
{% if show_graph %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="no-margin"><i class="fa fa-area-chart"></i> {% trans "Graphs" %}</h3>
</div>
<div class="text-center panel-body">
<div class="graph-buttons">
{% include "dashboard/_graph-time-buttons.html" %}
</div>
<div class="text-center graph-images">
<img src="{% url "dashboard.views.template-graph" object.pk "instances" graph_time %}"/>
</div>
{% if request.user.is_superuser %}
<a href="{% url "dashboard.views.vm-list" %}?s=template:{{object.pk}}&stype=all">
{% trans "List all template instances" %}
</a>
{% endif %}
</div>
</div>
{% endif %}
</div><!-- .col-md-4 -->
</div><!-- .row -->
......
......@@ -66,6 +66,17 @@
{% endfor %}
</div>
<hr />
{% if instance.disks.all %}
<div id="vm-details-resize-how-to">
<i class="fa fa-question"></i>
{% url "info.resize" as resize_url %}
{% blocktrans with url=resize_url %}
If you need help resizing the disks check out our <a href="{{ url }}">resize how-to.</a>
{% endblocktrans %}
</div>
{% endif %}
{% if user.is_superuser %}
<hr/>
......
......@@ -18,9 +18,6 @@
from django.template import Library
import arrow
from dashboard.arrow_local import HungarianLocale
for name in HungarianLocale.names:
arrow.locales._locales[name] = HungarianLocale
register = Library()
......
......@@ -601,8 +601,8 @@ class CircleSeleniumMixin(SeleniumMixin):
choices = self.driver.find_elements_by_css_selector(
"input[type='radio']")
choice_list = [item for item in choices if (
'test' not in item.get_attribute('value')
and item.get_attribute('value') != 'base_vm')]
'test' not in item.get_attribute('value') and
item.get_attribute('value') != 'base_vm')]
chosen = random.randint(0, len(choice_list) - 1)
choice_list[chosen].click()
self.driver.find_element_by_id(
......
......@@ -47,7 +47,7 @@ from .views import (
LeaseAclUpdateView,
toggle_template_tutorial,
ClientCheck, TokenLogin,
VmGraphView, NodeGraphView, NodeListGraphView,
VmGraphView, NodeGraphView, NodeListGraphView, TemplateGraphView,
TransferInstanceOwnershipView, TransferInstanceOwnershipConfirmView,
TransferTemplateOwnershipView, TransferTemplateOwnershipConfirmView,
OpenSearchDescriptionView,
......@@ -152,6 +152,10 @@ urlpatterns = patterns(
r'(?P<time>[0-9]{1,2}[hdwy])$'),
NodeListGraphView.as_view(),
name='dashboard.views.node-list-graph'),
url((r'^template/(?P<pk>\d+)/graph/(?P<metric>[a-z]+)/'
r'(?P<time>[0-9]{1,2}[hdwy])$'),
TemplateGraphView.as_view(),
name='dashboard.views.template-graph'),
url(r'^group/(?P<pk>\d+)/$', GroupDetailView.as_view(),
name='dashboard.views.group-detail'),
url(r'^group/(?P<pk>\d+)/update/$', GroupProfileUpdate.as_view(),
......
......@@ -28,7 +28,7 @@ from django.views.generic import View
from braces.views import LoginRequiredMixin
from vm.models import Instance, Node
from vm.models import Instance, Node, InstanceTemplate
logger = logging.getLogger(__name__)
......@@ -152,6 +152,28 @@ class NodeGraphView(GraphViewBase):
return self.model.objects.get(id=pk)
class TemplateGraphView(GraphViewBase):
model = InstanceTemplate
base = Metric
def get_object(self, request, pk):
instance = super(TemplateGraphView, self).get_object(request, pk)
if not instance.has_level(request.user, 'operator'):
raise PermissionDenied()
return instance
class TemplateVms(object):
metric_name = "instances.running"
title = _("Instance count")
label = _("instance count")
def get_minmax(self):
return (0, None)
register_graph(TemplateVms, 'instances', TemplateGraphView)
class NodeListGraphView(GraphViewBase):
model = Node
base = Metric
......
......@@ -136,6 +136,10 @@ class HelpView(TemplateView):
return ctx
class ResizeHelpView(TemplateView):
template_name = "info/resize.html"
class OpenSearchDescriptionView(TemplateView):
template_name = "dashboard/vm-opensearch.xml"
content_type = "application/opensearchdescription+xml"
......
......@@ -47,7 +47,8 @@ from ..tables import TemplateListTable, LeaseListTable
from .util import (
AclUpdateView, FilterMixin,
TransferOwnershipConfirmView, TransferOwnershipView,
DeleteViewBase
DeleteViewBase,
GraphMixin
)
logger = logging.getLogger(__name__)
......@@ -258,7 +259,8 @@ class TemplateDelete(DeleteViewBase):
object.delete()
class TemplateDetail(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
class TemplateDetail(LoginRequiredMixin, GraphMixin,
SuccessMessageMixin, UpdateView):
model = InstanceTemplate
template_name = "dashboard/template-edit.html"
form_class = TemplateForm
......@@ -300,6 +302,7 @@ class TemplateDetail(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
context['is_owner'] = obj.has_level(self.request.user, 'owner')
context['aclform'] = AclUserOrGroupAddForm()
context['parent'] = obj.parent
context['show_graph'] = obj.has_level(self.request.user, 'operator')
return context
def get_success_url(self):
......
......@@ -545,8 +545,8 @@ class UserList(LoginRequiredMixin, PermissionRequiredMixin, SingleTableView):
q = self.search_form.cleaned_data.get('s')
if q:
filters = (Q(username__icontains=q) | Q(email__icontains=q)
| Q(profile__org_id__icontains=q))
filters = (Q(username__icontains=q) | Q(email__icontains=q) |
Q(profile__org_id__icontains=q))
for w in q.split()[:3]:
filters |= (
Q(first_name__icontains=w) | Q(last_name__icontains=w))
......
......@@ -150,8 +150,8 @@ class VmDetailView(GraphMixin, CheckedDetailView):
# resources forms
can_edit = (
instance.has_level(user, "owner")
and self.request.user.has_perm("vm.change_resources"))
instance.has_level(user, "owner") and
self.request.user.has_perm("vm.change_resources"))
context['resources_form'] = VmResourcesForm(
can_edit=can_edit, instance=instance)
......@@ -174,8 +174,10 @@ class VmDetailView(GraphMixin, CheckedDetailView):
context['is_owner'] = is_owner
# operation also allows RUNNING (if with_shutdown is present)
context['save_resources_enabled'] = instance.status not in ("RUNNING",
"PENDING")
context['save_resources_enabled'] = instance.status in (
"STOPPED",
"PENDING",
)
return context
......@@ -567,8 +569,8 @@ class VmResourcesChangeView(VmOperationView):
content_type="application=json"
)
else:
return HttpResponseRedirect(instance.get_absolute_url()
+ "#resources")
return HttpResponseRedirect(instance.get_absolute_url() +
"#resources")
else:
extra = form.cleaned_data
extra['max_ram_size'] = extra['ram_size']
......@@ -1259,8 +1261,9 @@ def vm_activity(request, pk):
response['status'] = instance.status
response['icon'] = instance.get_status_icon()
latest = instance.get_latest_activity_in_progress()
response['is_new_state'] = (latest and latest.resultant_state is not None
and instance.status != latest.resultant_state)
response['is_new_state'] = (latest and
latest.resultant_state is not None and
instance.status != latest.resultant_state)
context = {
'instance': instance,
......
......@@ -188,11 +188,11 @@ class IPNetworkField(models.Field):
if isinstance(value, IPNetwork):
if self.version == 4:
return ('.'.join("%03d" % x for x in value.ip.words)
+ '/%02d' % value.prefixlen)
return ('.'.join("%03d" % x for x in value.ip.words) +
'/%02d' % value.prefixlen)
else:
return (':'.join("%04X" % x for x in value.ip.words)
+ '/%03d' % value.prefixlen)
return (':'.join("%04X" % x for x in value.ip.words) +
'/%03d' % value.prefixlen)
return value
def formfield(self, **kwargs):
......
......@@ -21,6 +21,8 @@ from django.core.management.base import BaseCommand
from firewall.tasks.local_tasks import reloadtask
from argparse import ArgumentTypeError
class Command(BaseCommand):
......@@ -33,6 +35,20 @@ class Command(BaseCommand):
default=False,
help='synchronous reload')
parser.add_argument('--timeout',
action='store',
dest='timeout',
default=15,
type=self.positive_int,
help='timeout for synchronous reload')
def handle(self, *args, **options):
reloadtask('Vlan', sync=options["sync"])
reloadtask('Vlan', sync=options["sync"], timeout=options["timeout"])
def positive_int(self, val):
if not val.isdigit():
raise ArgumentTypeError("'%s' is not a valid positive int" % val)
return int(val)
......@@ -700,8 +700,8 @@ class Host(models.Model):
return self.vlan.network_type != 'public'
def clean(self):
if (self.external_ipv4 and not self.shared_ip and self.behind_nat
and Host.objects.exclude(id=self.id).filter(
if (self.external_ipv4 and not self.shared_ip and self.behind_nat and
Host.objects.exclude(id=self.id).filter(
external_ipv4=self.external_ipv4)):
raise ValidationError(_("If shared_ip has been checked, "
"external_ipv4 has to be unique."))
......
......@@ -109,4 +109,4 @@ def reloadtask(type='Host', timeout=15, sync=False):
if all([cache.add("%s_lock" % i, 'true', 30) for i in reload]):
res = reloadtask_worker.apply_async(queue='localhost.man', countdown=5)
if sync:
res.get(15)
res.get(timeout)
......@@ -55,8 +55,8 @@ def select_node(instance, nodes):
'''
# check required traits
nodes = [n for n in nodes
if n.schedule_enabled and n.online
and has_traits(instance.req_traits.all(), n)]
if n.schedule_enabled and n.online and
has_traits(instance.req_traits.all(), n)]
if not nodes:
logger.warning('select_node: no usable node for %s', unicode(instance))
raise TraitsUnsatisfiableException()
......
......@@ -54,7 +54,7 @@ def measure_response_time():
@celery.task(ignore_result=True)
def check_celery_queues():
graphite_string = lambda component, hostname, celery, is_alive, time: (
def graphite_string(component, hostname, celery, is_alive, time): return (
"%s.%s.celery-queues.%s %d %s" % (
component, hostname, celery, 1 if is_alive else 0, time)
)
......@@ -92,7 +92,7 @@ def check_celery_queues():
@celery.task(ignore_result=True)
def instance_per_template():
graphite_string = lambda pk, state, val, time: (
def graphite_string(pk, state, val, time): return (
"template.%d.instances.%s %d %s" % (
pk, state, val, time)
)
......@@ -111,7 +111,7 @@ def instance_per_template():
@celery.task(ignore_result=True)
def allocated_memory():
graphite_string = lambda hostname, val, time: (
def graphite_string(hostname, val, time): return (
"circle.%s.memory.allocated %d %s" % (
hostname, val, time)
)
......
......@@ -979,8 +979,8 @@ def remove_switch_port_device(request, **kwargs):
def add_switch_port_device(request, **kwargs):
device_name = request.POST.get('device_name')
if (request.method == "POST" and device_name and len(device_name) > 0
and EthernetDevice.objects.filter(name=device_name).count() == 0):
if (request.method == "POST" and device_name and len(device_name) > 0 and
EthernetDevice.objects.filter(name=device_name).count() == 0):
switch_port = SwitchPort.objects.get(pk=kwargs['pk'])
new_device = EthernetDevice(name=device_name, switch_port=switch_port)
......
......@@ -22,6 +22,8 @@ from django.utils.translation import ugettext_lazy as _
from django.template import RequestContext
from django.template.loader import render_to_string
from sizefield.widgets import FileSizeWidget
from sizefield.utils import filesizeformat
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit
......@@ -70,34 +72,32 @@ class InitialFromFileMixin(object):
RequestContext(request, {}),
)
def clean(self):
cleaned_data = super(InitialFromFileMixin, self).clean()
if cleaned_data['message'].strip() == self.initial['message'].strip():
raise ValidationError(
_("Fill in the message."),
code="invalid")
return cleaned_data
def clean_message(self):
message = self.cleaned_data['message']
if message.strip() == self.initial['message'