flapjack 0.6.61 → 0.7.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.
Files changed (38) hide show
  1. data/Gemfile +2 -1
  2. data/README.md +8 -4
  3. data/features/events.feature +269 -146
  4. data/features/notification_rules.feature +93 -0
  5. data/features/steps/events_steps.rb +162 -21
  6. data/features/steps/notifications_steps.rb +1 -1
  7. data/features/steps/time_travel_steps.rb +30 -19
  8. data/features/support/env.rb +71 -1
  9. data/flapjack.gemspec +3 -0
  10. data/lib/flapjack/data/contact.rb +256 -57
  11. data/lib/flapjack/data/entity.rb +2 -1
  12. data/lib/flapjack/data/entity_check.rb +22 -7
  13. data/lib/flapjack/data/global.rb +1 -0
  14. data/lib/flapjack/data/message.rb +2 -0
  15. data/lib/flapjack/data/notification_rule.rb +172 -0
  16. data/lib/flapjack/data/tag.rb +7 -2
  17. data/lib/flapjack/data/tag_set.rb +16 -0
  18. data/lib/flapjack/executive.rb +147 -13
  19. data/lib/flapjack/filters/delays.rb +21 -9
  20. data/lib/flapjack/gateways/api.rb +407 -27
  21. data/lib/flapjack/gateways/pagerduty.rb +1 -1
  22. data/lib/flapjack/gateways/web.rb +50 -22
  23. data/lib/flapjack/gateways/web/views/self_stats.haml +2 -0
  24. data/lib/flapjack/utility.rb +10 -0
  25. data/lib/flapjack/version.rb +1 -1
  26. data/spec/lib/flapjack/data/contact_spec.rb +103 -6
  27. data/spec/lib/flapjack/data/global_spec.rb +2 -0
  28. data/spec/lib/flapjack/data/message_spec.rb +6 -0
  29. data/spec/lib/flapjack/data/notification_rule_spec.rb +22 -0
  30. data/spec/lib/flapjack/data/notification_spec.rb +6 -0
  31. data/spec/lib/flapjack/gateways/api_spec.rb +727 -4
  32. data/spec/lib/flapjack/gateways/jabber_spec.rb +1 -0
  33. data/spec/lib/flapjack/gateways/web_spec.rb +11 -1
  34. data/spec/spec_helper.rb +10 -0
  35. data/tmp/notification_rules.rb +73 -0
  36. data/tmp/test_json_post.rb +16 -0
  37. data/tmp/test_notification_rules_api.rb +170 -0
  38. metadata +59 -2
data/flapjack.gemspec CHANGED
@@ -35,6 +35,9 @@ Gem::Specification.new do |gem|
35
35
  gem.add_dependency 'blather', '0.8.1'
36
36
  gem.add_dependency 'chronic'
37
37
  gem.add_dependency 'chronic_duration'
38
+ gem.add_dependency 'activesupport'
39
+ gem.add_dependency 'ice_cube'
40
+ gem.add_dependency 'tzinfo'
38
41
 
39
42
  gem.add_development_dependency 'rake'
40
43
  end
@@ -4,8 +4,11 @@
4
4
  # structure to avoid the need for this type of query
5
5
 
6
6
  require 'set'
7
-
7
+ require 'ice_cube'
8
8
  require 'flapjack/data/entity'
9
+ require 'flapjack/data/notification_rule'
10
+ require 'flapjack/data/tag'
11
+ require 'flapjack/data/tag_set'
9
12
 
10
13
  module Flapjack
11
14
 
@@ -13,14 +16,14 @@ module Flapjack
13
16
 
14
17
  class Contact
15
18
 
16
- attr_accessor :first_name, :last_name, :email, :media, :pagerduty_credentials, :id
19
+ attr_accessor :id, :first_name, :last_name, :email, :media, :pagerduty_credentials
20
+
21
+ TAG_PREFIX = 'contact_tag'
17
22
 
