kensa 0.4.2 → 1.0.0.beta1
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/Rakefile +6 -4
- data/bin/kensa +17 -95
- data/kensa.gemspec +28 -18
- data/lib/heroku/kensa.rb +4 -589
- data/lib/heroku/kensa/check.rb +453 -0
- data/lib/heroku/kensa/client.rb +136 -0
- data/lib/heroku/kensa/http.rb +53 -0
- data/lib/heroku/kensa/manifest.rb +52 -0
- data/lib/heroku/kensa/sso.rb +55 -0
- data/test/all_check_test.rb +26 -0
- data/test/deprovision_check.rb +3 -3
- data/test/helper.rb +1 -0
- data/test/manifest_check_test.rb +3 -33
- data/test/manifest_test.rb +24 -0
- data/test/provision_check_test.rb +3 -3
- data/test/provision_response_check_test.rb +3 -3
- data/test/resources/runner.rb +1 -0
- data/test/resources/{test_server.rb → server.rb} +47 -3
- data/test/sso_check_test.rb +24 -3
- data/test/sso_test.rb +58 -0
- metadata +56 -29
- data/TODO +0 -8
- data/a-server.rb +0 -21
- data/server.rb +0 -13
@@ -0,0 +1,453 @@
|
|
1
|
+
require 'yajl'
|
2
|
+
require 'mechanize'
|
3
|
+
require 'socket'
|
4
|
+
require 'timeout'
|
5
|
+
require 'uri'
|
6
|
+
|
7
|
+
module Heroku
|
8
|
+
module Kensa
|
9
|
+
|
10
|
+
class NilScreen
|
11
|
+
|
12
|
+
def test(msg)
|
13
|
+
end
|
14
|
+
|
15
|
+
def check(msg)
|
16
|
+
end
|
17
|
+
|
18
|
+
def error(msg)
|
19
|
+
end
|
20
|
+
|
21
|
+
def result(status)
|
22
|
+
end
|
23
|
+
|
24
|
+
def message(msg)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
|
29
|
+
class Check
|
30
|
+
attr_accessor :screen, :data
|
31
|
+
|
32
|
+
class CheckError < StandardError ; end
|
33
|
+
|
34
|
+
def initialize(data, screen=NilScreen.new)
|
35
|
+
@data = data
|
36
|
+
@screen = screen
|
37
|
+
end
|
38
|
+
|
39
|
+
def test(msg)
|
40
|
+
screen.test msg
|
41
|
+
end
|
42
|
+
|
43
|
+
def check(msg)
|
44
|
+
screen.check(msg)
|
45
|
+
if yield
|
46
|
+
screen.result(true)
|
47
|
+
else
|
48
|
+
raise CheckError
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def run(klass, data)
|
53
|
+
c = klass.new(data, screen)
|
54
|
+
instance_eval(&c)
|
55
|
+
end
|
56
|
+
|
57
|
+
def error(msg)
|
58
|
+
raise CheckError, msg
|
59
|
+
end
|
60
|
+
|
61
|
+
def call
|
62
|
+
call!
|
63
|
+
true
|
64
|
+
rescue CheckError => boom
|
65
|
+
screen.result(false)
|
66
|
+
screen.error boom.message if boom.message != boom.class.name
|
67
|
+
|
68
|
+
false
|
69
|
+
end
|
70
|
+
|
71
|
+
def to_proc
|
72
|
+
me = self
|
73
|
+
Proc.new { me.call! }
|
74
|
+
end
|
75
|
+
|
76
|
+
end
|
77
|
+
|
78
|
+
|
79
|
+
class ManifestCheck < Check
|
80
|
+
|
81
|
+
ValidPriceUnits = %w[month dyno_hour]
|
82
|
+
|
83
|
+
def call!
|
84
|
+
test "manifest id key"
|
85
|
+
check "if exists" do
|
86
|
+
data.has_key?("id")
|
87
|
+
end
|
88
|
+
check "is a string" do
|
89
|
+
data["id"].is_a?(String)
|
90
|
+
end
|
91
|
+
check "is not blank" do
|
92
|
+
!data["id"].empty?
|
93
|
+
end
|
94
|
+
|
95
|
+
test "manifest api key"
|
96
|
+
check "if exists" do
|
97
|
+
data.has_key?("api")
|
98
|
+
end
|
99
|
+
check "is a hash" do
|
100
|
+
data["api"].is_a?(Hash)
|
101
|
+
end
|
102
|
+
check "contains username" do
|
103
|
+
data["api"].has_key?("username") && data["api"]["username"] != ""
|
104
|
+
end
|
105
|
+
check "contains password" do
|
106
|
+
data["api"].has_key?("password") && data["api"]["password"] != ""
|
107
|
+
end
|
108
|
+
check "contains test url" do
|
109
|
+
data["api"].has_key?("test")
|
110
|
+
end
|
111
|
+
check "contains production url" do
|
112
|
+
data["api"].has_key?("production")
|
113
|
+
end
|
114
|
+
check "production url uses SSL" do
|
115
|
+
data["api"]["production"] =~ /^https:/
|
116
|
+
end
|
117
|
+
check "contains config_vars array" do
|
118
|
+
data["api"].has_key?("config_vars") && data["api"]["config_vars"].is_a?(Array)
|
119
|
+
end
|
120
|
+
check "containst at least one config var" do
|
121
|
+
!data["api"]["config_vars"].empty?
|
122
|
+
end
|
123
|
+
check "all config vars are uppercase strings" do
|
124
|
+
data["api"]["config_vars"].each do |k, v|
|
125
|
+
if k =~ /^[A-Z][0-9A-Z_]+$/
|
126
|
+
true
|
127
|
+
else
|
128
|
+
error "#{k.inspect} is not a valid ENV key"
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
check "all config vars are prefixed with the addon id" do
|
133
|
+
data["api"]["config_vars"].each do |k|
|
134
|
+
if k =~ /^#{data['id'].upcase}_/
|
135
|
+
true
|
136
|
+
else
|
137
|
+
error "#{k} is not a valid ENV key - must be prefixed with #{data['id'].upcase}_"
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
test "plans"
|
143
|
+
check "key must exist" do
|
144
|
+
data.has_key?("plans")
|
145
|
+
end
|
146
|
+
check "is an array" do
|
147
|
+
data["plans"].is_a?(Array)
|
148
|
+
end
|
149
|
+
check "contains at least one plan" do
|
150
|
+
!data["plans"].empty?
|
151
|
+
end
|
152
|
+
check "all plans are a hash" do
|
153
|
+
data["plans"].all? {|plan| plan.is_a?(Hash) }
|
154
|
+
end
|
155
|
+
check "all plans must have an id" do
|
156
|
+
data["plans"].all? {|plan| plan.has_key?("id") }
|
157
|
+
end
|
158
|
+
check "all plans have an unique id" do
|
159
|
+
ids = data["plans"].map {|plan| plan["id"] }
|
160
|
+
ids.size == ids.uniq.size
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
end
|
165
|
+
|
166
|
+
|
167
|
+
class ProvisionResponseCheck < Check
|
168
|
+
|
169
|
+
def call!
|
170
|
+
response = data[:provision_response]
|
171
|
+
test "response"
|
172
|
+
check "contains an id" do
|
173
|
+
response.is_a?(Hash) && response.has_key?("id")
|
174
|
+
end
|
175
|
+
|
176
|
+
if response.has_key?("config")
|
177
|
+
test "config data"
|
178
|
+
check "is a hash" do
|
179
|
+
response["config"].is_a?(Hash)
|
180
|
+
end
|
181
|
+
|
182
|
+
check "all config keys were previously defined in the manifest" do
|
183
|
+
response["config"].keys.each do |key|
|
184
|
+
error "#{key} is not in the manifest" unless data["api"]["config_vars"].include?(key)
|
185
|
+
end
|
186
|
+
true
|
187
|
+
end
|
188
|
+
|
189
|
+
check "all config values are strings" do
|
190
|
+
response["config"].each do |k, v|
|
191
|
+
if v.is_a?(String)
|
192
|
+
true
|
193
|
+
else
|
194
|
+
error "the key #{k} doesn't contain a string (#{v.inspect})"
|
195
|
+
end
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
check "URL configs vars" do
|
200
|
+
response["config"].each do |key, value|
|
201
|
+
next unless key =~ /_URL$/
|
202
|
+
begin
|
203
|
+
uri = URI.parse(value)
|
204
|
+
error "#{value} is not a valid URI - missing host" unless uri.host
|
205
|
+
error "#{value} is not a valid URI - missing scheme" unless uri.scheme
|
206
|
+
error "#{value} is not a valid URI - pointing to localhost" if @data[:env] == 'production' && uri.host == 'localhost'
|
207
|
+
rescue URI::Error
|
208
|
+
error "#{value} is not a valid URI"
|
209
|
+
end
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
end
|
217
|
+
|
218
|
+
|
219
|
+
class ApiCheck < Check
|
220
|
+
def url
|
221
|
+
env = data[:env] || 'test'
|
222
|
+
data["api"][env].chomp("/")
|
223
|
+
end
|
224
|
+
|
225
|
+
def credentials
|
226
|
+
%w( username password ).map { |attr| data["api"][attr] }
|
227
|
+
end
|
228
|
+
end
|
229
|
+
|
230
|
+
class ProvisionCheck < ApiCheck
|
231
|
+
include HTTP
|
232
|
+
|
233
|
+
READLEN = 1024 * 10
|
234
|
+
APPID = "app#{rand(10000)}@kensa.heroku.com"
|
235
|
+
APPNAME = "myapp"
|
236
|
+
|
237
|
+
def call!
|
238
|
+
json = nil
|
239
|
+
response = nil
|
240
|
+
|
241
|
+
code = nil
|
242
|
+
json = nil
|
243
|
+
path = "/heroku/resources"
|
244
|
+
callback = "http://localhost:7779/callback/999"
|
245
|
+
reader, writer = nil
|
246
|
+
|
247
|
+
payload = {
|
248
|
+
:heroku_id => APPID,
|
249
|
+
:plan => @data[:plan] || @data['plans'].first['id'],
|
250
|
+
:callback_url => callback
|
251
|
+
}
|
252
|
+
|
253
|
+
if data[:async]
|
254
|
+
reader, writer = IO.pipe
|
255
|
+
end
|
256
|
+
|
257
|
+
test "POST /heroku/resources"
|
258
|
+
check "response" do
|
259
|
+
if data[:async]
|
260
|
+
child = fork do
|
261
|
+
Timeout.timeout(10) do
|
262
|
+
reader.close
|
263
|
+
server = TCPServer.open(7779)
|
264
|
+
client = server.accept
|
265
|
+
writer.write(client.readpartial(READLEN))
|
266
|
+
client.write("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
|
267
|
+
client.close
|
268
|
+
writer.close
|
269
|
+
end
|
270
|
+
end
|
271
|
+
sleep(1)
|
272
|
+
end
|
273
|
+
|
274
|
+
code, json = post(credentials, path, payload)
|
275
|
+
|
276
|
+
if code == 200
|
277
|
+
# noop
|
278
|
+
elsif code == -1
|
279
|
+
error("unable to connect to #{url}")
|
280
|
+
else
|
281
|
+
error("expected 200, got #{code}")
|
282
|
+
end
|
283
|
+
|
284
|
+
true
|
285
|
+
end
|
286
|
+
|
287
|
+
if data[:async]
|
288
|
+
check "async response to PUT #{callback}" do
|
289
|
+
out = reader.readpartial(READLEN)
|
290
|
+
_, json = out.split("\r\n\r\n")
|
291
|
+
end
|
292
|
+
end
|
293
|
+
|
294
|
+
check "valid JSON" do
|
295
|
+
begin
|
296
|
+
response = Yajl::Parser.parse(json)
|
297
|
+
rescue Yajl::ParseError => boom
|
298
|
+
error boom.message
|
299
|
+
end
|
300
|
+
true
|
301
|
+
end
|
302
|
+
|
303
|
+
check "authentication" do
|
304
|
+
wrong_credentials = ['wrong', 'secret']
|
305
|
+
code, _ = post(wrong_credentials, path, payload)
|
306
|
+
error("expected 401, got #{code}") if code != 401
|
307
|
+
true
|
308
|
+
end
|
309
|
+
|
310
|
+
data[:provision_response] = response
|
311
|
+
|
312
|
+
run ProvisionResponseCheck, data
|
313
|
+
end
|
314
|
+
|
315
|
+
ensure
|
316
|
+
reader.close rescue nil
|
317
|
+
writer.close rescue nil
|
318
|
+
end
|
319
|
+
|
320
|
+
|
321
|
+
class DeprovisionCheck < ApiCheck
|
322
|
+
include HTTP
|
323
|
+
|
324
|
+
def call!
|
325
|
+
id = data[:id]
|
326
|
+
raise ArgumentError, "No id specified" if id.nil?
|
327
|
+
|
328
|
+
path = "/heroku/resources/#{CGI::escape(id.to_s)}"
|
329
|
+
|
330
|
+
test "DELETE #{path}"
|
331
|
+
check "response" do
|
332
|
+
code, _ = delete(credentials, path, nil)
|
333
|
+
if code == 200
|
334
|
+
true
|
335
|
+
elsif code == -1
|
336
|
+
error("unable to connect to #{url}")
|
337
|
+
else
|
338
|
+
error("expected 200, got #{code}")
|
339
|
+
end
|
340
|
+
end
|
341
|
+
|
342
|
+
check "authentication" do
|
343
|
+
wrong_credentials = ['wrong', 'secret']
|
344
|
+
code, _ = delete(wrong_credentials, path, nil)
|
345
|
+
error("expected 401, got #{code}") if code != 401
|
346
|
+
true
|
347
|
+
end
|
348
|
+
|
349
|
+
end
|
350
|
+
|
351
|
+
end
|
352
|
+
|
353
|
+
|
354
|
+
class SsoCheck < ApiCheck
|
355
|
+
include HTTP
|
356
|
+
|
357
|
+
def agent
|
358
|
+
@agent ||= Mechanize.new
|
359
|
+
end
|
360
|
+
|
361
|
+
def mechanize_get url
|
362
|
+
page = agent.get(url)
|
363
|
+
return page, 200
|
364
|
+
rescue Mechanize::ResponseCodeError => error
|
365
|
+
return nil, error.response_code.to_i
|
366
|
+
rescue Errno::ECONNREFUSED
|
367
|
+
error("connection refused to #{url}")
|
368
|
+
end
|
369
|
+
|
370
|
+
def call!
|
371
|
+
error("need an sso salt to perform sso test") unless data['api']['sso_salt']
|
372
|
+
|
373
|
+
sso = Sso.new(data)
|
374
|
+
t = Time.now.to_i
|
375
|
+
|
376
|
+
test "GET #{sso.path}"
|
377
|
+
check "validates token" do
|
378
|
+
page, respcode = mechanize_get sso.url + sso.path + "?token=invalid×tamp=#{t}"
|
379
|
+
error("expected 403, got 200") unless respcode == 403
|
380
|
+
true
|
381
|
+
end
|
382
|
+
|
383
|
+
check "validates timestamp" do
|
384
|
+
prev = (Time.now - 60*6).to_i
|
385
|
+
page, respcode = mechanize_get sso.url + sso.path + "?token=#{sso.make_token(prev)}×tamp=#{prev}"
|
386
|
+
error("expected 403, got 200") unless respcode == 403
|
387
|
+
true
|
388
|
+
end
|
389
|
+
|
390
|
+
page_logged_in = nil
|
391
|
+
check "logs in" do
|
392
|
+
page_logged_in, respcode = mechanize_get sso.url + sso.path + sso.querystring
|
393
|
+
error("expected 200, got #{respcode}") unless respcode == 200
|
394
|
+
true
|
395
|
+
end
|
396
|
+
|
397
|
+
check "creates the heroku-nav-data cookie" do
|
398
|
+
cookie = agent.cookie_jar.cookies(URI.parse(sso.full_url)).detect { |c| c.name == 'heroku-nav-data' }
|
399
|
+
error("could not find cookie heroku-nav-data") unless cookie
|
400
|
+
error("expected #{sso.sample_nav_data}, got #{cookie.value}") unless cookie.value == sso.sample_nav_data
|
401
|
+
true
|
402
|
+
end
|
403
|
+
|
404
|
+
check "displays the heroku layout" do
|
405
|
+
error("could not find Heroku layout") if page_logged_in.search('div#heroku-header').empty?
|
406
|
+
true
|
407
|
+
end
|
408
|
+
end
|
409
|
+
end
|
410
|
+
|
411
|
+
|
412
|
+
##
|
413
|
+
# On Testing:
|
414
|
+
# I've opted to not write tests for this
|
415
|
+
# due to the simple nature it's currently in.
|
416
|
+
# If this becomes more complex in even the
|
417
|
+
# least amount, find me (blake) and I'll
|
418
|
+
# help get tests in.
|
419
|
+
class AllCheck < Check
|
420
|
+
|
421
|
+
def call!
|
422
|
+
args = data[:args]
|
423
|
+
run ProvisionCheck, data
|
424
|
+
|
425
|
+
response = data[:provision_response]
|
426
|
+
data.merge!(:id => response["id"])
|
427
|
+
config = response["config"] || Hash.new
|
428
|
+
|
429
|
+
if args
|
430
|
+
screen.message "\n\n"
|
431
|
+
screen.message "Starting #{args.first}..."
|
432
|
+
screen.message ""
|
433
|
+
|
434
|
+
run_in_env(config) { system(*args) }
|
435
|
+
error("run exited abnormally, expected 0, got #{$?.to_i}") unless $?.to_i == 0
|
436
|
+
|
437
|
+
screen.message ""
|
438
|
+
screen.message "End of #{args.first}"
|
439
|
+
end
|
440
|
+
|
441
|
+
run DeprovisionCheck, data
|
442
|
+
end
|
443
|
+
|
444
|
+
def run_in_env(env)
|
445
|
+
env.each {|key, value| ENV[key] = value }
|
446
|
+
yield
|
447
|
+
env.keys.each {|key| ENV.delete(key) }
|
448
|
+
end
|
449
|
+
|
450
|
+
end
|
451
|
+
|
452
|
+
end
|
453
|
+
end
|