You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@couchdb.apache.org by ja...@apache.org on 2013/07/31 18:56:05 UTC

[1/4] git commit: updated refs/heads/1867-feature-plugins to 7789506

Updated Branches:
  refs/heads/1867-feature-plugins [created] 778950622


add couch_plugins


Project: http://git-wip-us.apache.org/repos/asf/couchdb/repo
Commit: http://git-wip-us.apache.org/repos/asf/couchdb/commit/66faf3a8
Tree: http://git-wip-us.apache.org/repos/asf/couchdb/tree/66faf3a8
Diff: http://git-wip-us.apache.org/repos/asf/couchdb/diff/66faf3a8

Branch: refs/heads/1867-feature-plugins
Commit: 66faf3a899f0b8b13c3cc774c629154f9e001a30
Parents: 198f936
Author: Jan Lehnardt <ja...@apache.org>
Authored: Wed Jul 31 15:12:30 2013 +0200
Committer: Jan Lehnardt <ja...@apache.org>
Committed: Wed Jul 31 15:12:30 2013 +0200

----------------------------------------------------------------------
 src/couch_plugins/ebin/couch_plugins.app      |   9 +
 src/couch_plugins/src/couch_plugins.app.src   |  12 +
 src/couch_plugins/src/couch_plugins.erl       | 248 +++++++++++++++++++++
 src/couch_plugins/src/couch_plugins_httpd.erl |  10 +
 4 files changed, 279 insertions(+)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/couchdb/blob/66faf3a8/src/couch_plugins/ebin/couch_plugins.app
----------------------------------------------------------------------
diff --git a/src/couch_plugins/ebin/couch_plugins.app b/src/couch_plugins/ebin/couch_plugins.app
new file mode 100644
index 0000000..b67645e
--- /dev/null
+++ b/src/couch_plugins/ebin/couch_plugins.app
@@ -0,0 +1,9 @@
+{application,couch_plugins,
+             [{description,"A CouchDB Plugin Installer"},
+              {vsn,"1"},
+              {registered,[]},
+              {applications,[kernel,stdlib]},
+              {mod,{couch_plugins_app,[]}},
+              {env,[]},
+              {modules,[couch_plugins,couch_plugins_app,couch_plugins_httpd,
+                        couch_plugins_sup]}]}.

http://git-wip-us.apache.org/repos/asf/couchdb/blob/66faf3a8/src/couch_plugins/src/couch_plugins.app.src
----------------------------------------------------------------------
diff --git a/src/couch_plugins/src/couch_plugins.app.src b/src/couch_plugins/src/couch_plugins.app.src
new file mode 100644
index 0000000..059663c
--- /dev/null
+++ b/src/couch_plugins/src/couch_plugins.app.src
@@ -0,0 +1,12 @@
+{application, couch_plugins,
+ [
+  {description, "A CouchDB Plugin Installer"},
+  {vsn, "1"},
+  {registered, []},
+  {applications, [
+                  kernel,
+                  stdlib
+                 ]},
+  {mod, { couch_plugins_app, []}},
+  {env, []}
+ ]}.

