chef-handler-sns 1.2.0 → 2.0.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.
@@ -1,5 +1,6 @@
1
1
  #
2
- # Author:: Xabier de Zuazo (<xabier@onddo.com>)
2
+ # Author:: Xabier de Zuazo (<xabier@zuazo.org>)
3
+ # Copyright:: Copyright (c) 2015 Xabier de Zuazo
3
4
  # Copyright:: Copyright (c) 2014 Onddo Labs, SL.
4
5
  # License:: Apache License, Version 2.0
5
6
  #
@@ -22,76 +23,284 @@ require 'aws-sdk'
22
23
  require 'erubis'
23
24
 
24
25
  class Chef
26
+ #
27
+ # Chef report handlers.
28
+ #
25
29
  class Handler
30
+ #
31
+ # Chef Handler SNS main class.
32
+ #
33
+ # A simple Chef report handler that reports status of a Chef run through
34
+ # [Amazon SNS](http://aws.amazon.com/sns/),
35
+ # [including IAM roles support](#usage-with-amazon-iam-roles).
36
+ #
26
37
  class Sns < ::Chef::Handler
38
+ #
39
+ # Include {Config.config_init} and {Config.config_check} methods.
40
+ #
27
41
  include ::Chef::Handler::Sns::Config
28
42
 
29
- def initialize(config={})
30
- Chef::Log.debug("#{self.class.to_s} initialized.")
43
+ #
44
+ # Constructs a new `Sns` object.
45
+ #
46
+ # @example `/etc/chef/client.rb` Configuration Example
47
+ # require 'chef/handler/sns'
48
+ # sns_handler = Chef::Handler::Sns.new
49
+ # sns_handler.access_key '***AMAZON-KEY***'
50
+ # sns_handler.secret_key '***AMAZON-SECRET***'
51
+ # sns_handler.topic_arn 'arn:aws:sns:***'
52
+ # sns_handler.region 'us-east-1' # optional
53
+ # exception_handlers << sns_handler
54
+ #
55
+ # @example `/etc/chef/client.rb` Example Using a Hash for Configuration
56
+ # require 'chef/handler/sns'
57
+ # exception_handlers << Chef::Handler::Sns.new(
58
+ # access_key: '***AMAZON-KEY***',
59
+ # secret_key: '***AMAZON-SECRET***',
60
+ # topic_arn: 'arn:aws:sns:***',
61
+ # region: 'us-east-1' # optional
62
+ # )
63
+ #
64
+ # @example `/etc/chef/client.rb` Using IAM Roles
65
+ # require 'chef/handler/sns'
66
+ # exception_handlers << Chef::Handler::Sns.new(
67
+ # topic_arn: 'arn:aws:sns:us-east-1:12341234:MyTopicName'
68
+ # )
69
+ #
70
+ #
71
+ # @example Using the `chef_handler` Cookbook
72
+ # # Install the `chef-handler-sns` RubyGem during the compile phase
73
+ # chef_gem 'chef-handler-sns' do
74
+ # compile_time true # Only for Chef 12
75
+ # end
76
+ # # Then activate the handler with the `chef_handler` LWRP
77
+ # chef_handler 'Chef::Handler::Sns' do
78
+ # source 'chef/handler/sns'
79
+ # arguments(
80
+ # access_key: '***AMAZON-KEY***',
81
+ # secret_key: '***AMAZON-SECRET***',
82
+ # topic_arn: 'arn:aws:sns:***'
83
+ # )
84
+ # supports exception: true
85
+ # action :enable
86
+ # end
87
+ #
88
+ # @example Using the `chef-client` Cookbook
89
+ # node.default['chef_client']['config']['exception_handlers'] = [{
90
+ # 'class' => 'Chef::Handler::Sns',
91
+ # 'arguments' => {
92
+ # access_key: '***AMAZON-KEY***',
93
+ # secret_key: '***AMAZON-SECRET***',
94
+ # topic_arn: 'arn:aws:sns:***'
95
+ # }.map { |k, v| "#{k}: #{v.inspect}" }
96
+ # }]
97
+ #
98
+ # @param config [Hash] Configuration options.
99
+ #
100
+ # @option config [String] :access_key AWS access key (required, but will
101
+ # try to read it from Ohai with IAM roles).
102
+ # @option config [String] :secret_key AWS secret key (required, but will
103
+ # try to read it from Ohai with IAM roles).
104
+ # @option config [String] :token AWS security token (optional, read from
105
+ # Ohai with IAM roles). Set to `false` to disable the token detected by
106
+ # Ohai.
107
+ # @option config [String] :topic_arn AWS topic ARN name (required).
108
+ # @option config [String] :region AWS region (optional).
109
+ # @option config [String] :subject Message subject string in erubis
110
+ # format (optional).
111
+ # @option config [String] :body_template Full path of an erubis template
112
+ # file to use for the message body (optional).
113
+ # @option config [Array] :filter_opsworks_activities An array of
114
+ # OpsWorks activities to be triggered with (optional). When set,
115
+ # everything else will be discarded.
116
+ #
117
+ # @api public
118
+ #
119
+ def initialize(config = {})
120
+ Chef::Log.debug("#{self.class} initialized.")
31
121
  config_init(config)
