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>&hellip;/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()