broham 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,17 +1,105 @@
1
- = broham
1
+ h2. Broham: A simple, global, highly-available, none-too-bright service registry.
2
2
 
3
- Description goes here.
3
+ **Broham always knows where his bros are, bro!** Using broham, a newly-created cloud machine can annouce its availability for a certain role ("nfs_server" or "db_slave-2"), allowing any other interested nodes to discover its public_ip, private_ip, etc.
4
+
5
+ It uses Amazon's SimpleDB service, so the registry is global, highly-available, secure, accessible from any programming language (or even @curl@ if you're clever), and simple. As for Broham himself, he fills out a suit OK but isn't too bright: there's no monitoring or logging, for instance.
6
+
7
+ Broham plays nicely with "Chef":http://github.com/opscode/chef and indeed was written to coordinate node assignment for a "hadoop cluster chef setup.":http://github.com/mrflip/hadoop_cluster_chef
8
+
9
+ Examples:
10
+
11
+ <pre><code>
12
+ require 'broham'
13
+ Settings.access_key_id = 'XXXXXXXXXX'
14
+ Settings.secret_access_key = 'XXXXXXXXXX'
15
+
16
+ # create a context for the 'chad' cluster
17
+ class Chad < Broham::Cluster ; end
18
+ Chad.establish_connection
19
+ Chad.create_domain
20
+
21
+ # On NFS server start, register an nfs share
22
+ Chad.register_nfs_share '/home'
23
+ #=> #<Chad @attributes={"timestamp"=>["20100405072638"], "client_path"=>["/home"], "server_path"=>["/home"], "role"=>["nfs_server"], "public_ip"=>["250.249.248.247"], "private_ip"=>["192.168.69.22"], "default_ip"=>["192.168.69.22"]}
24
+ # On the nfs clients, get the local IP and server-side path of the share, ready to insert into /etc/fstab
25
+ Chad.nfs_device_path
26
+ #=> "192.168.69.22:/home"
27
+
28
+ # Register as one of many nodes with a given role
29
+ Chad.register_as_next 'dj'
30
+ #=> #<Chad @attributes={"timestamp"=>["20100405072931"], "role"=>["dj-1"], "idx"=>["1"], "default_ip"=>["192.168.69.10"], "public_ip"=>["250.249.248.247"], "private_ip"=>["192.168.69.10"]}
31
+
32
+ # Find the highest-yet-registered node in the 'dj' role and immediately
33
+ # register as the next one. Even if a thundering herd of hosts try to register
34
+ # in this role, Broham will ensure that exactly one host claims each index.
35
+ Chad.host('dj') ; Chad.register_as_next 'dj'
36
+ #=> #<Chad @attributes={"timestamp"=>["20100405073626"], "role"=>["dj"], "idx"=>["2"], "default_ip"=>["192.168.69.14"], "public_ip"=>["250.249.248.247"], "private_ip"=>["192.168.69.14"]}]
37
+ #=> #<Chad @attributes={"timestamp"=>["20100405073626"], "role"=>["dj-4"], "idx"=>["4"], "default_ip"=>["192.168.69.22"], "public_ip"=>["250.249.248.247"], "private_ip"=>["192.168.69.22"]}]
38
+ </code></pre>
39
+
40
+ Alternate interface:
41
+
42
+ <pre><code>
43
+ Chad.yo! 'beer-stand'
44
+ #<Chad @attributes={"timestamp"=>["20100405071446"], "role"=>["beer-stand"], "public_ip"=>["250.249.248.247"], "private_ip"=>["10.0.69.69"]}
45
+
46
+ Chad.sup? 'beer-stand'
47
+ #<Chad @attributes={"timestamp"=>["20100405071446"], "role"=>["beer-stand"], "public_ip"=>["250.249.248.247"], "private_ip"=>["10.0.69.69"]}
48
+ </code></pre>
49
+
50
+ h4. Commandline Interface
51
+
52
+ <pre><code>
53
+ $ broham-register chad nfs_server --set-path=/home
54
+ Registering as nfs_server in chad cluster, with {:path=>"/home"}
55
+ {"timestamp":["20100405092638"],"role":["nfs_server"],"path":["/home"],"default_ip":["192.168.69.22"]}
56
+
57
+ $ broham-host chad nfs_server
58
+ {"timestamp":["20100405092638"],"role":["nfs_server"],"path":["/home"],"default_ip":["192.168.69.22"]}
59
+
60
+ # Alternative interface works too.
61
+ $ broham-sup chad nfs_server
62
+ {"timestamp":["20100405092638"],"role":["nfs_server"],"path":["/home"],"default_ip":["192.168.69.22"]}
63
+
64
+ $ broham-unregister-all chad nfs_server
65
+ Removing nfs_server from chad cluster
66
+ {"timestamp":["20100405092638"],"role":["nfs_server"],"path":["/home"],"default_ip":["192.168.69.22"]}
67
+ </code></pre>
68
+
69
+ h4. Setup
70
+
71
+ For setup, we recommend configliere
72
+
73
+ <pre><code>
74
+ require 'configliere'
75
+ Configliere.use :config_file
76
+ Settings.read 'broham.yaml'; Settings.resolve!
77
+ </code></pre>
78
+
79
+ h3.
80
+
81
+ bq. Like Broseph Stalin, you are leading the way to the dictatorship of the broletariate. It is truly revbrolutionary. Like the Bro v. Wade of our generation. You brobliterate the enemy from the very peak of Mt. Brolympus. That's some shit. That's brolific. But that's the kind of bro you are. -- "Zach Caldwell":http://j.mp/amongbros
82
+
83
+
84
+ h3. Warnings!
85
+
86
+ Make sure you are using a recent (>= 1.11,0) version of right_aws, and set the SDB_API_VERSION environment variable to '2009-04-15':
87
+
88
+ <pre><code>
89
+ export SDB_API_VERSION='2009-04-15'
90
+ </code></pre>
91
+
92
+ For @register_as_next@, you'll need a version of right_aws that supports conditional puts: "http://github.com/mrflip/right_aws":http://github.com/mrflip/right_aws
93
+
94
+
95
+ h3. Note on Patches/Pull Requests
4
96
 