18
23
  def self.all(options = {})
19
24
  raise "Redis connection not set" unless redis = options[:redis]
20
25
 
21
- contact_keys = redis.keys('contact:*')
22
-
23
- contact_keys.inject([]) {|ret, k|
26
+ redis.keys('contact:*').inject([]) {|ret, k|
24
27
  k =~ /^contact:(\d+)$/
25
28
  id = $1
26
29
  contact = self.find_by_id(id, :redis => redis)
@@ -29,66 +32,88 @@ module Flapjack
29
32
  }.sort_by {|c| [c.last_name, c.first_name]}
30
33
  end
31
34
 
32
- def self.delete_all(options = {})
35
+ def self.find_by_id(contact_id, options = {})
33
36
  raise "Redis connection not set" unless redis = options[:redis]
37
+ raise "No id value passed" unless contact_id
38
+ logger = options[:logger]
34
39
 
35
- keys_to_delete = redis.keys("contact:*") +
36
- redis.keys("contact_media:*") +
37
- # FIXME: when we do source tagging we can properly
38
- # clean up contacts_for: keys
39
- # redis.keys('contacts_for:*') +
40
- redis.keys("contact_pagerduty:*")
40
+ # sanity check
41
+ return unless redis.hexists("contact:#{contact_id}", 'first_name')
41
42
 
42
- redis.del(keys_to_delete) unless keys_to_delete.length == 0
43
+ contact = self.new(:id => contact_id, :redis => redis)
44
+ contact.refresh
45
+ contact
43
46
  end
44
47
 
45
- def self.find_by_id(contact_id, options = {})
48
+ def self.add(contact_data, options = {})
46
49
  raise "Redis connection not set" unless redis = options[:redis]
47
- raise "No id value passed" unless contact_id
48
- logger = options[:logger]
50
+ contact_id = contact_data['id']
51
+ raise "Contact id value not provided" if contact_id.nil?
49
52
 
50
- return unless redis.hexists("contact:#{contact_id}", 'first_name')
53
+ if contact = self.find_by_id(contact_id, :redis => redis)
54
+ contact.delete!
55
+ end
56
+
57
+ self.add_or_update(contact_id, contact_data, :redis => redis)
58
+ self.find_by_id(contact_id, :redis => redis)
59
+ end
60
+
61
+ def self.delete_all(options = {})
62
+ raise "Redis connection not set" unless redis = options[:redis]
51
63
 
52
- fn, ln, em = redis.hmget("contact:#{contact_id}", 'first_name', 'last_name', 'email')
53
- me = redis.hgetall("contact_media:#{contact_id}")
64
+ self.all(:redis => redis).each do |contact|
65
+ contact.delete!
66
+ end
67
+ end
68
+
69
+ # ensure that instance variables match redis state
70
+ # TODO may want to make this protected/private, it's only
71
+ # used in this class
72
+ def refresh
73
+ self.first_name, self.last_name, self.email =
74
+ @redis.hmget("contact:#{@id}", 'first_name', 'last_name', 'email')
75
+ self.media = @redis.hgetall("contact_media:#{@id}")
54
76
 
55
77
  # similar to code in instance method pagerduty_credentials
56
- pc = nil
57
- if service_key = redis.hget("contact_media:#{contact_id}", 'pagerduty')
58
- pc = redis.hgetall("contact_pagerduty:#{contact_id}").merge('service_key' => service_key)
78
+ if service_key = @redis.hget("contact_media:#{@id}", 'pagerduty')
79
+ self.pagerduty_credentials =
80
+ @redis.hgetall("contact_pagerduty:#{@id}").merge('service_key' => service_key)
59
81
  end
82
+ end
60
83
 
61
- self.new(:first_name => fn, :last_name => ln,
62
- :email => em, :id => contact_id, :media => me, :pagerduty_credentials => pc, :redis => redis )
84
+ def update(contact_data)
85
+ self.class.add_or_update(@id, contact_data, :redis => @redis)
86
+ self.refresh
63
87
  end
64
88
 
89
+ def delete!
90
+ # remove entity & check registrations -- ugh, this will be slow.
91
+ # rather than check if the key is present we'll just request its
92
+ # deletion anyway, fewer round-trips
93
+ @redis.keys('contacts_for:*').each do |cfk|
94
+ @redis.srem(cfk, self.id)
95
+ end
65
96
 
66
- # NB: should probably be called in the context of a Redis multi block; not doing so
67
- # here as calling classes may well be adding/updating multiple records in the one
68
- # operation
69
- # TODO maybe return the instantiated Contact record?
70
- def self.add(contact, options = {})
71
- raise "Redis connection not set" unless redis = options[:redis]
97
+ @redis.del("drop_alerts_for_contact:#{self.id}")
98
+ dafc = @redis.keys("drop_alerts_for_contact:#{self.id}:*")
99
+ @redis.del(*dafc) unless dafc.empty?
72
100
 
73
- redis.del("contact:#{contact['id']}",
74
- "contact_media:#{contact['id']}",
75
- "contact_pagerduty:#{contact['id']}")
101
+ # TODO if implemented, alerts_by_contact & alerts_by_check:
102
+ # list all alerts from all matched keys, remove them from
103
+ # the main alerts sorted set, remove all alerts_by sorted sets
104
+ # for the contact
76
105
 
77
- redis.hmset("contact:#{contact['id']}",
78
- *['first_name', 'last_name', 'email'].collect {|f| [f, contact[f]]})
106
+ # remove this contact from all tags it's marked with
107
+ self.delete_tags(*self.tags.to_a)
79
108
 
80
- unless contact['media'].nil?
81
- contact['media'].each_pair {|medium, address|
82
- case medium
83
- when 'pagerduty'
84
- redis.hset("contact_media:#{contact['id']}", medium, address['service_key'])
85
- redis.hmset("contact_pagerduty:#{contact['id']}",
86
- *['subdomain', 'username', 'password'].collect {|f| [f, address[f]]})
87
- else
88
- redis.hset("contact_media:#{contact['id']}", medium, address)
89
- end
90
- }
109
+ # remove all associated notification rules
110
+ self.notification_rules.each do |nr|
111
+ self.delete_notification_rule(nr)
91
112
  end
113
+
114
+ @redis.del("contact:#{self.id}", "contact_media:#{self.id}",
115
+ "contact_media_intervals:#{self.id}",
116
+ "contact_tz:#{self.id}", "contact_pagerduty:#{self.id}")
92
117
  end
93
118
 
94
119
  def pagerduty_credentials
@@ -97,7 +122,9 @@ module Flapjack
97
122
  merge('service_key' => service_key)
98
123
  end
99
124
 
100
- def entities_and_checks
125
+ # NB ideally contacts_for:* keys would scope the entity and check by an
126
+ # input source, for namespacing purposes
127
+ def entities(options = {})
101
128
  @redis.keys('contacts_for:*').inject({}) {|ret, k|
102
129
  if @redis.sismember(k, self.id)
103
130
  if k =~ /^contacts_for:([a-zA-Z0-9][a-zA-Z0-9\.\-]*[a-zA-Z0-9])(?::(\w+))?$/
@@ -111,16 +138,20 @@ module Flapjack
111
138
  :id => entity_id, :redis => @redis)
112
139
  ret[entity_id][:entity] = entity
113
140
  end
114
- # using a set to ensure unique check values
115
- ret[entity_id][:checks] = Set.new
141
+ if options[:checks]
142
+ # using a set to ensure unique check values
143
+ ret[entity_id][:checks] = Set.new
144
+ end
116
145
  end
117
146
 
118
- if check
119
- # just add this check for the entity
120
- ret[entity_id][:checks] |= check
121
- else
122
- # registered for the entity so add all checks
123
- ret[entity_id][:checks] |= entity.check_list
147
+ if options[:checks]
148
+ # if not registered for the check, then was registered for
149
+ # the entity, so add all checks
150
+ ret[entity_id][:checks] |= (check || entity.check_list)
151
+ end
152
+
153
+ if options[:tags]
154
+ ret[entity_id][:tags] = entity.tags
124
155
  end
125
156
  end
126
157
  end
@@ -132,12 +163,180 @@ module Flapjack
132
163
  [(self.first_name || ''), (self.last_name || '')].join(" ").strip
133
164
  end
134
165
 
166
+ # return an array of the notification rules of this contact
167
+ def notification_rules
168
+ @redis.smembers("contact_notification_rules:#{self.id}").collect { |rule_id|
169
+ next if (rule_id.nil? || rule_id == '')
170
+ Flapjack::Data::NotificationRule.find_by_id(rule_id, {:redis => @redis })
171
+ }.compact
172
+ end
173
+
174
+ def add_notification_rule(rule_data)
175
+ Flapjack::Data::NotificationRule.add(rule_data.merge(:contact_id => self.id),
176
+ :redis => @redis)
177
+ end
178
+
179
+ def delete_notification_rule(rule)
180
+ @redis.srem("contact_notification_rules:#{self.id}", rule.id)
181
+ @redis.del("notification_rule:#{rule.id}")
182
+ end
183
+
184
+ def media_intervals
185
+ @redis.hgetall("contact_media_intervals:#{self.id}")
186
+ end
187
+
188
+ # how often to notify this contact on the given media
189
+ # return 15 mins if no value is set
190
+ def interval_for_media(media)
191
+ interval = @redis.hget("contact_media_intervals:#{self.id}", media)
192
+ (interval.nil? || (interval.to_i <= 0)) ? (15 * 60) : interval.to_i
193
+ end
194
+
195
+ def set_interval_for_media(media, interval)
196
+ raise "invalid interval" unless interval.is_a?(Integer)
197
+ @redis.hset("contact_media_intervals:#{self.id}", media, interval)
198
+ end
199
+
200
+ def set_address_for_media(media, address)
201
+ @redis.hset("contact_media:#{self.id}", media, address)
202
+ if media == 'pagerduty'
203
+ # FIXME - work out what to do when changing the pagerduty service key (address)
204
+ # probably best solution is to remove the need to have the username and password
205
+ # and subdomain as pagerduty's updated api's mean we don't them anymore I think...
206
+ end
207
+ end
208
+
209
+ def remove_media(media)
210
+ @redis.hdel("contact_media:#{self.id}", media)
211
+ @redis.hdel("contact_media_intervals:#{self.id}", media)
212
+ if media == 'pagerduty'
213
+ @redis.del("contact_pagerduty:#{self.id}")
214
+ end
215
+ end
216
+
217
+ # drop notifications for
218
+ def drop_notifications?(opts)
219
+ media = opts[:media]
220
+ check = opts[:check]
221
+ state = opts[:state]
222
+ # build it and they will come
223
+ @redis.exists("drop_alerts_for_contact:#{self.id}") ||
224
+ (media && @redis.exists("drop_alerts_for_contact:#{self.id}:#{media}")) ||
225
+ (media && check &&
226
+ @redis.exists("drop_alerts_for_contact:#{self.id}:#{media}:#{check}")) ||
227
+ (media && check && state &&
228
+ @redis.exists("drop_alerts_for_contact:#{self.id}:#{media}:#{check}:#{state}"))
229
+ end
230
+
231
+ def update_sent_alert_keys(opts)
232
+ media = opts[:media]
233
+ check = opts[:check]
234
+ state = opts[:state]
235
+ key = "drop_alerts_for_contact:#{self.id}:#{media}:#{check}:#{state}"
236
+ @redis.set(key, 'd')
237
+ @redis.expire(key, self.interval_for_media(media))
238
+ end
239
+
240
+ # FIXME
241
+ # do a mixin with the following tag methods, they will be the same
242
+ # across all objects we allow tags on
243
+
244
+ # return the set of tags for this contact
245
+ def tags
246
+ @tags ||= Flapjack::Data::TagSet.new( @redis.keys("#{TAG_PREFIX}:*").inject([]) {|memo, tag|
247
+ if Flapjack::Data::Tag.find(tag, :redis => @redis).include?(@id.to_s)
248
+ memo << tag.sub(/^#{TAG_PREFIX}:/, '')
249
+ end
250
+ memo
251
+ } )
252
+ end
253
+
254
+ # adds tags to this contact
255
+ def add_tags(*enum)
256
+ enum.each do |t|
257
+ Flapjack::Data::Tag.create("#{TAG_PREFIX}:#{t}", [@id], :redis => @redis)
258
+ tags.add(t)
259
+ end
260
+ end
261
+
262
+ # removes tags from this contact
263
+ def delete_tags(*enum)
264
+ enum.each do |t|
265
+ tag = Flapjack::Data::Tag.find("#{TAG_PREFIX}:#{t}", :redis => @redis)
266
+ tag.delete(@id)
267
+ tags.delete(t)
268
+ end
269
+ end
270
+
271
+ # return a list of media enabled for this contact
272
+ # eg [ 'email', 'sms' ]
273
+ def media_list
274
+ @redis.hkeys("contact_media:#{self.id}")
275
+ end
276
+
277
+ # return the timezone of the contact, or the system default if none is set
278
+ def timezone(opts = {})
279
+ tz_string = @redis.get("contact_tz:#{self.id}")
280
+ tz = opts[:default] if (tz_string.nil? || tz_string.empty?)
281
+
282
+ if tz.nil?
283
+ begin
284
+ tz = ActiveSupport::TimeZone.new(tz_string)
285
+ rescue ArgumentError
286
+ logger.warn("Invalid timezone string set for contact #{self.id} or TZ (#{tz_string}), using 'UTC'!")
287
+ tz = ActiveSupport::TimeZone.new('UTC')
288
+ end
289
+ end
290
+ tz
291
+ end
292
+
293
+ # sets or removes the timezone for the contact
294
+ def timezone=(tz)
295
+ if tz.nil?
296
+ @redis.del("contact_tz:#{self.id}")
297
+ else
298
+ # ActiveSupport::TimeZone or String
299
+ @redis.set("contact_tz:#{self.id}",
300
+ tz.respond_to?(:name) ? tz.name : tz )
301
+ end
302
+ end
303
+
304
+ def to_json(*args)
305
+ { "id" => self.id,
306
+ "first_name" => self.first_name,
307
+ "last_name" => self.last_name,
308
+ "email" => self.email,
309
+ "tags" => self.tags.to_a }.to_json
310
+ end
311
+
135
312
  private
136
313
 
137
314
  def initialize(options = {})
138
315
  raise "Redis connection not set" unless @redis = options[:redis]
139
- [:first_name, :last_name, :email, :media, :id].each do |field|
140
- instance_variable_set(:"@#{field.to_s}", options[field])
316
+ @id = options[:id]
317
+ end
318
+
319
+ # NB: should probably be called in the context of a Redis multi block; not doing so
320
+ # here as calling classes may well be adding/updating multiple records in the one
321
+ # operation
322
+ def self.add_or_update(contact_id, contact_data, options = {})
323
+ raise "Redis connection not set" unless redis = options[:redis]
324
+
325
+ # TODO check that the rest of this is safe for the update case
326
+ redis.hmset("contact:#{contact_id}",
327
+ *['first_name', 'last_name', 'email'].collect {|f| [f, contact_data[f]]})
328
+
329
+ unless contact_data['media'].nil?
330
+ contact_data['media'].each_pair {|medium, address|
331
+ case medium
332
+ when 'pagerduty'
333
+ redis.hset("contact_media:#{contact_id}", medium, address['service_key'])
334
+ redis.hmset("contact_pagerduty:#{contact_id}",
335
+ *['subdomain', 'username', 'password'].collect {|f| [f, address[f]]})
336
+ else
337
+ redis.hset("contact_media:#{contact_id}", medium, address)
338
+ end
339
+ }
141
340
  end
142
341
  end
143
342
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'flapjack/data/contact'
4
4
  require 'flapjack/data/tag'
5
+ require 'flapjack/data/tag_set'
5
6
 
6
7
  module Flapjack
7
8
 
@@ -110,7 +111,7 @@ module Flapjack
110
111
  end
111
112
 
112
113
  def tags
113
- @tags ||= ::Set.new( @redis.keys("#{TAG_PREFIX}:*").inject([]) {|memo, entity_tag|
114
+ @tags ||= Flapjack::Data::TagSet.new( @redis.keys("#{TAG_PREFIX}:*").inject([]) {|memo, entity_tag|
114
115
  if Flapjack::Data::Tag.find(entity_tag, :redis => @redis).include?(@id.to_s)
115
116
  memo << entity_tag.sub(/^#{TAG_PREFIX}:/, '')
116
117
  end
@@ -271,6 +271,18 @@ module Flapjack
271
271
  lpn.to_i
272
272
  end
273
273
 
274
+ def last_warning_notification
275
+ lwn = @redis.get("#{@key}:last_warning_notification")
276
+ return unless (lwn && lwn =~ /^\d+$/)
277
+ lwn.to_i
278
+ end
279
+
280
+ def last_critical_notification
281
+ lcn = @redis.get("#{@key}:last_critical_notification")
282
+ return unless (lcn && lcn =~ /^\d+$/)
283
+ lcn.to_i
284
+ end
285
+
274
286
  def last_recovery_notification
275
287
  lrn = @redis.get("#{@key}:last_recovery_notification")
276
288
  return unless (lrn && lrn =~ /^\d+$/)
@@ -285,6 +297,8 @@ module Flapjack
285
297
 
286
298
  def last_notifications_of_each_type
287
299
  ln = {:problem => last_problem_notification,
300
+ :warning => last_warning_notification,
301
+ :critical => last_critical_notification,
288
302
  :recovery => last_recovery_notification,
289
303
  :acknowledgement => last_acknowledgement_notification }
290
304
  ln
@@ -295,9 +309,10 @@ module Flapjack
295
309
  def last_notification
296
310
  nils = { :type => nil, :timestamp => nil }
297
311
  lne = last_notifications_of_each_type
298
- ln = lne.delete_if {|type, timestamp|
299
- timestamp.nil? || timestamp.to_i == 0
300
- }
312
+ ln = lne.delete_if {|type, timestamp| timestamp.nil? || timestamp.to_i == 0 }
313
+ if ln.find {|type, timestamp| type == :warning or type == :critical}
314
+ ln = ln.delete_if {|type, timestamp| type == :problem }
315
+ end
301
316
  return nils unless ln.length > 0
302
317
  lns = ln.sort_by { |type, timestamp| timestamp }.last
303
318
  { :type => lns[0], :timestamp => lns[1] }
@@ -422,11 +437,11 @@ module Flapjack
422
437
 
423
438
  private
424
439
 
425
- def initialize(ent, che, options = {})
440
+ def initialize(entity, check, options = {})
426
441
  raise "Redis connection not set" unless @redis = options[:redis]
427
- raise "Invalid entity" unless @entity = ent
428
- raise "Invalid check" unless @check = che
429
- @key = "#{ent.name}:#{che}"
442
+ raise "Invalid entity" unless @entity = entity
443
+ raise "Invalid check" unless @check = check
444
+ @key = "#{entity.name}:#{check}"
430
445
  end
431
446
 
432
447
  def validate_state(state)