tomdoc 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/LICENSE +20 -0
- data/README.md +104 -0
- data/Rakefile +80 -0
- data/bin/tomdoc +6 -0
- data/lib/tomdoc.rb +25 -0
- data/lib/tomdoc/arg.rb +14 -0
- data/lib/tomdoc/cli.rb +150 -0
- data/lib/tomdoc/generator.rb +138 -0
- data/lib/tomdoc/generators/console.rb +68 -0
- data/lib/tomdoc/generators/html.rb +42 -0
- data/lib/tomdoc/method.rb +21 -0
- data/lib/tomdoc/scope.rb +46 -0
- data/lib/tomdoc/source_parser.rb +145 -0
- data/lib/tomdoc/tomdoc.rb +133 -0
- data/lib/tomdoc/version.rb +3 -0
- data/man/tomdoc.5 +320 -0
- data/man/tomdoc.5.html +285 -0
- data/man/tomdoc.5.ronn +203 -0
- data/test/console_generator_test.rb +20 -0
- data/test/fixtures/chimney.rb +711 -0
- data/test/fixtures/multiplex.rb +47 -0
- data/test/fixtures/simple.rb +10 -0
- data/test/generator_test.rb +47 -0
- data/test/helper.rb +21 -0
- data/test/html_generator_test.rb +18 -0
- data/test/source_parser_test.rb +66 -0
- data/test/tomdoc_parser_test.rb +127 -0
- metadata +143 -0
data/man/tomdoc.5.ronn
ADDED
@@ -0,0 +1,203 @@
|
|
1
|
+
TomDoc for Ruby - Version 0.9.0
|
2
|
+
===============================
|
3
|
+
|
4
|
+
Purpose
|
5
|
+
-------
|
6
|
+
|
7
|
+
TomDoc is a code documentation specification that helps you write precise
|
8
|
+
documentation that is nice to read in plain text, yet structured enough to be
|
9
|
+
automatically extracted and processed by a machine.
|
10
|
+
|
11
|
+
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD",
|
12
|
+
"SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be
|
13
|
+
interpreted as described in RFC 2119.
|
14
|
+
|
15
|
+
|
16
|
+
Class/Module Documentation
|
17
|
+
--------------------------
|
18
|
+
|
19
|
+
TomDoc for classes and modules consists of a block of single comment markers
|
20
|
+
(#) that appear directly above the class/module definition. Lines SHOULD be
|
21
|
+
wrapped at 80 characters. Lines that contain text MUST be separated from the
|
22
|
+
comment marker by a single space. Lines that do not contain text SHOULD
|
23
|
+
consist of just a comment marker (no trailing spaces).
|
24
|
+
|
25
|
+
Code examples SHOULD be indented two spaces (three spaces from the comment
|
26
|
+
marker).
|
27
|
+
|
28
|
+
# Various methods useful for performing mathematical operations. All
|
29
|
+
# methods are module methods and should be called on the Math module.
|
30
|
+
# For example:
|
31
|
+
#
|
32
|
+
# Math.square_root(9)
|
33
|
+
# # => 3
|
34
|
+
#
|
35
|
+
module Math
|
36
|
+
...
|
37
|
+
end
|
38
|
+
|
39
|
+
|
40
|
+
Method Documentation
|
41
|
+
--------------------
|
42
|
+
|
43
|
+
A quick example will serve to best illustrate the TomDoc method documentation
|
44
|
+
format:
|
45
|
+
|
46
|
+
# Duplicate some text an abitrary number of times.
|
47
|
+
#
|
48
|
+
# text - The String to be duplicated.
|
49
|
+
# count - The Integer number of times to duplicate the text.
|
50
|
+
#
|
51
|
+
# Examples
|
52
|
+
#
|
53
|
+
# multiplex('Tom', 4)
|
54
|
+
# # => 'TomTomTomTom'
|
55
|
+
#
|
56
|
+
# Returns the duplicated String.
|
57
|
+
def multiplex(text, count)
|
58
|
+
text * count
|
59
|
+
end
|
60
|
+
|
61
|
+
TomDoc for a specific method consists of a block of single comment markers (#)
|
62
|
+
that appears directly above the method. There MUST NOT be a blank line between
|
63
|
+
the comment block and the method definition. A TomDoc method block consists of
|
64
|
+
a description section (required), an arguments section (required if the method
|
65
|
+
takes any arguments), an examples section (optional), and a returns section
|
66
|
+
(required). Lines that contain text MUST be separated from the comment
|
67
|
+
marker by a single space. Lines that do not contain text SHOULD consist of
|
68
|
+
just a comment marker (no trailing spaces).
|
69
|
+
|
70
|
+
### The Description Section
|
71
|
+
|
72
|
+
The description section SHOULD be in plain sentences. Each sentence SHOULD end
|
73
|
+
with a period. Good descriptions explain what the code does at a high level.
|
74
|
+
Make sure to explain any unexpected behavior that the method may have, or any
|
75
|
+
pitfalls that the user may experience. Lines SHOULD be wrapped at 80
|
76
|
+
characters.
|
77
|
+
|
78
|
+
If a method's description begins with "Public:" then that method will be
|
79
|
+
considered part of the project's public API. For example:
|
80
|
+
|
81
|
+
# Public: Initialize a new Widget.
|
82
|
+
|
83
|
+
This annotation is designed to let developers know which methods are
|
84
|
+
considered stable. You SHOULD use this to document the public API of your
|
85
|
+
project. This information can then be used along with [Semantic
|
86
|
+
Versioning](http://semver.org) to inform decisions on when major, minor, and
|
87
|
+
patch versions should be incremented.
|
88
|
+
|
89
|
+
If a method's description begins with "Deprecated:" then that method will be
|
90
|
+
considered as deprecated and users will know that it will be removed in a
|
91
|
+
future version.
|
92
|
+
|
93
|
+
### The Arguments Section
|
94
|
+
|
95
|
+
The arguments section consists of a list of arguments. Each list item MUST be
|
96
|
+
comprised of the name of the argument, a dash, and an explanation of the
|
97
|
+
argument in plain sentences. The expected type (or types) of each argument
|
98
|
+
SHOULD be clearly indicated in the explanation. When you specify a type, use
|
99
|
+
the proper classname of the type (for instance, use 'String' instead of
|
100
|
+
'string' to refer to a String type). The dashes following each argument name
|
101
|
+
should be lined up in a single column. Lines SHOULD be wrapped at 80 columns.
|
102
|
+
If an explanation is longer than that, additional lines MUST be indented at
|
103
|
+
least two spaces but SHOULD be indented to match the indentation of the
|
104
|
+
explanation. For example:
|
105
|
+
|
106
|
+
# element - The Symbol representation of the element. The Symbol should
|
107
|
+
# contain only lowercase ASCII alpha characters.
|
108
|
+
|
109
|
+
All arguments are assumed to be required. If an argument is optional, you MUST
|
110
|
+
specify the default value:
|
111
|
+
|
112
|
+
# host - The String hostname to bind (default: '0.0.0.0').
|
113
|
+
|
114
|
+
For hash arguments, you SHOULD enumerate each valid option in a way similar
|
115
|
+
to how normal arguments are defined:
|
116
|
+
|
117
|
+
# options - The Hash options used to refine the selection (default: {}):
|
118
|
+
# :color - The String color to restrict by (optional).
|
119
|
+
# :weight - The Float weight to restrict by. The weight should
|
120
|
+
# be specified in grams (optional).
|
121
|
+
|
122
|
+
### The Examples Section
|
123
|
+
|
124
|
+
The examples section MUST start with the word "Examples" on a line by
|
125
|
+
itself. The next line SHOULD be blank. The following lines SHOULD be indented
|
126
|
+
by two spaces (three spaces from the initial comment marker) and contain code
|
127
|
+
that shows off how to call the method and (optional) examples of what it
|
128
|
+
returns. Everything under the "Examples" line should be considered code, so
|
129
|
+
make sure you comment out lines that show return values. Separate examples
|
130
|
+
should be separated by a blank line. For example:
|
131
|
+
|
132
|
+
# Examples
|
133
|
+
#
|
134
|
+
# multiplex('x', 4)
|
135
|
+
# # => 'xxxx'
|
136
|
+
#
|
137
|
+
# multiplex('apple', 2)
|
138
|
+
# # => 'appleapple'
|
139
|
+
|
140
|
+
### The Returns Section
|
141
|
+
|
142
|
+
The returns section should explain in plain sentences what is returned from
|
143
|
+
the method. The line MUST begin with "Returns". If only a single thing is
|
144
|
+
returned, state the nature and type of the value. For example:
|
145
|
+
|
146
|
+
# Returns the duplicated String.
|
147
|
+
|
148
|
+
If several different types may be returned, list all of them. For example:
|
149
|
+
|
150
|
+
# Returns the given element Symbol or nil if none was found.
|
151
|
+
|
152
|
+
If the return value of the method is not intended to be used, then you should
|
153
|
+
simply state:
|
154
|
+
|
155
|
+
# Returns nothing.
|
156
|
+
|
157
|
+
If the method raises exceptions that the caller may be interested in, add
|
158
|
+
additional lines that explain each exception and under what conditions it may
|
159
|
+
be encountered. The lines MUST begin with "Raises". For example:
|
160
|
+
|
161
|
+
# Returns nothing.
|
162
|
+
# Raises Errno::ENOENT if the file cannot be found.
|
163
|
+
# Raises Errno::EACCES if the file cannot be accessed.
|
164
|
+
|
165
|
+
Lines SHOULD be wrapped at 80 columns. Wrapped lines MUST be indented under
|
166
|
+
the above line by at least two spaces. For example:
|
167
|
+
|
168
|
+
# Returns the atomic mass of the element as a Float. The value is in
|
169
|
+
# unified atomic mass units.
|
170
|
+
|
171
|
+
|
172
|
+
Special Considerations
|
173
|
+
----------------------
|
174
|
+
|
175
|
+
### Attributes
|
176
|
+
|
177
|
+
Ruby's built in `attr_reader`, `attr_writer`, and `attr_accessor` require a
|
178
|
+
bit more consideration. With TomDoc you SHOULD NOT use `attr_access` since it
|
179
|
+
represents two methods with different signatures. Restricting yourself in this
|
180
|
+
way also makes you think more carefully about the read vs. write behavior and
|
181
|
+
whether each should be part of the Public API.
|
182
|
+
|
183
|
+
Here is an example TomDoc for `attr_reader`.
|
184
|
+
|
185
|
+
# Public: Get the user's name.
|
186
|
+
#
|
187
|
+
# Returns the String name of the user.
|
188
|
+
attr_reader :name
|
189
|
+
|
190
|
+
Here is an example TomDoc for `attr_writer`. The parameter name should be the
|
191
|
+
same as the attribute name.
|
192
|
+
|
193
|
+
# Set the user's name.
|
194
|
+
#
|
195
|
+
# name - The String name of the user.
|
196
|
+
#
|
197
|
+
# Returns nothing.
|
198
|
+
attr_writer :name
|
199
|
+
|
200
|
+
While this approach certainly takes up more space than listing dozens of
|
201
|
+
attributes on a single line, it allows for individual documentation of each
|
202
|
+
attribute. Attributes are an extremely important part of a class and should be
|
203
|
+
treated with the same care as any other methods.
|
@@ -0,0 +1,20 @@
|
|
1
|
+
require 'test/helper'
|
2
|
+
|
3
|
+
class ConsoleGeneratorTest < TomDoc::Test
|
4
|
+
def setup
|
5
|
+
@text = TomDoc::Generators::Console.generate(fixture(:simple))
|
6
|
+
end
|
7
|
+
|
8
|
+
test "works" do
|
9
|
+
assert_equal <<text, @text
|
10
|
+
--------------------------------------------------------------------------------
|
11
|
+
\e[1mSimple#string(text)\e[0m
|
12
|
+
|
13
|
+
Just a simple method.
|
14
|
+
|
15
|
+
\e[32mtext\e[0m - The \e[36mString\e[0m to return.
|
16
|
+
|
17
|
+
Returns a \e[36mString\e[0m.
|
18
|
+
text
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,711 @@
|
|
1
|
+
module Butter
|
2
|
+
class Something
|
3
|
+
end
|
4
|
+
end
|
5
|
+
|
6
|
+
module GitHub
|
7
|
+
# Sings you a poem.
|
8
|
+
#
|
9
|
+
# name - Your name as a String.
|
10
|
+
#
|
11
|
+
# Returns a String poem.
|
12
|
+
def self.poem(name)
|
13
|
+
"Roses are red, " +
|
14
|
+
"violets are blue, " +
|
15
|
+
"#{name}'s a sucker, " +
|
16
|
+
"and now you are, too."
|
17
|
+
end
|
18
|
+
|
19
|
+
# Chimney is the API for getting and setting Smoke routes.
|
20
|
+
#
|
21
|
+
# Setup
|
22
|
+
# -----
|
23
|
+
#
|
24
|
+
# In order for Chimney to function, some setup keys are required to exist in the
|
25
|
+
# routing Redis. This sections shows you how to enter the required
|
26
|
+
# information. Start by connecting to the routing Redis:
|
27
|
+
#
|
28
|
+
# require 'chimney'
|
29
|
+
# chimney = Chimney.new('router.example.com:21201')
|
30
|
+
#
|
31
|
+
# The routing Redis must contain one or more storage host values.
|
32
|
+
#
|
33
|
+
# chimney.add_storage_server('s1.example.com')
|
34
|
+
# chimney.add_storage_server('s2.example.com')
|
35
|
+
#
|
36
|
+
# Each storage host is expected to have disk usage information (percent of disk
|
37
|
+
# used) that is kept up to date (via cron or similar). If these are not set, the
|
38
|
+
# host that will be chosen for new routes is arbitrary, but will always be the
|
39
|
+
# same. This is a simple example of a cron script that is responsible for
|
40
|
+
# updating the usage keys:
|
41
|
+
#
|
42
|
+
# (0..15).map { |num| num.to_s(16) }.each do |part|
|
43
|
+
# host = get_current_host # => 's1.example.com'
|
44
|
+
# percent_used = get_partition_usage(part) # => 17.23
|
45
|
+
# chimney.set_partition_usage(host, part, percent_used)
|
46
|
+
# end
|
47
|
+
#
|
48
|
+
# Usage
|
49
|
+
# -----
|
50
|
+
#
|
51
|
+
# Make sure you require this sucker.
|
52
|
+
#
|
53
|
+
# require 'chimney'
|
54
|
+
#
|
55
|
+
# Chimney must be initialized with the host:port of the routing Redis server.
|
56
|
+
#
|
57
|
+
# chimney = Chimney.new('router.example.com:21201')
|
58
|
+
#
|
59
|
+
# Looking up a route for a user is simple. This command simply finds the host
|
60
|
+
# upon which the user is stored. If the router Redis is unreachable, Chimney
|
61
|
+
# will check its internal cache. If that is a miss, it will try to reconnect to
|
62
|
+
# the router. If that fails, it will fallback on making calls to Smoke and
|
63
|
+
# checking each storage server for the user. Subsequent lookups will then be
|
64
|
+
# able to find the route in the cache. This mechanism should ensure high
|
65
|
+
# tolerance to failures of the routing server.
|
66
|
+
#
|
67
|
+
# chimney.get_user_route('mojombo')
|
68
|
+
# # => 'domU-12-31-38-01-C8-F1.compute-1.internal'
|
69
|
+
#
|
70
|
+
# Setting a route for a new user is also a simple call. This command will first
|
71
|
+
# refresh the cached list of available storage hosts, then figure out which one
|
72
|
+
# of them is least loaded. This host will be set as the route for the user and
|
73
|
+
# returned. If the user already exists in the routing table, the host is
|
74
|
+
# returned and the routing table is unaffected.
|
75
|
+
#
|
76
|
+
# chimney.set_user_route('franko')
|
77
|
+
# # => domU-12-31-38-01-C8-F1.compute-1.internal
|
78
|
+
#
|
79
|
+
# If you need to change the name of the user, but keep the host the same:
|
80
|
+
#
|
81
|
+
# chimney.rename_user_route('oldname', 'newname')
|
82
|
+
#
|
83
|
+
# If you need to remove a route for a user:
|
84
|
+
#
|
85
|
+
# chimney.delete_user_route('mojombo')
|
86
|
+
#
|
87
|
+
# If you need the absolute path to a user on disk (class or instance method):
|
88
|
+
#
|
89
|
+
# Chimney.shard_user_path('mojombo')
|
90
|
+
# chimney.shard_user_path('mojombo')
|
91
|
+
# # => "/data/repositories/2/a8/e2/95/mojombo"
|
92
|
+
#
|
93
|
+
# If you need the absolute path to a repo on disk (class or instance method):
|
94
|
+
#
|
95
|
+
# Chimney.shard_repo_path('mojombo', 'god')
|
96
|
+
# chimney.shard_repo_path('mojombo', 'god')
|
97
|
+
# # => "/data/repositories/2/a8/e2/95/mojombo/god.git"
|
98
|
+
#
|
99
|
+
# Getting and setting routes for gists is similar to that for users:
|
100
|
+
#
|
101
|
+
# chimney.get_gist_route('1234')
|
102
|
+
# # => 'domU-12-31-38-01-C8-F1.compute-1.internal'
|
103
|
+
#
|
104
|
+
# chimney.set_gist_route('4e460bfd6c184058c7a3')
|
105
|
+
# # => 'domU-12-31-38-01-C8-F1.compute-1.internal'
|
106
|
+
#
|
107
|
+
# If you need the absolute path to a gist on disk (class or instance method):
|
108
|
+
#
|
109
|
+
# Chimney.shard_gist_path('1234')
|
110
|
+
# chimney.shard_gist_path('1234')
|
111
|
+
# # => "/data/repositories/0/81/dc/9b/gist/1234.git"
|
112
|
+
#
|
113
|
+
# If you need the unix user that has access to the repository data (class or
|
114
|
+
# instance method):
|
115
|
+
#
|
116
|
+
# Chimney.unix_user
|
117
|
+
# chimney.unix_user
|
118
|
+
# # => 'root'
|
119
|
+
#
|
120
|
+
# That's it!
|
121
|
+
class Chimney
|
122
|
+
SMOKE_HOSTS_FILE = '/tmp/smoke_hosts'
|
123
|
+
REPO_DIR = ENV['REPO_ROOT'] || '/data/repositories'
|
124
|
+
UNIX_USER = 'git'
|
125
|
+
|
126
|
+
attr_accessor :host, :port
|
127
|
+
attr_accessor :client, :hosts, :cache, :verbose, :logger
|
128
|
+
|
129
|
+
# Instantiate a new Chimney object.
|
130
|
+
#
|
131
|
+
# server - The host:port of the routing redis instance.
|
132
|
+
# logger - An optional Logger object. If none is given, Chimney
|
133
|
+
# writes to /dev/null.
|
134
|
+
#
|
135
|
+
# Returns a configured Chimney instance.
|
136
|
+
def initialize(server, logger = nil)
|
137
|
+
self.cache = {}
|
138
|
+
self.hosts = []
|
139
|
+
self.logger = logger || Logger.new('/dev/null')
|
140
|
+
|
141
|
+
self.host = server.split(':').first
|
142
|
+
self.port = server.split(':').last.to_i
|
143
|
+
ensure_client_connection
|
144
|
+
end
|
145
|
+
|
146
|
+
# Add a storage server to the list.
|
147
|
+
#
|
148
|
+
# host - The String hostname to add.
|
149
|
+
#
|
150
|
+
# Returns the Array of String hostnames after the addition.
|
151
|
+
def self.add_storage_server(host)
|
152
|
+
if current_servers = self.client.get('gh.storage.servers')
|
153
|
+
new_servers = [current_servers, host].join(',')
|
154
|
+
else
|
155
|
+
new_servers = host
|
156
|
+
end
|
157
|
+
self.client.set('gh.storage.servers', new_servers)
|
158
|
+
new_servers.split(',')
|
159
|
+
end
|
160
|
+
|
161
|
+
# Remove a storage server from the list.
|
162
|
+
#
|
163
|
+
# host - The String hostname to remove.
|
164
|
+
#
|
165
|
+
# Returns the Array of String hostnames after the removal.
|
166
|
+
# Raises Chimney::NoSuchStorageServer if the storage server is not currently
|
167
|
+
# in the list.
|
168
|
+
def remove_storage_server(host)
|
169
|
+
if current_servers = self.client.get('gh.storage.servers')
|
170
|
+
servers = current_servers.split(',')
|
171
|
+
if servers.delete(host)
|
172
|
+
self.client.set('gh.storage.servers', servers.join(','))
|
173
|
+
return servers
|
174
|
+
else
|
175
|
+
raise NoSuchStorageServer.new(host)
|
176
|
+
end
|
177
|
+
else
|
178
|
+
raise NoSuchStorageServer.new(host)
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
# The list of storage server hostnames.
|
183
|
+
#
|
184
|
+
# Returns an Array of String hostnames.
|
185
|
+
def storage_servers
|
186
|
+
self.client.get('gh.storage.servers').split(',')
|
187
|
+
end
|
188
|
+
|
189
|
+
# Checks if the storage server is currently online.
|
190
|
+
#
|
191
|
+
# host - The String hostname to check.
|
192
|
+
#
|
193
|
+
# Returns true if the server is online, false if not.
|
194
|
+
def storage_server_online?(host)
|
195
|
+
!self.client.exists("gh.storage.server.offline.#{host}")
|
196
|
+
rescue Errno::ECONNREFUSED
|
197
|
+
# If we can't connect to Redis, check to see if the BERTRPC
|
198
|
+
# server is alive manually.
|
199
|
+
begin
|
200
|
+
smoke(host).alive?
|
201
|
+
rescue BERTRPC::ReadTimeoutError
|
202
|
+
false
|
203
|
+
end
|
204
|
+
end
|
205
|
+
|
206
|
+
# Sets a storage server as being online.
|
207
|
+
#
|
208
|
+
# host - The String hostname to set.
|
209
|
+
#
|
210
|
+
# Returns nothing.
|
211
|
+
def set_storage_server_online(host)
|
212
|
+
self.client.delete("gh.storage.server.offline.#{host}")
|
213
|
+
end
|
214
|
+
|
215
|
+
# Sets a storage server as being offline.
|
216
|
+
#
|
217
|
+
# host - The String hostname to set.
|
218
|
+
# duration - An optional number of seconds after which the
|
219
|
+
# server will no longer be considered offline; with
|
220
|
+
# no duration, servers are kept offline until marked
|
221
|
+
# online manually.
|
222
|
+
#
|
223
|
+
# Returns true if the server was not previously offline, nil otherwise.
|
224
|
+
def set_storage_server_offline(host, duration=nil)
|
225
|
+
key = "gh.storage.server.offline.#{host}"
|
226
|
+
if self.client.set_unless_exists(key, Time.now.to_i)
|
227
|
+
self.client.expire(key, duration) if duration
|
228
|
+
true
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
232
|
+
# If a server is offline, tells us when we first noticed.
|
233
|
+
#
|
234
|
+
# host - The String hostname to check.
|
235
|
+
#
|
236
|
+
# Returns nothing if the storage server is online.
|
237
|
+
# Returns an instance of Time representing the moment we set the
|
238
|
+
# server as offline if it is offline.
|
239
|
+
def self.storage_server_offline_since(host)
|
240
|
+
if time = self.client.get("gh.storage.server.offline.#{host}")
|
241
|
+
Time.at(time.to_i)
|
242
|
+
end
|
243
|
+
rescue Errno::ECONNREFUSED
|
244
|
+
# If we can't connect to Redis and we're wondering when the
|
245
|
+
# storage server went offline, return whatever.
|
246
|
+
Time.now
|
247
|
+
end
|
248
|
+
|
249
|
+
# Maximum number of network failures that can occur with a file server
|
250
|
+
# before it's marked offline.
|
251
|
+
DISRUPTION_THRESHOLD = 10
|
252
|
+
|
253
|
+
# The window of time, in seconds, under which no more than
|
254
|
+
# DISRUPTION_THRESHOLD failures may occur.
|
255
|
+
DISRUPTION_WINDOW = 5
|
256
|
+
|
257
|
+
# Called when some kind of network disruption occurs when communicating
|
258
|
+
# with a file server. When more than DISRUPTION_THRESHOLD failures are
|
259
|
+
# reported within DISRUPTION_WINDOW seconds, the server is marked offline
|
260
|
+
# for two minutes.
|
261
|
+
#
|
262
|
+
# The return value can be used to determine the action taken:
|
263
|
+
# nil when the storage server is already marked offline.
|
264
|
+
# > 0 when the number of disruptions is under the threshold.
|
265
|
+
# -1 when the server has been marked offline due to too many disruptions.
|
266
|
+
def storage_server_disruption(host)
|
267
|
+
return if !self.storage_server_online?(host)
|
268
|
+
key = "gh.storage.server.disrupt.#{host}"
|
269
|
+
if counter_suffix = self.client.get(key)
|
270
|
+
count = self.client.incr("#{key}.#{counter_suffix}")
|
271
|
+
if count > DISRUPTION_THRESHOLD
|
272
|
+
if self.set_storage_server_offline(host, 30)
|
273
|
+
self.client.del(key, "#{key}.#{counter_suffix}")
|
274
|
+
-1
|
275
|
+
end
|
276
|
+
else
|
277
|
+
count
|
278
|
+
end
|
279
|
+
else
|
280
|
+
if self.client.set_unless_exists(key, Time.now.to_f * 1000)
|
281
|
+
self.client.expire(key, DISRUPTION_WINDOW)
|
282
|
+
self.storage_server_disruption(host)
|
283
|
+
else
|
284
|
+
# we raced to set first and lost, wrap around and try again
|
285
|
+
self.storage_server_disruption(host)
|
286
|
+
end
|
287
|
+
end
|
288
|
+
end
|
289
|
+
|
290
|
+
# Lookup a route for the given user.
|
291
|
+
#
|
292
|
+
# user - The String username.
|
293
|
+
#
|
294
|
+
# Returns the hostname of the storage server.
|
295
|
+
def get_user_route(user)
|
296
|
+
try_route(:user, user)
|
297
|
+
end
|
298
|
+
|
299
|
+
# Lookup a route for the given gist.
|
300
|
+
#
|
301
|
+
# gist - The String gist ID.
|
302
|
+
#
|
303
|
+
# Returns the hostname of the storage server.
|
304
|
+
def get_gist_route(gist)
|
305
|
+
try_route(:gist, gist)
|
306
|
+
end
|
307
|
+
|
308
|
+
# Find the least loaded storage server and set a route there for
|
309
|
+
# the given +user+. If the user already exists, do nothing and
|
310
|
+
# simply return the host that user is on.
|
311
|
+
#
|
312
|
+
# user - The String username.
|
313
|
+
#
|
314
|
+
# Returns the chosen hostname.
|
315
|
+
def set_user_route(user)
|
316
|
+
set_route(:user, user)
|
317
|
+
end
|
318
|
+
|
319
|
+
# Explicitly set the user route to the given host.
|
320
|
+
#
|
321
|
+
# user - The String username.
|
322
|
+
# host - The String hostname.
|
323
|
+
#
|
324
|
+
# Returns the new String hostname.
|
325
|
+
# Raises Chimney::NoSuchStorageServer if the storage server is not currently
|
326
|
+
# in the list.
|
327
|
+
def set_user_route!(user, host)
|
328
|
+
unless self.storage_servers.include?(host)
|
329
|
+
raise NoSuchStorageServer.new(host)
|
330
|
+
end
|
331
|
+
set_route(:user, user, host)
|
332
|
+
end
|
333
|
+
|
334
|
+
# Find the least loaded storage server and set a route there for
|
335
|
+
# the given +gist+. If the gist already exists, do nothing and
|
336
|
+
# simply return the host that gist is on.
|
337
|
+
#
|
338
|
+
# gist - The String gist ID.
|
339
|
+
#
|
340
|
+
# Returns the chosen hostname.
|
341
|
+
def set_gist_route(gist)
|
342
|
+
set_route(:gist, gist)
|
343
|
+
end
|
344
|
+
|
345
|
+
# Change the name of the given user without changing the associated host.
|
346
|
+
#
|
347
|
+
# old_user - The old user name.
|
348
|
+
# new_user - The new user name.
|
349
|
+
#
|
350
|
+
# Returns the hostname on success, or nil if the old user was not found
|
351
|
+
# or if the new user already exists.
|
352
|
+
def rename_user_route(old_user, new_user)
|
353
|
+
if (host = get_user_route(old_user)) && !get_user_route(new_user)
|
354
|
+
delete_user_route(old_user)
|
355
|
+
set_route(:user, new_user, host)
|
356
|
+
else
|
357
|
+
nil
|
358
|
+
end
|
359
|
+
end
|
360
|
+
|
361
|
+
# Delete the route for the given user.
|
362
|
+
#
|
363
|
+
# user - The String username.
|
364
|
+
#
|
365
|
+
# Returns nothing.
|
366
|
+
def delete_user_route(user)
|
367
|
+
self.client.delete("gh.storage.user.#{user}")
|
368
|
+
end
|
369
|
+
|
370
|
+
# Delete the route for the given gist.
|
371
|
+
#
|
372
|
+
# gist - The String gist ID.
|
373
|
+
#
|
374
|
+
# Returns nothing.
|
375
|
+
def delete_gist_route(gist)
|
376
|
+
self.client.delete("gh.storage.gist.#{gist}")
|
377
|
+
end
|
378
|
+
|
379
|
+
# Set the partition usage for a given host.
|
380
|
+
#
|
381
|
+
# host - The String hostname.
|
382
|
+
# partition - The single lowercase hex digit partition String.
|
383
|
+
# usage - The percent of disk space used as a Float [0.0-100.0].
|
384
|
+
#
|
385
|
+
# Returns nothing.
|
386
|
+
def set_partition_usage(host, partition, usage)
|
387
|
+
self.client.set("gh.storage.server.usage.percent.#{host}.#{partition}", usage.to_s)
|
388
|
+
end
|
389
|
+
|
390
|
+
# The list of partition usage percentages.
|
391
|
+
#
|
392
|
+
# host - The optional String hostname to restrict the response to.
|
393
|
+
#
|
394
|
+
# Returns an Array of [partition:String, percentage:Float].
|
395
|
+
def partition_usage(host = nil)
|
396
|
+
pattern = "gh.storage.server.usage.percent."
|
397
|
+
pattern += host ? "#{host}.*" : "*"
|
398
|
+
self.client.keys(pattern).map do |x|
|
399
|
+
[x, self.client.get(x).to_f]
|
400
|
+
end
|
401
|
+
end
|
402
|
+
|
403
|
+
# Calculate the absolute path of the user's storage directory.
|
404
|
+
#
|
405
|
+
# user - The String username.
|
406
|
+
#
|
407
|
+
# Returns the String path:
|
408
|
+
# e.g. '/data/repositories/2/a8/e2/95/mojombo'.
|
409
|
+
def self.shard_user_path(user)
|
410
|
+
hex = Digest::MD5.hexdigest(user)
|
411
|
+
partition = partition_hex(user)
|
412
|
+
shard = File.join(partition, hex[0..1], hex[2..3], hex[4..5])
|
413
|
+
File.join(REPO_DIR, shard, user)
|
414
|
+
end
|
415
|
+
|
416
|
+
def shard_user_path(user)
|
417
|
+
Chimney.shard_user_path(user)
|
418
|
+
end
|
419
|
+
|
420
|
+
# Calculate the absolute path of the repo's storage directory.
|
421
|
+
#
|
422
|
+
# user - The String username.
|
423
|
+
# repo - The String repo name.
|
424
|
+
#
|
425
|
+
# Returns the String path:
|
426
|
+
# e.g. '/data/repositories/2/a8/e2/95/mojombo/god.git'.
|
427
|
+
def self.shard_repo_path(user, repo)
|
428
|
+
hex = Digest::MD5.hexdigest(user)
|
429
|
+
partition = partition_hex(user)
|
430
|
+
shard = File.join(partition, hex[0..1], hex[2..3], hex[4..5])
|
431
|
+
File.join(REPO_DIR, shard, user, "#{repo}.git")
|
432
|
+
end
|
433
|
+
|
434
|
+
def shard_repo_path(user, repo)
|
435
|
+
Chimney.shard_repo_path(user, repo)
|
436
|
+
end
|
437
|
+
|
438
|
+
# Calculate the absolute path of the gist's storage directory.
|
439
|
+
#
|
440
|
+
# gist - The String gist ID.
|
441
|
+
#
|
442
|
+
# Returns String path:
|
443
|
+
# e.g. '/data/repositories/0/81/dc/9b/gist/1234.git'.
|
444
|
+
def self.shard_gist_path(gist)
|
445
|
+
hex = Digest::MD5.hexdigest(gist)
|
446
|
+
partition = partition_hex(gist)
|
447
|
+
shard = File.join(partition, hex[0..1], hex[2..3], hex[4..5])
|
448
|
+
File.join(REPO_DIR, shard, 'gist', "#{gist}.git")
|
449
|
+
end
|
450
|
+
|
451
|
+
def shard_gist_path(gist)
|
452
|
+
Chimney.shard_gist_path(gist)
|
453
|
+
end
|
454
|
+
|
455
|
+
# Calculate the partition hex digit.
|
456
|
+
#
|
457
|
+
# name - The String username or gist.
|
458
|
+
#
|
459
|
+
# Returns a single lowercase hex digit [0-9a-f] as a String.
|
460
|
+
def self.partition_hex(name)
|
461
|
+
Digest::MD5.hexdigest(name)[0].chr
|
462
|
+
end
|
463
|
+
|
464
|
+
def partition_hex(name)
|
465
|
+
Chimney.partition_hex(name)
|
466
|
+
end
|
467
|
+
|
468
|
+
# The unix user account that has access to the repository data.
|
469
|
+
#
|
470
|
+
# Returns the String user e.g. 'root'.
|
471
|
+
def self.unix_user
|
472
|
+
UNIX_USER
|
473
|
+
end
|
474
|
+
|
475
|
+
def unix_user
|
476
|
+
Chimney.unix_user
|
477
|
+
end
|
478
|
+
|
479
|
+
# The short name of the server currently executing this code. If this is a
|
480
|
+
# front end and we're on fe2.rs.github.com, this will return "fe2".
|
481
|
+
#
|
482
|
+
# Returns a String host short name e.g. "fe2".
|
483
|
+
def self.current_server
|
484
|
+
if hostname =~ /github\.com/
|
485
|
+
hostname.split('.').first
|
486
|
+
else
|
487
|
+
"localhost"
|
488
|
+
end
|
489
|
+
end
|
490
|
+
|
491
|
+
def current_server
|
492
|
+
Chimney.current_server
|
493
|
+
end
|
494
|
+
|
495
|
+
# The full hostname of the current server.
|
496
|
+
#
|
497
|
+
# Returns a String hostname e.g. "fe2.rs.github.com".
|
498
|
+
def self.hostname
|
499
|
+
`hostname`.chomp
|
500
|
+
end
|
501
|
+
|
502
|
+
private
|
503
|
+
|
504
|
+
# Ensure that a valid connection to the routing server has been made
|
505
|
+
# and that the list of hosts has been fetched.
|
506
|
+
#
|
507
|
+
# Returns nothing.
|
508
|
+
def ensure_client_connection
|
509
|
+
logger.info "Starting Chimney..."
|
510
|
+
self.client = Redis.new(:host => self.host, :port => self.port)
|
511
|
+
if hosts = self.client.get('gh.storage.servers')
|
512
|
+
self.hosts = hosts.split(',')
|
513
|
+
write_hosts_to_file
|
514
|
+
logger.info "Found #{self.hosts.size} hosts from Router."
|
515
|
+
else
|
516
|
+
read_hosts_from_file
|
517
|
+
raise InvalidRoutingServer.new("Hosts could not be loaded.") if self.hosts.empty?
|
518
|
+
logger.warn "Router does not contain hosts list; loaded #{self.hosts.size} hosts from file."
|
519
|
+
end
|
520
|
+
rescue Errno::ECONNREFUSED
|
521
|
+
read_hosts_from_file
|
522
|
+
raise InvalidRoutingServer.new("Hosts could not be loaded.") if self.hosts.empty?
|
523
|
+
logger.warn "Unable to connect to Router; loaded #{self.hosts.size} hosts from file."
|
524
|
+
end
|
525
|
+
|
526
|
+
# Write the hosts list to a file.
|
527
|
+
#
|
528
|
+
# Returns nothing.
|
529
|
+
def write_hosts_to_file
|
530
|
+
File.open(SMOKE_HOSTS_FILE, 'w') do |f|
|
531
|
+
f.write(self.hosts.join(','))
|
532
|
+
end
|
533
|
+
end
|
534
|
+
|
535
|
+
# Read the hosts from a file.
|
536
|
+
#
|
537
|
+
# Returns nothing.
|
538
|
+
def read_hosts_from_file
|
539
|
+
if File.exists?(SMOKE_HOSTS_FILE)
|
540
|
+
self.hosts = File.read(SMOKE_HOSTS_FILE).split(',')
|
541
|
+
end
|
542
|
+
end
|
543
|
+
|
544
|
+
# Reload the hosts list from the router.
|
545
|
+
#
|
546
|
+
# Returns nothing.
|
547
|
+
def reload_hosts_list
|
548
|
+
self.hosts = self.storage_servers
|
549
|
+
write_hosts_to_file
|
550
|
+
end
|
551
|
+
|
552
|
+
# Find the storage server with the least disk usage for the target partition.
|
553
|
+
#
|
554
|
+
# type - Either :user or :gist.
|
555
|
+
# name - The String username or gist.
|
556
|
+
#
|
557
|
+
# Returns a hostname.
|
558
|
+
def find_least_loaded_host(name)
|
559
|
+
partition = partition_hex(name)
|
560
|
+
self.hosts.select { |h| storage_server_online?(h) }.map do |host|
|
561
|
+
[self.client.get("gh.storage.server.usage.percent.#{host}.#{partition}").to_f, host]
|
562
|
+
end.sort.first.last
|
563
|
+
end
|
564
|
+
|
565
|
+
# Set the route for a given user or gist.
|
566
|
+
#
|
567
|
+
# type - Either :user or :gist.
|
568
|
+
# name - The String username or gist.
|
569
|
+
# host - The String hostname that will be set if it is present (optional).
|
570
|
+
#
|
571
|
+
# Returns the String hostname that was set.
|
572
|
+
def set_route(type, name, host = nil)
|
573
|
+
if !host && existing_host = self.client.get("gh.storage.#{type}.#{name}")
|
574
|
+
return existing_host
|
575
|
+
end
|
576
|
+
|
577
|
+
unless host
|
578
|
+
reload_hosts_list
|
579
|
+
host = find_least_loaded_host(name)
|
580
|
+
end
|
581
|
+
|
582
|
+
self.client.set("gh.storage.#{type}.#{name}", host)
|
583
|
+
host
|
584
|
+
end
|
585
|
+
|
586
|
+
# Try to find a route using a variety of different fallbacks.
|
587
|
+
#
|
588
|
+
# type - Either :user or :gist.
|
589
|
+
# name - The String username or gist.
|
590
|
+
#
|
591
|
+
# Returns the hostname of the storage server.
|
592
|
+
def try_route(type, name)
|
593
|
+
try_route_with_redis(type, name)
|
594
|
+
end
|
595
|
+
|
596
|
+
# Try the lookup from redis. If redis is unavailable, try
|
597
|
+
# to do the lookup from internal cache.
|
598
|
+
#
|
599
|
+
# type - Either :user or :gist.
|
600
|
+
# name - The String username or gist.
|
601
|
+
#
|
602
|
+
# Returns the hostname of the storage server.
|
603
|
+
def try_route_with_redis(type, name)
|
604
|
+
if host = self.client.get("gh.storage.#{type}.#{name}")
|
605
|
+
logger.debug "Found host '#{host}' for #{type} '#{name}' from Router."
|
606
|
+
self.cache[name] = host
|
607
|
+
else
|
608
|
+
self.cache.delete(name)
|
609
|
+
end
|
610
|
+
host
|
611
|
+
rescue Errno::ECONNREFUSED
|
612
|
+
logger.warn "No connection to Router..."
|
613
|
+
try_route_with_internal_cache(type, name)
|
614
|
+
end
|
615
|
+
|
616
|
+
# Try the lookup from the internal route cache. If the key is not
|
617
|
+
# in internal cache, try to reconnect to redis and redo the lookup.
|
618
|
+
#
|
619
|
+
# type - Either :user or :gist.
|
620
|
+
# name - The String username or gist.
|
621
|
+
#
|
622
|
+
# Returns the hostname of the storage server.
|
623
|
+
def try_route_with_internal_cache(type, name)
|
624
|
+
if host = self.cache[name]
|
625
|
+
logger.debug "Found '#{host}' for #{type} '#{name}' from Internal Cache."
|
626
|
+
host
|
627
|
+
else
|
628
|
+
logger.warn "No entry in Internal Cache..."
|
629
|
+
try_route_with_new_redis_connection(type, name)
|
630
|
+
end
|
631
|
+
end
|
632
|
+
|
633
|
+
# Try the lookup with a new redis connection. If redis is still
|
634
|
+
# unavailable, try each storage server in turn to look for the user/gist.
|
635
|
+
#
|
636
|
+
# type - Either :user or :gist.
|
637
|
+
# name - The String username or gist.
|
638
|
+
#
|
639
|
+
# Returns the hostname of the storage server.
|
640
|
+
def try_route_with_new_redis_connection(type, name)
|
641
|
+
self.client.connect_to_server
|
642
|
+
host = self.client.get("gh.storage.#{type}.#{name}")
|
643
|
+
logger.debug "Found host '#{host}' for #{type} '#{name}' from Router after reconnect."
|
644
|
+
host
|
645
|
+
rescue Errno::ECONNREFUSED
|
646
|
+
logger.warn "Still no connection to Router..."
|
647
|
+
try_route_with_individual_storage_checks(type, name)
|
648
|
+
end
|
649
|
+
|
650
|
+
# Try the lookup by asking each storage server if the user or gist dir exists.
|
651
|
+
#
|
652
|
+
# type - Either :user or :gist.
|
653
|
+
# name - The String username or gist.
|
654
|
+
#
|
655
|
+
# Returns the hostname of the storage server or nil.
|
656
|
+
def try_route_with_individual_storage_checks(type, name)
|
657
|
+
self.hosts.each do |host|
|
658
|
+
logger.debug "Trying host '#{host}' via Smoke for existence of #{type} '#{name}'..."
|
659
|
+
|
660
|
+
svc = smoke(host)
|
661
|
+
exist =
|
662
|
+
case type
|
663
|
+
when :user: svc.user_dir_exist?(name)
|
664
|
+
when :gist: svc.gist_dir_exist?(name)
|
665
|
+
else false
|
666
|
+
end
|
667
|
+
|
668
|
+
if exist
|
669
|
+
self.cache[name] = host
|
670
|
+
logger.debug "Found host '#{host}' for #{type} '#{name}' from Smoke."
|
671
|
+
return host
|
672
|
+
end
|
673
|
+
end
|
674
|
+
logger.warn "No host found for #{type} '#{name}'."
|
675
|
+
nil
|
676
|
+
rescue Object => e
|
677
|
+
logger.error "No host found for #{type} '#{name}' because of '#{e.message}'."
|
678
|
+
nil
|
679
|
+
end
|
680
|
+
|
681
|
+
def smoke(host)
|
682
|
+
BERTRPC::Service.new(host, 8149, 2).call.store
|
683
|
+
end
|
684
|
+
end
|
685
|
+
|
686
|
+
class Math
|
687
|
+
# Duplicate some text an abitrary number of times.
|
688
|
+
#
|
689
|
+
# text - The String to be duplicated.
|
690
|
+
# count - The Integer number of times to duplicate the text.
|
691
|
+
#
|
692
|
+
# Examples
|
693
|
+
# multiplex('Tom', 4)
|
694
|
+
# # => 'TomTomTomTom'
|
695
|
+
#
|
696
|
+
# Returns the duplicated String.
|
697
|
+
def multiplex(text, count)
|
698
|
+
text * count
|
699
|
+
end
|
700
|
+
end
|
701
|
+
end
|
702
|
+
|
703
|
+
module GitHub
|
704
|
+
class Jobs
|
705
|
+
# Performs a job.
|
706
|
+
#
|
707
|
+
# Returns nothing.
|
708
|
+
def perform
|
709
|
+
end
|
710
|
+
end
|
711
|
+
end
|