flapjack 0.7.2 → 0.7.3

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.
@@ -1,5 +1,12 @@
1
1
  ## Flapjack Changelog
2
2
 
3
+ # 0.7.3 - 2013-05-14
4
+ - Bug: Web and api gateways have configuable http timeout gh-170 (@jessereynolds)
5
+ - Bug: Support POSTs to API larger than ~112 KB gh-169 (@jessereynolds)
6
+ - Bug: Validate notification rules before adding, updating gh-146 (@ali-graham)
7
+ - Bug: Web UI very slow with large number of keys gh-164 (@jessereynolds)
8
+ - Bug: crash in executive should exit flapjack gh-143 (@ali-graham)
9
+
3
10
  # 0.7.2 - 2013-05-06
4
11
  - Feature: executive instance keys now expire after 7 days, touched every event gh-111 (@jessereynolds)
5
12
  - Feature: slightly less sucky looking web UI, also now includes entity listing screens (@jessereynolds)
@@ -80,12 +80,14 @@ development:
80
80
  web:
81
81
  enabled: yes
82
82
  port: 5080
83
+ timeout: 300
83
84
  access_log: "log/web_access.log"
84
85
  logger:
85
86
  level: INFO
86
87
  api:
87
88
  enabled: yes
88
89
  port: 5081
90
+ timeout: 300
89
91
  access_log: "log/api_access.log"
90
92
  logger:
91
93
  level: INFO
@@ -3,9 +3,9 @@ Feature: Notification rules on a per contact basis
3
3
 
4
4
  Background:
5
5
  Given the following users exist:
6
- | id | first_name | last_name | email | sms |
7
- | 1 | Malak | Al-Musawi | malak@example.com | +61400000001 |
8
- | 2 | Imani | Farooq | imani@example.com | +61400000002 |
6
+ | id | first_name | last_name | email | sms | timezone |
7
+ | 1 | Malak | Al-Musawi | malak@example.com | +61400000001 | Asia/Baghdad |
8
+ | 2 | Imani | Farooq | imani@example.com | +61400000002 | Europe/Moscow |
9
9
 
10
10
  And the following entities exist:
11
11
  | id | name | contacts |
@@ -25,7 +25,7 @@ Feature: Notification rules on a per contact basis
25
25
 
26
26
  @time_restrictions @time
27
27
  Scenario: Alerts only during specified time restrictions
28
- Given the timezone is America/New_York
28
+ Given the timezone is Asia/Baghdad
29
29
  And the time is February 1 2013 6:59
30
30
  And the check is check 'ping' on entity 'foo'
31
31
  And the check is in an ok state
@@ -111,6 +111,19 @@ def submit_acknowledgement(entity, check)
111
111
  submit_event(event)
112
112
  end
113
113
 
114
+ def icecube_schedule_to_time_restriction(sched, time_zone)
115
+ tr = sched.to_hash
116
+ tr[:start_time] = time_zone.utc_to_local(tr[:start_date][:time]).strftime "%Y-%m-%d %H:%M:%S"
117
+ tr[:end_time] = time_zone.utc_to_local(tr[:end_time][:time]).strftime "%Y-%m-%d %H:%M:%S"
118
+
119
+ # rewrite IceCube::WeeklyRule to Weekly, etc
120
+ tr[:rrules].each {|rrule|
121
+ rrule[:rule_type] = /^.*\:\:(.*)Rule$/.match(rrule[:rule_type])[1]
122
+ }
123
+
124
+ tr
125
+ end
126
+
114
127
  Given /^an entity '([\w\.\-]+)' exists$/ do |entity|
115
128
  Flapjack::Data::Entity.add({'id' => '5000',
116
129
  'name' => entity},
@@ -240,7 +253,7 @@ Given /^the following users exist:$/ do |contacts|
240
253
  'last_name' => contact['last_name'],
241
254
  'email' => contact['email'],
242
255
  'media' => media},
243
- :redis => @redis )
256
+ :redis => @redis ).timezone = contact['timezone']
244
257
  end
