pagerduty_tools 0.3.0
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/.gitignore +41 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +160 -0
- data/README.md +165 -0
- data/Rakefile +2 -0
- data/bin/oncall-email.rb +143 -0
- data/bin/pagerduty_history.rb +47 -0
- data/bin/pagerduty_oncall.rb +78 -0
- data/bin/pagerduty_summary.rb +277 -0
- data/images/campfire-example.png +0 -0
- data/lib/cacert.pem +7815 -0
- data/lib/pagerduty_tools.rb +4 -0
- data/lib/pagerduty_tools/campfire.rb +86 -0
- data/lib/pagerduty_tools/pagerduty.rb +248 -0
- data/lib/pagerduty_tools/report.rb +127 -0
- data/lib/pagerduty_tools/version.rb +3 -0
- data/pagerduty_tools.gemspec +32 -0
- metadata +144 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Copyright 2011 Marc Hedlund <marc@precipice.org>
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
# campfire.rb -- Totally stupid Campfire API client
|
|
16
|
+
#
|
|
17
|
+
# This is the simplest possible Campfire client, intended just
|
|
18
|
+
# to set the topic in a Campfire room, and nothing else.
|
|
19
|
+
#
|
|
20
|
+
# Adapted from https://gist.github.com/7cefe083682cdd3e4e10
|
|
21
|
+
#
|
|
22
|
+
# To make this work, add a configuration file at ~/.pagerduty-campfire.yaml
|
|
23
|
+
# containing the following:
|
|
24
|
+
#
|
|
25
|
+
# site: https://example.campfirenow.com
|
|
26
|
+
# room: 99999
|
|
27
|
+
# token: abababababababababababababababababababab
|
|
28
|
+
#
|
|
29
|
+
# (with the values changed to match your configuration).
|
|
30
|
+
|
|
31
|
+
require 'net/http'
|
|
32
|
+
require 'nokogiri'
|
|
33
|
+
require 'uri'
|
|
34
|
+
require 'yaml'
|
|
35
|
+
|
|
36
|
+
CONFIG_FILE = "~/.pagerduty-campfire.yaml"
|
|
37
|
+
CA_FILE = "#{File.dirname(__FILE__)}/cacert.pem"
|
|
38
|
+
|
|
39
|
+
module Campfire
|
|
40
|
+
class Bot
|
|
41
|
+
def initialize
|
|
42
|
+
# TODO: make sure that the file is there and that all the keys are, too.
|
|
43
|
+
config = YAML.load_file(File.expand_path(CONFIG_FILE))
|
|
44
|
+
@uri = URI.parse config["site"]
|
|
45
|
+
@room = config["room"]
|
|
46
|
+
@token = config["token"]
|
|
47
|
+
@pass = 'x'
|
|
48
|
+
|
|
49
|
+
@http = Net::HTTP.new(@uri.host, @uri.port)
|
|
50
|
+
@http.use_ssl = true
|
|
51
|
+
@http.ca_file = File.expand_path(CA_FILE)
|
|
52
|
+
@http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def topic(topic)
|
|
56
|
+
request = Net::HTTP::Put.new "/room/#{@room}.xml"
|
|
57
|
+
message = Nokogiri::XML::Builder.new do |xml|
|
|
58
|
+
xml.room {
|
|
59
|
+
xml.topic topic
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
return do_request(request, message.to_xml)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def paste(body)
|
|
66
|
+
request = Net::HTTP::Post.new("/room/#{@room}/speak.xml")
|
|
67
|
+
message = Nokogiri::XML::Builder.new do |xml|
|
|
68
|
+
xml.message {
|
|
69
|
+
xml.type_ "PasteMessage"
|
|
70
|
+
xml.body body
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
return do_request(request, message.to_xml)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def do_request(request, message)
|
|
79
|
+
@http.start do |connection|
|
|
80
|
+
request['Content-Type'] = 'application/xml'
|
|
81
|
+
request.basic_auth @token, @pass
|
|
82
|
+
return connection.request(request, message)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# Copyright 2011 Marc Hedlund <marc@precipice.org>
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
# pagerduty.rb -- tools for working with data from the PagerDuty site.
|
|
16
|
+
#
|
|
17
|
+
# PagerDuty does provide an API, but it is fairly limited. This code is
|
|
18
|
+
# intended to make it look like the API was more extensive and could
|
|
19
|
+
# provide some better reporting options.
|
|
20
|
+
|
|
21
|
+
require 'nokogiri'
|
|
22
|
+
require 'mechanize'
|
|
23
|
+
require 'highline/import'
|
|
24
|
+
|
|
25
|
+
require "#{File.dirname(__FILE__)}/report"
|
|
26
|
+
|
|
27
|
+
COOKIE_FILE = "~/.pagerduty-cookies"
|
|
28
|
+
EMAIL_PROMPT = "PagerDuty account email address: "
|
|
29
|
+
PASSWORD_PROMPT = "PagerDuty password: "
|
|
30
|
+
ACCOUNT_PROMPT = "Select your PagerDuty domain: "
|
|
31
|
+
|
|
32
|
+
module PagerDuty
|
|
33
|
+
class Agent
|
|
34
|
+
attr_accessor :domain
|
|
35
|
+
|
|
36
|
+
def initialize
|
|
37
|
+
# Works around a bug in highline, producing "input stream exhausted" errors. See:
|
|
38
|
+
# http://groups.google.com/group/comp.lang.ruby/browse_thread/thread/939d9f86a18e6f9e/ec1c3f1921cd66ea
|
|
39
|
+
HighLine.track_eof = false
|
|
40
|
+
|
|
41
|
+
@cookie_file = File.expand_path(COOKIE_FILE)
|
|
42
|
+
@agent = Mechanize.new
|
|
43
|
+
|
|
44
|
+
load_cookies
|
|
45
|
+
find_domain
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def fetch(path)
|
|
49
|
+
uri = URI.parse "https://#{@domain}#{path}"
|
|
50
|
+
page = @agent.get uri
|
|
51
|
+
|
|
52
|
+
# If we asked for a page and didn't get it, we probably have to log in.
|
|
53
|
+
# TODO: check for non-login pages, like server error pages.
|
|
54
|
+
while page.uri.path != uri.path
|
|
55
|
+
page = login page
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if @cookie_file
|
|
59
|
+
@agent.cookie_jar.save_as(@cookie_file)
|
|
60
|
+
File.chmod(0600, @cookie_file)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
return page
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def load_cookies
|
|
69
|
+
if File.exist?(@cookie_file)
|
|
70
|
+
@agent.cookie_jar.load(@cookie_file)
|
|
71
|
+
|
|
72
|
+
# Try to find the user's PagerDuty domain from their auth_token cookie.
|
|
73
|
+
token = @agent.cookie_jar.to_a.select { |cookie| cookie.name == "auth_token" }.first
|
|
74
|
+
|
|
75
|
+
if token
|
|
76
|
+
@domain = token.domain
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def find_domain
|
|
82
|
+
return if @domain
|
|
83
|
+
|
|
84
|
+
@email = ask(EMAIL_PROMPT)
|
|
85
|
+
|
|
86
|
+
accounts_search_page = @agent.get URI.parse "http://app.pagerduty.com/accounts/search"
|
|
87
|
+
accounts_search_form = accounts_search_page.form_with(:action => "/accounts/search_results")
|
|
88
|
+
accounts_search_form.email = @email
|
|
89
|
+
|
|
90
|
+
search_results_page = accounts_search_form.submit
|
|
91
|
+
search_results = Nokogiri::HTML(search_results_page.body)
|
|
92
|
+
account_list = search_results.css("ul.accounts_list")
|
|
93
|
+
domains = account_list.css("a").map { |account| URI.parse(account["href"]).host }
|
|
94
|
+
|
|
95
|
+
if domains.count == 0
|
|
96
|
+
puts "No PagerDuty accounts found for that address."
|
|
97
|
+
elsif domains.count == 1
|
|
98
|
+
@domain = domains.first
|
|
99
|
+
else
|
|
100
|
+
say(ACCOUNT_PROMPT)
|
|
101
|
+
@domain = choose(*domains)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def login(page)
|
|
106
|
+
login_form = page.form_with(:action => "/session")
|
|
107
|
+
@email ||= ask(EMAIL_PROMPT)
|
|
108
|
+
|
|
109
|
+
login_form.email = @email
|
|
110
|
+
login_form.password = ask(PASSWORD_PROMPT) {|q| q.echo = "*" }
|
|
111
|
+
|
|
112
|
+
return login_form.submit
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
class Escalation
|
|
117
|
+
def initialize(levels=nil, policy = nil)
|
|
118
|
+
@levels = levels
|
|
119
|
+
@policy = policy
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def parse(dashboard_body)
|
|
123
|
+
# Scrape out the on-call list from the Dashboard HTML.
|
|
124
|
+
policies = Nokogiri::HTML(dashboard_body).css("div.whois_oncall")
|
|
125
|
+
|
|
126
|
+
oncall = if @policy
|
|
127
|
+
policies.detect do |policy|
|
|
128
|
+
policy.css('h4 a').text == @policy
|
|
129
|
+
end
|
|
130
|
+
else
|
|
131
|
+
policies.first
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
@results = []
|
|
135
|
+
|
|
136
|
+
oncall.css("div").each do |div|
|
|
137
|
+
level_text = div.css("span > strong").text
|
|
138
|
+
level_text =~ /Level (\d+)\:/
|
|
139
|
+
level = $1
|
|
140
|
+
|
|
141
|
+
# PagerDuty sometimes adds a comment saying what the rotation is called
|
|
142
|
+
# for this level. If it's there, use it, or fall back to a generic label.
|
|
143
|
+
label_text = div.xpath("span/comment()").text
|
|
144
|
+
label = label_text[/\(<[^>]+>(.+) on-call<\/a>\)/, 1] || "Level #{level}"
|
|
145
|
+
|
|
146
|
+
person = div.css("span > a")
|
|
147
|
+
|
|
148
|
+
start_time, end_time = div.css("span.time").text.split(" - ").map {|text| text.strip }
|
|
149
|
+
|
|
150
|
+
if @levels == nil or @levels.length == 0 or @levels.include?(level)
|
|
151
|
+
@results << {
|
|
152
|
+
'level' => level,
|
|
153
|
+
'label' => label,
|
|
154
|
+
'person' => person.text,
|
|
155
|
+
'person_path' => person.first['href'],
|
|
156
|
+
'start_time' => start_time,
|
|
157
|
+
'end_time' => end_time
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
return @results
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def label_for_level(level)
|
|
166
|
+
@results.find {|result| result['level'] == level }['label']
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def label_for_person(person)
|
|
170
|
+
@results.find {|result| result['person'] == person }['label']
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def level_for_person(person)
|
|
174
|
+
@results.find {|result| result['person'] == person }['level']
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
class Alert < Report::Item
|
|
179
|
+
attr_accessor :type, :user
|
|
180
|
+
|
|
181
|
+
def initialize(time, type, user)
|
|
182
|
+
# This charming little chunk works around an apparent bug in Chronic:
|
|
183
|
+
# if the parsed month is the same as the current month, :context =>
|
|
184
|
+
# :past will fail to set the month correctly. (Looks like
|
|
185
|
+
# 'if @now.month > target_month' on line 28 of
|
|
186
|
+
# chronic-0.3.0/lib/chronic/repeaters/repeater_month_name.rb
|
|
187
|
+
# should be 'if @now.month >= target_month'.) Anyway, there, I
|
|
188
|
+
# fixed it.
|
|
189
|
+
if time.start_with?(Time.now.strftime("%b"))
|
|
190
|
+
super(Chronic.parse(time))
|
|
191
|
+
else
|
|
192
|
+
super(Chronic.parse(time, :context => :past))
|
|
193
|
+
end
|
|
194
|
+
@type = type
|
|
195
|
+
@user = user
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def phone_or_sms?
|
|
199
|
+
type == "Phone" or type == "SMS"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def email?
|
|
203
|
+
type == "Email"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
class Incident < Report::Item
|
|
208
|
+
attr_accessor :status, :resolver, :service, :trigger, :event
|
|
209
|
+
|
|
210
|
+
def initialize(incident)
|
|
211
|
+
super(Time.xmlschema(incident['created_on']))
|
|
212
|
+
@status = incident['status']
|
|
213
|
+
@service = incident['service']['name']
|
|
214
|
+
@trigger = incident['trigger_details']
|
|
215
|
+
@event = incident['trigger_details']['event']
|
|
216
|
+
|
|
217
|
+
if status == 'resolved'
|
|
218
|
+
if incident['resolved_by'].nil? # nil resolvers are automatic, e.g. Nagios
|
|
219
|
+
@resolver = "[Automatic]"
|
|
220
|
+
else
|
|
221
|
+
@resolver = incident['resolved_by']['name']
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def resolved?
|
|
227
|
+
status == 'resolved'
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def trigger_name
|
|
231
|
+
if trigger['type'] == 'nagios_trigger'
|
|
232
|
+
return "#{service}: #{event['host']} - #{event['service']}"
|
|
233
|
+
else
|
|
234
|
+
return "#{service}: #{event['description']}"
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
class Person
|
|
240
|
+
attr_accessor :email
|
|
241
|
+
|
|
242
|
+
def parse page_body
|
|
243
|
+
user = Nokogiri::HTML(page_body).css("div#user_profile").first
|
|
244
|
+
div = user.css("div").first
|
|
245
|
+
@email = div.css("td > a").text
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Copyright 2011 Marc Hedlund <marc@precipice.org>
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
# report.rb - basic tool for building up reports.
|
|
16
|
+
|
|
17
|
+
require 'chronic'
|
|
18
|
+
|
|
19
|
+
module Report
|
|
20
|
+
class Item
|
|
21
|
+
attr_accessor :time
|
|
22
|
+
|
|
23
|
+
def initialize(time)
|
|
24
|
+
@time = time
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def between?(start_time, end_time)
|
|
28
|
+
time >= start_time and time < end_time
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def off_hours?
|
|
32
|
+
# Outside normal work hours - 6p to 8a (localtime)
|
|
33
|
+
time.hour >= 18 or graveyard?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def graveyard?
|
|
37
|
+
# Worst of the worst - midnight to 8am (localtime)
|
|
38
|
+
time.hour < 8
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class Summary
|
|
43
|
+
attr_accessor :current_start, :current_end, :previous_start, :previous_end
|
|
44
|
+
|
|
45
|
+
def initialize(current_start, current_end, previous_start, previous_end)
|
|
46
|
+
@current_start = current_start
|
|
47
|
+
@current_end = current_end
|
|
48
|
+
@previous_start = previous_start
|
|
49
|
+
@previous_end = previous_end
|
|
50
|
+
@items = []
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def <<(item)
|
|
54
|
+
@items << item
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def current_items
|
|
58
|
+
select_between?(current_start, current_end)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def previous_items
|
|
62
|
+
select_between?(previous_start, previous_end)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def current_count(&selector)
|
|
66
|
+
count_from(current_items, &selector)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def previous_count(&selector)
|
|
70
|
+
count_from(previous_items, &selector)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def current_summary(&selector)
|
|
74
|
+
summarize(current_items, &selector)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def previous_summary(&selector)
|
|
78
|
+
summarize(previous_items, &selector)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def pct_change(&selector)
|
|
82
|
+
old_value = previous_count(&selector)
|
|
83
|
+
new_value = current_count(&selector)
|
|
84
|
+
Report.pct_change old_value, new_value
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def summarize(collection)
|
|
90
|
+
summary = Hash.new(0)
|
|
91
|
+
|
|
92
|
+
collection.each do |item|
|
|
93
|
+
yield item, summary
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
return summary.sort {|a, b| b[1] <=> a[1] }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def count_from(collection)
|
|
100
|
+
if block_given?
|
|
101
|
+
return collection.count {|item| yield item }
|
|
102
|
+
else
|
|
103
|
+
return collection.count
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def select_between?(a, b)
|
|
108
|
+
@items.select {|item| item.between?(a, b) }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self.pct_change(old_value, new_value)
|
|
113
|
+
if old_value == 0
|
|
114
|
+
return "no occurrences last week"
|
|
115
|
+
else
|
|
116
|
+
change = (((new_value.to_f - old_value.to_f) / old_value.to_f) * 100).to_i
|
|
117
|
+
|
|
118
|
+
if change == 0
|
|
119
|
+
return "no change vs. last week"
|
|
120
|
+
elsif change < 0
|
|
121
|
+
return "#{change}% vs. last week"
|
|
122
|
+
else
|
|
123
|
+
return "+#{change}% vs. last week"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|