kensa 0.4.2 → 1.0.0.beta1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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&timestamp=#{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)}&timestamp=#{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