245
258
  end
246
259
 
@@ -258,27 +271,27 @@ Given /^user (\d+) has the following notification rules:$/ do |contact_id, rules
258
271
  entity_tags = rule['entity_tags'].split(',').map { |x| x.strip }
259
272
  warning_media = rule['warning_media'].split(',').map { |x| x.strip }
260
273
  critical_media = rule['critical_media'].split(',').map { |x| x.strip }
261
- warning_blackhole = rule['warning_blackhole'].downcase == 'true' ? 'true' : 'false'
262
- critical_blackhole = rule['critical_blackhole'].downcase == 'true' ? 'true' : 'false'
274
+ warning_blackhole = (rule['warning_blackhole'].downcase == 'true')
275
+ critical_blackhole = (rule['critical_blackhole'].downcase == 'true')
276
+ timezone = Flapjack::Data::Contact.find_by_id(contact_id, :redis => @redis).timezone
263
277
  time_restrictions = []
264
278
  rule['time_restrictions'].split(',').map { |x| x.strip }.each do |time_restriction|
265
279
  case time_restriction
266
280
  when '8-18 weekdays'
267
- # FIXME: get timezone from the user definition (or config[:default_contact_timezone])
268
- time_zone = ActiveSupport::TimeZone.new("America/New_York")
269
- weekdays_8_18 = IceCube::Schedule.new(time_zone.local(2013,2,1,8,0,0), :duration => 60 * 60 * 10)
281
+ weekdays_8_18 = IceCube::Schedule.new(timezone.local(2013,2,1,8,0,0), :duration => 60 * 60 * 10)
270
282
  weekdays_8_18.add_recurrence_rule(IceCube::Rule.weekly.day(:monday, :tuesday, :wednesday, :thursday, :friday))
271
- time_restrictions << Flapjack::Data::NotificationRule.time_restriction_from_ice_cube_hash(weekdays_8_18.to_hash, time_zone)
283
+ time_restrictions << icecube_schedule_to_time_restriction(weekdays_8_18, timezone)
272
284
  end
273
285
  end
274
- Flapjack::Data::NotificationRule.add({:contact_id => contact_id,
275
- :entities => entities,
276
- :entity_tags => entity_tags,
277
- :warning_media => warning_media,
278
- :critical_media => critical_media,
279
- :warning_blackhole => warning_blackhole,
280
- :critical_blackhole => critical_blackhole,
281
- :time_restrictions => time_restrictions}, :redis => @redis)
286
+ rule_data = {:contact_id => contact_id,
287
+ :entities => entities,
288
+ :entity_tags => entity_tags,
289
+ :warning_media => warning_media,
290
+ :critical_media => critical_media,
291
+ :warning_blackhole => warning_blackhole,
292
+ :critical_blackhole => critical_blackhole,
293
+ :time_restrictions => time_restrictions}
294
+ Flapjack::Data::NotificationRule.add(rule_data, :redis => @redis)
282
295
  end
283
296
  end
284
297
 
@@ -128,11 +128,19 @@ module Flapjack
128
128
 
129
129
  # passed a hash with {PIKELET_TYPE => PIKELET_CFG, ...}
130
130
  def add_pikelets(pikelets_data = {})
131
+ start_piks = []
131
132
  pikelets_data.each_pair do |type, cfg|
132
133
  next unless pikelet = Flapjack::Pikelet.create(type,
133
134
  :config => cfg, :redis_config => @redis_options)
135
+ start_piks << pikelet
134
136
  @pikelets << pikelet
135
- pikelet.start
137
+ end
138
+ begin
139
+ start_piks.each {|pik| pik.start }
140
+ rescue Exception => e
141
+ trace = e.backtrace.join("\n")
142
+ @logger.fatal "#{e.message}\n#{trace}"
143
+ stop
136
144
  end
137
145
  end
138
146
 
@@ -280,6 +280,7 @@ module Flapjack
280
280
  end
281
281
 
282
282
  # return the timezone of the contact, or the system default if none is set
