flapjack 0.6.61 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
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)