32
122
  end
33
123
 
124
+ #
125
+ # Send a SNS report message.
126
+ #
127
+ # This is called by Chef internally.
128
+ #
129
+ # @return void
130
+ #
131
+ # @api public
132
+ #
34
133
  def report
35
134
  config_check(node)
36
- if allow_publish(node)
37
- sns.topics[topic_arn].publish(
38
- sns_body,
39
- { :subject => sns_subject }
40
- )
41
- end
42
- end
43
-
44
- def get_region
45
- sns.config.region
135
+ return unless allow_publish(node)
136
+ sns.publish(
137
+ topic_arn: topic_arn,
138
+ message: sns_body,
139
+ subject: sns_subject
140
+ )
46
141
  end
47
142
 
48
143
  protected
49
144
 
145
+ #
146
+ # Checks if the message will be published based in configured OpsWorks
147
+ # activities.
148
+ #
149
+ # @param node [Chef::Node] Chef Node that contains the activities.
150
+ #
151
+ # @return [Boolean] Whether the message needs to be sent.
152
+ #
153
+ # @api private
154
+ #
50
155
  def allow_publish(node)
51
- if filter_opsworks_activity.nil?
52
- return true
53
- end
156
+ return true if filter_opsworks_activity.nil?
54
157
 
55
- if node.attribute?('opsworks') && node['opsworks'].attribute?('activity')
158
+ if node.attribute?('opsworks') &&
159
+ node['opsworks'].attribute?('activity')
56
160
  return filter_opsworks_activity.include?(node['opsworks']['activity'])
57
161
  end
58
162
 
59
- Chef::Log.debug('You supplied opsworks activity filters, but node attr was not found. Returning false')
60
- return false
163
+ Chef::Log.debug(
164
+ 'You supplied opsworks activity filters, but node attr was not '\
165
+ 'found. Returning false'
166
+ )
167
+ false
61
168
  end
62
169
 
170
+ #
171
+ # Returns the {Aws::SNS} object used to send the messages.
172
+ #
173
+ # @return [Aws::SNS::Client] The SNS client.
63
174
  def sns
64
175
  @sns ||= begin
65
176
  params = {
66
- :access_key_id => access_key,
67
- :secret_access_key => secret_key,
68
- :logger => Chef::Log
177
+ access_key_id: access_key,
178
+ secret_access_key: secret_key,
179
+ logger: Chef::Log
69
180
  }
70
181
  params[:region] = region if region
71
182
  params[:session_token] = token if token
72
- AWS::SNS.new(params)
183
+ Aws::SNS::Client.new(params)
73
184
  end
74
185
  end
75
186
 
