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.
@@ -0,0 +1,4 @@
1
+ require "pagerduty_tools/campfire"
2
+ require "pagerduty_tools/pagerduty"
3
+ require "pagerduty_tools/report"
4
+ require "pagerduty_tools/version"
@@ -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