flapjack 0.7.27 → 0.7.28

Sign up to get free protection for your applications and to get access to all the features.
Files changed (64) hide show
  1. data/.gitignore +1 -0
  2. data/CHANGELOG.md +9 -0
  3. data/bin/flapjack +22 -28
  4. data/bin/flapjack-nagios-receiver +5 -27
  5. data/bin/flapjack-populator +2 -2
  6. data/bin/flapper +13 -14
  7. data/bin/receive-events +3 -20
  8. data/bin/simulate-failed-check +3 -20
  9. data/etc/flapjack_config.yaml.example +119 -86
  10. data/features/cli.feature +69 -0
  11. data/features/events.feature +15 -0
  12. data/features/packaging-lintian.feature +4 -6
  13. data/features/rollup.feature +198 -0
  14. data/features/steps/cli_steps.rb +81 -0
  15. data/features/steps/events_steps.rb +26 -16
  16. data/features/steps/notifications_steps.rb +2 -2
  17. data/features/steps/packaging-lintian_steps.rb +2 -2
  18. data/features/support/daemons.rb +113 -0
  19. data/features/support/env.rb +26 -4
  20. data/lib/flapjack/configuration.rb +2 -0
  21. data/lib/flapjack/data/contact.rb +76 -5
  22. data/lib/flapjack/data/entity_check.rb +16 -0
  23. data/lib/flapjack/data/message.rb +11 -8
  24. data/lib/flapjack/data/notification.rb +31 -3
  25. data/lib/flapjack/data/notification_rule.rb +1 -1
  26. data/lib/flapjack/filters/delays.rb +1 -5
  27. data/lib/flapjack/gateways/api/contact_methods.rb +12 -6
  28. data/lib/flapjack/gateways/email.rb +35 -26
  29. data/lib/flapjack/gateways/email/alert.html.erb +4 -4
  30. data/lib/flapjack/gateways/email/alert.text.erb +2 -2
  31. data/lib/flapjack/gateways/email/alert_subject.text.erb +14 -0
  32. data/lib/flapjack/gateways/email/rollup.html.erb +48 -0
  33. data/lib/flapjack/gateways/email/rollup.text.erb +20 -0
  34. data/lib/flapjack/gateways/email/rollup_subject.text.erb +19 -0
  35. data/lib/flapjack/gateways/jabber.rb +97 -47
  36. data/lib/flapjack/gateways/sms_messagenet.rb +26 -24
  37. data/lib/flapjack/gateways/sms_messagenet/alert.text.erb +15 -0
  38. data/lib/flapjack/gateways/sms_messagenet/rollup.text.erb +34 -0
  39. data/lib/flapjack/gateways/web/views/contact.html.erb +16 -8
  40. data/lib/flapjack/notifier.rb +17 -4
  41. data/lib/flapjack/processor.rb +1 -1
  42. data/lib/flapjack/version.rb +1 -1
  43. data/spec/lib/flapjack/coordinator_spec.rb +19 -19
  44. data/spec/lib/flapjack/data/contact_spec.rb +100 -25
  45. data/spec/lib/flapjack/data/event_spec.rb +1 -1
  46. data/spec/lib/flapjack/data/message_spec.rb +1 -1
  47. data/spec/lib/flapjack/data/notification_spec.rb +11 -3
  48. data/spec/lib/flapjack/gateways/api/contact_methods_spec.rb +36 -17
  49. data/spec/lib/flapjack/gateways/api/entity_check_presenter_spec.rb +1 -1
  50. data/spec/lib/flapjack/gateways/api/entity_methods_spec.rb +38 -38
  51. data/spec/lib/flapjack/gateways/api/entity_presenter_spec.rb +15 -15
  52. data/spec/lib/flapjack/gateways/email_spec.rb +4 -4
  53. data/spec/lib/flapjack/gateways/jabber_spec.rb +13 -14
  54. data/spec/lib/flapjack/gateways/oobetet_spec.rb +2 -2
  55. data/spec/lib/flapjack/gateways/pagerduty_spec.rb +5 -5
  56. data/spec/lib/flapjack/gateways/sms_messagenet.spec.rb +1 -1
  57. data/spec/lib/flapjack/gateways/web/views/contact.html.erb_spec.rb +2 -2
  58. data/spec/lib/flapjack/gateways/web_spec.rb +4 -4
  59. data/spec/lib/flapjack/logger_spec.rb +3 -3
  60. data/spec/lib/flapjack/pikelet_spec.rb +10 -10
  61. data/spec/lib/flapjack/processor_spec.rb +4 -4
  62. data/spec/lib/flapjack/redis_pool_spec.rb +1 -1
  63. metadata +70 -5
  64. checksums.yaml +0 -15
