diff --git a/Makefile.am b/Makefile.am index 7b8bae0b33c083938095d76a85a177fff51a4cbf..1156730d4b97290529c8d5cf931a67189e71146a 100644 --- a/Makefile.am +++ b/Makefile.am @@ -99,6 +99,7 @@ docsgml = \ doc/hooks.sgml \ doc/install.sgml \ doc/admin.sgml \ + doc/rapi.sgml \ doc/iallocator.sgml doc_DATA = \ @@ -131,6 +132,7 @@ EXTRA_DIST = \ autotools/docbook-wrapper \ devel/upload.in \ $(docsgml) \ + doc/build-rapi-resources-doc \ doc/examples/ganeti.initd.in \ doc/examples/ganeti.cron.in \ doc/examples/dumb-allocator \ @@ -186,6 +188,7 @@ dist_TESTS = \ test/ganeti.locking_unittest.py \ test/ganeti.serializer_unittest.py \ test/ganeti.workerpool_unittest.py \ + test/ganeti.rapi.resources_unittest.py \ test/ganeti.constants_unittest.py nodist_TESTS = @@ -218,6 +221,13 @@ doc/%.pdf: doc/%.in $(DOCBOOK_WRAPPER) doc/%.html: doc/%.in $(DOCBOOK_WRAPPER) $(DOCBOOK_WRAPPER) $< $@ +doc/rapi.pdf doc/rapi.html: doc/rapi-resources.sgml + +doc/rapi-resources.sgml: doc/build-rapi-resources-doc lib/rapi/resources.py + PYTHONPATH=.:$(top_builddir) $< > $@ || rm -f $@ + +.INTERMEDIATE: doc/rapi-resources.sgml + man/%.7: man/%.in man/footer.sgml $(DOCBOOK_WRAPPER) $(DOCBOOK_WRAPPER) $< $@ @@ -226,7 +236,7 @@ man/%.8: man/%.in man/footer.sgml $(DOCBOOK_WRAPPER) man/footer.sgml $(TESTS): srclinks -$(TESTS): ganeti +$(TESTS) doc/build-rapi-resources-doc: ganeti lib/_autoconf.py lib/_autoconf.py: Makefile stamp-directories set -e; \ diff --git a/doc/build-rapi-resources-doc b/doc/build-rapi-resources-doc new file mode 100755 index 0000000000000000000000000000000000000000..c73e51883b664d33f58eda7677a34d714c7b7831 --- /dev/null +++ b/doc/build-rapi-resources-doc @@ -0,0 +1,87 @@ +#!/usr/bin/python +# + +# Copyright (C) 2008 Google Inc. +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA +# 02110-1301, USA. + +"""Script to generate documentation for remote API resources. + +""" + +import re +import cgi +import inspect + +from ganeti.rapi import resources + + +CHECKED_COMMANDS = ["GET", "POST", "PUT", "DELETE"] + + +def main(): + # Get list of all resources + all = list(resources._CONNECTOR.itervalues()) + + # Sort resources by URI + all.sort(cmp=lambda a, b: cmp(a.DOC_URI, b.DOC_URI)) + + print "<!-- Automatically generated, do not edit -->" + + for cls in all: + print "<sect2>" + print "<title>%s</title>" % cgi.escape(cls.DOC_URI) + + # Class docstring + description = inspect.getdoc(cls) + if description: + print ("<literallayout>%s</literallayout>" % + cgi.escape(description.strip())) + + print '<informaltable><tgroup cols="2">' + print '<colspec colwidth="1*">' + print '<colspec colwidth="5*">' + print "<thead>" + print " <row>" + print " <entry>Method</entry>" + print " <entry>Description</entry>" + print " </row>" + print "</thead>" + print '<tbody valign="top">' + + for cmd in CHECKED_COMMANDS: + if not hasattr(cls, cmd): + continue + + # Get docstring + text = inspect.getdoc(getattr(cls, cmd)) + if not text: + text = "" + + print "<row>" + print " <entry>%s</entry>" % cgi.escape(cmd) + print (" <entry><literallayout>%s</literallayout></entry>" % + cgi.escape(text.strip())) + print "</row>" + + print "</tbody>" + print "</tgroup></informaltable>" + + print "</sect2>" + + +if __name__ == "__main__": + main() diff --git a/doc/rapi.sgml b/doc/rapi.sgml new file mode 100644 index 0000000000000000000000000000000000000000..51bf56e4fa36f5579b76f13e54d41bda88236b50 --- /dev/null +++ b/doc/rapi.sgml @@ -0,0 +1,94 @@ +<!DOCTYPE article PUBLIC "-//OASIS//DTD DocBook V4.2//EN" [ +<!ENTITY JsonLink "http://www.json.org/"> +<!ENTITY WikipediaRESTLink + "http://en.wikipedia.org/wiki/Representational_State_Transfer"> +<!ENTITY IncludeResources SYSTEM "doc/rapi-resources.sgml"> +]> +<article class="specification"> +<articleinfo> + <title>Ganeti remote API</title> +</articleinfo> + +<para>Documents Ganeti version 1.2</para> + +<sect1> + <title>Introduction</title> + + <para>Ganeti supports a remote API for enable external tools to easily + retrieve information about a cluster's state. The remote API daemon, + <computeroutput>ganeti-rapi</computeroutput>, is automatically started on + the master node if the <computeroutput>--enable-rapi</computeroutput> + parameter is passed to the <computeroutput>configure</computeroutput> + script. Alternatively you can start it manually. By default it runs on TCP + port 5080, but this can be changed either in + <filename>…/constants.py</filename> or via the command line + parameter <computeroutput>-p</computeroutput>. SSL support can also be + enabled by passing command line parameters.</para> + + <note> + <para>Ganeti 1.2 only supports a limited set of calls, all of them + read-only. The next major version will have support for write + operations.</para> + </note> +</sect1> + +<sect1> + <title>Protocol</title> + + <para>The protocol used is <ulink url="&JsonLink;">JSON</ulink> over HTTP + designed after the <ulink url="&WikipediaRESTLink;">REST</ulink> principle. + </para> +</sect1> + +<sect1> + <title>Usage examples</title> + + <para>You can access the API using your favorite programming language as long + as it supports network connections.</para> + + <sect2> + <title>Shell</title> + <screen>wget -q -O - http://<replaceable>CLUSTERNAME</replaceable>:5080/info</screen> + </sect2> + + <sect2> + <title>Python</title> + <screen>import urllib2 +f = urllib2.urlopen('http://<replaceable>CLUSTERNAME</replaceable>:5080/info') +print f.read()</screen> + </sect2> + + <sect2> + <title>JavaScript</title> + <note> + <para>While it's possible to use JavaScript, it poses several potential + problems, including browser blocking request due to non-standard ports + or different domain names. Fetching the data on the webserver is + easier.</para> + </note> + <screen>var url = 'http://<replaceable>CLUSTERNAME</replaceable>:5080/info'; +var info; + +var xmlreq = new XMLHttpRequest(); +xmlreq.onreadystatechange = function () { + if (xmlreq.readyState != 4) return; + if (xmlreq.status == 200) { + info = eval("(" + xmlreq.responseText + ")"); + alert(info); + } else { + alert('Error fetching cluster info'); + } + xmlreq = null; +}; +xmlreq.open('GET', url, true); +xmlreq.send(null);</screen> + </sect2> + +</sect1> + +<sect1> + <title>Resources</title> + &IncludeResources; +</sect1> + +</article> diff --git a/test/ganeti.rapi.resources_unittest.py b/test/ganeti.rapi.resources_unittest.py new file mode 100755 index 0000000000000000000000000000000000000000..8fc40eee797d6c8df5b41928694e5322b339557b --- /dev/null +++ b/test/ganeti.rapi.resources_unittest.py @@ -0,0 +1,144 @@ +#!/usr/bin/python +# + +# Copyright (C) 2007, 2008 Google Inc. +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA +# 02110-1301, USA. + + +"""Script for unittesting the rapi.resources module""" + + +import os +import unittest +import tempfile +import time + +from ganeti import errors +from ganeti.rapi import httperror +from ganeti.rapi import resources +from ganeti.rapi import RESTHTTPServer + + +class MapperTests(unittest.TestCase): + """Tests for remote API URI mapper.""" + + def setUp(self): + self.map = resources.Mapper() + + def _TestUri(self, uri, result): + self.assertEquals(self.map.getController(uri), result) + + def _TestFailingUri(self, uri): + self.failUnlessRaises(httperror.HTTPNotFound, self.map.getController, uri) + + def testMapper(self): + """Testing resources.Mapper""" + + self._TestUri("/tags", (resources.R_tags, [], {})) + + self._TestUri('/instances/www.test.com', + (resources.R_instances_name, + ['www.test.com'], + {})) + + self._TestUri('/instances/www.test.com/tags?f=5&f=6&alt=html', + (resources.R_instances_name_tags, + ['www.test.com'], + {'alt': ['html'], + 'f': ['5', '6'], + })) + + self._TestFailingUri("/tag") + self._TestFailingUri("/instances/does/not/exist") + + +class R_RootTests(unittest.TestCase): + """Testing for R_root class.""" + + def setUp(self): + self.root = resources.R_root(None, None, None) + + def testGet(self): + expected = [ + {'name': 'info', 'uri': '/info'}, + {'name': 'instances', 'uri': '/instances'}, + {'name': 'nodes', 'uri': '/nodes'}, + {'name': 'os', 'uri': '/os'}, + {'name': 'tags', 'uri': '/tags'}, + {'name': 'version', 'uri': '/version'}, + ] + self.assertEquals(self.root.GET(), expected) + + +class HttpLogfileTests(unittest.TestCase): + """Rests for HttpLogfile class.""" + + class FakeRequest: + FAKE_ADDRESS = "1.2.3.4" + + def address_string(self): + return self.FAKE_ADDRESS + + def setUp(self): + self.tmpfile = tempfile.NamedTemporaryFile() + self.logfile = RESTHTTPServer.HttpLogfile(self.tmpfile.name) + + def testFormatLogTime(self): + self._TestInTimezone(1208646123.0, "Europe/London", + "19/Apr/2008:23:02:03 +0000") + self._TestInTimezone(1208646123, "Europe/Zurich", + "19/Apr/2008:23:02:03 +0000") + self._TestInTimezone(1208646123, "Australia/Sydney", + "19/Apr/2008:23:02:03 +0000") + + def _TestInTimezone(self, seconds, timezone, expected): + """Tests HttpLogfile._FormatLogTime with a specific timezone + + """ + # Preserve environment + old_TZ = os.environ.get("TZ", None) + try: + os.environ["TZ"] = timezone + time.tzset() + result = self.logfile._FormatLogTime(seconds) + finally: + # Restore environment + if old_TZ is not None: + os.environ["TZ"] = old_TZ + elif "TZ" in os.environ: + del os.environ["TZ"] + time.tzset() + + self.assertEqual(result, expected) + + def testClose(self): + self.logfile.Close() + + def testCloseAndWrite(self): + request = self.FakeRequest() + self.logfile.Close() + self.assertRaises(errors.ProgrammerError, self.logfile.LogRequest, + request, "Message") + + def testLogRequest(self): + request = self.FakeRequest() + self.logfile.LogRequest(request, "This is only a %s", "test") + self.logfile.Close() + + +if __name__ == '__main__': + unittest.main()