76
- def sns_subject
77
- if subject
78
- context = self
79
- eruby = Erubis::Eruby.new(subject)
80
- eruby.evaluate(context)
81
- else
82
- chef_client = Chef::Config[:solo] ? 'Chef Solo' : 'Chef Client'
83
- status = run_status.success? ? 'success' : 'failure'
84
- "#{chef_client} #{status} in #{node.name}"
187
+ #
188
+ # Fixes or forces the correct encoding of strings.
189
+ #
190
+ # Replaces wrong characters with `'?'`s.
191
+ #
192
+ # @param o [String, Object] The string to fix.
193
+ # @param encoding [String] The encoding to use.
194
+ #
195
+ # @return [String] The message fixed.
196
+ #
197
+ # @api private
198
+ #
199
+ def fix_encoding(o, encoding)
200
+ encode_opts = { invalid: :replace, undef: :replace, replace: '?' }
201
+
202
+ return o.to_s.encode(encoding, encode_opts) if RUBY_VERSION >= '2.1.0'
203
+ # Fix ArgumentError: invalid byte sequence in UTF-8 (issue #7)
204
+ o.to_s.encode(encoding, 'binary', encode_opts)
205
+ end
206
+
207
+ #
208
+ # Fixes the encoding of SNS subjects.
209
+ #
210
+ # @param o [String, Object] The subject to fix.
211
+ #
212
+ # @return [String] The message fixed.
213
+ #
214
+ # @api private
215
+ #
216
+ def fix_subject_encoding(o)
217
+ fix_encoding(o, 'ASCII')
218
+ end
219
+
220
+ #
221
+ # Fixes the encoding of SNS bodies.
222
+ #
223
+ # @param o [String, Object] The body to fix.
224
+ #
225
+ # @return [String] The message fixed.
226
+ #
227
+ # @api private
228
+ #
229
+ def fix_body_encoding(o)
230
+ fix_encoding(o, 'UTF-8')
231
+ end
232
+
233
+ #
234
+ # Returns the SNS subject used by default.
235
+ #
236
+ # @return [String] The SNS subject.
237
+ #
238
+ # @api private
239
+ #
240
+ def default_sns_subject
241
+ chef_client = Chef::Config[:solo] ? 'Chef Solo' : 'Chef Client'
242
+ status = run_status.success? ? 'success' : 'failure'
243
+ fix_subject_encoding("#{chef_client} #{status} in #{node.name}"[0..99])
244
+ end
245
+
246
+ #
247
+ # Limits the size of a UTF-8 string in bytes without breaking it.
248
+ #
249
+ # Based on http://stackoverflow.com/questions/12536080/
250
+ # ruby-limiting-a-utf-8-string-by-byte-length
251
+ #
252
+ # @param str [String] The string to limit.
253
+ # @param size [Fixnum] The string size in bytes.
254
+ #
255
+ # @return [String] The final string.
256
+ #
257
+ # @note This code does not work properly on Ruby `< 2.1`.
258
+ #
259
+ # @api private
260
+ #
261
+ def limit_utf8_size(str, size)
262
+ # Start with a string of the correct byte size, but with a possibly
263
+ # incomplete char at the end.
264
+ new_str = str.byteslice(0, size)
265
+
266
+ # We need to force_encoding from utf-8 to utf-8 so ruby will
267
+ # re-validate (idea from halfelf).
268
+ until new_str[-1].force_encoding('utf-8').valid_encoding?
269
+ # Remove the invalid char
270
+ new_str = new_str.slice(0..-2)
85
271
  end
272
+ new_str
86
273
  end
87
274
 
88
- def sns_body
89
- template = IO.read(body_template || "#{File.dirname(__FILE__)}/sns/templates/body.erb")
275
+ #
276
+ # Generates the SNS subject.
277
+ #
278
+ # @return [String] The subject string.
279
+ #
280
+ # @api private
281
+ #
282
+ def sns_subject
283
+ return default_sns_subject unless subject
90
284
  context = self
91
- eruby = Erubis::Eruby.new(template)
92
- eruby.evaluate(context)
285
+ eruby = Erubis::Eruby.new(fix_subject_encoding(subject))
286
+ fix_subject_encoding(eruby.evaluate(context))[0..99]
93
287
  end
94
288
 
289
+ #
290
+ # Generates the SNS body.
291
+ #
292
+ # @return [String] The body string.
293
+ #
294
+ # @api private
295
+ #
296
+ def sns_body
297
+ template = IO.read(body_template ||
298
+ "#{File.dirname(__FILE__)}/sns/templates/body.erb")
299
+ context = self
300
+ eruby = Erubis::Eruby.new(fix_body_encoding(template))
301
+ body = fix_body_encoding(eruby.evaluate(context))
302
+ limit_utf8_size(body, 262_144)
303
+ end
95
304
  end
96
305
  end
97
306
  end
@@ -1,5 +1,6 @@
1
1
  #
2
- # Author:: Xabier de Zuazo (<xabier@onddo.com>)
2
+ # Author:: Xabier de Zuazo (<xabier@zuazo.org>)
3
+ # Copyright:: Copyright (c) 2015 Xabier de Zuazo
3
4
  # Copyright:: Copyright (c) 2014 Onddo Labs, SL.