@@ -136,7 +136,7 @@ end
136
136
 
137
137
  # TODO may need to get more complex, depending which SMS provider is used
138
138
  When /^the SMS notification handler runs successfully$/ do
139
- @request = stub_request(:get, /^#{Regexp.escape(Flapjack::Gateways::SmsMessagenet::MESSAGENET_URL)}/)
139
+ @request = stub_request(:get, /^#{Regexp.escape(Flapjack::Gateways::SmsMessagenet::MESSAGENET_DEFAULT_URL)}/)
140
140
 
141
141
  Flapjack::Gateways::SmsMessagenet.instance_variable_set('@config', {'username' => 'abcd', 'password' => 'efgh'})
142
142
  Flapjack::Gateways::SmsMessagenet.instance_variable_set('@redis', @redis)
@@ -147,7 +147,7 @@ When /^the SMS notification handler runs successfully$/ do
147
147
  end
148
148
 
149
149
  When /^the SMS notification handler fails to send an SMS$/ do
150
- @request = stub_request(:get, /^#{Regexp.escape(Flapjack::Gateways::SmsMessagenet::MESSAGENET_URL)}/).to_return(:status => [500, "Internal Server Error"])
150
+ @request = stub_request(:get, /^#{Regexp.escape(Flapjack::Gateways::SmsMessagenet::MESSAGENET_DEFAULT_URL)}/).to_return(:status => [500, "Internal Server Error"])
151
151
  Flapjack::Gateways::SmsMessagenet.instance_variable_set('@config', {'username' => 'abcd', 'password' => 'efgh'})
152
152
  Flapjack::Gateways::SmsMessagenet.instance_variable_set('@redis', @redis)
153
153
  Flapjack::Gateways::SmsMessagenet.instance_variable_set('@logger', @logger)
@@ -12,7 +12,7 @@ Then /^every file in the output should start with "([^\"]*)"$/ do |string|
12
12
  end
13
13
  end
14
14
 
15
- When /^I run "([^"]*)"$/ do |cmd|
15
+ When /^I run `([^"]*)`$/ do |cmd|
16
16
  #bin_path = '/usr/bin'
17
17
  #command = "#{bin_path}/#{cmd}"
18
18
 
@@ -21,7 +21,7 @@ When /^I run "([^"]*)"$/ do |cmd|
21
21
  @exit_status = $?.exitstatus
22
22
  end
23
23
 
24
- Then /^the exit status should be (\d+)$/ do |number|
24
+ Then /^the exit value should be (\d+)$/ do |number|
25
25
  @exit_status.should == number.to_i
26
26
  end
