diff --git a/nodepool/cmd/nodepoolcmd.py b/nodepool/cmd/nodepoolcmd.py index e4a8217ba..02e7047ba 100644 --- a/nodepool/cmd/nodepoolcmd.py +++ b/nodepool/cmd/nodepoolcmd.py @@ -21,6 +21,7 @@ import time from nodepool import nodedb from nodepool import nodepool +from nodepool import status from nodepool.cmd import NodepoolApp from nodepool.version import version_info as npc_version_info from config_validator import ConfigValidator @@ -177,47 +178,13 @@ class NodePoolCmd(NodepoolApp): '%(message)s') def list(self, node_id=None): - t = PrettyTable(["ID", "Provider", "AZ", "Label", "Target", "Manager", - "Hostname", "NodeName", "Server ID", "IP", "State", - "Age", "Comment"]) - t.align = 'l' - with self.pool.getDB().getSession() as session: - for node in session.getNodes(): - if node_id and node.id != node_id: - continue - t.add_row([node.id, node.provider_name, node.az, - node.label_name, node.target_name, - node.manager_name, node.hostname, - node.nodename, node.external_id, node.ip, - nodedb.STATE_NAMES[node.state], - NodePoolCmd._age(node.state_time), - node.comment]) - print t + print status.node_list(self.pool.getDB(), node_id) def dib_image_list(self): - t = PrettyTable(["ID", "Image", "Filename", "Version", - "State", "Age"]) - t.align = 'l' - with self.pool.getDB().getSession() as session: - for image in session.getDibImages(): - t.add_row([image.id, image.image_name, - image.filename, image.version, - nodedb.STATE_NAMES[image.state], - NodePoolCmd._age(image.state_time)]) - print t + print status.dib_image_list(self.pool.getDB()) def image_list(self): - t = PrettyTable(["ID", "Provider", "Image", "Hostname", "Version", - "Image ID", "Server ID", "State", "Age"]) - t.align = 'l' - with self.pool.getDB().getSession() as session: - for image in session.getSnapshotImages(): - t.add_row([image.id, image.provider_name, image.image_name, - image.hostname, image.version, - image.external_id, image.server_external_id, - nodedb.STATE_NAMES[image.state], - NodePoolCmd._age(image.state_time)]) - print t + print status.image_list(self.pool.getDB()) def image_update(self): threads = [] diff --git a/nodepool/cmd/nodepoold.py b/nodepool/cmd/nodepoold.py index 4ccf5e915..76e9f2df1 100644 --- a/nodepool/cmd/nodepoold.py +++ b/nodepool/cmd/nodepoold.py @@ -33,6 +33,7 @@ import threading import nodepool.builder import nodepool.cmd import nodepool.nodepool +import nodepool.webapp def stack_dump_handler(signum, frame): @@ -110,6 +111,7 @@ class NodePoolDaemon(nodepool.cmd.NodepoolApp): self.pool.stop() if self.args.builder: self.builder.stop() + self.webapp.stop() sys.exit(0) def term_handler(self, signum, frame): @@ -124,6 +126,8 @@ class NodePoolDaemon(nodepool.cmd.NodepoolApp): self.args.config, self.args.build_workers, self.args.upload_workers) + self.webapp = nodepool.webapp.WebApp(self.pool) + signal.signal(signal.SIGINT, self.exit_handler) # For back compatibility: signal.signal(signal.SIGUSR1, self.exit_handler) @@ -136,6 +140,8 @@ class NodePoolDaemon(nodepool.cmd.NodepoolApp): nb_thread = threading.Thread(target=self.builder.runForever) nb_thread.start() + self.webapp.start() + while True: signal.pause() diff --git a/nodepool/status.py b/nodepool/status.py new file mode 100644 index 000000000..4183156db --- /dev/null +++ b/nodepool/status.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python +# +# Copyright 2013 OpenStack Foundation +# +# 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. + +import time + +from nodepool import nodedb + +from prettytable import PrettyTable + + +def age(timestamp): + now = time.time() + dt = now - timestamp + m, s = divmod(dt, 60) + h, m = divmod(m, 60) + d, h = divmod(h, 24) + return '%02d:%02d:%02d:%02d' % (d, h, m, s) + + +def node_list(db, node_id=None): + t = PrettyTable(["ID", "Provider", "AZ", "Label", "Target", + "Manager", "Hostname", "NodeName", "Server ID", + "IP", "State", "Age", "Comment"]) + t.align = 'l' + with db.getSession() as session: + for node in session.getNodes(): + if node_id and node.id != node_id: + continue + t.add_row([node.id, node.provider_name, node.az, + node.label_name, node.target_name, + node.manager_name, node.hostname, + node.nodename, node.external_id, node.ip, + nodedb.STATE_NAMES[node.state], + age(node.state_time), node.comment]) + return str(t) + + +def dib_image_list(db): + t = PrettyTable(["ID", "Image", "Filename", "Version", + "State", "Age"]) + t.align = 'l' + with db.getSession() as session: + for image in session.getDibImages(): + t.add_row([image.id, image.image_name, + image.filename, image.version, + nodedb.STATE_NAMES[image.state], + age(image.state_time)]) + return str(t) + + +def image_list(db): + t = PrettyTable(["ID", "Provider", "Image", "Hostname", "Version", + "Image ID", "Server ID", "State", "Age"]) + t.align = 'l' + with db.getSession() as session: + for image in session.getSnapshotImages(): + t.add_row([image.id, image.provider_name, image.image_name, + image.hostname, image.version, + image.external_id, image.server_external_id, + nodedb.STATE_NAMES[image.state], + age(image.state_time)]) + return str(t) diff --git a/nodepool/tests/__init__.py b/nodepool/tests/__init__.py index 859ea8c89..c8b86436f 100644 --- a/nodepool/tests/__init__.py +++ b/nodepool/tests/__init__.py @@ -34,7 +34,7 @@ import kazoo.client import testresources import testtools -from nodepool import allocation, builder, fakeprovider, nodepool, nodedb +from nodepool import allocation, builder, fakeprovider, nodepool, nodedb, webapp TRUE_VALUES = ('true', '1', 'yes') @@ -338,6 +338,9 @@ class BaseTestCase(testtools.TestCase, testresources.ResourcedTestCase): if t.name.startswith("Thread-"): # apscheduler thread pool continue + if t.name.startswith("worker "): + # paste web server + continue if t.name not in whitelist: done = False if done: @@ -516,6 +519,11 @@ class DBTestCase(BaseTestCase): self.addCleanup(pool.stop) return pool + def useWebApp(self, *args, **kwargs): + app = webapp.WebApp(*args, **kwargs) + self.addCleanup(app.stop) + return app + def _useBuilder(self, configfile): self.useFixture(BuilderFixture(configfile)) diff --git a/nodepool/tests/test_webapp.py b/nodepool/tests/test_webapp.py new file mode 100644 index 000000000..2c799b2de --- /dev/null +++ b/nodepool/tests/test_webapp.py @@ -0,0 +1,40 @@ +# Copyright (C) 2014 OpenStack Foundation +# +# 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. + +import logging +import urllib2 + +from nodepool import tests + + +class TestWebApp(tests.DBTestCase): + log = logging.getLogger("nodepool.TestWebApp") + + def test_image_list(self): + configfile = self.setup_config('node.yaml') + pool = self.useNodepool(configfile, watermark_sleep=1) + pool.start() + webapp = self.useWebApp(pool, port=0) + webapp.start() + port = webapp.server.socket.getsockname()[1] + + self.waitForImage(pool, 'fake-provider', 'fake-image') + self.waitForNodes(pool) + + req = urllib2.Request( + "http://localhost:%s/image-list" % port) + f = urllib2.urlopen(req) + data = f.read() + self.assertTrue('fake-image' in data) diff --git a/nodepool/webapp.py b/nodepool/webapp.py new file mode 100644 index 000000000..8dce987b8 --- /dev/null +++ b/nodepool/webapp.py @@ -0,0 +1,100 @@ +# Copyright 2012 Hewlett-Packard Development Company, L.P. +# Copyright 2013 OpenStack Foundation +# +# 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. + +import logging +import threading +import time +from paste import httpserver +import webob +from webob import dec + +import status + +"""Nodepool main web app. + +Nodepool supports HTTP requests directly against it for determining +status. These responses are provided as preformatted text for now, but +should be augmented or replaced with JSON data structures. +""" + + +class Cache(object): + def __init__(self, expiry=1): + self.cache = {} + self.expiry = expiry + + def get(self, key): + now = time.time() + if key in self.cache: + lm, value = self.cache[key] + if now > lm + self.expiry: + del self.cache[key] + return None + return (lm, value) + + def put(self, key, value): + now = time.time() + res = (now, value) + self.cache[key] = res + return res + + +class WebApp(threading.Thread): + log = logging.getLogger("nodepool.WebApp") + + def __init__(self, nodepool, port=8001, cache_expiry=1): + threading.Thread.__init__(self) + self.nodepool = nodepool + self.port = port + self.cache = Cache(cache_expiry) + self.cache_expiry = cache_expiry + self.daemon = True + self.server = httpserver.serve(dec.wsgify(self.app), host='0.0.0.0', + port=self.port, start_loop=False) + + def run(self): + self.server.serve_forever() + + def stop(self): + self.server.server_close() + + def get_cache(self, path): + result = self.cache.get(path) + if result: + return result + if path == '/image-list': + table = status.image_list(self.nodepool.getDB()) + elif path == '/dib-image-list': + table = status.dib_image_list(self.nodepool.getDB()) + else: + return None + return self.cache.put(path, table) + + def app(self, request): + result = self.get_cache(request.path) + if result is None: + raise webob.exc.HTTPNotFound() + last_modified, table = result + + response = webob.Response(body=table, + content_type='text/plain') + response.headers['Access-Control-Allow-Origin'] = '*' + + response.cache_control.public = True + response.cache_control.max_age = self.cache_expiry + response.last_modified = last_modified + response.expires = last_modified + self.cache_expiry + + return response.conditional_response_app diff --git a/requirements.txt b/requirements.txt index a7038987f..7af84e342 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,3 +19,5 @@ shade>=1.8.0 diskimage-builder voluptuous kazoo +Paste +WebOb>=1.2.3