283
+ # TODO cache?
283
284
  def timezone(opts = {})
284
285
  logger = opts[:logger]
285
286
 
@@ -29,13 +29,11 @@ module Flapjack
29
29
  # sanity check
30
30
  return unless redis.exists("notification_rule:#{rule_id}")
31
31
 
32
- rule = self.new({:id => rule_id}, {:redis => redis})
33
- rule.refresh
34
- rule
32
+ self.new({:id => rule_id.to_s}, {:redis => redis})
35
33
  end
36
34
 
37
35
  # replacing save! etc
38
- def self.add(rule_data, options)
36
+ def self.add(rule_data, options = {})
39
37
  raise "Redis connection not set" unless redis = options[:redis]
40
38
 
41
39
  rule_id = SecureRandom.uuid
@@ -43,129 +41,235 @@ module Flapjack
43
41
  self.find_by_id(rule_id, :redis => redis)
44
42
  end
45
43
 
46
- # add user's timezone string to the hash, deserialise
47
- # time in the user's timezone also
48
- def self.time_restriction_to_ice_cube_hash(tr, time_zone)
49
- tr = symbolize(tr)
50
-
51
- tr[:start_date] = tr[:start_time].dup
52
- tr.delete(:start_time)
53
-
54
- if tr[:start_date].is_a?(String)
55
- tr[:start_date] = { :time => tr[:start_date] }
56
- end
57
- if tr[:start_date].is_a?(Hash)
58
- tr[:start_date][:time] = time_zone.parse(tr[:start_date][:time])
59
- tr[:start_date][:zone] = time_zone.name
60
- end
61
-
62
- if tr[:end_time].is_a?(String)
63
- tr[:end_time] = { :time => tr[:end_time] }
64
- end
65
- if tr[:end_time].is_a?(Hash)
66
- tr[:end_time][:time] = time_zone.parse(tr[:end_time][:time])
67
- tr[:end_time][:zone] = time_zone.name
68
- end
69
-
70
- # rewrite Weekly to IceCube::WeeklyRule, etc
71
- tr[:rrules].each {|rrule|
72
- rrule[:rule_type] = "IceCube::#{rrule[:rule_type]}Rule"
73
- }
74
-
75
- tr
76
- end
77
-
78
- def self.time_restriction_from_ice_cube_hash(tr, time_zone)
79
- tr[:start_date] = time_zone.utc_to_local(tr[:start_date][:time]).strftime "%Y-%m-%d %H:%M:%S"
80
- tr[:end_time] = time_zone.utc_to_local(tr[:end_time][:time]).strftime "%Y-%m-%d %H:%M:%S"
81
-
82
- # rewrite IceCube::WeeklyRule to Weekly, etc
83
- tr[:rrules].each {|rrule|
84
- rrule[:rule_type] = /^.*\:\:(.*)Rule$/.match(rrule[:rule_type])[1]
85
- }
86
-
87
- tr[:start_time] = tr[:start_date].dup
88
- tr.delete(:start_date)
89
-
90
- tr
91
- end
92
-
93
- def refresh
94
- rule_data = @redis.hgetall("notification_rule:#{@id}")
95
-
96
- @contact_id = rule_data['contact_id']
97
- @entity_tags = Yajl::Parser.parse(rule_data['entity_tags'] || '')
98
- @entities = Yajl::Parser.parse(rule_data['entities'] || '')
99
- @time_restrictions = Yajl::Parser.parse(rule_data['time_restrictions'] || '')
100
- @warning_media = Yajl::Parser.parse(rule_data['warning_media'] || '')
101
- @critical_media = Yajl::Parser.parse(rule_data['critical_media'] || '')
102
- @warning_blackhole = ((rule_data['warning_blackhole'] || 'false').downcase == 'true')
103
- @critical_blackhole = ((rule_data['critical_blackhole'] || 'false').downcase == 'true')
104
-
44
+ def update(rule_data)
45
+ return false unless self.class.add_or_update(rule_data.merge(:id => @id),
46
+ :redis => @redis)
47
+ refresh
48
+ true
105
49
  end