27
27
 
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ def red(text); "\e[0;31;49m#{text}\e[0m"; end
4
+ def yellow(text); "\e[0;33;49m#{text}\e[0m"; end
5
+ def green(text); "\e[0;32;49m#{text}\e[0m"; end
6
+ def blue(text); "\e[0;34;49m#{text}\e[0m"; end
7
+
8
+ After('@process') do
9
+ kill_lingering_processes
10
+ end
11
+
12
+ PROCESSES = []
13
+
14
+ def current_dir
15
+ File.join(*dirs)
16
+ end
17
+
18
+ def dirs
19
+ @dirs ||= ['tmp', 'cucumber_cli']
20
+ end
21
+
22
+ def write_file(file_name, file_content)
23
+ _create_file(file_name, file_content, false)
24
+ end
25
+
26
+ def _create_file(file_name, file_content, check_presence)
27
+ in_current_dir do
28
+ raise "expected #{file_name} to be present" if check_presence && !File.file?(file_name)
29
+ _mkdir(File.dirname(file_name))
30
+ File.open(file_name, 'w') { |f| f << file_content }
31
+ end
32
+ end
33
+
34
+ def in_current_dir(&block)
35
+ _mkdir(current_dir)
36
+ Dir.chdir(current_dir, &block)
37
+ end
38
+
39
+ def _mkdir(dir_name)
40
+ FileUtils.mkdir_p(dir_name) unless File.directory?(dir_name)
41
+ end
42
+
43
+ def kill_lingering_processes
44
+ PROCESSES && PROCESSES.each do |process_h|
45
+ process = process_h[:process]
46
+ pid = process ? process.pid : process_h[:pid]
47
+ command = process_h[:command]
48
+
49
+ begin
50
+ puts yellow("Killing process #{pid}") if @debug
51
+ Process.kill("KILL", pid)
52
+ rescue Errno::ESRCH
53
+ puts green("Process #{pid} has already exited.") if @debug
54
+ end
55
+
56
+ if @debug
57
+ if process
58
+ puts blue("Output from #{pid} #{command}\n")
59
+ puts process.read + "\n"
60
+ else
61
+ # TODO capture STDOUT from daemonize?
62
+ end
63
+ end
64
+ end
65
+ puts yellow("Done killing processes") if @debug
66
+ PROCESSES.clear
67
+ end
68
+
69
+ def time_and_pid_from_file(pid_file, cutoff_time = nil)
70
+ return unless File.exists?(pid_file)
71
+ file_time = File.mtime(pid_file)
72
+ return unless cutoff_time.nil? || (file_time.to_i >= cutoff_time.to_i)
73
+ [file_time, File.read(pid_file).strip.to_i]
74
+ end
75
+
76
+ def spawn_process(command, opts={})
77
+ puts yellow("Running: #{command}") if @debug
78
+
79
+ process_h = nil
80
+
81
+ file = opts[:daemon_pidfile]
82
+
83
+ if file
84
+ time = Time.now
85
+ attempts = 0
86
+
87
+ `#{command}`
88
+
89
+ while attempts < 50
90
+ time_and_pid = time_and_pid_from_file(file, time)
91
+ if time_and_pid
92
+ process_h = {:pid => time_and_pid.last, :command => command}
93
+ break
94
+ end
95
+ attempts += 1
96
+ sleep 0.2
97
+ end
98
+ else
99
+ process = IO.popen(command)
100
+ pid = process.pid
101
+ process_h = {:process => process, :command => command}
102
+ end
103
+
104
+ raise "failed to spawn process #{command}" if process_h.nil?
105
+
106
+ PROCESSES << process_h
107
+
108
+ process_h
109
+ end
110
+
111
+ at_exit do
112
+ kill_lingering_processes
113
+ end
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env ruby
2
2
  #
3
+
3
4
  require 'delorean'
4
5
  require 'chronic'
5
6
  require 'active_support/time'
@@ -12,6 +13,10 @@ if ENV['COVERAGE']
12
13
  SimpleCov.start do
13
14
  add_filter '/features/'
14
15
  end
16
+ SimpleCov.at_exit do
17
+ Oj.default_options = { :mode => :compat }
18
+ SimpleCov.result.format!
19
+ end
15
20
  end
16
21
 
17
22
  ENV["FLAPJACK_ENV"] = 'test'
@@ -24,6 +29,11 @@ require 'pathname'
24
29
  require 'webmock/cucumber'
25
30
  WebMock.disable_net_connect!
26
31
 
32
+ require 'oj'
33
+ Oj.mimic_JSON
34
+ Oj.default_options = { :indent => 0, :mode => :strict }
35
+ require 'active_support/json'
36
+
27
37
  require 'flapjack/notifier'
28
38
  require 'flapjack/processor'
29
39
  require 'flapjack/patches'
@@ -81,11 +91,11 @@ EXPIRE_AS_IF_AT
81
91
  end
82
92
 
83
93
  def self.time_travel_to(dest_time)
84
- # puts "travelling to #{Time.now.in_time_zone}, real time is #{Time.now_without_delorean.in_time_zone}"
94
+ #puts "travelling to #{Time.now.in_time_zone}, real time is #{Time.now_without_delorean.in_time_zone}"
85
95
  old_maybe_fake_time = Time.now.in_time_zone
86
96
 
87
97
  Delorean.time_travel_to(dest_time)
88
- # puts "travelled to #{Time.now.in_time_zone}, real time is #{Time.now_without_delorean.in_time_zone}"
98
+ #puts "travelled to #{Time.now.in_time_zone}, real time is #{Time.now_without_delorean.in_time_zone}"
89
99
  return if dest_time < old_maybe_fake_time