4
5
  # License:: Apache License, Version 2.0
5
6
  #
@@ -23,111 +24,242 @@ require 'chef/exceptions'
23
24
  class Chef
24
25
  class Handler
25
26
  class Sns < ::Chef::Handler
27
+ #
28
+ # Reads Chef Handler SNS configuration options or calculate them if not
29
+ # set.
30
+ #
26
31
  module Config
27
- Config.extend Config # let Config use the methods it contains as instance methods
32
+ #
33
+ # Let Config use the methods it contains as instance methods:
34
+ #
35
+ Config.extend Config
36
+
37
+ #
38
+ # Include `#set_or_return` code.
39
+ #
28
40
  include ::Chef::Mixin::ParamsValidate
29
41
 
30
- REQUIRED = [ 'access_key', 'secret_key', 'topic_arn' ]
42
+ #
43
+ # Required configuration options.
44
+ #
45
+ REQUIRED = %w(access_key secret_key topic_arn)
31
46
 
47
+ #
48
+ # Reads some configuration options from Ohai information.
49
+ #
50
+ # Called from {.config_check}.
51
+ #
52
+ # @param node [Chef::Node] No objects to read the information from.
53
+ #
54
+ # @return void
55
+ #
56
+ # @api private
57
+ #
32
58
  def config_from_ohai(node)
33
59
  config_ohai = Config::Ohai.new(node)
34
60
  [
35
- :region,
36
61
  :access_key,
37
62
  :secret_key,
38
- :token,
63
+ :token
39
64
  ].each do |attr|
40
- self.send(attr, config_ohai.send(attr)) if self.send(attr).nil?
65
+ send(attr, config_ohai.send(attr)) if send(attr).nil?
41
66
  end
42
67
  end
43
68
 
44
- def config_init(config={})
69
+ #
70
+ # Sets configuration reading it from a Hash.
71
+ #
72
+ # @param config [Hash] Configuration options to set.
73
+ #
74
+ # @return void
75
+ #
76
+ # @see Sns.initialize
77
+ #
78
+ # @api public
79
+ #
80
+ def config_init(config = {})
45
81
  config.each do |key, value|
46
- if Config.respond_to?(key) and not /^config_/ =~ key.to_s
47
- self.send(key, value)
82
+ if Config.respond_to?(key) && !key.to_s.match(/^config_/)
83
+ send(key, value)
48
84
  else
49
- Chef::Log.warn("#{self.class.to_s}: configuration method not found: #{key}.")
85
+ Chef::Log.warn(
86
+ "#{self.class}: configuration method not found: #{key}."
87
+ )
50
88
  end
51
89
  end
52
90
  end
53
91
 
54
- def config_check(node=nil)
92
+ #
93
+ # Checks if any required configuration option is not set.
94
+ #
95
+ # Tries to read some configuration options from Ohai before checking
96
+ # them.
97
+ #
98
+ # @param node [Chef::Node] Node to read Ohai information from.
99
+ #
100
+ # @return void
101
+ #
102
+ # @raise [Exceptions::ValidationFailed] When any required configuration
103
+ # option is not set.
104
+ #
105
+ # @api public
106
+ #
107
+ def config_check(node = nil)
55
108
  config_from_ohai(node) if node
56
109
  REQUIRED.each do |key|
57
- if self.send(key).nil?
58
- raise Exceptions::ValidationFailed,
59
- "Required argument #{key.to_s} is missing!"
60
- end
110
+ next unless send(key).nil?
111
+ fail Exceptions::ValidationFailed,
112
+ "Required argument #{key} is missing!"
61
113
  end
62
114
 
63
- if body_template and not ::File.exists?(body_template)
64
- raise Exceptions::ValidationFailed,
65
- "Template file not found: #{body_template}."
66
- end
115
+ return unless body_template && !::File.exist?(body_template)
116
+ fail Exceptions::ValidationFailed,
117
+ "Template file not found: #{body_template}."
67
118
  end
68
119
 
69
- def access_key(arg=nil)
120
+ #
121
+ # Gets or sets AWS access key.
122
+ #
123
+ # @param arg [String] Access key.
124
+ #
125
+ # @return [String] Access Key.
126
+ #
127
+ # @api public
128
+ #
129
+ def access_key(arg = nil)
70
130
  set_or_return(
71
131
  :access_key,
72
132
  arg,
73
- :kind_of => String
133
+ kind_of: String
74
134
  )