106
50
 
107
- def update(rule_data)
108
- self.class.add_or_update(rule_data.merge(:id => @id), :redis => @redis)
109
- self.refresh
51
+ # NB: ice_cube doesn't have much rule data validation, and has
52
+ # problems with infinite loops if the data can't logically match; see
53
+ # https://github.com/seejohnrun/ice_cube/issues/127 &
54
+ # https://github.com/seejohnrun/ice_cube/issues/137
55
+ # We may want to consider some sort of timeout-based check around
56
+ # anything that could fall into that.
57
+ #
58
+ # We don't want to replicate IceCube's from_hash behaviour here,
59
+ # but we do need to apply some sanity checking on the passed data.
60
+ def self.time_restriction_to_icecube_schedule(tr, timezone)
61
+ return unless !tr.nil? && tr.is_a?(Hash)
62
+ return if timezone.nil? && !timezone.is_a?(ActiveSupport::TimeZone)
63
+ return unless tr = prepare_time_restriction(tr, timezone)
64
+
65
+ IceCube::Schedule.from_hash(tr)
110
66
  end
111
67
 
112
68
  def to_json(*args)
113
- hash = (Hash[ *([:id, :contact_id, :entity_tags, :entities,
114
- :time_restrictions, :warning_media, :critical_media,
115
- :warning_blackhole, :critical_blackhole].collect {|k|
116
- [k, self.send(k)]
117
- }).flatten(1) ])
118
- hash.to_json
69
+ self.class.hashify(:id, :contact_id, :entity_tags, :entities,
70
+ :time_restrictions, :warning_media, :critical_media,
71
+ :warning_blackhole, :critical_blackhole) {|k|
72
+ [k, self.send(k)]
73
+ }.to_json
119
74
  end
120
75
 
121
76
  # tags or entity names match?
122
77
  # nil @entity_tags and nil @entities matches
123
78
  def match_entity?(event)
124
- return true if (@entity_tags.nil? or @entity_tags.empty?) and
125
- (@entities.nil? or @entities.empty?)
126
- return true if @entities.include?(event.split(':').first)
127
79
  # TODO: return true if event's entity tags match entity tag list on the rule
128
- return false
80
+ ((@entity_tags.nil? || @entity_tags.empty?) && (@entities.nil? || @entities.empty?)) ||
81
+ (@entities.include?(event.split(':').first))
129
82
  end
130
83
 
131
84
  def blackhole?(severity)
132
- return true if 'warning'.eql?(severity.downcase) and @warning_blackhole
133
- return true if 'critical'.eql?(severity.downcase) and @critical_blackhole
134
- return false
85
+ ('warning'.eql?(severity.downcase) && @warning_blackhole) ||
86
+ ('critical'.eql?(severity.downcase) && @critical_blackhole)
135
87
  end
136
88
 
137
89
  def media_for_severity(severity)
138
90
  case severity
139
91
  when 'warning'
140
- media_list = @warning_media
92
+ @warning_media
141
93
  when 'critical'
142
- media_list = @critical_media
94
+ @critical_media
143
95
  end
144
- media_list
145
96
  end
146
97
 
147
98
  private
148
99
 
149
100
  def initialize(rule_data, opts = {})
150
101
  @redis ||= opts[:redis]
151
- @logger = opts[:logger]
152
102
  raise "a redis connection must be supplied" unless @redis
153
- @id = rule_data[:id]
103
+ @logger = opts[:logger]
104
+ @id = rule_data[:id]
105
+ refresh
154
106
  end
155
107
 
156
108
  def self.add_or_update(rule_data, options = {})
157
- raise ":id is a required key in rule_data" unless rule_data[:id]
158
-
159
109
  redis = options[:redis]
