diff --git a/README b/README index e69de29..70b3bb7 100644 --- a/README +++ b/README @@ -0,0 +1,3 @@ +StackTach is a debugging tool for OpenStack Nova. + +It takes events from AMQP and sends them to the StackTach server for web display. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..3e4eedc --- /dev/null +++ b/manage.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python +from django.core.management import execute_manager +import imp +try: + imp.find_module('settings') # Assumed to be in the same directory. +except ImportError: + import sys + sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n" % __file__) + sys.exit(1) + +import settings + +if __name__ == "__main__": + execute_manager(settings) diff --git a/stacktach/__init__.py b/stacktach/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stacktach/models.py b/stacktach/models.py new file mode 100644 index 0000000..0e403a7 --- /dev/null +++ b/stacktach/models.py @@ -0,0 +1,53 @@ +# Copyright 2012 - Dark Secret Software Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from django import forms +from django.db import models + + +class Tenant(models.Model): + email = models.CharField(max_length=50) + project_name = models.CharField(max_length=50) + tenant_id = models.AutoField(primary_key=True, unique=True) + + +class RawData(models.Model): + tenant = models.ForeignKey(Tenant, db_index=True, + to_field='tenant_id') + nova_tenant = models.CharField(max_length=50, null=True, + blank=True, db_index=True) + json = models.TextField() + routing_key = models.CharField(max_length=50, null=True, + blank=True, db_index=True) + state = models.CharField(max_length=50, null=True, + blank=True, db_index=True) + when = models.DateTimeField(db_index=True) + microseconds = models.IntegerField(default=0) + publisher = models.CharField(max_length=50, null=True, + blank=True, db_index=True) + event = models.CharField(max_length=50, null=True, + blank=True, db_index=True) + service = models.CharField(max_length=50, null=True, + blank=True, db_index=True) + host = models.CharField(max_length=50, null=True, + blank=True, db_index=True) + instance = models.CharField(max_length=50, null=True, + blank=True, db_index=True) + + +class TenantForm(forms.ModelForm): + class Meta: + model = Tenant + fields = ('email', 'project_name') diff --git a/stacktach/urls.py b/stacktach/urls.py new file mode 100644 index 0000000..2360446 --- /dev/null +++ b/stacktach/urls.py @@ -0,0 +1,28 @@ +from django.conf.urls.defaults import patterns, include, url + +# Uncomment the next two lines to enable the admin: +# from django.contrib import admin +# admin.autodiscover() + +urlpatterns = patterns('', + url(r'^$', 'dss.stackmon.views.welcome', name='welcome'), + url(r'new_tenant', 'dss.stackmon.views.new_tenant', name='new_tenant'), + url(r'logout', 'dss.stackmon.views.logout', name='logout'), + url(r'^(?P\d+)/$', 'dss.stackmon.views.home', name='home'), + url(r'^(?P\d+)/data/$', 'dss.stackmon.views.data', + name='data'), + url(r'^(?P\d+)/details/(?P\w+)/(?P\d+)/$', + 'dss.stackmon.views.details', name='details'), + url(r'^(?P\d+)/expand/(?P\d+)/$', + 'dss.stackmon.views.expand', name='expand'), + url(r'^(?P\d+)/host_status/$', + 'dss.stackmon.views.host_status', name='host_status'), + url(r'^(?P\d+)/instance_status/$', + 'dss.stackmon.views.instance_status', name='instance_status'), + + # Uncomment the admin/doc line below to enable admin documentation: + # url(r'^admin/doc/', include('django.contrib.admindocs.urls')), + + # Uncomment the next line to enable the admin: + # url(r'^admin/', include(admin.site.urls)), +) diff --git a/stacktach/views.py b/stacktach/views.py new file mode 100644 index 0000000..36ff13d --- /dev/null +++ b/stacktach/views.py @@ -0,0 +1,249 @@ +# Copyright 2012 - Dark Secret Software Inc. + +from django.shortcuts import render_to_response +from django import http +from django import template +from django.utils.functional import wraps + +from dss.stackmon import models + +import datetime +import json +import logging +import pprint +import random +import sys + +logger = logging.getLogger(__name__) + +VERSION = 4 + + +class My401(BaseException): + pass + + +class HttpResponseUnauthorized(http.HttpResponse): + status_code = 401 + + +def _monitor_message(routing_key, body): + event = body['event_type'] + publisher = body['publisher_id'] + parts = publisher.split('.') + service = parts[0] + host = parts[1] + payload = body['payload'] + request_spec = payload.get('request_spec', None) + instance = None + instance = payload.get('instance_id', instance) + nova_tenant = body.get('_context_project_id', None) + nova_tenant = payload.get('tenant_id', nova_tenant) + return dict(host=host, instance=instance, publisher=publisher, + service=service, event=event, nova_tenant=nova_tenant) + + +def _compute_update_message(routing_key, body): + publisher = None + instance = None + args = body['args'] + host = args['host'] + service = args['service_name'] + event = body['method'] + nova_tenant = args.get('_context_project_id', None) + return dict(host=host, instance=instance, publisher=publisher, + service=service, event=event, nova_tenant=nova_tenant) + + +# routing_key : handler +HANDLERS = {'monitor.info':_monitor_message, + 'monitor.error':_monitor_message, + '':_compute_update_message} + + +def _parse(tenant, args, json_args): + routing_key, body = args + handler = HANDLERS.get(routing_key, None) + if handler: + values = handler(routing_key, body) + if not values: + return {} + + values['tenant'] = tenant + when = body['_context_timestamp'] + when = datetime.datetime.strptime(when, "%Y-%m-%dT%H:%M:%S.%f") + values['when'] = when + values['microseconds'] = when.microsecond + values['routing_key'] = routing_key + values['json'] = json_args + record = models.RawData(**values) + record.save() + return values + return {} + + +def _post_process_raw_data(rows, highlight=None): + for row in rows: + if "error" in row.routing_key: + row.is_error = True + if highlight and row.id == int(highlight): + row.highlight = True + row.when += datetime.timedelta(microseconds=row.microseconds) + +class State(object): + def __init__(self): + self.version = VERSION + self.tenant = None + + def __str__(self): + tenant = "?" + if self.tenant: + tenant = "'%s' - %s (%d)" % (self.tenant.project_name, + self.tenant.email, self.tenant.id) + return "[Version %s, Tenant %s]" % (self.version, tenant) + + +def _reset_state(request): + state = State() + request.session['state'] = state + return state + + +def _get_state(request, tenant_id=None): + tenant = None + if tenant_id: + try: + tenant = models.Tenant.objects.get(tenant_id=tenant_id) + except models.Tenant.DoesNotExist: + raise My401() + + if 'state' in request.session: + state = request.session['state'] + else: + state =_reset_state(request) + + if hasattr(state, 'version') and state.version < VERSION: + state =_reset_state(request) + + state.tenant = tenant + + return state + + +def tenant_check(view): + @wraps(view) + def inner(*args, **kwargs): + try: + return view(*args, **kwargs) + # except HttpResponseUnauthorized, e: + except My401: + return HttpResponseUnauthorized() + + return inner + + +def _default_context(state): + context = dict(utc=datetime.datetime.utcnow(), state=state) + return context + + +def welcome(request): + state = _reset_state(request, None) + return render_to_response('stackmon/welcome.html', _default_context(state)) + + +@tenant_check +def home(request, tenant_id): + state = _get_state(request, tenant_id) + return render_to_response('stackmon/index.html', _default_context(state)) + + +def logout(request): + del request.session['state'] + return render_to_response('stackmon/welcome.html', _default_context(None)) + + +@tenant_check +def new_tenant(request): + state = _get_state(request) + context = _default_context(state) + if request.method == 'POST': + form = models.TenantForm(request.POST) + if form.is_valid(): + rec = models.Tenant(**form.cleaned_data) + rec.save() + _reset_state(request, rec.tenant_id) + return http.HttpResponseRedirect('/stacktach/%d' % rec.tenant_id) + else: + form = models.TenantForm() + context['form'] = form + return render_to_response('stackmon/new_tenant.html', context) + + +@tenant_check +def data(request, tenant_id): + state = _get_state(request, tenant_id) + raw_args = request.POST.get('args', "{}") + args = json.loads(raw_args) + c = _default_context(state) + fields = _parse(state.tenant, args, raw_args) + c['cooked_args'] = fields + return render_to_response('stackmon/data.html', c) + + +@tenant_check +def details(request, tenant_id, column, row_id): + state = _get_state(request, tenant_id) + c = _default_context(state) + row = models.RawData.objects.get(pk=row_id) + value = getattr(row, column) + rows = models.RawData.objects.filter(tenant_id=tenant_id) + if column != 'when': + rows = rows.filter(**{column:value}) + else: + from_time = value - datetime.timedelta(minutes=1) + to_time = value + datetime.timedelta(minutes=1) + rows = rows.filter(when__range=(from_time, to_time)) + + rows = rows.order_by('-when', '-microseconds')[:200] + _post_process_raw_data(rows, highlight=row_id) + c['rows'] = rows + c['allow_expansion'] = True + c['show_absolute_time'] = True + return render_to_response('stackmon/rows.html', c) + + +@tenant_check +def expand(request, tenant_id, row_id): + state = _get_state(request, tenant_id) + c = _default_context(state) + row = models.RawData.objects.get(pk=row_id) + payload = json.loads(row.json) + pp = pprint.PrettyPrinter() + c['payload'] = pp.pformat(payload) + return render_to_response('stackmon/expand.html', c) + + +@tenant_check +def host_status(request, tenant_id): + state = _get_state(request, tenant_id) + c = _default_context(state) + hosts = models.RawData.objects.filter(tenant_id=tenant_id).\ + filter(host__gt='').\ + order_by('-when', '-microseconds')[:20] + _post_process_raw_data(hosts) + c['rows'] = hosts + return render_to_response('stackmon/host_status.html', c) + + +@tenant_check +def instance_status(request, tenant_id): + state = _get_state(request, tenant_id) + c = _default_context(state) + instances = models.RawData.objects.filter(tenant_id=tenant_id).\ + exclude(instance='n/a').\ + exclude(instance__isnull=True).\ + order_by('-when', '-microseconds')[:20] + _post_process_raw_data(instances) + c['rows'] = instances + return render_to_response('stackmon/instance_status.html', c) diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..49495d5 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + +
StackTach
+ +{% block body %} +{% endblock %} + + + diff --git a/templates/data.html b/templates/data.html new file mode 100644 index 0000000..4ee0c95 --- /dev/null +++ b/templates/data.html @@ -0,0 +1 @@ +{{cooked_args|safe}} diff --git a/templates/expand.html b/templates/expand.html new file mode 100644 index 0000000..8981d88 --- /dev/null +++ b/templates/expand.html @@ -0,0 +1,5 @@ +
+
+{{payload|safe}}
+
+
diff --git a/templates/host_status.html b/templates/host_status.html new file mode 100644 index 0000000..efda3cb --- /dev/null +++ b/templates/host_status.html @@ -0,0 +1,8 @@ +{% include "rows.html" %} + + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..47967bd --- /dev/null +++ b/templates/index.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} + +{% block extra_js %} + function details(tenant_id, column, row_id) + { + $("#detail").load('/' + tenant_id + '/details/' + column + '/' + row_id); + }; + + function expand(tenant_id, row_id) + { + $("#row_expansion_" + row_id).load('/' + tenant_id + '/expand/' + row_id); + }; +{% endblock %} + +{% block extra_init_js %} + $('#host-box').resizable(); + $('#instance-box').resizable(); +{% endblock %} + +{% block body %} +
{{state.tenant.email}} (TID:{{state.tenant.tenant_id}}) - {{state.tenant.project_name}} logout
+
Recent Host Activity
+
+
+ {% include "host_status.html" %} +
+
+ +
Recent Instance Activity
+
+
+ {% include "instance_status.html" %} +
+
+ +
Details
+
+
+
click on an item above to see more of the same type.
+
+
+{% endblock %} diff --git a/templates/instance_status.html b/templates/instance_status.html new file mode 100644 index 0000000..9c1e3aa --- /dev/null +++ b/templates/instance_status.html @@ -0,0 +1,8 @@ +{% include "rows.html" %} + + + diff --git a/templates/new_tenant.html b/templates/new_tenant.html new file mode 100644 index 0000000..2201195 --- /dev/null +++ b/templates/new_tenant.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block body %} +
New Tenant
+
+
+
{% csrf_token %} + {{ form.as_p }} + +
+
+
+{% endblock %} diff --git a/templates/rows.html b/templates/rows.html new file mode 100644 index 0000000..d4ab9d3 --- /dev/null +++ b/templates/rows.html @@ -0,0 +1,50 @@ + + + + + + + + + + +{% for row in rows %} + + + + + + + + + + + +{% if allow_expansion %} + + + +{% endif %} +{% endfor %} +
+ sourcetenantservicehosteventinstancewhen
+ {% if allow_expansion %} + [+] + {% endif %} + + + {{row.routing_key}} + + + + {% if row.nova_tenant %}{{row.nova_tenant}}{% endif %} + + {{row.service}}{{row.host}}{{row.event}} + + {% if row.instance %} + {{row.instance}} + {% endif %} + + {% if show_absolute_time %}{{row.when}}{%else%}{{row.when|timesince:utc}} ago{%endif%}
+
+
diff --git a/templates/welcome.html b/templates/welcome.html new file mode 100644 index 0000000..bcaa144 --- /dev/null +++ b/templates/welcome.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} + +{% block body %} +
About
+
+
+ StackTach is a hosted debug/monitoring tool for OpenStack Nova + deployments. +
+
+ +
Connecting StackTach to OpenStack
+
+
+
    +
  • Get a StackTach Tenant ID +
  • Add
    --notification_driver=nova.notifier.rabbit_notifier
    and +
  • --notification_topics=monitor
    to your nova.conf file. +
  • Configure and run the StackTach Worker somewhere in your Nova development environment. +
  • Restart Nova and visit http://darksecretsoftware.com/stacktach/[your_tenant_id]/ to see your Nova installation in action! +