75
135
  end
76
136
 
77
- def secret_key(arg=nil)
137
+ #
138
+ # Gets or sets AWS secret key.
139
+ #
140
+ # @param arg [String] Secret key.
141
+ #
142
+ # @return [String] Secret Key.
143
+ #
144
+ # @api public
145
+ #
146
+ def secret_key(arg = nil)
78
147
  set_or_return(
79
148
  :secret_key,
80
149
  arg,
81
- :kind_of => String
150
+ kind_of: String
82
151
  )
83
152
  end
84
153
 
85
- def region(arg=nil)
154
+ #
155
+ # Gets or sets AWS region.
156
+ #
157
+ # @param arg [String] Region.
158
+ #
159
+ # @return [String] Region.
160
+ #
161
+ # @api public
162
+ #
163
+ def region(arg = nil)
86
164
  set_or_return(
87
165
  :region,
88
166
  arg,
89
- :kind_of => String
167
+ kind_of: String
90
168
  )
91
169
  end
92
170
 
93
- def token(arg=nil)
171
+ #
172
+ # Gets or sets AWS token.
173
+ #
174
+ # @param arg [String] Token.
175
+ #
176
+ # @return [String] Token.
177
+ #
178
+ # @api public
179
+ #
180
+ def token(arg = nil)
94
181
  set_or_return(
95
182
  :token,
96
183
  arg,
97
- :kind_of => [ String, FalseClass ]
184
+ kind_of: [String, FalseClass]
98
185
  )
99
186
  end
100
187
 
101
- def topic_arn(arg=nil)
188
+ #
189
+ # Gets or sets AWS Topic ARN.
190
+ #
191
+ # It also tries to set the AWS region reading it from the ARN string.
192
+ #
193
+ # @param arg [String] Topic ARN.
194
+ #
195
+ # @return [String] Topic ARN.
196
+ #
197
+ # @api public
198
+ #
199
+ def topic_arn(arg = nil)
102
200
  set_or_return(
103
201
  :topic_arn,
104
202
  arg,
105
- :kind_of => String
106
- )
203
+ kind_of: String
204
+ ).tap do |arn|
205
+ # Get the region from the ARN:
206
+ next if arn.nil? || !region.nil?
207
+ region(arn.split(':', 5)[3])
208
+ end
107
209
  end
108
210
 
109
- def subject(arg=nil)
211
+ #
212
+ # Gets or sets SNS message subject.
213
+ #
214
+ # @param arg [String] SNS subject.
215
+ #
216
+ # @return [String] SNS subject.
217
+ #
218
+ # @api public
219
+ #
220
+ def subject(arg = nil)
110
221
  set_or_return(
111
222
  :subject,
112
223
  arg,
113
- :kind_of => String
224
+ kind_of: String
114
225
  )
115
226
  end
116
227
 
117
- def body_template(arg=nil)
228
+ #
229
+ # Gets or sets SNS message body template file path.
230
+ #
231
+ # @param arg [String] SNS body template.
232
+ #
233
+ # @return [String] SNS body template.
234
+ #
235
+ # @api public
236
+ #
237
+ def body_template(arg = nil)
118
238
  set_or_return(
119
239
  :body_template,
120
240
  arg,
121
- :kind_of => String
241
+ kind_of: String
122
242
  )
123
243
  end
124
244
 
125
- def filter_opsworks_activity(arg=nil)
245
+ #
246
+ # Gets or sets [OpsWorks](https://aws.amazon.com/opsworks/) activities.
247
+ #
248
+ # Notifications will only be triggered for the activities in the array,
249
+ # everything else will be discarded.
250
+ #
251
+ # @param arg [Array] Activities list.
252
+ #
253
+ # @return [Array] Activities list.
254
+ #
255
+ # @api public
256
+ #
257
+ def filter_opsworks_activity(arg = nil)
126
258
  arg = Array(arg) if arg.is_a? String
127
259
  set_or_return(
128
260
  :filter_opsworks_activity,
129
261
  arg,
130
- :kind_of => Array
262
+ kind_of: Array
131
263
  )
132
264
  end
133
265
  end