90
100
 
91
101
  # dumps the first offset -- we're not interested in time difference from
@@ -98,11 +108,11 @@ EXPIRE_AS_IF_AT
98
108
  delta = -offsets.inject(0){ |sum, val| sum + val }.floor
99
109
 
100
110
  real_time = Time.now_without_delorean.to_i
101
- # puts "delta #{delta}, expire before real time #{Time.at(real_time + delta)}"
111
+ #puts "delta #{delta}, expire before real time #{Time.at(real_time + delta)}"
102
112
 
103
113
  result = @redis.evalsha(@expire_as_if_at_sha, ['*'],
104
114
  [real_time, real_time + delta])
105
- # puts "Expired #{result} key#{(result == 1) ? '' : 's'}"
115
+ #puts "Expired #{result} key#{(result == 1) ? '' : 's'}"
106
116
  end
107
117
 
108
118
  end
@@ -170,3 +180,15 @@ After('@time') do
170
180
  Delorean.back_to_the_present
171
181
  end
172
182
 
183
+ After('@process') do
184
+ ['tmp/cucumber_cli/flapjack_cfg.yml',
185
+ 'tmp/cucumber_cli/flapjack_cfg.yml.bak',
186
+ 'tmp/cucumber_cli/flapjack_cfg_d.yml',
187
+ 'tmp/cucumber_cli/flapjack_d.log',
188
+ 'tmp/cucumber_cli/flapjack_d.pid'].each do |file|
189
+ next unless File.exists?(file)
190
+ File.unlink(file)
191
+ end
192
+ end
193
+
194
+
@@ -7,6 +7,8 @@ module Flapjack
7
7
 
8
8
  class Configuration
9
9
 
10
+ DEFAULT_CONFIG_PATH = '/etc/flapjack/flapjack_config.yaml'
11
+
10
12
  attr_reader :filename
11
13
 
12
14
  def initialize(opts = {})
@@ -16,9 +16,10 @@ module Flapjack
16
16
 
17
17
  class Contact
18
18
 
19
- attr_accessor :id, :first_name, :last_name, :email, :media, :media_intervals, :pagerduty_credentials
19
+ attr_accessor :id, :first_name, :last_name, :email, :media, :media_intervals, :media_rollup_thresholds, :pagerduty_credentials
20
20
 
21
21
  TAG_PREFIX = 'contact_tag'
22
+ ALL_MEDIA = ['email', 'sms', 'jabber', 'pagerduty']
22
23
 
23
24
  def self.all(options = {})
24
25
  raise "Redis connection not set" unless redis = options[:redis]
@@ -40,7 +41,7 @@ module Flapjack
40
41
  # sanity check
41
42
  return unless redis.hexists("contact:#{contact_id}", 'first_name')
42
43
 
43
- contact = self.new(:id => contact_id, :redis => redis)
44
+ contact = self.new(:id => contact_id, :redis => redis, :logger => logger)
44
45
  contact.refresh
45
46
  contact
46
47
  end
@@ -77,6 +78,7 @@ module Flapjack
77
78
  @redis.hmget("contact:#{@id}", 'first_name', 'last_name', 'email')
78
79
  self.media = @redis.hgetall("contact_media:#{@id}")
79
80
  self.media_intervals = @redis.hgetall("contact_media_intervals:#{self.id}")
81
+ self.media_rollup_thresholds = @redis.hgetall("contact_media_rollup_thresholds:#{self.id}")
80
82
 
81
83
  # similar to code in instance method pagerduty_credentials
82
84
  if service_key = @redis.hget("contact_media:#{@id}", 'pagerduty')
@@ -117,6 +119,7 @@ module Flapjack
117
119
 
118
120
  @redis.del("contact:#{self.id}", "contact_media:#{self.id}",
119
121
  "contact_media_intervals:#{self.id}",
122
+ "contact_media_rollup_thresholds:#{self.id}",
120
123
  "contact_tz:#{self.id}", "contact_pagerduty:#{self.id}")
121
124
  end
122
125
 
@@ -177,8 +180,8 @@ module Flapjack
177
180
  :entities => [],
178
181
  :tags => Flapjack::Data::TagSet.new([]),
179
182
  :time_restrictions => [],