+
+
+{% endblock %} diff --git a/urls.py b/urls.py new file mode 100644 index 0000000..c236830 --- /dev/null +++ b/urls.py @@ -0,0 +1,15 @@ +from django.conf.urls.defaults import patterns, include, url + +# Uncomment the next two lines to enable the admin: +# from django.contrib import admin +# admin.autodiscover() + +urlpatterns = patterns('', + url(r'^', include('stacktach.urls')), + + # Uncomment the admin/doc line below to enable admin documentation: + # url(r'^admin/doc/', include('django.contrib.admindocs.urls')), + + # Uncomment the next line to enable the admin: + # url(r'^admin/', include(admin.site.urls)), +) diff --git a/worker.py b/worker.py index 1ca02c3..85da605 100644 --- a/worker.py +++ b/worker.py @@ -1,4 +1,20 @@ # Copyright 2012 - Dark Secret Software Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +# This is the worker you run in your OpenStack environment. You need +# to set TENANT_ID and URL to point to your StackTach web server. import json import kombu @@ -9,7 +25,9 @@ import threading import urllib import urllib2 -url = 'http://darksecretsoftware.com/stacktach/data/' +# CHANGE THESE FOR YOUR INSTALLATION ... +TENANT_ID = 1 +URL = 'http://darksecretsoftware.com/stacktach/%d/data/' % TENANT_ID # For now we'll just grab all the fanout messages from compute to scheduler ... scheduler_exchange = kombu.entity.Exchange("scheduler_fanout", type="fanout", @@ -57,18 +75,21 @@ class SchedulerFanoutConsumer(kombu.mixins.ConsumerMixin): try: raw_data = dict(args=jvalues) cooked_data = urllib.urlencode(raw_data) - req = urllib2.Request(url, cooked_data) + req = urllib2.Request(URL, cooked_data) response = urllib2.urlopen(req) page = response.read() print page except urllib2.HTTPError, e: + if e.code == 401: + print "Unauthorized. Correct tenant id of %d?" % TENANT_ID print e page = e.read() print page raise def on_scheduler(self, body, message): - self._process(body, message) + # Uncomment if you want periodic compute node status updates. + # self._process(body, message) message.ack() def on_nova(self, body, message):