stubby 0.0.1
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 +0 -0
- data/README.md +219 -0
- data/bin/stubby +8 -0
- data/lib/stubby/cli/application.rb +160 -0
- data/lib/stubby/cli.rb +3 -0
- data/lib/stubby/extensions/dns/osx.rb +58 -0
- data/lib/stubby/extensions/dns.rb +115 -0
- data/lib/stubby/extensions/http.rb +203 -0
- data/lib/stubby/extensions/reload.rb +46 -0
- data/lib/stubby/master.rb +182 -0
- data/lib/stubby/registry.rb +148 -0
- data/lib/stubby/stub.rb +56 -0
- data/lib/stubby.rb +10 -0
- metadata +251 -0
@@ -0,0 +1,203 @@
|
|
1
|
+
require 'logger'
|
2
|
+
require 'sinatra/base'
|
3
|
+
require 'liquid'
|
4
|
+
require 'httpi'
|
5
|
+
require 'rack/ssl'
|
6
|
+
|
7
|
+
module Extensions
|
8
|
+
module HTTP
|
9
|
+
class NotFoundException < Exception
|
10
|
+
end
|
11
|
+
|
12
|
+
class HTTPApp < Sinatra::Base
|
13
|
+
class << self
|
14
|
+
def port
|
15
|
+
80
|
16
|
+
end
|
17
|
+
|
18
|
+
def run!(session, server_settings={})
|
19
|
+
puts self.inspect + ": " + port.to_s
|
20
|
+
|
21
|
+
set :bind, STUBBY_MASTER
|
22
|
+
set :port, port
|
23
|
+
set :stubby_session, session
|
24
|
+
set :server, 'thin'
|
25
|
+
|
26
|
+
super(:server_settings => server_settings)
|
27
|
+
end
|
28
|
+
|
29
|
+
def adapter(name, &block)
|
30
|
+
adapters[name] = block
|
31
|
+
end
|
32
|
+
|
33
|
+
def adapters
|
34
|
+
@@adapters ||= {}
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
set :run, false
|
39
|
+
set :static, false
|
40
|
+
|
41
|
+
adapter "http-redirect" do
|
42
|
+
url.scheme = "http"
|
43
|
+
url.path = request.path if url.path.to_s.empty?
|
44
|
+
redirect to(url.to_s)
|
45
|
+
end
|
46
|
+
|
47
|
+
adapter "https-redirect" do
|
48
|
+
url.scheme = "https"
|
49
|
+
url.path = request.path if url.path.to_s.empty?
|
50
|
+
redirect to(url.to_s)
|
51
|
+
end
|
52
|
+
|
53
|
+
adapter "file" do
|
54
|
+
paths = []
|
55
|
+
|
56
|
+
if url.host == "-"
|
57
|
+
paths << File.expand_path(File.join("~/.stubby/#{request.host}", request.path))
|
58
|
+
paths << File.expand_path(File.join("/usr/local/stubby/#{request.host}", request.path))
|
59
|
+
else
|
60
|
+
paths << File.expand_path(File.join(url.path, request.path))
|
61
|
+
end
|
62
|
+
|
63
|
+
paths.each do |path|
|
64
|
+
next if path.index(url.path) != 0
|
65
|
+
|
66
|
+
p = [path, File.join(path, "index.html")].select { |path|
|
67
|
+
File.exists?(path) and !File.directory?(path)
|
68
|
+
}.first
|
69
|
+
|
70
|
+
send_file(p) and break unless p.nil?
|
71
|
+
end
|
72
|
+
|
73
|
+
not_found(paths.join(",\n"))
|
74
|
+
end
|
75
|
+
|
76
|
+
adapter "default" do
|
77
|
+
if url.path.empty?
|
78
|
+
# Proxy all requests, preserve incoming path
|
79
|
+
out = url.dup
|
80
|
+
out.path = request.path
|
81
|
+
request = HTTPI::Request.new
|
82
|
+
request.url = out.to_s
|
83
|
+
|
84
|
+
response = HTTPI.get(request)
|
85
|
+
|
86
|
+
response.headers.delete "transfer-encoding"
|
87
|
+
response.headers.delete "connection"
|
88
|
+
|
89
|
+
status(response.code)
|
90
|
+
headers(response.headers)
|
91
|
+
body(response.body)
|
92
|
+
|
93
|
+
else
|
94
|
+
# Proxy to the given path
|
95
|
+
request = HTTPI::Request.new
|
96
|
+
request.url = url.to_s
|
97
|
+
|
98
|
+
response = HTTPI.get(request)
|
99
|
+
|
100
|
+
response.headers.delete "transfer-encoding"
|
101
|
+
response.headers.delete "connection"
|
102
|
+
|
103
|
+
status(response.code)
|
104
|
+
headers(response.headers)
|
105
|
+
body(response.body)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
get(//) do
|
110
|
+
if instruction.nil?
|
111
|
+
not_found
|
112
|
+
elsif adapter=self.class.adapters[url.scheme]
|
113
|
+
instance_eval &adapter
|
114
|
+
else
|
115
|
+
instance_eval &self.class.adapters["default"]
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
private
|
120
|
+
def forbidden
|
121
|
+
[403, "Forbidden"]
|
122
|
+
end
|
123
|
+
|
124
|
+
def not_found(resource="*unknown*")
|
125
|
+
[404, "Not Found:\n #{resource}"]
|
126
|
+
end
|
127
|
+
|
128
|
+
def instruction
|
129
|
+
MultiJson.load(HTTPI.post("http://#{STUBBY_MASTER}:9000/rules/search.json",
|
130
|
+
trigger: "#{request.scheme}://#{request.host}").body)
|
131
|
+
end
|
132
|
+
|
133
|
+
def url
|
134
|
+
@url ||= URI.parse(instruction)
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
# TODO: get SSL support running. Self signed cert
|
139
|
+
class HTTPSApp < HTTPApp
|
140
|
+
use Rack::SSL
|
141
|
+
|
142
|
+
class << self
|
143
|
+
def port
|
144
|
+
443
|
145
|
+
end
|
146
|
+
|
147
|
+
def run!(session)
|
148
|
+
set :bind, STUBBY_MASTER
|
149
|
+
set :port, port
|
150
|
+
set :stubby_session, session
|
151
|
+
|
152
|
+
#super(session, {
|
153
|
+
# :SSLEnable => true,
|
154
|
+
# :SSLCertName => %w[CN localhost]
|
155
|
+
#})
|
156
|
+
|
157
|
+
Rack::Handler::Thin.run(self, {
|
158
|
+
:Host => STUBBY_MASTER,
|
159
|
+
:Port => 443
|
160
|
+
}) do |server|
|
161
|
+
server.ssl = true
|
162
|
+
server.ssl_options = {
|
163
|
+
:verify_peer => false
|
164
|
+
}
|
165
|
+
end
|
166
|
+
rescue => e
|
167
|
+
puts "#{e.inspect}"
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
class Server
|
173
|
+
def initialize
|
174
|
+
@log = Logger.new(STDOUT)
|
175
|
+
end
|
176
|
+
|
177
|
+
def run!(session, options)
|
178
|
+
return if options[:http] == false
|
179
|
+
|
180
|
+
@session = session
|
181
|
+
HTTPApp.run!(session)
|
182
|
+
end
|
183
|
+
|
184
|
+
def stop!
|
185
|
+
HTTPApp.quit!
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
class SSLServer < Server
|
190
|
+
def run!(session, options)
|
191
|
+
return if options[:https] == false
|
192
|
+
|
193
|
+
@session = session
|
194
|
+
HTTPSApp.run!(session)
|
195
|
+
end
|
196
|
+
|
197
|
+
def stop!
|
198
|
+
HTTPSApp.quit!
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
@@ -0,0 +1,46 @@
|
|
1
|
+
require 'listen'
|
2
|
+
|
3
|
+
module Extensions
|
4
|
+
class Reload
|
5
|
+
def run!(session, options)
|
6
|
+
@options = options
|
7
|
+
@session = session
|
8
|
+
return if @options[:reload] == false
|
9
|
+
|
10
|
+
listener.start
|
11
|
+
sleep 1 while @listener
|
12
|
+
puts 'run! done'
|
13
|
+
end
|
14
|
+
|
15
|
+
def stop!
|
16
|
+
return if @options[:reload] == false
|
17
|
+
@listener.stop and @listener = nil if @listener
|
18
|
+
puts 'stop! done'
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
def root_path
|
23
|
+
@session.system.root_path
|
24
|
+
end
|
25
|
+
|
26
|
+
def session_config_path
|
27
|
+
@session.system.session_config_path
|
28
|
+
end
|
29
|
+
|
30
|
+
def listener
|
31
|
+
return @listener if @listener
|
32
|
+
|
33
|
+
puts "NEW LISTENER"
|
34
|
+
|
35
|
+
@listener ||= Listen.to(root_path, debug: true) do |modified, added, removed|
|
36
|
+
(modified + added).each do |mpath|
|
37
|
+
puts "[INFO] Detected change, test identical #{mpath}"
|
38
|
+
if File.identical?(session_config_path, mpath)
|
39
|
+
puts "[INFO] Detected config change, reloading #{mpath}..."
|
40
|
+
@session.system.reload
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,182 @@
|
|
1
|
+
require 'sinatra/json'
|
2
|
+
|
3
|
+
module Stubby
|
4
|
+
class Api < Sinatra::Base
|
5
|
+
class << self
|
6
|
+
attr_accessor :enabled_stubs
|
7
|
+
attr_accessor :registry
|
8
|
+
attr_accessor :environments, :environment
|
9
|
+
|
10
|
+
def enabled_stubs
|
11
|
+
@enabled_stubs ||= {}
|
12
|
+
end
|
13
|
+
|
14
|
+
def registry
|
15
|
+
@registry ||= Stubby::Registry.new
|
16
|
+
end
|
17
|
+
|
18
|
+
def reset
|
19
|
+
@enabled_stubs = nil
|
20
|
+
@environment = nil
|
21
|
+
@registry = nil
|
22
|
+
end
|
23
|
+
|
24
|
+
def env_settings
|
25
|
+
(@environments[environment] || {}).dup
|
26
|
+
end
|
27
|
+
|
28
|
+
def environment=(name)
|
29
|
+
reset
|
30
|
+
@environment = name
|
31
|
+
|
32
|
+
(env_settings["dependencies"] || []).each do |depname, mode|
|
33
|
+
activate(depname, mode)
|
34
|
+
end
|
35
|
+
|
36
|
+
env_settings.delete("dependencies")
|
37
|
+
|
38
|
+
activate_transient(env_settings)
|
39
|
+
end
|
40
|
+
|
41
|
+
def activate(name, mode)
|
42
|
+
registry_item = registry.latest(name)
|
43
|
+
registry_item.install
|
44
|
+
self.enabled_stubs[name] = registry_item.stub(mode)
|
45
|
+
end
|
46
|
+
|
47
|
+
def activate_transient(options)
|
48
|
+
self.enabled_stubs["_"] = TransientStub.new(options)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
set :bind, STUBBY_MASTER
|
53
|
+
set :port, 9000
|
54
|
+
set :run, false
|
55
|
+
set :static, false
|
56
|
+
|
57
|
+
get "/status" do
|
58
|
+
json status: "ok"
|
59
|
+
end
|
60
|
+
|
61
|
+
get "/stubs/available.json" do
|
62
|
+
json Api.registry.index
|
63
|
+
end
|
64
|
+
|
65
|
+
get "/stubs/activated.json" do
|
66
|
+
json Hash[Api.enabled_stubs.collect { |name, stub|
|
67
|
+
[name, stub.options]
|
68
|
+
}]
|
69
|
+
end
|
70
|
+
|
71
|
+
post "/reset.json" do
|
72
|
+
Api.reset
|
73
|
+
json status: "ok"
|
74
|
+
end
|
75
|
+
|
76
|
+
get "/environment.json" do
|
77
|
+
json environment: (Api.environment || "undefined")
|
78
|
+
end
|
79
|
+
|
80
|
+
post "/environment.json" do
|
81
|
+
Api.environment = params[:environment]
|
82
|
+
json status: "ok"
|
83
|
+
end
|
84
|
+
|
85
|
+
get "/environments.json" do
|
86
|
+
json environments: Api.environments
|
87
|
+
end
|
88
|
+
|
89
|
+
post "/stubs/transient/activate.json" do
|
90
|
+
Api.activate_transient(params[:options])
|
91
|
+
json status: "ok"
|
92
|
+
end
|
93
|
+
|
94
|
+
post "/stubs/activate.json" do
|
95
|
+
Api.activate(params[:name], params[:mode])
|
96
|
+
json status: "ok"
|
97
|
+
end
|
98
|
+
|
99
|
+
post "/rules/search.json" do
|
100
|
+
json Api.enabled_stubs.collect { |_, stub|
|
101
|
+
stub.search(params[:trigger])
|
102
|
+
}.compact.first
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
class Master
|
107
|
+
attr_accessor :extensions, :config
|
108
|
+
|
109
|
+
def initialize(environments)
|
110
|
+
@extensions = [
|
111
|
+
Extensions::DNS::Server.new,
|
112
|
+
Extensions::HTTP::Server.new,
|
113
|
+
Extensions::HTTP::SSLServer.new
|
114
|
+
]
|
115
|
+
|
116
|
+
@config = Api
|
117
|
+
@config.environments = environments
|
118
|
+
end
|
119
|
+
|
120
|
+
def environment=(environment)
|
121
|
+
@config.environment = environment
|
122
|
+
end
|
123
|
+
|
124
|
+
def run!(options={})
|
125
|
+
begin
|
126
|
+
assume_network_interface
|
127
|
+
|
128
|
+
running.each do |process|
|
129
|
+
puts "wait for #{process}"
|
130
|
+
Process.waitpid(process)
|
131
|
+
end
|
132
|
+
ensure
|
133
|
+
unassume_network_interface
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
private
|
138
|
+
def stop_extensions
|
139
|
+
puts "Shutting down..."
|
140
|
+
|
141
|
+
running.each do |process|
|
142
|
+
Process.kill("INT", process)
|
143
|
+
end
|
144
|
+
|
145
|
+
puts "Bye."
|
146
|
+
end
|
147
|
+
|
148
|
+
def running
|
149
|
+
@running ||= [run_master_api, run_extensions].flatten
|
150
|
+
end
|
151
|
+
|
152
|
+
def run_master_api
|
153
|
+
Process.fork {
|
154
|
+
$0 = "stubby: [config api]"
|
155
|
+
Api.run!
|
156
|
+
}
|
157
|
+
end
|
158
|
+
|
159
|
+
def run_extensions
|
160
|
+
@running_extensions ||= @extensions.collect { |plugin|
|
161
|
+
Process.fork {
|
162
|
+
$0 = "stubby: [extension worker] #{plugin.class.name}"
|
163
|
+
plugin.run!(self, {})
|
164
|
+
}
|
165
|
+
}
|
166
|
+
|
167
|
+
trap("INT") {
|
168
|
+
stop_extensions
|
169
|
+
}
|
170
|
+
|
171
|
+
return @running_extensions
|
172
|
+
end
|
173
|
+
|
174
|
+
def assume_network_interface
|
175
|
+
`ifconfig lo0 alias #{STUBBY_MASTER}`
|
176
|
+
end
|
177
|
+
|
178
|
+
def unassume_network_interface
|
179
|
+
`ifconfig lo0 -alias #{STUBBY_MASTER}`
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
@@ -0,0 +1,148 @@
|
|
1
|
+
require 'pry'
|
2
|
+
require 'httpi'
|
3
|
+
|
4
|
+
module Stubby
|
5
|
+
class RegistryItem
|
6
|
+
attr_accessor :name, :version, :source, :location
|
7
|
+
|
8
|
+
def initialize(name, version, source)
|
9
|
+
@name = name
|
10
|
+
@version = version
|
11
|
+
@source = source
|
12
|
+
@location = "~/.stubby/#{name}"
|
13
|
+
end
|
14
|
+
|
15
|
+
def version
|
16
|
+
@version.slice(1, @version.length)
|
17
|
+
end
|
18
|
+
|
19
|
+
def install
|
20
|
+
if File.exists? source
|
21
|
+
uninstall
|
22
|
+
`ln -s #{source} ~/.stubby/#{name}`
|
23
|
+
else
|
24
|
+
`mkdir -p ~/.stubby`
|
25
|
+
download source, "~/.stubby/#{name}.zip"
|
26
|
+
`curl #{source} > ~/.stubby/#{name}.zip`
|
27
|
+
`unzip -d ~/.stubby/ #{source}`
|
28
|
+
`rm ~/.stubby/#{name}.zip`
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def uninstall
|
33
|
+
`rm -rf ~/.stubby/#{name}`
|
34
|
+
end
|
35
|
+
|
36
|
+
def installed?
|
37
|
+
File.exists? @location
|
38
|
+
end
|
39
|
+
|
40
|
+
def download(source, destination)
|
41
|
+
`curl #{source} #{destination}`
|
42
|
+
end
|
43
|
+
|
44
|
+
def config
|
45
|
+
File.join("~", ".stubby", name, "stubby.json")
|
46
|
+
end
|
47
|
+
|
48
|
+
def stub(target=nil)
|
49
|
+
install unless installed?
|
50
|
+
Stub.new(config, target)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
class Registry
|
55
|
+
def index
|
56
|
+
Hash[(remote_index || local_index).collect { |name, versions|
|
57
|
+
[name, versions.collect { |version, source|
|
58
|
+
RegistryItem.new name, version, source
|
59
|
+
}]
|
60
|
+
}]
|
61
|
+
end
|
62
|
+
|
63
|
+
def versions(name)
|
64
|
+
if index[name]
|
65
|
+
index[name].sort { |x, y|
|
66
|
+
Gem::Version.new(y.version) <=> Gem::Version.new(x.version)
|
67
|
+
}
|
68
|
+
else
|
69
|
+
[]
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def version(name, version)
|
74
|
+
version = version.gsub("v", "")
|
75
|
+
|
76
|
+
index[name].detect { |stub|
|
77
|
+
stub.version == version
|
78
|
+
}
|
79
|
+
end
|
80
|
+
|
81
|
+
def latest(name)
|
82
|
+
versions(name).first
|
83
|
+
end
|
84
|
+
|
85
|
+
def install(name, opts={})
|
86
|
+
source = opts[:source]
|
87
|
+
v = opts[:version]
|
88
|
+
|
89
|
+
if name =~ /https?:\/\//
|
90
|
+
source = name
|
91
|
+
name = File.basename(name).split(".").first
|
92
|
+
RegistryItem.new(name, "v1.0.0", source).install
|
93
|
+
else
|
94
|
+
stub = v.nil? ? latest(name) : version(name, v)
|
95
|
+
|
96
|
+
if stub
|
97
|
+
stub.install
|
98
|
+
elsif source
|
99
|
+
add_new_source(name, source, v)
|
100
|
+
else
|
101
|
+
puts "[ERROR] Cannot find #{name} at #{v}"
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def uninstall(name)
|
107
|
+
# TODO: we're not doing a search of the installed stubs'
|
108
|
+
# version, but we have a convention of using a ~/.stubby/NAME
|
109
|
+
# location, so this shouldn't be a problem for the POC
|
110
|
+
if name =~ /https?:\/\//
|
111
|
+
name = File.basename(name).split(".").first
|
112
|
+
end
|
113
|
+
|
114
|
+
latest(name).uninstall
|
115
|
+
end
|
116
|
+
|
117
|
+
|
118
|
+
private
|
119
|
+
|
120
|
+
def remote_index
|
121
|
+
response = HTTPI.get("http://github.com/jkassemi/stubby/index.json")
|
122
|
+
MultiJson.load(response.body) if response.code == 200
|
123
|
+
end
|
124
|
+
|
125
|
+
def local_index
|
126
|
+
MultiJson.load(File.read(File.expand_path(File.join('~', '.stubby', "index.json"))))
|
127
|
+
rescue
|
128
|
+
{}
|
129
|
+
end
|
130
|
+
|
131
|
+
def write_local_index(&block)
|
132
|
+
File.open File.expand_path(File.join('~', '.stubby', "index.json")), "w", &block
|
133
|
+
end
|
134
|
+
|
135
|
+
def add_new_source(name, source, v=nil)
|
136
|
+
version = v.nil? ? 'v0.0.1' : v
|
137
|
+
|
138
|
+
item = RegistryItem.new name, version, source
|
139
|
+
item.install
|
140
|
+
|
141
|
+
current_index = local_index
|
142
|
+
|
143
|
+
write_local_index do |index|
|
144
|
+
index.puts MultiJson.dump(local_index.merge({item.name => {item.version => item.location}}))
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
data/lib/stubby/stub.rb
ADDED
@@ -0,0 +1,56 @@
|
|
1
|
+
require 'pry'
|
2
|
+
|
3
|
+
module Stubby
|
4
|
+
class Stub
|
5
|
+
attr_accessor :target, :path, :modes
|
6
|
+
|
7
|
+
# TODO: target is mode, rename
|
8
|
+
def initialize(path, target=nil)
|
9
|
+
self.path = path
|
10
|
+
self.target = target
|
11
|
+
end
|
12
|
+
|
13
|
+
def modes
|
14
|
+
@modes ||= MultiJson.load(File.read(path))
|
15
|
+
rescue
|
16
|
+
{}
|
17
|
+
end
|
18
|
+
|
19
|
+
def path=(v)
|
20
|
+
unless v and File.exists?(File.expand_path(v))
|
21
|
+
puts "'#{v}' not found. Use --config to specify a different config file"
|
22
|
+
exit
|
23
|
+
end
|
24
|
+
|
25
|
+
@path = File.expand_path(v)
|
26
|
+
end
|
27
|
+
|
28
|
+
def options
|
29
|
+
modes[target] || {}
|
30
|
+
end
|
31
|
+
|
32
|
+
def search(trigger)
|
33
|
+
options.each do |rule, instruction|
|
34
|
+
if Regexp.new(rule, Regexp::EXTENDED | Regexp::IGNORECASE).match(trigger)
|
35
|
+
return instruction
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
return nil
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
class TransientStub < Stub
|
44
|
+
def initialize(options)
|
45
|
+
@options = options
|
46
|
+
end
|
47
|
+
|
48
|
+
def modes
|
49
|
+
{}
|
50
|
+
end
|
51
|
+
|
52
|
+
def options
|
53
|
+
@options
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
data/lib/stubby.rb
ADDED
@@ -0,0 +1,10 @@
|
|
1
|
+
STUBBY_MASTER="172.16.123.1"
|
2
|
+
|
3
|
+
require 'multi_json'
|
4
|
+
require 'stubby/extensions/dns/osx'
|
5
|
+
require 'stubby/extensions/dns'
|
6
|
+
require 'stubby/extensions/http'
|
7
|
+
require 'stubby/extensions/reload'
|
8
|
+
require 'stubby/registry'
|
9
|
+
require 'stubby/stub'
|
10
|
+
require 'stubby/master'
|