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.
@@ -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