last-resort 0.0.8 → 0.0.10
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +2 -1
- data/.rspec +3 -0
- data/README.md +23 -20
- data/Rakefile +5 -0
- data/bin/last-resort +39 -2
- data/last-resort.gemspec +9 -5
- data/lib/last-resort.rb +10 -17
- data/lib/last-resort/application.rb +120 -0
- data/lib/last-resort/commands.rb +159 -0
- data/lib/last-resort/config.rb +131 -0
- data/lib/last-resort/contextio.rb +1 -0
- data/lib/last-resort/scheduler.rb +29 -18
- data/lib/last-resort/twilio.rb +49 -47
- data/lib/last-resort/version.rb +2 -4
- data/lib/last-resort/webhooks.rb +6 -5
- data/spec/controller_spec.rb +106 -0
- data/spec/scheduler_spec.rb +77 -0
- data/spec/spec_helper.rb +11 -2
- data/support/Gemfile +3 -0
- data/support/config.ru +11 -0
- data/support/dot_env +7 -0
- data/support/dot_gitignore +1 -0
- metadata +88 -30
- data/config/config.rb +0 -35
- data/lib/last-resort/config-lang.rb +0 -74
- data/lib/last-resort/controller.rb +0 -78
data/.gitignore
CHANGED
data/.rspec
ADDED
data/README.md
CHANGED
@@ -1,41 +1,41 @@
|
|
1
|
-
Last Resort is a Ruby gem for monitoring
|
2
|
-
|
3
|
-
|
4
|
-
|
1
|
+
Last Resort is a Ruby gem for monitoring email sent by automated services (monit, logging packages,
|
2
|
+
external ping services, etc.) and calling your phone to tell you about the important ones. Using free and trial tiers
|
3
|
+
available from [context.io](http://context.io), [twilio](http://twilio.com) and [heroku](http://heroku.com),
|
4
|
+
Last Resort can be deployed in a reliable environment and perform up to 1500 emergency calls **for free**.
|
5
5
|
|
6
|
-
###
|
6
|
+
### Requirements
|
7
|
+
* Ruby 1.9.x
|
8
|
+
* Accounts with [context.io](http://context.io), [twilio](http://twilio.com) and optionally [heroku](http://heroku.com),
|
9
|
+
but don't worry -- our commandline utility will help you through the process.
|
10
|
+
* Git (if you're deploying to Heroku)
|
7
11
|
|
12
|
+
### Installation
|
8
13
|
```sh
|
9
14
|
$ gem install last-resort
|
10
15
|
```
|
11
16
|
|
12
17
|
### Getting started
|
13
|
-
|
14
18
|
```sh
|
15
19
|
$ last-resort new my-awesome-project
|
16
20
|
```
|
17
|
-
This will create a
|
21
|
+
This will create a new monitoring project with a sample `my-awesome-project/schedule.rb` file, and all that's
|
22
|
+
needed to get up and running on a Rack server (or Heroku) quickly.
|
18
23
|
|
19
|
-
### Example
|
24
|
+
### Example schedule.rb file
|
20
25
|
|
21
26
|
```ruby
|
22
|
-
configure :
|
23
|
-
:twilio_sid => "",
|
24
|
-
:twilio_auth_token => "",
|
25
|
-
:contextio_account => "",
|
26
|
-
:contextio_key => "",
|
27
|
-
:contextio_secret => ""
|
27
|
+
configure :from_env
|
28
28
|
|
29
29
|
# DEFINE YOUR CONTACTS
|
30
30
|
|
31
|
-
contact :ian, ""
|
32
|
-
contact :scott, ""
|
33
|
-
contact :victor, ""
|
31
|
+
contact :ian, "416-123-1234"
|
32
|
+
contact :scott, "416-321-4321"
|
33
|
+
contact :victor, "416-123-4321"
|
34
34
|
|
35
35
|
# DEFINE WHAT EMAILS YOU WANT TO WATCH FOR
|
36
36
|
|
37
|
-
match :subject => /
|
38
|
-
match :subject => /
|
37
|
+
match :subject => /Server down/ # external ping service
|
38
|
+
match :subject => /Resource limit matched/ # monit
|
39
39
|
|
40
40
|
# DEFINE WHO TO CALL AND WHEN
|
41
41
|
|
@@ -52,7 +52,10 @@ between :all_hours, :on => :weekends do
|
|
52
52
|
end
|
53
53
|
```
|
54
54
|
|
55
|
+
### Roadmap
|
56
|
+
If there is sufficient demand, we plan on adding more complicated schedules.
|
57
|
+
|
55
58
|
### Credit
|
56
59
|
Victor Mota ([@vimota](http://www.twitter.com/vimota))
|
57
60
|
Scott Hyndman ([@scotthyndman](http://www.twitter.com/scotthyndman))
|
58
|
-
Ian Ha ([@ianpha](http://www.twitter.com/ianpha))
|
61
|
+
Ian Ha ([@ianpha](http://www.twitter.com/ianpha))
|
data/Rakefile
CHANGED
data/bin/last-resort
CHANGED
@@ -1,5 +1,42 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
2
|
|
3
|
-
|
3
|
+
$: << File.expand_path(File.dirname(File.realpath(__FILE__)) + '/../lib/last-resort')
|
4
4
|
|
5
|
-
|
5
|
+
require 'version'
|
6
|
+
require 'commands'
|
7
|
+
require 'rubygems'
|
8
|
+
require 'colored'
|
9
|
+
require 'gli';
|
10
|
+
|
11
|
+
include GLI
|
12
|
+
|
13
|
+
|
14
|
+
program_desc 'Last Resort is a Ruby gem for monitoring critical emails sent by automated services (monit, logging packages, external ping services, etc.) and calling your phone to tell you about it.'
|
15
|
+
version LastResort::VERSION
|
16
|
+
|
17
|
+
desc 'Creates a new Last Resort project'
|
18
|
+
arg_name 'project name'
|
19
|
+
command :new do |c|
|
20
|
+
c.desc 'skip Q&A session'
|
21
|
+
c.switch [:s, :skip]
|
22
|
+
|
23
|
+
c.action do |global_options, options, args|
|
24
|
+
raise "Please specify a project name" if args.empty?
|
25
|
+
LastResort::Commands::q_and_a args[0]
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
desc 'Run the last-resort server'
|
30
|
+
command :run do |c|
|
31
|
+
c.action do |global_options, options, args|
|
32
|
+
LastResort::Commands.run_heroku_or_rackup
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
on_error do |exception|
|
37
|
+
# Error logic here
|
38
|
+
# return false to skip default error handling
|
39
|
+
true
|
40
|
+
end
|
41
|
+
|
42
|
+
exit GLI.run(ARGV)
|
data/last-resort.gemspec
CHANGED
@@ -4,23 +4,27 @@ require File.expand_path('../lib/last-resort/version', __FILE__)
|
|
4
4
|
Gem::Specification.new do |gem|
|
5
5
|
gem.authors = ["Ian Ha", "Victor Mota", "Scott Hyndman"]
|
6
6
|
gem.email = ["ianha0@gmail.com", "vimota@gmail.com", "scotty.hyndman@gmail.com"]
|
7
|
-
gem.
|
8
|
-
gem.
|
9
|
-
gem.homepage = ""
|
7
|
+
gem.summary = "Calls your phone when critical emails arrive"
|
8
|
+
gem.description = "Last Resort is a Ruby gem for monitoring email sent by automated services (monit, logging packages, external ping services, etc.) and calling your phone to tell you about the important ones. Using free and trial tiers available from context.io, twilio and heroku, Last Resort can be deployed in a reliable environment and perform up to 1500 emergency calls for free."
|
9
|
+
gem.homepage = "https://github.com/ianha/LastResort"
|
10
10
|
|
11
11
|
gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
12
12
|
gem.files = `git ls-files`.split("\n")
|
13
13
|
gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
14
14
|
gem.name = "last-resort"
|
15
15
|
gem.require_paths = ["lib"]
|
16
|
-
gem.version =
|
16
|
+
gem.version = LastResort::VERSION
|
17
17
|
|
18
18
|
gem.add_dependency "sinatra", "~> 1.3"
|
19
19
|
gem.add_dependency "twilio-ruby", "~> 3.6"
|
20
|
-
gem.add_dependency "thor", "~> 0.14"
|
21
20
|
gem.add_dependency "oauth", "~> 0.4"
|
22
21
|
gem.add_dependency "gli", "~> 1.6"
|
22
|
+
gem.add_dependency "colored"
|
23
|
+
gem.add_dependency "launchy", "~> 2.1"
|
23
24
|
gem.add_development_dependency 'rspec', '~> 2.9.0'
|
24
25
|
gem.add_development_dependency 'webmock', '~> 1.8.5'
|
25
26
|
gem.add_development_dependency "awesome_print"
|
27
|
+
gem.add_development_dependency "rack"
|
28
|
+
gem.add_development_dependency "rack-test"
|
29
|
+
gem.add_development_dependency "nokogiri"
|
26
30
|
end
|
data/lib/last-resort.rb
CHANGED
@@ -1,23 +1,16 @@
|
|
1
1
|
require "rubygems"
|
2
2
|
require "bundler/setup"
|
3
|
-
require "sinatra"
|
3
|
+
require "sinatra/base"
|
4
4
|
|
5
5
|
FILE_DIR = File.expand_path(File.dirname(__FILE__))
|
6
6
|
|
7
|
-
|
7
|
+
require 'last-resort/version'
|
8
|
+
require 'last-resort/config'
|
9
|
+
require 'last-resort/contextio'
|
10
|
+
require 'last-resort/scheduler'
|
11
|
+
require 'last-resort/twilio'
|
12
|
+
require 'last-resort/webhooks'
|
13
|
+
require 'last-resort/application'
|
8
14
|
|
9
|
-
|
10
|
-
|
11
|
-
require file_path
|
12
|
-
end
|
13
|
-
|
14
|
-
|
15
|
-
# SINATRA BASICS
|
16
|
-
|
17
|
-
set :port, 80
|
18
|
-
|
19
|
-
get "/" do
|
20
|
-
"Last Resort server running"
|
21
|
-
end
|
22
|
-
|
23
|
-
# LastResort::WebHookCreator.create_hooks
|
15
|
+
# Run the application if called directly
|
16
|
+
LastResort::Application.run! if $0 == __FILE__
|
@@ -0,0 +1,120 @@
|
|
1
|
+
require 'sinatra/base'
|
2
|
+
|
3
|
+
module LastResort
|
4
|
+
# The main Sinatra application. Defines the API interface and webhooks called by Twilio and ContextIO
|
5
|
+
# for call handling logic and email matching, respectively.
|
6
|
+
class Application < Sinatra::Base
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
super
|
10
|
+
end
|
11
|
+
|
12
|
+
# The exception session keeps state between twilio and context-io webhooks. Currently, the
|
13
|
+
# system can only handle one call session at a time, although we plan to change that in future
|
14
|
+
# versions.
|
15
|
+
def self.exception_session
|
16
|
+
@exception_session
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.exception_session=(session)
|
20
|
+
@exception_session = session
|
21
|
+
end
|
22
|
+
|
23
|
+
# ====== SERVER CHECK ROUTE
|
24
|
+
|
25
|
+
get "/" do
|
26
|
+
"Last Resort server running"
|
27
|
+
end
|
28
|
+
|
29
|
+
# ====== CONTEXT-IO TWILIO ENDPOINTS
|
30
|
+
|
31
|
+
# This is the webhook callback from context-io, called context-io has detected a matching pattern
|
32
|
+
post '/matched_email' do
|
33
|
+
scheduler = new_scheduler
|
34
|
+
matching_schedule = scheduler.get_matching_schedule
|
35
|
+
|
36
|
+
return if matching_schedule.nil?
|
37
|
+
|
38
|
+
matched_email = JSON.parse(get_request_body)
|
39
|
+
contacts = matching_schedule[:contacts].map { |name| LastResort::Contact.new(name.to_s, @config.contacts[name][:phone]) }
|
40
|
+
|
41
|
+
Application.exception_session = new_exception_session(contacts, matched_email["message_data"]["subject"])
|
42
|
+
Application.exception_session.notify
|
43
|
+
end
|
44
|
+
|
45
|
+
|
46
|
+
# ====== SINATRA TWILIO ENDPOINTS
|
47
|
+
|
48
|
+
# Service check method
|
49
|
+
get '/twilio' do
|
50
|
+
"Twilio callbacks up and running!"
|
51
|
+
end
|
52
|
+
|
53
|
+
# Performs a test call based on the user's configuration
|
54
|
+
get '/twilio/test' do
|
55
|
+
Application.exception_session.notify
|
56
|
+
end
|
57
|
+
|
58
|
+
# Method invoked to determine how the machine should interact with the user.
|
59
|
+
post '/twilio/call' do
|
60
|
+
content_type 'text/xml'
|
61
|
+
puts "call with #{params.inspect}"
|
62
|
+
|
63
|
+
if params[:CallStatus] == 'no-answer'
|
64
|
+
return Twilio::TwiML::Response.new { |r| r.Hangup }.text
|
65
|
+
end
|
66
|
+
|
67
|
+
response = Twilio::TwiML::Response.new do |r|
|
68
|
+
r.Say "Hello #{Application.exception_session.callee_name}. The following error has occured: #{Application.exception_session.description}", :voice => 'man'
|
69
|
+
r.Gather :numDigits => 1, :action => "http://#{HOST}/twilio/gather_digits" do |d|
|
70
|
+
d.Say "Please enter 1 to handle this bug that you probably didn't even create or 0 or hangup to go back to spending quality time with your family."
|
71
|
+
end
|
72
|
+
end
|
73
|
+
response.text
|
74
|
+
end
|
75
|
+
|
76
|
+
# Called when a user's call ends
|
77
|
+
post '/twilio/status_callback' do
|
78
|
+
puts "status_callback with #{params.inspect}"
|
79
|
+
|
80
|
+
Application.exception_session.call_next
|
81
|
+
end
|
82
|
+
|
83
|
+
# Called to respond to user phone input
|
84
|
+
post '/twilio/gather_digits' do
|
85
|
+
puts "gather_digits with #{params.inspect}"
|
86
|
+
|
87
|
+
content_type 'text/xml'
|
88
|
+
digit = params[:Digits].to_i
|
89
|
+
|
90
|
+
case digit
|
91
|
+
when 1 # User handles call, so don't call anyone else
|
92
|
+
Application.exception_session.end
|
93
|
+
|
94
|
+
response = Twilio::TwiML::Response.new do |r|
|
95
|
+
r.Say "Thank you for handling this exception. Goodbye.", :voice => 'man'
|
96
|
+
r.Hangup
|
97
|
+
end
|
98
|
+
return response.text
|
99
|
+
else # Hangup this call and go to the next person
|
100
|
+
return Twilio::TwiML::Response.new {|r| r.Hangup}.text
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
private
|
105
|
+
|
106
|
+
# ===== The following methods are wrappers to make our code more testable.
|
107
|
+
|
108
|
+
def new_scheduler
|
109
|
+
LastResort::Scheduler.new
|
110
|
+
end
|
111
|
+
|
112
|
+
def get_request_body
|
113
|
+
request_body.read
|
114
|
+
end
|
115
|
+
|
116
|
+
def new_exception_session *args
|
117
|
+
LastResort::ExceptionSession.new(*args)
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
@@ -0,0 +1,159 @@
|
|
1
|
+
require 'launchy'
|
2
|
+
require 'config'
|
3
|
+
|
4
|
+
module LastResort
|
5
|
+
class Commands
|
6
|
+
def self.q_and_a project_name
|
7
|
+
@last_resort_path = File.expand_path(File.dirname(File.realpath(__FILE__)) + '/../../')
|
8
|
+
|
9
|
+
puts "#{"Last Resort".green} is a Ruby gem for monitoring critical emails sent by automated services (monit, logging packages, external \nping services, etc.) and calling your phone to tell you about it.\n\n"
|
10
|
+
|
11
|
+
get_twillio_info
|
12
|
+
get_contextio_info
|
13
|
+
|
14
|
+
create_project project_name
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.ask_yes_no message
|
18
|
+
begin
|
19
|
+
answer = ask message
|
20
|
+
end while !(answer.casecmp('y') == 0 or answer.casecmp('n') == 0)
|
21
|
+
answer.casecmp('y') == 0
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.ask message
|
25
|
+
begin
|
26
|
+
print message
|
27
|
+
response = $stdin.gets.strip
|
28
|
+
if response.empty?
|
29
|
+
puts "Sorry you must enter something."
|
30
|
+
end
|
31
|
+
end while response.empty?
|
32
|
+
|
33
|
+
response
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.get_twillio_info
|
37
|
+
answer = ask_yes_no "Do you already have a #{'Twillio'.red} account? [Y/n]"
|
38
|
+
Launchy.open("http://www.twilio.com/try-twilio") unless answer
|
39
|
+
@twillio_sid = ask "#{'Twillio'.red} SID: "
|
40
|
+
@twillio_auth_token = ask "#{'Twillio'.red} Auth Token: "
|
41
|
+
puts ''
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.get_contextio_info
|
45
|
+
answer = ask_yes_no "Do you already have a #{'ContextIO'.yellow} account? [Y/n]"
|
46
|
+
Launchy.open("http://www.context.io") unless answer
|
47
|
+
puts ''
|
48
|
+
puts "Please find the ContextIO key and secret tokens, as well as the ContextIO Account ID of the email account you wish to monitor."
|
49
|
+
@contextio_key = ask "#{'ContextIO'.yellow} Key: "
|
50
|
+
@contextio_secret = ask "#{'ContextIO'.yellow} Secret: "
|
51
|
+
@contextio_account = ask "#{'ContextIO'.yellow} Email Account ID: "
|
52
|
+
end
|
53
|
+
|
54
|
+
def self.create_project project_name
|
55
|
+
@project_path = project_name.to_s
|
56
|
+
|
57
|
+
create_project_folder
|
58
|
+
copy_files
|
59
|
+
copy_schedule_and_add_utc
|
60
|
+
|
61
|
+
old_dir = Dir.pwd
|
62
|
+
Dir.chdir("#{@project_path}")
|
63
|
+
|
64
|
+
`bundle install`
|
65
|
+
set_up_heroku unless ask_if_heroku
|
66
|
+
set_up_git unless @no_heroku
|
67
|
+
create_heroku_project unless @no_heroku
|
68
|
+
create_env
|
69
|
+
`git push heroku master` unless @no_heroku
|
70
|
+
|
71
|
+
Dir.chdir("#{old_dir}")
|
72
|
+
end
|
73
|
+
|
74
|
+
def self.create_project_folder
|
75
|
+
puts "\nCreating project folder"
|
76
|
+
FileUtils.mkdir @project_path
|
77
|
+
end
|
78
|
+
|
79
|
+
def self.copy_files
|
80
|
+
puts '* creating .gitignore'.green
|
81
|
+
FileUtils.cp "#{@last_resort_path}/support/dot_gitignore", "#{@project_path}/.gitignore"
|
82
|
+
|
83
|
+
puts '* creating config.ru'.green
|
84
|
+
FileUtils.cp "#{@last_resort_path}/support/config.ru", "#{@project_path}"
|
85
|
+
|
86
|
+
puts '* creating Gemfile'.green
|
87
|
+
FileUtils.cp "#{@last_resort_path}/support/Gemfile", "#{@project_path}"
|
88
|
+
end
|
89
|
+
|
90
|
+
def self.copy_schedule_and_add_utc
|
91
|
+
puts '* creating schedule.rb'.green
|
92
|
+
schedule_file = open(@project_path + '/schedule.rb', 'w') do |f|
|
93
|
+
f.puts open(@last_resort_path + '/support/schedule.rb').read % {
|
94
|
+
:utc_offset => Time.now.utc_offset/60/60
|
95
|
+
}
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def self.ask_if_heroku
|
100
|
+
puts ''
|
101
|
+
@no_heroku = !(ask_yes_no "Do you want it to be hosted on #{'Heroku'.magenta} (recommended)? [Y/n] ")
|
102
|
+
end
|
103
|
+
|
104
|
+
def self.set_up_heroku
|
105
|
+
puts 'Installing heroku'.green
|
106
|
+
`gem install heroku --no-rdoc --no-ri`
|
107
|
+
`heroku plugins:install git://github.com/ddollar/heroku-config.git`
|
108
|
+
|
109
|
+
answer = ask "Do you already have a #{'Heroku'.magenta} account? [Y/n]"
|
110
|
+
Launchy.open("http://heroku.com") if answer.casecmp('n') == 0
|
111
|
+
puts 'Please login to Heroku'
|
112
|
+
system 'heroku login'
|
113
|
+
end
|
114
|
+
|
115
|
+
def self.set_up_git
|
116
|
+
puts 'Initiating git repo'.green
|
117
|
+
`git init`
|
118
|
+
`git add .`
|
119
|
+
`git commit -m "Initializing git"`
|
120
|
+
end
|
121
|
+
|
122
|
+
def self.create_heroku_project
|
123
|
+
puts 'Creating Heroku project'.green
|
124
|
+
heroku_output = `heroku create --stack cedar`
|
125
|
+
@host = heroku_output.match(/http(.*).com\//)[0]
|
126
|
+
end
|
127
|
+
|
128
|
+
def self.create_env
|
129
|
+
open('.env', 'w') do |f|
|
130
|
+
f.puts open(@last_resort_path + '/support/dot_env').read % {
|
131
|
+
:host => (@no_heroku) ? '' : @host,
|
132
|
+
:twilio_sid => @twillio_sid,
|
133
|
+
:twilio_auth_token => @twillio_auth_token,
|
134
|
+
:contextio_account => @contextio_account,
|
135
|
+
:contextio_key => @contextio_key,
|
136
|
+
:contextio_secret => @contextio_secret,
|
137
|
+
:no_heroku => @no_heroku
|
138
|
+
}
|
139
|
+
end
|
140
|
+
|
141
|
+
if @no_heroku
|
142
|
+
puts 'Settings for Last Resort are stored in a .env file that can be found in the project directory.'.yellow
|
143
|
+
puts 'In order to run your project, you must modify the .env to include the domain name of your server.'.yellow
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
# ====== RUN
|
148
|
+
|
149
|
+
def self.run_heroku_or_rackup
|
150
|
+
begin
|
151
|
+
LastResort::Config::populate_env_if_required
|
152
|
+
rescue
|
153
|
+
puts 'Make sure to run "last-resort run" from a last-resort project'.yellow
|
154
|
+
return
|
155
|
+
end
|
156
|
+
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|