110
+ raise "a redis connection must be supplied" unless redis
111
+
112
+ return unless self.validate_data(rule_data, options)
113
+
114
+ # whitelisting fields, rather than passing through submitted data directly
115
+ json_rule_data = {
116
+ :id => rule_data[:id].to_s,
117
+ :contact_id => rule_data[:contact_id].to_s,
118
+ :entities => Yajl::Encoder.encode(rule_data[:entities]),
119
+ :entity_tags => Yajl::Encoder.encode(rule_data[:entity_tags]),
120
+ :time_restrictions => Yajl::Encoder.encode(rule_data[:time_restrictions]),
121
+ :warning_media => Yajl::Encoder.encode(rule_data[:warning_media]),
122
+ :critical_media => Yajl::Encoder.encode(rule_data[:critical_media]),
123
+ :warning_blackhole => rule_data[:warning_blackhole],
124
+ :critical_blackhole => rule_data[:critical_blackhole],
125
+ }
126
+
127
+ redis.sadd("contact_notification_rules:#{json_rule_data[:contact_id]}",
128
+ json_rule_data[:id])
129
+ redis.hmset("notification_rule:#{json_rule_data[:id]}",
130
+ *json_rule_data.flatten)
131
+ true
132
+ end
133
+
134
+ def self.prepare_time_restriction(time_restriction, timezone = nil)
135
+ # this will hand back a 'deep' copy
136
+ tr = symbolize(time_restriction)
137
+
138
+ return unless tr.has_key?(:start_time) && tr.has_key?(:end_time)
139
+
140
+ parsed_time = proc {|t|
141
+ if t.is_a?(Time)
142
+ t
143
+ else
144
+ begin; (timezone || Time).parse(t); rescue ArgumentError; nil; end
145
+ end
146
+ }
147
+
148
+ start_time = case tr[:start_time]
149
+ when String, Time
150
+ parsed_time.call(tr.delete(:start_time).dup)
151
+ when Hash
152
+ time_hash = tr.delete(:start_time).dup
153
+ parsed_time.call(time_hash[:time])
154
+ end
155
+
156
+ end_time = case tr[:end_time]
157
+ when String, Time
158
+ parsed_time.call(tr.delete(:end_time).dup)
159
+ when Hash
160
+ time_hash = tr.delete(:end_time).dup
161
+ parsed_time.call(time_hash[:time])
162
+ end
163
+
164
+ return unless start_time && end_time
165
+
166
+ tr[:start_date] = timezone ?
167
+ {:time => start_time, :zone => timezone.name} :
168
+ start_time
169
+
170
+ tr[:end_date] = timezone ?
171
+ {:time => end_time, :zone => timezone.name} :
172
+ end_time
173
+
174
+ tr[:duration] = end_time - start_time
175
+
176
+ # check that rrule types are valid IceCube rule types
177
+ return unless tr[:rrules].is_a?(Array) &&
178
+ tr[:rrules].all? {|rr| rr.is_a?(Hash)} &&
179
+ (tr[:rrules].map {|rr| rr[:rule_type]} -
180
+ ['Daily', 'Hourly', 'Minutely', 'Monthly', 'Secondly',
181
+ 'Weekly', 'Yearly']).empty?
182
+
183
+ # rewrite Weekly to IceCube::WeeklyRule, etc
184
+ tr[:rrules].each {|rrule|
185
+ rrule[:rule_type] = "IceCube::#{rrule[:rule_type]}Rule"
186
+ }
160
187
 
161
- rule_data[:entities] = Yajl::Encoder.encode(rule_data[:entities])
162
- rule_data[:entity_tags] = Yajl::Encoder.encode(rule_data[:entity_tags])
163
- rule_data[:time_restrictions] = Yajl::Encoder.encode(rule_data[:time_restrictions])
164
- rule_data[:warning_media] = Yajl::Encoder.encode(rule_data[:warning_media])
165
- rule_data[:critical_media] = Yajl::Encoder.encode(rule_data[:critical_media])
188
+ # TODO does this need to check classes for the following values?
189
+ # "validations": {
190
+ # "day": [1,2,3,4,5]
191
+ # },
192
+ # "interval": 1,
193
+ # "week_start": 0
166
194
 