http://git-wip-us.apache.org/repos/asf/couchdb/blob/66faf3a8/src/couch_plugins/src/couch_plugins.erl
----------------------------------------------------------------------
diff --git a/src/couch_plugins/src/couch_plugins.erl b/src/couch_plugins/src/couch_plugins.erl
new file mode 100644
index 0000000..e406b2a
--- /dev/null
+++ b/src/couch_plugins/src/couch_plugins.erl
@@ -0,0 +1,248 @@
+-module(couch_plugins).
+-include("couch_db.hrl").
+%% Application callbacks
+-export([install/1]).
+
+
+% couch_plugins:install({"geocouch", "http://127.0.0.1:8000", "1.0.0", [{"R15B03", "+XOJP6GSzmuO2qKdnjO+mWckXVs="}]}).
+% couch_plugins:install({"geocouch", "http://people.apache.org/~jan/", "couchdb1.2.x_v0.3.0-11-gd83ba22", [{"R15B03", "Z9xK+OKLRvqKx3uoQHsiTuv6mrY="}]}).
+
+
+-define(PLUGIN_DIR, "/tmp/couchdb_plugins").
+
+log(T) -> 
+  ?LOG_DEBUG("[couch_plugins] ~p ~n", [T]).
+
+%% "geocouch", "http://localhost:8000/dist", "1.0.0"
+-type plugin() :: {string(), string(), string(), list()}.
+-spec install(plugin()) -> ok | {error, string()}.
+install({Name, _BaseUrl, Version, Checksums}=Plugin) ->
+  log("Installing " ++ Name),
+
+  {ok, LocalFilename} = download(Plugin),
+  log("downloaded to " ++ LocalFilename),
+
+  ok = verify_checksum(LocalFilename, Checksums),
+  log("checksum verified"),
+
+  ok = untargz(LocalFilename),
+  log("extraction done"),
+
+  ok = add_code_path(Name, Version),
+  log("added code path"),
+
+  ok = load_config(Name, Version),
+  load_plugin(Name),
+
+  log("loaded plugin"),
+  ok.
+
+-spec load_config(string(), string()) -> ok | {error, string()}.
+load_config(Name, Version) ->
+  ConfigFile = ?PLUGIN_DIR ++ "/" ++ get_file_slug(Name, Version) ++ "/priv/config.erlt",
+  load_config_file(file_exists(ConfigFile), ConfigFile).
+
+-spec load_config_file(boolean(), string()) -> ok | {error, string()}.
+load_config_file(false, _) -> ok;
+load_config_file(true, ConfigFile) ->
+  % read file
+  {ok, ConfigFileData} = file:read_file(ConfigFile),
+  % split by \n
+  Lines = binary:split(ConfigFileData, <<"\n">>, [global]),
+  % feed each line...
+  lists:foreach(
+    fun(<<>>) ->
+      ok; % skip empty lines
+    (<<";", _Rest/binary>>) ->
+      ok; % ignore comments
+    (Line) ->
+    % ...to couch_util:parse_term()...
+    case couch_util:parse_term(Line) of
+      {ok, {{Section, Key}, Value}} ->
+        % ...and set the configs
+        ?LOG_DEBUG("parsed Line correctly: ~p", [Line]),
+        couch_config:set(Section, Key, Value);
+      Else ->
+        ?LOG_ERROR("Error parsing plugin config from line ~s", [Line]),
+        Else
+      end
+  end, Lines),
+  ok.
+
+-spec add_code_path(string(), string()) -> ok | {error, bad_directory}.
+add_code_path(Name, Version) ->
+  PluginPath = ?PLUGIN_DIR ++ "/" ++ get_file_slug(Name, Version) ++ "/ebin",
+  case code:add_path(PluginPath) of
+    true -> ok;
+    Else -> 
+      ?LOG_ERROR("Failed to add PluginPath: '~s'", [PluginPath]),
+      Else
+  end.
+
+load_plugin(NameList) ->
+  Name = list_to_atom(NameList),
+  application:load(Name).
+
+
+-spec untargz(string()) -> {ok, string()} | {error, string()}.
+untargz(Filename) ->
+  % read .gz file
+  {ok, GzData} = file:read_file(Filename),
+  % gunzip
+  log("unzipped"),
+  TarData = zlib:gunzip(GzData),
+  ok = filelib:ensure_dir(?PLUGIN_DIR),
+  % untar
+  erl_tar:extract({binary, TarData}, [{cwd, ?PLUGIN_DIR}, keep_old_files]).
+  
+
+% downloads a pluygin .tar.gz into a local plugins directory
+-spec download(string()) -> ok | {error, string()}.
+download({Name, _BaseUrl, Version, _Checksums}=Plugin) ->
+  TargetFile = "/tmp/" ++ get_filename(Name, Version),
+  case file_exists(TargetFile) of
+    %% wipe and redownload
+    true -> file:delete(TargetFile);
+    _Else -> ok
+  end,
+  Url = get_url(Plugin),
+  HTTPOptions = [
+    {connect_timeout, 30*1000}, % 30 seconds
+    {timeout, 30*1000} % 30 seconds
+  ],
+  % todo: windows
+  Options = [
+    {stream, TargetFile}, % /tmp/something
+    {body_format, binary},
+    {full_result, false}
+  ],
+  % todo: reduce to just httpc:request()
+  case httpc:request(get, {Url, []}, HTTPOptions, Options) of
+    {ok, _Result} ->
+      log("downloading " ++ Url),
+      {ok, TargetFile};
+    Error -> Error
+  end.
+
+-spec verify_checksum(string(), list()) -> ok | {error, string()}.
+verify_checksum(Filename, Checksums) ->
+  OTPRelease = erlang:system_info(otp_release),
+  case proplists:get_value(OTPRelease, Checksums) of
+  undefined ->
+    ?LOG_ERROR("[couch_plugins] Can't find checksum for OTP Release '~s'", [OTPRelease]),
+    {error, no_checksum};
+  Checksum ->
+    do_verify_checksum(Filename, Checksum)
+  end.
+
+-spec do_verify_checksum(string(), string()) -> ok | {error, string()}.
+do_verify_checksum(Filename, Checksum) ->
+  case file:read_file(Filename) of
+  {ok, Data} ->
+    ComputedChecksum = binary_to_list(base64:encode(crypto:sha(Data))),
+    case ComputedChecksum of
+    Checksum -> ok;
+    _Else ->
+      ?LOG_ERROR("Checksum mismatch. Wanted: '~p'. Got '~p'", [Checksum, ComputedChecksum]),
+      {error, checksum_mismatch}
+    end;
+  Error -> Error
+  end.
+
+
+
+
+-spec get_url(plugin()) -> string().
+get_url({Name, BaseUrl, Version, _Checksums}) ->
+  BaseUrl ++ "/" ++ get_filename(Name, Version).
+
+-spec get_filename(string(), string()) -> string().
+get_filename(Name, Version) ->
+  get_file_slug(Name, Version) ++ ".tar.gz".
+
+-spec get_file_slug(string(), string()) -> string().
+get_file_slug(Name, Version) ->
+  % OtpRelease does not include patch levels like the -1 in R15B03-1
+  OTPRelease = erlang:system_info(otp_release),
+  Name ++ "-" ++ Version ++ "-" ++ OTPRelease.
+
+-spec file_exists(string()) -> boolean().
+file_exists(Filename) ->
+  does_file_exist(file:read_file_info(Filename)).
+-spec does_file_exist(term()) -> boolean().
+does_file_exist({error, enoent}) -> false;
+does_file_exist(_Else) -> true.
+
+% installing a plugin:
+%  - POST /_plugins -d {plugin-def}
+%  - get plugin definition
+%  - get download URL (matching erlang version)
+%  - download archive
+%  - match checksum
+%  - untar-gz archive into a plugins dir
+%  - code:add_path(“geocouch-{geocouch_version}-{erlang_version}/ebin”)
+%  - [cp geocouch-{geocouch_version}-{erlang_version}/etc/ ]
+%  - application:start(geocouch)
+%  - register plugin in plugin registry
+
+% Plugin registry impl:
+%  - _plugins database
+%   - pro: known db ops
+%   - con: no need for replication, needs to be system db etc.
+%  - _config/plugins namespace in config
+%   - pro: lightweight, fits rarely-changing nature better
+%   - con: potentially not flexible enough
+
+
+
+% /geocouch
+% /geocouch/dist/
+% /geocouch/dist/geocouch-{geocouch_version}-{erlang_version}.tar.gz
+
+% tar.gz includes:
+% geocouch-{geocouch_version}-{erlang_version}/
+% geocouch-{geocouch_version}-{erlang_version}/ebin
+% [geocouch-{geocouch_version}-{erlang_version}/config/config.erlt]
+% [geocouch-{geocouch_version}-{erlang_version}/share/]
+
+
+
+% config.erlt:
+% // {{Section, Key}, Value}
+% {{"httpd_db_handlers", "_spatial_cleanup"}, "{couch_spatial_http, handle_cleanup_req}"}
+% {{"httpd_design_handlers", "_spatial"}, "{couch_spatial_http, handle_spatial_req}"}
+% {{"httpd_design_handlers", "_list"}, "{couch_spatial_list, handle_view_list_req}"}
+% {{"httpd_design_handlers", "_info"}, "{couch_spatial_http, handle_info_req}"}
+% {{"httpd_design_handlers", "_compact"}, "{couch_spatial_http, handle_compact_req}"}
+
+% milestones
+% 1. MVP
+%  - erlang plugins only
+%  - no c deps
+%  - install via futon (admin only)
+%  - uninstall via futon (admin only)
+%  - load plugin.tgz from the web
+%  - no security checking
+%  - no identity checking
+%  - hardcoded list of plugins in futon
+%  - must publish on *.apache.org/*
+
+% 2. Creator friendly
+%  - couchdb plugin template
+%  - easy to publish
+
+% 3. Public registry
+%  - plugin authors can publish stuff independently, shows up in futon
+%
+
+% XXX Later
+%  - signing of plugin releases
+%  - signing verification of plugin releases
+
+
+% Questions:
+% - where should the downloaded .beam files put?
+%  - in couch 1.x.x context
+%  - in bigcouch context
+%  - what is a server-user owned data/ dir we can use for this, that isn’t db_dir or index_dir or log or var/run or /tmp
+

http://git-wip-us.apache.org/repos/asf/couchdb/blob/66faf3a8/src/couch_plugins/src/couch_plugins_httpd.erl
----------------------------------------------------------------------
diff --git a/src/couch_plugins/src/couch_plugins_httpd.erl b/src/couch_plugins/src/couch_plugins_httpd.erl
new file mode 100644
index 0000000..1e61aa2
--- /dev/null
+++ b/src/couch_plugins/src/couch_plugins_httpd.erl
@@ -0,0 +1,10 @@
+-module(couch_plugins_httpd).
+
+-export([handle_req/1]).
+
+-include_lib("couch_db.hrl").
+
+handle_req(#httpd{method='PUT'}=Req) ->
+    couch_httpd:send_json(Req, 202, {[{ok, true}]});
+handle_req(Req) ->
+    couch_httpd:send_method_not_allowed(Req, "PUT").


[3/4] git commit: updated refs/heads/1867-feature-plugins to 7789506

Posted by ja...@apache.org.
add docs/debug output


Project: http://git-wip-us.apache.org/repos/asf/couchdb/repo
Commit: http://git-wip-us.apache.org/repos/asf/couchdb/commit/449b458f
Tree: http://git-wip-us.apache.org/repos/asf/couchdb/tree/449b458f
Diff: http://git-wip-us.apache.org/repos/asf/couchdb/diff/449b458f

Branch: refs/heads/1867-feature-plugins
Commit: 449b458f26b632bb9f99258583cb9880282fbe31
Parents: f823b77
Author: Jan Lehnardt <ja...@apache.org>
Authored: Wed Jul 31 18:48:19 2013 +0200
Committer: Jan Lehnardt <ja...@apache.org>
Committed: Wed Jul 31 18:48:19 2013 +0200

----------------------------------------------------------------------
 src/couch_plugins/src/couch_plugins.erl       |  8 +++----
 src/couch_plugins/src/couch_plugins_httpd.erl | 25 +++++++++++++++++++---
 2 files changed, 26 insertions(+), 7 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/couchdb/blob/449b458f/src/couch_plugins/src/couch_plugins.erl
----------------------------------------------------------------------
diff --git a/src/couch_plugins/src/couch_plugins.erl b/src/couch_plugins/src/couch_plugins.erl
index 0a65bf7..7dd3bd2 100644
--- a/src/couch_plugins/src/couch_plugins.erl
+++ b/src/couch_plugins/src/couch_plugins.erl
@@ -1,13 +1,10 @@
 -module(couch_plugins).
 -include("couch_db.hrl").
-%% Application callbacks
 -export([install/1]).
 
-
 % couch_plugins:install({"geocouch", "http://127.0.0.1:8000", "1.0.0", [{"R15B03", "+XOJP6GSzmuO2qKdnjO+mWckXVs="}]}).
 % couch_plugins:install({"geocouch", "http://people.apache.org/~jan/", "couchdb1.2.x_v0.3.0-11-gd83ba22", [{"R15B03", "ZetgdHj2bY2w37buulWVf3USOZs="}]}).
 
-
 -define(PLUGIN_DIR, "/tmp/couchdb_plugins").
 
 log(T) -> 
@@ -79,6 +76,7 @@ add_code_path(Name, Version) ->
       Else
   end.
 
+-spec load_plugin(string()) -> ok | {error, atom()}.
 load_plugin(NameList) ->
   Name = list_to_atom(NameList),
   application:load(Name).
@@ -126,6 +124,7 @@ download({Name, _BaseUrl, Version, _Checksums}=Plugin) ->
 
 -spec verify_checksum(string(), list()) -> ok | {error, string()}.
 verify_checksum(Filename, Checksums) ->
+
   OTPRelease = erlang:system_info(otp_release),
   case proplists:get_value(OTPRelease, Checksums) of
   undefined ->
@@ -137,6 +136,7 @@ verify_checksum(Filename, Checksums) ->
 
 -spec do_verify_checksum(string(), string()) -> ok | {error, string()}.
 do_verify_checksum(Filename, Checksum) ->
+  ?LOG_DEBUG("Filename: ~s", [Filename]),
   case file:read_file(Filename) of
   {ok, Data} ->
     ComputedChecksum = binary_to_list(base64:encode(crypto:sha(Data))),
@@ -150,7 +150,7 @@ do_verify_checksum(Filename, Checksum) ->
   end.
 
 
-
+%% utils
 
 -spec get_url(plugin()) -> string().
 get_url({Name, BaseUrl, Version, _Checksums}) ->

http://git-wip-us.apache.org/repos/asf/couchdb/blob/449b458f/src/couch_plugins/src/couch_plugins_httpd.erl
----------------------------------------------------------------------
diff --git a/src/couch_plugins/src/couch_plugins_httpd.erl b/src/couch_plugins/src/couch_plugins_httpd.erl
index 1e61aa2..6d987ae 100644
--- a/src/couch_plugins/src/couch_plugins_httpd.erl
+++ b/src/couch_plugins/src/couch_plugins_httpd.erl
@@ -4,7 +4,26 @@
 
 -include_lib("couch_db.hrl").
 
-handle_req(#httpd{method='PUT'}=Req) ->
-    couch_httpd:send_json(Req, 202, {[{ok, true}]});
+handle_req(#httpd{method='POST'}=Req) ->
+    ok = couch_httpd:verify_is_server_admin(Req),
+    couch_httpd:validate_ctype(Req, "application/json"),
+
+    {PluginSpec} = couch_httpd:json_body_obj(Req),
+  ?LOG_DEBUG("Plugin Spec: ~p", [PluginSpec]),
+    Url = binary_to_list(couch_util:get_value(<<"url">>, PluginSpec)),
+    Name = binary_to_list(couch_util:get_value(<<"name">>, PluginSpec)),
+    Version = binary_to_list(couch_util:get_value(<<"version">>, PluginSpec)),
+    {Checksums0} = couch_util:get_value(<<"checksums">>, PluginSpec),
+    Checksums = lists:map(fun({K, V}) ->
+      {binary_to_list(K), binary_to_list(V)}
+    end, Checksums0),
+
+    case couch_plugins:install({Name, Url, Version, Checksums}}) of
+    ok ->
+        couch_httpd:send_json(Req, 202, {[{ok, true}]});
+    Error ->
+        ?LOG_DEBUG("Plugin Spec: ~p", [PluginSpec]),
+        couch_httpd:send_error(Req, {bad_request, Error})
+    end;
 handle_req(Req) ->
-    couch_httpd:send_method_not_allowed(Req, "PUT").
+    couch_httpd:send_method_not_allowed(Req, "POST").


[2/4] git commit: updated refs/heads/1867-feature-plugins to 7789506

Posted by ja...@apache.org.
update comment


Project: http://git-wip-us.apache.org/repos/asf/couchdb/repo
Commit: http://git-wip-us.apache.org/repos/asf/couchdb/commit/f823b773
Tree: http://git-wip-us.apache.org/repos/asf/couchdb/tree/f823b773
Diff: http://git-wip-us.apache.org/repos/asf/couchdb/diff/f823b773

Branch: refs/heads/1867-feature-plugins
Commit: f823b773e2c6320ec05f63925831d1f8ba04f214
Parents: 66faf3a
Author: Jan Lehnardt <ja...@apache.org>
Authored: Wed Jul 31 15:14:55 2013 +0200
Committer: Jan Lehnardt <ja...@apache.org>
Committed: Wed Jul 31 15:14:55 2013 +0200

----------------------------------------------------------------------
 src/couch_plugins/src/couch_plugins.erl | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/couchdb/blob/f823b773/src/couch_plugins/src/couch_plugins.erl
----------------------------------------------------------------------
diff --git a/src/couch_plugins/src/couch_plugins.erl b/src/couch_plugins/src/couch_plugins.erl
index e406b2a..0a65bf7 100644
--- a/src/couch_plugins/src/couch_plugins.erl
+++ b/src/couch_plugins/src/couch_plugins.erl
@@ -5,7 +5,7 @@
 
 
 % couch_plugins:install({"geocouch", "http://127.0.0.1:8000", "1.0.0", [{"R15B03", "+XOJP6GSzmuO2qKdnjO+mWckXVs="}]}).
-% couch_plugins:install({"geocouch", "http://people.apache.org/~jan/", "couchdb1.2.x_v0.3.0-11-gd83ba22", [{"R15B03", "Z9xK+OKLRvqKx3uoQHsiTuv6mrY="}]}).
+% couch_plugins:install({"geocouch", "http://people.apache.org/~jan/", "couchdb1.2.x_v0.3.0-11-gd83ba22", [{"R15B03", "ZetgdHj2bY2w37buulWVf3USOZs="}]}).
 
 
 -define(PLUGIN_DIR, "/tmp/couchdb_plugins").


[4/4] git commit: updated refs/heads/1867-feature-plugins to 7789506

Posted by ja...@apache.org.
hook up futon to /_plugins


Project: http://git-wip-us.apache.org/repos/asf/couchdb/repo
Commit: http://git-wip-us.apache.org/repos/asf/couchdb/commit/77895062
Tree: http://git-wip-us.apache.org/repos/asf/couchdb/tree/77895062
Diff: http://git-wip-us.apache.org/repos/asf/couchdb/diff/77895062

Branch: refs/heads/1867-feature-plugins
Commit: 778950622d0682c161a350df34a89d44efc28844
Parents: 449b458
Author: Jan Lehnardt <ja...@apache.org>
Authored: Wed Jul 31 18:49:32 2013 +0200
Committer: Jan Lehnardt <ja...@apache.org>
Committed: Wed Jul 31 18:49:32 2013 +0200

----------------------------------------------------------------------
 share/www/_sidebar.html |  1 +
 share/www/plugins.html  | 82 ++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 83 insertions(+)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/couchdb/blob/77895062/share/www/_sidebar.html
----------------------------------------------------------------------
diff --git a/share/www/_sidebar.html b/share/www/_sidebar.html
index e68bf73..26a1bc8 100644
--- a/share/www/_sidebar.html
+++ b/share/www/_sidebar.html
@@ -23,6 +23,7 @@ specific language governing permissions and limitations under the License.
       <li><a href="config.html">Configuration</a></li>
       <li><a href="replicator.html">Replicator</a></li>
       <li><a href="status.html">Status</a></li>
+      <li><a href="plugins.html">Plugins</a></li>
     </ul></li>
     <li><span>Documentation</span><ul>
       <li><a href="docs/">Manual</a></li>

http://git-wip-us.apache.org/repos/asf/couchdb/blob/77895062/share/www/plugins.html
----------------------------------------------------------------------
diff --git a/share/www/plugins.html b/share/www/plugins.html
new file mode 100644
index 0000000..a99826c
--- /dev/null
+++ b/share/www/plugins.html
@@ -0,0 +1,82 @@
+<!DOCTYPE html>
+<!--
+
+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.
+
+-->
+<html lang="en">
+  <head>
+    <title>Plugins</title>
+    <meta http-equiv="Content-Type" content="text/html;charset=utf-8">
+    <link rel="stylesheet" href="style/layout.css?0.11.0" type="text/css">
+    <script src="script/json2.js"></script>
+    <script src="script/sha1.js"></script>
+    <script src="script/jquery.js"></script>
+    <script src="script/jquery.couch.js"></script>
+    <script src="script/jquery.dialog.js"></script>
+    <script src="script/futon.js"></script>
+  </head>
+  <body><div id="wrap">
+    <h1>
+      <a href="index.html">Overview</a>
+      <strong>Plugins</strong>
+    </h1>
+    <div id="content">
+      <div class="row">
+        <h2>GeoCouch</h2>
+        <p>Version: <strong>couchdb1.2.x_v0.3.0-11-gd83ba22</strong></p>
+        <p>
+          Available Erlang Versions:
+          <ul>
+            <li>R15B01</li>
+          </ul>
+        </p>
+        <p>
+          <button href="#" id="install_plugin" data-url="http://people.apache.org/~jan" data-checksums='{"R15B03":"mw7RWJtbt7WMOF/ypwpgkRHT0Wo="}' data-name="geocouch" data-version="couchdb1.2.x_v0.3.0-12-g4ea0bea">Install GeoCouch Now</button>
+        </p>
+      </div>
+
+    </div>
+  </div></body>
+  <script>
+    $('#install_plugin').click(function(event) {
+      var button = $(this);
+      var plugin_spec = JSON.stringify({
+        name: button.data('name'),
+        url: button.data('url'),
+        version: button.data('version'),
+        checksums: button.data('checksums')
+      });
+      var url = '/_plugins'
+      $.ajax({
+        url: url,
+        type: 'POST',
+        data: plugin_spec,
+        contentType: 'application/json', // what we send to the server
+        dataType: 'json', // expected from the server
+        processData: false, // keep our precious JSON
+        success: function(data, textStatus, jqXhr) {
+          button.html(textStatus);
+        },
+        beforeSend: function(xhr) {
+          xhr.setRequestHeader('Accept', 'application/json');
+        },
+      });
+    });
+  </script>
+  <style type="text/css">
+  .row {
+    background-color: #EEE;
+    padding:1em;
+  }
+  </style>
+</html>