This repository has been archived by the owner on Jan 31, 2019. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
/
service.rb
830 lines (733 loc) · 20.9 KB
/
service.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
# Represents a single triggered Service call. Each Service tracks the event
# type, the configuration data, and the payload for the current call.
class Service
class Contributor < Struct.new(:value)
def self.contributor_types
@contributor_types ||= []
end
def self.inherited(contributor_type)
contributor_types << contributor_type
super
end
def self.create(type, keys)
klass = contributor_types.detect { |struct| struct.contributor_type == type }
if klass
Array(keys).map do |key|
klass.new(key)
end
else
raise ArgumentError, "Invalid Contributor type #{type.inspect}"
end
end
def to_contributor_hash(key)
{:type => self.class.contributor_type, key => value}
end
end
class EmailContributor < Contributor
def self.contributor_type
:email
end
def to_hash
to_contributor_hash(:address)
end
end
class GitHubContributor < Contributor
def self.contributor_type
:github
end
def to_hash
to_contributor_hash(:login)
end
end
class TwitterContributor < Contributor
def self.contributor_type
:twitter
end
def to_hash
to_contributor_hash(:login)
end
end
class WebContributor < Contributor
def self.contributor_type
:web
end
def to_hash
to_contributor_hash(:url)
end
end
dir = File.expand_path '../service', __FILE__
Dir["#{dir}/events/helpers/*.rb"].each do |helper|
require helper
end
Dir["#{dir}/events/*.rb"].each do |helper|
require helper
end
ALL_EVENTS = %w[
commit_comment create delete download follow fork fork_apply gist gollum
issue_comment issues member public pull_request push team_add watch
pull_request_review_comment status
].sort
class << self
attr_accessor :root, :env, :host
%w(development test production staging fi).each do |m|
define_method "#{m}?" do
env == m
end
end
# The SHA1 of the commit that was HEAD when the process started. This is
# used in production to determine which version of the app is deployed.
#
# Returns the 40 char commit SHA1 string.
def current_sha
@current_sha ||=
`cd #{root}; git rev-parse HEAD 2>/dev/null || echo unknown`.
chomp.freeze
end
attr_writer :current_sha
# Returns the Service instance if it responds to this event, or nil.
def receive(event, data, payload = nil)
new(event, data, payload).receive
end
def load_services
path = File.expand_path("../services/**/*.rb", __FILE__)
Dir[path].each { |lib| require(lib) }
end
# Tracks the defined services.
#
# Returns an Array of Service Classes.
def services
@services ||= []
end
# Gets the default events that this Service will listen for. This defines
# the default event configuration when Hooks are created on GitHub. By
# default, GitHub Hooks will only send `push` events.
#
# Returns an Array of Strings (or Symbols).
def default_events(*events)
if events.empty?
@default_events ||= [:push]
else
@default_events = events
end
end
# Gets a list of events support by the service. Should be a superset of
# default_events.
def supported_events
return ALL_EVENTS.dup if method_defined? :receive_event
ALL_EVENTS.select { |event| method_defined? "receive_#{event}" }
end
# Gets the current schema for the data attributes that this Service
# expects. This schema is used to generate the GitHub repository admin
# interface. The attribute types loosely to HTML input elements.
#
# Example:
#
# class FooService < Service
# string :token
# end
#
# FooService.schema
# # => [[:string, :token]]
#
# Returns an Array of [Symbol attribute type, Symbol attribute name] tuples.
def schema
@schema ||= []
end
# Public: Adds the given attributes as String attributes in the Service's
# schema.
#
# Example:
#
# class FooService < Service
# string :token
# end
#
# FooService.schema
# # => [[:string, :token]]
#
# *attrs - Array of Symbol attribute names.
#
# Returns nothing.
def string(*attrs)
add_to_schema :string, attrs
end
# Public: Adds the given attributes as Password attributes in the Service's
# schema.
#
# Example:
#
# class FooService < Service
# password :token
# end
#
# FooService.schema
# # => [[:password, :token]]
#
# *attrs - Array of Symbol attribute names.
#
# Returns nothing.
def password(*attrs)
add_to_schema :password, attrs
end
# Public: Adds the given attributes as Boolean attributes in the Service's
# schema.
#
# Example:
#
# class FooService < Service
# boolean :digest
# end
#
# FooService.schema
# # => [[:boolean, :digest]]
#
# *attrs - Array of Symbol attribute names.
#
# Returns nothing.
def boolean(*attrs)
add_to_schema :boolean, attrs
end
# Public: get a list of attributes that are approved for logging. Don't
# add things like tokens or passwords here.
#
# Returns an Array of String attribute names.
def white_listed
@white_listed ||= []
end
def white_list(*attrs)
attrs.each do |attr|
white_listed << attr.to_s
end
end
# Adds the given attributes to the Service's data schema.
#
# type - A Symbol specifying the type: :string, :password, :boolean.
# attrs - Array of Symbol attribute names.
#
# Returns nothing.
def add_to_schema(type, attrs)
attrs.each do |attr|
schema << [type, attr.to_sym]
end
end
# Gets the official title of this Service. This is used in any
# user-facing documentation regarding the Service.
#
# Returns a String.
def title(value = nil)
if value
@title = value
else
@title ||= begin
hook = name.dup
hook.sub! /.*:/, ''
hook
end
end
end
# Sets the official title of this Service.
#
# title - The String title.
#
# Returns nothing.
attr_writer :title
# Gets the name that identifies this Service type. This is a
# short string that is used to uniquely identify the service internally.
#
# Returns a String.
def hook_name(value = nil)
if value
@hook_name = value
else
@hook_name ||= begin
hook = name.dup
hook.downcase!
hook.sub! /.*:/, ''
hook
end
end
end
# Sets the uniquely identifying name for this Service type.
#
# hook_name - The String name.
#
# Returns a String.
attr_writer :hook_name
attr_reader :url, :logo_url
def url(value = nil)
if value
@url = value
else
@url
end
end
def logo_url(value = nil)
if value
@logo_url = value
else
@logo_url
end
end
def supporters
@supporters ||= []
end
def maintainers
@maintainers ||= []
end
def supported_by(values)
values.each do |contributor_type, value|
supporters.push(*Contributor.create(contributor_type, value))
end
end
def maintained_by(values)
values.each do |contributor_type, value|
maintainers.push(*Contributor.create(contributor_type, value))
end
end
# Public: Gets the Hash of secret configuration options. These are set on
# the GitHub servers and never committed to git.
#
# Returns a Hash.
def secrets
@secrets ||= begin
jabber = ENV['SERVICES_JABBER'].to_s.split("::")
twitter = ENV['SERVICES_TWITTER'].to_s.split("::")
{ 'jabber' => {'user' => jabber[0], 'password' => jabber[1] },
'boxcar' => {'apikey' => ENV['SERVICES_BOXCAR'].to_s},
'twitter' => {'key' => twitter[0], 'secret' => twitter[1]},
'bitly' => {'key' => ENV['SERVICES_BITLY'].to_s}
}
end
end
# Public: Gets the Hash of email configuration options. These are set on
# the GitHub servers and never committed to git.
#
# Returns a Hash.
def email_config
@email_config ||= begin
hash = (File.exist?(email_config_file) && YAML.load_file(email_config_file)) || {}
EMAIL_KEYS.each do |key|
env_key = "EMAIL_SMTP_#{key.upcase}"
if value = ENV[env_key]
hash[key] = value
end
end
hash
end
end
EMAIL_KEYS = %w(address port domain authentication user_name password
enable_starttls_auto openssl_verify_mode enable_logging
noreply_address)
# Gets the path to the secret configuration file.
#
# Returns a String path.
def secret_file
@secret_file ||= File.expand_path("../../config/secrets.yml", __FILE__)
end
# Gets the path to the email configuration file.
#
# Returns a String path.
def email_config_file
@email_config_file ||= File.expand_path('../../config/email.yml', __FILE__)
end
def objectify(hash)
struct = OpenStruct.new
hash.each do |key, value|
struct.send("#{key}=", value.is_a?(Hash) ? objectify(value) : value)
end
struct
end
# Sets the path to the secrets configuration file.
#
# secret_file - String path.
#
# Returns nothing.
attr_writer :secret_file
# Sets the default private configuration data for all Services.
#
# secrets - Configuration Hash.
#
# Returns nothing.
attr_writer :secrets
# Sets the path to the email configuration file.
#
# email_config_file - The String path.
#
# Returns nothing.
attr_writer :email_config_file
# Sets the default email configuration data for all Services.
#
# email_config - Email configuration Hash.
#
# Returns nothing.
attr_writer :email_config
# Binds the current Service to the Sinatra App.
#
# Returns nothing.
def inherited(svc)
Service.services << svc
super
end
end
# Determine #root from this file's location
self.root ||= File.expand_path('../..', __FILE__)
self.host ||= `hostname -s`.chomp
# Determine #env from the environment
self.env ||= ENV['RACK_ENV'] || ENV['GEM_STRICT'] ? 'production' : 'development'
# Public: Gets the configuration data for this Service instance.
#
# Returns a Hash.
attr_reader :data
# Public: Gets the unique payload data for this Service instance.
#
# Returns a Hash.
attr_reader :payload
# Public: Gets the identifier for the Service's event.
#
# Returns a Symbol.
attr_reader :event
# Sets the Faraday::Connection for this Service instance.
#
# http - New Faraday::Connection instance.
#
# Returns a Faraday::Connection.
attr_writer :http
# Sets the private configuration data.
#
# secrets - Configuration Hash.
#
# Returns nothing.
attr_writer :secrets
# Sets the email configuration data.
#
# email_config - Email configuration Hash.
#
# Returns nothing.
attr_writer :email_config
# Sets the path to the SSL Certificate Authority file.
#
# ca_file - String path.
#
# Returns nothing.
attr_writer :ca_file
attr_reader :event_method
attr_reader :http_calls
attr_reader :remote_calls
def initialize(event = :push, data = {}, payload = nil)
helper_name = "#{event.to_s.classify}Helpers"
if Service.const_defined?(helper_name)
@helper = Service.const_get(helper_name)
extend @helper
end
@event = event.to_sym
@data = data || {}
@payload = payload || sample_payload
@event_method = ["receive_#{event}", "receive_event"].detect do |method|
respond_to?(method)
end
@http = @secrets = @email_config = nil
@http_calls = []
@remote_calls = []
end
def respond_to_event?
!@event_method.nil?
end
# Public: Shortens the given URL with git.io.
#
# url - String URL to be shortened.
#
# Returns the String URL response from git.io.
def shorten_url(url)
res = http_post("http://git.io", :url => url)
if res.status == 201
res.headers['location']
else
url
end
rescue TimeoutError
url
end
# Public: Makes an HTTP GET call.
#
# url - Optional String URL to request.
# params - Optional Hash of GET parameters to set.
# headers - Optional Hash of HTTP headers to set.
#
# Examples
#
# http_get("http://github.com")
# # => <Faraday::Response>
#
# # GET http://github.com?page=1
# http_get("http://github.com", :page => 1)
# # => <Faraday::Response>
#
# http_get("http://github.com", {:page => 1},
# 'Accept': 'application/json')
# # => <Faraday::Response>
#
# # Yield the Faraday::Response for more control.
# http_get "http://github.com" do |req|
# req.basic_auth("username", "password")
# req.params[:page] = 1
# req.headers['Accept'] = 'application/json'
# end
# # => <Faraday::Response>
#
# Yields a Faraday::Request instance.
# Returns a Faraday::Response instance.
def http_get(url = nil, params = nil, headers = nil)
http.get do |req|
req.url(url) if url
req.params.update(params) if params
req.headers.update(headers) if headers
yield req if block_given?
end
end
# Public: Makes an HTTP POST call.
#
# url - Optional String URL to request.
# body - Optional String Body of the POST request.
# headers - Optional Hash of HTTP headers to set.
#
# Examples
#
# http_post("http://github.com/create", "foobar")
# # => <Faraday::Response>
#
# http_post("http://github.com/create", "foobar",
# 'Accept': 'application/json')
# # => <Faraday::Response>
#
# # Yield the Faraday::Response for more control.
# http_post "http://github.com/create" do |req|
# req.basic_auth("username", "password")
# req.params[:page] = 1 # http://github.com/create?page=1
# req.headers['Content-Type'] = 'application/json'
# req.body = {:foo => :bar}.to_json
# end
# # => <Faraday::Response>
#
# Yields a Faraday::Request instance.
# Returns a Faraday::Response instance.
def http_post(url = nil, body = nil, headers = nil)
block = Proc.new if block_given?
http_method :post, url, body, headers, &block
end
# Public: Makes an HTTP call.
#
# method - Symbol of the HTTP method. Example: :put
# url - Optional String URL to request.
# body - Optional String Body of the POST request.
# headers - Optional Hash of HTTP headers to set.
#
# Examples
#
# http_method(:put, "http://github.com/create", "foobar")
# # => <Faraday::Response>
#
# http_method(:put, "http://github.com/create", "foobar",
# 'Accept': 'application/json')
# # => <Faraday::Response>
#
# # Yield the Faraday::Response for more control.
# http_method :put, "http://github.com/create" do |req|
# req.basic_auth("username", "password")
# req.params[:page] = 1 # http://github.com/create?page=1
# req.headers['Content-Type'] = 'application/json'
# req.body = {:foo => :bar}.to_json
# end
# # => <Faraday::Response>
#
# Yields a Faraday::Request instance.
# Returns a Faraday::Response instance.
def http_method(method, url = nil, body = nil, headers = nil)
block = Proc.new if block_given?
check_ssl do
http.send(method) do |req|
req.url(url) if url
req.headers.update(headers) if headers
req.body = body if body
block.call req if block
end
end
end
# Public: Lazily loads the Faraday::Connection for the current Service
# instance.
#
# options - Optional Hash of Faraday::Connection options.
#
# Returns a Faraday::Connection instance.
def http(options = {})
@http ||= begin
self.class.default_http_options.each do |key, sub_options|
sub_hash = options[key] ||= {}
sub_options.each do |sub_key, sub_value|
sub_hash[sub_key] ||= sub_value
end
end
options[:ssl][:ca_file] ||= ca_file
Faraday.new(options) do |b|
b.use HttpReporter, self
b.request :url_encoded
b.adapter *(options[:adapter] || :net_http)
end
end
end
def self.default_http_options
@@default_http_options ||= {
:request => {:timeout => 10, :open_timeout => 5},
:ssl => {:verify_depth => 5},
:headers => {}
}
end
# Passes HTTP response debug data to the HTTP callbacks.
def receive_http(env)
@http_calls << env
end
# Passes raw debug data to remote call callbacks.
def receive_remote_call(text)
@remote_calls << text
end
def receive(timeout = nil)
return unless respond_to_event?
timeout_sec = (timeout || 20).to_i
Service::Timeout.timeout(timeout_sec, TimeoutError) do
send(event_method)
end
self
rescue Service::ConfigurationError, Errno::EHOSTUNREACH, Errno::ECONNRESET, SocketError, Net::ProtocolError => err
if !err.is_a?(Service::Error)
err = ConfigurationError.new(err)
end
raise err
end
# Public: Checks for an SSL error, and re-raises a Services configuration error.
#
# Returns nothing.
def check_ssl
yield
rescue OpenSSL::SSL::SSLError => e
raise_config_error "Invalid SSL cert"
end
# Public: Builds a log message for this Service request. Respects the white
# listed attributes in the Service schema.
#
# Returns a String.
def log_message(status = 0)
"[%s] %03d %s/%s %s" % [Time.now.utc.to_s(:db), status,
self.class.hook_name, @event, JSON.generate(log_data)]
end
# Public: Builds a sanitized Hash of the Data hash without passwords.
#
# Returns a Hash.
def log_data
@log_data ||= self.class.white_listed.inject({}) do |hash, key|
if value = data[key]
hash.update key => sanitize_log_value(value)
else
hash
end
end
end
# Attempts to sanitize passwords out of URI strings.
#
# value - The String attribute value.
#
# Returns a sanitized String.
def sanitize_log_value(value)
string = value.to_s
string.strip!
if string =~ /^[a-z]+\:\/\//
uri = Addressable::URI.parse(string)
uri.password = "*" * uri.password.size if uri.password
uri.to_s
else
string
end
rescue Addressable::URI::InvalidURIError
string
end
# Public: Gets the Hash of secret configuration options. These are set on
# the GitHub servers and never committed to git.
#
# Returns a Hash.
def secrets
@secrets || Service.secrets
end
# Public: Gets the Hash of email configuration options. These are set on
# the GitHub servers and never committed to git.
#
# Returns a Hash.
def email_config
@email_config || Service.email_config
end
# Public: Raises a configuration error inside a service, and halts further
# processing.
#
# Raises a Service;:ConfigurationError.
def raise_config_error(msg = "Invalid configuration")
raise ConfigurationError, msg
end
def raise_missing_error(msg = "Remote endpoint not found")
raise MissingError, msg
end
# Gets the path to the SSL Certificate Authority certs. These were taken
# from: http://curl.haxx.se/ca/cacert.pem
#
# Returns a String path.
def ca_file
@ca_file ||= File.expand_path('../../config/cacert.pem', __FILE__)
end
# Generates a sample payload for the current Service instance.
#
# Returns a Hash payload.
def sample_payload
@helper ? @helper.sample_payload : {}
end
# Raised when an unexpected error occurs during service hook execution.
class Error < StandardError
attr_reader :original_exception
def initialize(message, original_exception=nil)
original_exception = message if message.kind_of?(Exception)
@original_exception = original_exception
super(message)
end
end
class TimeoutError < Timeout::Error
end
# Raised when a service hook fails due to bad configuration. Services that
# fail with this exception may be automatically disabled.
class ConfigurationError < Error
end
class MissingError < Error
end
class HttpReporter < Faraday::Response::Middleware
def initialize(app, service = nil)
super(app)
@service = service
@time = Time.now
end
def on_complete(env)
ms = ((Time.now - @time) * 1000).round
@service.receive_http(
:request => {
:url => env[:url].to_s,
:headers => env[:request_headers]
}, :response => {
:status => env[:status],
:headers => env[:response_headers],
:body => env[:body].to_s,
:duration => "%.02fs" % [Time.now - @time]
}
)
end
end
end
require 'timeout'
begin
require 'system_timer'
Service::Timeout = SystemTimer
rescue LoadError
Service::Timeout = Timeout
end