167
- redis.sadd("contact_notification_rules:#{rule_data[:contact_id]}", rule_data[:id])
168
- redis.hmset("notification_rule:#{rule_data[:id]}", *rule_data.flatten)
195
+ tr
196
+ end
197
+
198
+ def self.validate_data(d, options = {})
199
+ # hash with validation => error_message
200
+ validations = {proc { d.has_key?(:id) } =>
201
+ "id not set",
202
+
203
+ proc { d.has_key?(:entities) &&
204
+ d[:entities].is_a?(Array) &&
205
+ d[:entities].all? {|e| e.is_a?(String)} } =>
206
+ "entities must be a list of strings",
207
+
208
+ proc { d.has_key?(:entity_tags) &&
209
+ d[:entity_tags].is_a?(Array) &&
210
+ d[:entity_tags].all? {|et| et.is_a?(String)}} =>
211
+ "entity_tags must be a list of strings",
212
+
213
+ proc { (d.has_key?(:entities) &&
214
+ d[:entities].is_a?(Array) &&
215
+ (d[:entities].size > 0)) ||
216
+ (d.has_key?(:entity_tags) &&
217
+ d[:entity_tags].is_a?(Array) &&
218
+ (d[:entity_tags].size > 0)) } =>
219
+ "entities or entity tags must have at least one value",
220
+
221
+ proc { d.has_key?(:time_restrictions) &&
222
+ d[:time_restrictions].all? {|tr|
223
+ !!prepare_time_restriction(symbolize(tr))
224
+ }
225
+ } =>
226
+ "time restrictions are invalid",
227
+
228
+ # TODO should the media types be checked against a whitelist?
229
+ proc { d.has_key?(:warning_media) &&
230
+ d[:warning_media].is_a?(Array) &&
231
+ d[:warning_media].all? {|et| et.is_a?(String)}} =>
232
+ "warning_media must be a list of strings",
233
+
234
+ proc { d.has_key?(:critical_media) &&
235
+ d[:critical_media].is_a?(Array) &&
236
+ d[:critical_media].all? {|et| et.is_a?(String)}} =>
237
+ "critical_media must be a list of strings",
238
+
239
+ proc { d.has_key?(:warning_blackhole) &&
240
+ [TrueClass, FalseClass].include?(d[:warning_blackhole].class) } =>
241
+ "warning_blackhole must be true or false",
242
+
243
+ proc { d.has_key?(:critical_blackhole) &&
244
+ [TrueClass, FalseClass].include?(d[:critical_blackhole].class) } =>
245
+ "critical_blackhole must be true or false",
246
+ }
247
+
248
+ errors = validations.keys.inject([]) {|ret,vk|
249
+ ret << "Rule #{validations[vk]}" unless vk.call
250
+ ret
251
+ }
252
+
253
+ return true if errors.empty?
254
+
255
+ if logger = options[:logger]
256
+ error_str = errors.join(", ")
257
+ logger.info "validation error: #{error_str}"
258
+ end
259
+ false
260
+ end
261
+
262
+ def refresh
263
+ rule_data = @redis.hgetall("notification_rule:#{@id}")
264
+
265
+ @contact_id = rule_data['contact_id']
266
+ @entity_tags = Yajl::Parser.parse(rule_data['entity_tags'] || '')
267
+ @entities = Yajl::Parser.parse(rule_data['entities'] || '')
268
+ @time_restrictions = Yajl::Parser.parse(rule_data['time_restrictions'] || '')
269
+ @warning_media = Yajl::Parser.parse(rule_data['warning_media'] || '')
270
+ @critical_media = Yajl::Parser.parse(rule_data['critical_media'] || '')
271
+ @warning_blackhole = ((rule_data['warning_blackhole'] || 'false').downcase == 'true')
272
+ @critical_blackhole = ((rule_data['critical_blackhole'] || 'false').downcase == 'true')
169
273
  end
170
274
 
171
275
  end