180
- :warning_media => ['email', 'sms', 'jabber', 'pagerduty'],
181
- :critical_media => ['email', 'sms', 'jabber', 'pagerduty'],
183
+ :warning_media => ALL_MEDIA,
184
+ :critical_media => ALL_MEDIA,
182
185
  :warning_blackhole => false,
183
186
  :critical_blackhole => false,
184
187
  }, :logger => opts[:logger])
@@ -216,6 +219,20 @@ module Flapjack
216
219
  self.media_intervals = @redis.hgetall("contact_media_intervals:#{self.id}")
217
220
  end
218
221
 
222
+ def rollup_threshold_for_media(media)
223
+ threshold = @redis.hget("contact_media_rollup_thresholds:#{self.id}", media)
224
+ (threshold.nil? || (threshold.to_i <= 0 )) ? nil : threshold.to_i
225
+ end
226
+
227
+ def set_rollup_threshold_for_media(media, threshold)
228
+ if threshold.nil?
229
+ @redis.hdel("contact_media_rollup_thresholds:#{self.id}", media)
230
+ return
231
+ end
232
+ @redis.hset("contact_media_rollup_thresholds:#{self.id}", media, threshold)
233
+ self.media_rollup_thresholds = @redis.hgetall("contact_media_rollup_thresholds:#{self.id}")
234
+ end
235
+
219
236
  def set_address_for_media(media, address)
220
237
  @redis.hset("contact_media:#{self.id}", media, address)
221
238
  if media == 'pagerduty'
@@ -229,6 +246,7 @@ module Flapjack
229
246
  def remove_media(media)
230
247
  @redis.hdel("contact_media:#{self.id}", media)
231
248
  @redis.hdel("contact_media_intervals:#{self.id}", media)
249
+ @redis.hdel("contact_media_rollup_thresholds:#{self.id}", media)
232
250
  if media == 'pagerduty'
233
251
  @redis.del("contact_pagerduty:#{self.id}")
234
252
  end
@@ -260,7 +278,57 @@ module Flapjack
260
278
  else
261
279
  @redis.set(key, 'd')
262
280
  @redis.expire(key, self.interval_for_media(media))
281
+ # TODO: #182 - update the alert history keys
282
+ end
283
+ end
284
+
285
+ def drop_rollup_notifications_for_media?(media)
286
+ @redis.exists("drop_rollup_alerts_for_contact:#{self.id}:#{media}")
287
+ end
288
+
289
+ def update_sent_rollup_alert_keys_for_media(media, opts = {})
290
+ delete = !! opts[:delete]
291
+ key = "drop_rollup_alerts_for_contact:#{self.id}:#{media}"
292
+ if delete
293
+ @redis.del(key)
294
+ else
295
+ @redis.set(key, 'd')
296
+ @redis.expire(key, self.interval_for_media(media))
297
+ end
298
+ end
299
+
300
+ def add_alerting_check_for_media(media, check)
301
+ @redis.zadd("contact_alerting_checks:#{self.id}:media:#{media}", Time.now.to_i, check)
302
+ end
303
+
304
+ def remove_alerting_check_for_media(media, check)
305
+ @redis.zrem("contact_alerting_checks:#{self.id}:media:#{media}", check)
306
+ end
307
+
308
+ # removes any checks that are in ok, scheduled or unscheduled maintenance
309
+ # from the alerting checks set for the given media
310
+ # returns the number of checks removed
311
+ def clean_alerting_checks_for_media(media)
312
+ key = "contact_alerting_checks:#{self.id}:media:#{media}"
313
+ cleaned = 0
314
+ alerting_checks_for_media(media).each do |check|
315
+ next unless Flapjack::Data::EntityCheck.state_for_event_id?(check, :redis => @redis) == 'ok' ||
316
+ Flapjack::Data::EntityCheck.in_unscheduled_maintenance_for_event_id?(check, :redis => @redis) ||
317
+ Flapjack::Data::EntityCheck.in_scheduled_maintenance_for_event_id?(check, :redis => @redis)
318
+
319
+ @logger.debug("removing from alerting checks for #{self.id}/#{media}: #{check}") if @logger
320
+ remove_alerting_check_for_media(media, check)
321
+ cleaned += 1
263
322
  end
323
+ cleaned
324
+ end
325
+
326
+ def alerting_checks_for_media(media)
327
+ @redis.zrange("contact_alerting_checks:#{self.id}:media:#{media}", 0, -1)
328
+ end
329
+
330
+ def count_alerting_checks_for_media(media)
331
+ @redis.zcard("contact_alerting_checks:#{self.id}:media:#{media}")
264
332
  end