5
- == Note on Patches/Pull Requests
6
-
7
97
  * Fork the project.
8
98
  * Make your feature addition or bug fix.
9
- * Add tests for it. This is important so I don't break it in a
10
- future version unintentionally.
11
- * Commit, do not mess with rakefile, version, or history.
12
- (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
99
+ * Add tests for it. This is important so I don't break it in a future version unintentionally.
100
+ * Commit, do not mess with rakefile, version, or history. (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
13
101
  * Send me a pull request. Bonus points for topic branches.
14
102
 
15
- == Copyright
103
+ h3. Copyright
16
104
 
17
105
  Copyright (c) 2010 Philip (flip) Kromer. See LICENSE for details.
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.0.1
1
+ 0.0.2
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env ruby
2
+ require 'rubygems'
3
+ require 'broham'
4
+ require 'broham/script'
5
+ require 'json'
6
+
7
+ cluster = Broham.get_cluster_settings
8
+ cluster_name, role_name = [Settings[:cluster_name], Settings[:role_name]]
9
+
10
+ $stderr.puts %Q{Retrieving '#{role_name}' for cluster '#{cluster_name}'}
11
+ resp = cluster.host role_name
12
+ $stderr.puts resp.to_pretty_json
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+ require 'rubygems'
3
+ require 'broham'
4
+ require 'broham/script'
5
+ Settings.define :set, :description => %Q{Any arg prefixed with "--set" will become an extra arg to register: 'broham-register foo --set-path=/path/to/foo' sets :path => '/path/to/foo' as an additional attribute}, :type => Hash
6
+
7
+ cluster = Broham.get_cluster_settings
8
+ cluster_name, role_name, broham_args = [Settings[:cluster_name], Settings[:role_name], Settings[:set]||{}]
9
+
10
+ $stderr.puts %Q{Registering as #{role_name} in #{cluster_name} cluster, with #{broham_args.inspect}}
11
+ resp = cluster.register role_name, broham_args
12
+ node = cluster.host role_name
13
+ $stderr.puts resp.to_pretty_json
14
+
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env ruby
2
+ require 'rubygems'
3
+ require 'broham'
4
+ require 'broham/script'
5
+
6
+ cluster = Broham.get_cluster_settings
7
+ cluster_name, role_name = [Settings[:cluster_name], Settings[:role_name] ]
8
+
9
+ $stderr.puts %Q{Removing all #{role_name} from #{cluster_name} cluster}
10
+ resps = cluster.unregister_all role_name
11
+ $stderr.puts resps.map{|resp| resp.to_pretty_json }
12
+
@@ -5,28 +5,31 @@
5
5
 
6
6
  Gem::Specification.new do |s|
7
7
  s.name = %q{broham}
8
- s.version = "0.0.1"
8
+ s.version = "0.0.2"
9
9
 
10
10
  s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
11
  s.authors = ["Philip (flip) Kromer"]
12
12
  s.date = %q{2010-04-05}
13
13
  s.description = %q{Bro! Broham always knows where his bros are, bro. Using broham, a newly-created cloud machine can annouce its availability for a certain role ("nfs_server" or "db_slave-2"), allowing any other interested nodes to discover its public_ip, private_ip, etc. See also: http://j.mp/amongbros}
14
14
  s.email = %q{flip@infochimps.org}
15
+ s.executables = ["broham-host", "broham-register", "broham-unregister-all"]
15
16
  s.extra_rdoc_files = [
16
17
  "LICENSE",
17
- "README.rdoc",
18
18
  "README.textile"
19
19
  ]
20
20
  s.files = [
21
21
  ".document",
22
22
  ".gitignore",
23
23
  "LICENSE",
24
- "README.rdoc",
25
24
  "README.textile",
26
25
  "Rakefile",
27
26
  "VERSION",
27
+ "bin/broham-host",
28
+ "bin/broham-register",
29
+ "bin/broham-unregister-all",
28
30
  "broham.gemspec",
29
31
  "lib/broham.rb",
32
+ "lib/broham/script.rb",
30
33
  "spec/broham_spec.rb",
31
34
  "spec/spec.opts",
32
35
  "spec/spec_helper.rb"
@@ -1,10 +1,15 @@
1
- require './settings.rb'
1
+ # SimpleDB interface
2
2
  require 'right_aws'
3
3
  RightAws::SdbInterface::API_VERSION = '2009-04-15'
4
4
  require 'sdb/active_sdb'
5
+ # Machine information
5
6
  require 'ohai'
6
7
  OHAI_INFO = Ohai::System.new unless defined?(OHAI_INFO)
7
8
  OHAI_INFO.all_plugins
9
+ # Settings from Configliere
10
+ require 'configliere'; Configliere.use :define
11
+ Settings.define :access_key, :required => true, :description => "Amazon AWS access key ID -- found in your AWS console (http://bit.ly/awsconsole)"
12
+ Settings.define :secret_access_key, :required => true, :description => "Amazon AWS secret access key -- found in your AWS console (http://bit.ly/awsconsole)"
8
13
 
9
14
  #
10
15
  # Make sure you are using a recent (>= 1.11,0) version of right_aws, and set the
@@ -13,105 +18,131 @@ OHAI_INFO.all_plugins
13
18
  #
14
19
 
15
20
  #
21
+ # Broham expects a hash constant +Settings+ with values for +:secret_access_key+
22
+ # and +:access_key+. The configliere gem (http://github.com/mrflip/configliere)
23
+ # can help with that.
16
24
  #
17
- #
18
- class Broham < RightAws::ActiveSdb::Base
19
- # Returns the last-registered host in the given role
20
- def self.host role
21
- select_by_role(role, :order => 'timestamp DESC')
22
- end
25
+ module Broham
26
+ class Cluster < RightAws::ActiveSdb::Base
27
+ # Returns the last-registered host in the given role
28
+ def self.host role
29
+ select_by_role(role, :order => 'timestamp DESC')
30
+ end
23
31
 
24
- # Returns all hosts in the given role
25
- def self.hosts role
26
- select_all_by_role(role, :order => 'timestamp DESC')
27
- end
32
+ # Returns all hosts in the given role
33
+ def self.hosts role
34
+ select_all_by_role(role, :order => 'timestamp DESC')
35
+ end
28
36
 
29
- def self.register role, attrs={}
30
- ahost = host(role) || new
31
- ahost.attributes = ({:role => role, :timestamp => timestamp,
32
- :private_ip => my_private_ip, :public_ip => my_public_ip, :default_ip => my_default_ip,
33
- :fqdn => my_fqdn}.merge(attrs))
34
- success = ahost.save
35
- success ? self.new(success) : false
36
- end
37
+ def self.host_attrs(role)
38
+ { :role => role, :timestamp => timestamp,
39
+ :private_ip => my_private_ip, :public_ip => my_public_ip, :default_ip => my_default_ip, :fqdn => my_fqdn }
40
+ end
37
41
 
38
- def yo!(*args) register *args ; end
39
- def sup?(*args) host *args ; end
40
- def sup_bros?(*args) hosts *args ; end
42
+ def self.register role, attrs={}
43
+ ahost = host(role) || new
44
+ ahost.attributes = (host_attrs(role).merge(attrs))
45
+ success = ahost.save
46
+ success ? self.new(success) : false
47
+ end
41
48
 
49
+ #
50
+ # Enlists as the next among many machines filling the given role.
51
+ #
52
+ # This is just a simple counter: it doesn't check whether the machine is
53
+ # already enlisted under a different index, or whether there are missing
54
+ # indices.
55
+ #
56
+ # It uses conditional save to be sure that the count is consistent
57
+ #
58
+ def self.register_as_next role, attrs={}
59
+ my_idx = 0
60
+ 100.times do
61
+ ahost = host(role) || new
62
+ current_max_idx = ahost[:idx] && ahost[:idx].first
63
+ my_idx = (current_max_idx.to_i + 1)
64
+ ahost.attributes = host_attrs(role).merge({ :idx => my_idx.to_s }.merge(attrs))
65
+ expected = current_max_idx ? {:idx => (current_max_idx.to_i + rand(5)).to_s} : {}
66
+ success = ahost.save_if(expected)
67
+ break if success
68
+ end
69
+ register role+'-'+my_idx.to_s, { :idx => my_idx }.merge(attrs)
70
+ end
42
71
 
43
- #
44
- # Enlists as the next among many machines filling the given role.
45
- #
46
- # This is just a simple counter: it doesn't check whether the machine is
47
- # already enlisted under a different index, or whether there are missing
48
- # indices.
49
- #
50
- # It uses conditional save to be sure that the count is consistent
51
- #
52
- def self.register_as_next role, attrs={}
53
- my_idx = 0
54
- 100.times do
55
- ahost = host(role) || new
56
- current_max_idx = ahost[:idx] && ahost[:idx].first
57
- my_idx = (current_max_idx.to_i + 1)
58
- ahost.attributes = ({:role => role, :timestamp => timestamp, :idx => my_idx.to_s }.merge(attrs))
59
- expected = current_max_idx ? {:idx => (current_max_idx.to_i + rand(5)).to_s} : {}
60
- success = ahost.save_if(expected)
61
- break if success
72
+ # alternative syntax for #register
73
+ def self.yo!(*args) register *args ; end
74
+ # alternative syntax for #register_as_next
75
+ def self.yo_yo_yo!(*args) register_as_next *args ; end
76
+ # alternative syntax for #host
77
+ def self.sup?(*args) host *args ; end
78
+ # alternative syntax for #hosts
79
+ def self.sup_bros?(*args) hosts *args ; end
80
+
81
+ #
82
+ # Removes all registrations for the given role
83
+ #
84
+ def self.unregister_all role
85
+ select_all_by_role(role).each(&:delete)
62
86
  end
63
- register role+'-'+my_idx.to_s, { :idx => my_idx }.merge(attrs)
64
- end
65
87
 
66
- #
67
- # Removes all registrations for the given role
68
- #
69
- def self.unregister_all role
70
- select_all_by_role(role).each(&:delete)
71
- end
88
+ #
89
+ # Registration attributes
90
+ #
91
+
92
+ def self.my_private_ip() OHAI_INFO[:cloud][:private_ips].first rescue nil ; end
93
+ def self.my_public_ip() OHAI_INFO[:cloud][:public_ips].first rescue nil ; end
94
+ def self.my_default_ip() OHAI_INFO[:ipaddress] ; end
95
+ def self.my_fqdn() OHAI_INFO[:fqdn] ; end
96
+ def self.my_availability_zone() OHAI_INFO[:ec2][:availability_zone] ; end
97
+ def self.timestamp() Time.now.utc.strftime("%Y%m%d%H%M%S") ; end
98
+
99
+ def private_ip() self['private_ip' ] || default_ip ; end
100
+ def public_ip() self['public_ip' ] || default_ip ; end
101
+ def default_ip() self['default_ip' ] ; end
102
+ def fqdn() self['fqdn' ] ; end
103
+ def availability_zone() self['availability_zone'] ; end
104
+ def idx()
105
+ self['idx']
106
+ end
72
107
 
73
- #
74
- # Registration attributes
75
- #
108
+ def self.establish_connection
109
+ @connection ||= RightAws::ActiveSdb.establish_connection(Settings[:access_key], Settings[:secret_access_key])
110
+ end
76
111
 
77
- def self.my_private_ip() OHAI_INFO[:cloud][:private_ips].first rescue nil ; end
78
- def self.my_public_ip() OHAI_INFO[:cloud][:public_ips].first rescue nil ; end
79
- def self.my_default_ip() OHAI_INFO[:ipaddress] ; end
80
- def self.my_fqdn() OHAI_INFO[:fqdn] ; end
81
- def self.my_availability_zone() OHAI_INFO[:ec2][:availability_zone] ; end
82
- def self.timestamp() Time.now.utc.strftime("%Y%m%d%H%M%S") ; end
83
-
84
- def private_ip() self['private_ip' ] ; end
85
- def public_ip() self['public_ip' ] ; end
86
- def default_ip() self['default_ip' ] ; end
87
- def fqdn() self['fqdn' ] ; end
88
- def availability_zone() self['availability_zone'] ; end
89
- def idx() self['idx']
90
- end
112
+ # Register an nfs server share
113
+ def self.register_nfs_share server_path, client_path=nil, role='nfs_server'
114
+ client_path ||= server_path
115
+ register(role, :server_path => server_path, :client_path => client_path)
116
+ end
91
117
 
92
- def self.establish_connection
93
- @connection ||= RightAws::ActiveSdb.establish_connection(Settings[:access_key], Settings[:secret_access_key])
94
- end
118
+ # NFS: device path, for stuffing into /etc/fstab
119
+ def self.nfs_device_path role='nfs_server'
120
+ nfs_server = host(role) or return
121
+ [nfs_server.private_ip, nfs_server[:server_path]].join(':')
122
+ end
95
123
 
96
- #
97
- #
98
- #
124
+ # Hadoop: master jobtracker node
125
+ def self.hadoop_jobtracker(role='hadoop_jobtracker') ; host(role) ; end
126
+ # Hadoop: master namenode
127
+ def self.hadoop_namenode( role='hadoop_namenode') ; host(role) ; end
128
+ # Hadoop: cloudera desktop node
129
+ def self.cloudera_desktop( role='cloudera_desktop') ; host(role) ; end
130
+
131
+ def to_hash() attributes ; end
132
+ def to_pretty_json
133
+ to_hash.reject{|k,v| k.to_s == 'id'}.to_json
134
+ end
99
135
 
100
- # NFS: device path, for stuffing into /etc/fstab
101
- def self.nfs_device_path role='nfs_server'
102
- nfs_server = host(role) or return
103
- [nfs_server[:private_ip], nfs_server[:remote_path]].join(':')
104
136
  end
105
137
 
106
- # Hadoop: master jobtracker node
107
- def self.hadoop_jobtracker(role='hadoop_jobtracker') ; host(role) ; end
108
- # Hadoop: master namenode
109
- def self.hadoop_namenode( role='hadoop_namenode') ; host(role) ; end
110
- # Hadoop: cloudera desktop node
111
- def self.cloudera_desktop( role='cloudera_desktop') ; host(role) ; end
112
138
 
113
- def to_hash() attributes ; end
139
+ #
140
+ # Metaprogramming
141
+ #
142
+ def self.new cluster
143
+ cluster_klass = '::'+Extlib::Inflection.classify(cluster.to_s)
144
+ module_eval(%Q{ class #{cluster_klass} < Broham::Cluster ; end })
145
+ cluster_klass.constantize rescue nil
146
+ end
114
147
  end
115
148
 
116
- Broham.establish_connection
117
- Broham.create_domain
@@ -0,0 +1,23 @@
1
+ module Broham
2
+
3
+ def self.get_cluster_settings
4
+ Configliere.use :commandline, :config_file
5
+ Settings.read('broham.yaml')
6
+ Settings.resolve!
7
+ Settings[:cluster_name] = Settings.rest.shift
8
+ Settings[:role_name] = Settings.rest.shift
9
+ check_args!
10
+ cluster = Broham.new(Settings[:cluster_name])
11
+ cluster.establish_connection
12
+ cluster.create_domain
13
+ cluster
14
+ end
15
+
16
+ def self.check_args!
17
+ if (Settings[:cluster_name].blank? || Settings[:role_name].blank?)
18
+ warn "Please supply a cluster name and a role as the first two arguments"
19
+ exit(-1)
20
+ end
21
+ end
22
+
23
+ end
metadata CHANGED
@@ -5,8 +5,8 @@ version: !ruby/object:Gem::Version
5
5
  segments:
6
6
  - 0
7
7
  - 0
8
- - 1
9
- version: 0.0.1
8
+ - 2
9
+ version: 0.0.2
10
10
  platform: ruby
11
11
  authors:
12
12
  - Philip (flip) Kromer
@@ -45,24 +45,28 @@ dependencies:
45
45
  version_requirements: *id002
46
46
  description: "Bro! Broham always knows where his bros are, bro. Using broham, a newly-created cloud machine can annouce its availability for a certain role (\"nfs_server\" or \"db_slave-2\"), allowing any other interested nodes to discover its public_ip, private_ip, etc. See also: http://j.mp/amongbros"
47
47
  email: flip@infochimps.org
48
- executables: []
49
-
48
+ executables:
49
+ - broham-host
50
+ - broham-register
51
+ - broham-unregister-all
50
52
  extensions: []
51
53
 
52
54
  extra_rdoc_files:
53
55
  - LICENSE
54
- - README.rdoc
55
56
  - README.textile
56
57
  files:
57
58
  - .document
58
59
  - .gitignore
59
60
  - LICENSE
60
- - README.rdoc
61
61
  - README.textile
62
62
  - Rakefile
63
63
  - VERSION
64
+ - bin/broham-host
65
+ - bin/broham-register
66
+ - bin/broham-unregister-all
64
67
  - broham.gemspec
65
68
  - lib/broham.rb
69
+ - lib/broham/script.rb
66
70
  - spec/broham_spec.rb
67
71
  - spec/spec.opts
68
72
  - spec/spec_helper.rb
@@ -1,38 +0,0 @@
1
- h2. Broham: A simple, global, highly-available, none-too-bright service registry. Broham always knows where his bros are, bro.
2
-
3
- Bro! Broham always knows where his bros are, bro. Using broham, a newly-created
4
- cloud machine can annouce its availability for a certain role ("nfs_server" or
5
- "db_slave-2"), allowing any other interested nodes to discover its public_ip,
6
- private_ip, etc.
7
-
8
- Examples:
9
-
10
-
11
-
12
- Alternate interface:
13
-
14
-
15
-
16
- ---------------------
17
-
18
- h3.
19
-
20
- bq. Like Broseph Stalin, you are leading the way to the dictatorship of the
21
- broletariate. It is truly revbrolutionary. Like the Bro v. Wade of our
22
- generation. You brobliterate the enemy from the very peak of
23
- Mt. Brolympus. That's some shit. That's brolific. But that's the kind of bro you
24
- are. -- "Zach Caldwell":http://j.mp/amongbros
25
-
26
- h3. Note on Patches/Pull Requests
27
-
28
- * Fork the project.
29
- * Make your feature addition or bug fix.
30
- * Add tests for it. This is important so I don't break it in a
31
- future version unintentionally.
32
- * Commit, do not mess with rakefile, version, or history.
33
- (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
34
- * Send me a pull request. Bonus points for topic branches.
35
-
36
- h3. Copyright
37
-
38
- Copyright (c) 2010 Philip (flip) Kromer. See LICENSE for details.