265
333
 
266
334
  # FIXME
@@ -344,7 +412,8 @@ module Flapjack
344
412
 
345
413
  def initialize(options = {})
346
414
  raise "Redis connection not set" unless @redis = options[:redis]
347
- @id = options[:id]
415
+ @id = options[:id]
416
+ @logger = options[:logger]
348
417
  end
349
418
 
350
419
  # NB: should probably be called in the context of a Redis multi block; not doing so
@@ -360,6 +429,7 @@ module Flapjack
360
429
  unless contact_data['media'].nil?
361
430
  redis.del("contact_media:#{contact_id}")
362
431
  redis.del("contact_media_intervals:#{contact_id}")
432
+ redis.del("contact_media_rollup_thresholds:#{contact_id}")
363
433
  redis.del("contact_pagerduty:#{contact_id}")
364
434
 
365
435
  contact_data['media'].each_pair {|medium, details|
@@ -371,6 +441,7 @@ module Flapjack
371
441
  else
372
442
  redis.hset("contact_media:#{contact_id}", medium, details['address'])
373
443
  redis.hset("contact_media_intervals:#{contact_id}", medium, details['interval']) if details['interval']
444
+ redis.hset("contact_media_rollup_thresholds:#{contact_id}", medium, details['rollup_threshold']) if details['rollup_threshold']
374
445
  end
375
446
  }
376
447
  end
@@ -114,6 +114,21 @@ module Flapjack
114
114
  result
115
115
  end
116
116
 
117
+ def self.in_unscheduled_maintenance_for_event_id?(event_id, options)
118
+ raise "Redis connection not set" unless redis = options[:redis]
119
+ redis.exists("#{event_id}:unscheduled_maintenance")
120
+ end
121
+
122
+ def self.in_scheduled_maintenance_for_event_id?(event_id, options)
123
+ raise "Redis connection not set" unless redis = options[:redis]
124
+ redis.exists("#{event_id}:scheduled_maintenance")
125
+ end
126
+
127
+ def self.state_for_event_id?(event_id, options)
128
+ raise "Redis connection not set" unless redis = options[:redis]
129
+ redis.hget("check:#{event_id}", 'state')
130
+ end
131
+
117
132
  def entity_name
118
133
  entity.name
119
134
  end
@@ -536,6 +551,7 @@ module Flapjack
536
551
  raise "Invalid entity" unless @entity = entity
537
552
  raise "Invalid check" unless @check = check
538
553
  @key = "#{entity.name}:#{check}"
554
+ @logger = options[:logger]
539
555
  end
540
556
 
541
557
  end
@@ -10,13 +10,14 @@ module Flapjack
10
10
  module Data
11
11
  class Message
12
12
 
13
- attr_reader :medium, :address, :duration, :contact
13
+ attr_reader :medium, :address, :duration, :contact, :rollup
14
14
 
15
15
  def self.for_contact(contact, opts = {})
16
- self.new(:contact => contact,
17
- :medium => opts[:medium],
18
- :address => opts[:address],
19
- :duration => opts[:duration])
16
+ self.new(:contact => contact,
17
+ :medium => opts[:medium],
18
+ :address => opts[:address],
19
+ :duration => opts[:duration],
20
+ :rollup => opts[:rollup])
20
21
  end
21
22
 
22
23
  def id
@@ -31,6 +32,7 @@ module Flapjack
31
32
  c = {'media' => medium,
32
33
  'address' => address,
33
34
  'id' => id,
35
+ 'rollup' => rollup,
34
36
  'contact_id' => contact.id,
35
37
  'contact_first_name' => contact.first_name,
36
38
  'contact_last_name' => contact.last_name}
@@ -41,10 +43,11 @@ module Flapjack
41
43
  private
42
44
 
43
45
  def initialize(opts = {})
44
- @contact = opts[:contact]
45
- @medium = opts[:medium]
46
- @address = opts[:address]
46
+ @contact = opts[:contact]
47
+ @medium = opts[:medium]
48
+ @address = opts[:address]
47
49
  @duration = opts[:duration]
50
+ @rollup = opts[:rollup]
48
51
  end
49
52
 
50
53
  end