qurd 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (74) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +15 -0
  3. data/.ruby-version +1 -0
  4. data/Gemfile +5 -0
  5. data/LICENSE.txt +22 -0
  6. data/README.md +367 -0
  7. data/Rakefile +9 -0
  8. data/bin/qurd +10 -0
  9. data/lib/hash.rb +6 -0
  10. data/lib/qurd.rb +49 -0
  11. data/lib/qurd/action.rb +92 -0
  12. data/lib/qurd/action/chef.rb +128 -0
  13. data/lib/qurd/action/dummy.rb +27 -0
  14. data/lib/qurd/action/route53.rb +168 -0
  15. data/lib/qurd/configuration.rb +182 -0
  16. data/lib/qurd/listener.rb +207 -0
  17. data/lib/qurd/message.rb +231 -0
  18. data/lib/qurd/mixins.rb +8 -0
  19. data/lib/qurd/mixins/aws_clients.rb +37 -0
  20. data/lib/qurd/mixins/configuration.rb +29 -0
  21. data/lib/qurd/mixins/configuration_helpers.rb +79 -0
  22. data/lib/qurd/processor.rb +97 -0
  23. data/lib/qurd/version.rb +5 -0
  24. data/lib/string.rb +12 -0
  25. data/qurd.gemspec +32 -0
  26. data/test/action_test.rb +115 -0
  27. data/test/chef_test.rb +206 -0
  28. data/test/configuration_test.rb +333 -0
  29. data/test/dummy_action_test.rb +51 -0
  30. data/test/inputs/foo.pem +27 -0
  31. data/test/inputs/knife.rb +9 -0
  32. data/test/inputs/qurd.yml +32 -0
  33. data/test/inputs/qurd_chef.yml +35 -0
  34. data/test/inputs/qurd_chef_route53.yml +43 -0
  35. data/test/inputs/qurd_route53.yml +39 -0
  36. data/test/inputs/qurd_route53_wrong.yml +37 -0
  37. data/test/inputs/validator.pem +27 -0
  38. data/test/listener_test.rb +135 -0
  39. data/test/message_test.rb +187 -0
  40. data/test/mixin_aws_clients_test.rb +28 -0
  41. data/test/mixin_configuration_test.rb +36 -0
  42. data/test/processor_test.rb +41 -0
  43. data/test/responses/aws/ec2-describe-instances-0.xml +2 -0
  44. data/test/responses/aws/ec2-describe-instances-1.xml +127 -0
  45. data/test/responses/aws/error-response.xml +1 -0
  46. data/test/responses/aws/route53-change-resource-record-sets.xml +2 -0
  47. data/test/responses/aws/route53-list-hosted-zones-by-name-0.xml +3 -0
  48. data/test/responses/aws/route53-list-hosted-zones-by-name-1.xml +4 -0
  49. data/test/responses/aws/route53-list-hosted-zones-by-name-n.xml +5 -0
  50. data/test/responses/aws/route53-list-resource-record-sets-0.xml +2 -0
  51. data/test/responses/aws/route53-list-resource-record-sets-1.xml +4 -0
  52. data/test/responses/aws/route53-list-resource-record-sets-n.xml +6 -0
  53. data/test/responses/aws/sqs-list-queues-0.xml +1 -0
  54. data/test/responses/aws/sqs-list-queues-n.xml +4 -0
  55. data/test/responses/aws/sqs-receive-message-1-launch.xml +6 -0
  56. data/test/responses/aws/sqs-receive-message-1-launch_error.xml +6 -0
  57. data/test/responses/aws/sqs-receive-message-1-other.xml +12 -0
  58. data/test/responses/aws/sqs-receive-message-1-terminate.xml +6 -0
  59. data/test/responses/aws/sqs-receive-message-1-terminate_error.xml +6 -0
  60. data/test/responses/aws/sqs-receive-message-1-test.xml +12 -0
  61. data/test/responses/aws/sqs-set-queue-attributes.xml +1 -0
  62. data/test/responses/aws/sts-assume-role.xml +17 -0
  63. data/test/responses/chef/search-client-name-0.json +6 -0
  64. data/test/responses/chef/search-client-name-1.json +7 -0
  65. data/test/responses/chef/search-client-name-n.json +8 -0
  66. data/test/responses/chef/search-node-instance-0.json +5 -0
  67. data/test/responses/chef/search-node-instance-1.json +784 -0
  68. data/test/responses/chef/search-node-instance-n.json +1565 -0
  69. data/test/responses/ec2/latest-meta-data-iam-security-credentials-client.txt +9 -0
  70. data/test/responses/ec2/latest-meta-data-iam-security-credentials.txt +1 -0
  71. data/test/route53_test.rb +231 -0
  72. data/test/support/web_mock_stubs.rb +109 -0
  73. data/test/test_helper.rb +10 -0
  74. metadata +307 -0
@@ -0,0 +1,182 @@
1
+ # rubocop:disable ClassLength
2
+ module Qurd
3
+ # Parse a configuration file, create a logger and various data structures
4
+ class Configuration
5
+ include Singleton
6
+ include Mixins::ConfigurationHelpers
7
+ # @!attribute [r] config
8
+ # Configuration options, ie
9
+ # +aws_credentials+, +auto_scaling_queues+, +actions+, +daemonize+,
10
+ # +dry_run+, +listen_timeout+, +log_file+, +log_level+, +pid_file+,
11
+ # +save_failures+, +sqs_set_attributes_timeout+, +visibility_timeout+,
12
+ # +wait_time+.
13
+ # Additional configuration keys include +listeners+.
14
+ # @return [Hashie::Mash] the config YAML as a Mash
15
+ # @!attribute [r] logger
16
+ # The logger
17
+ # @return [Cabin::Channel]
18
+ attr_reader :config, :logger
19
+
20
+ # Initialize Qurd
21
+ # @param [String] config_path The path to the config file, default
22
+ # +/etc/qurd/config.yml+
23
+ def init(config_path)
24
+ config_path ||= '/etc/qurd/config.yml'
25
+ @config = Hashie::Mash.new YAML.load(File.read(config_path))
26
+ @sqs_queues = {}
27
+ @config.daemonize = false if @config.daemonize.nil?
28
+ @config.dry_run = get_or_default(@config, :dry_run, false)
29
+ @config.pid_file ||= '/var/run/qurd/qurd.pid'
30
+ @config.save_failures = get_or_default(@config, :save_failures, true)
31
+ @queues = []
32
+ @aws_credentials = []
33
+ vt = get_or_default(@config, :visibility_timeout, 300, :to_s)
34
+ wt = get_or_default(@config, :wait_time, 20, :to_s)
35
+ st = get_or_default(@config, :sqs_set_attributes_timeout, 10, :to_f)
36
+ lt = get_or_default(@config, :listen_timeout, vt, :to_f)
37
+ @config.visibility_timeout = vt
38
+ @config.wait_time = wt
39
+ @config.sqs_set_attributes_timeout = st
40
+ @config.listen_timeout = lt
41
+ %w[launch launch_error terminate terminate_error test].each do |action|
42
+ @config.actions[action] ||= []
43
+ end
44
+
45
+ configure_logger
46
+ end
47
+
48
+ # Configure Qurd
49
+ # @param [String] config_path The path to the config file, default
50
+ # +/etc/qurd/config.yml+
51
+ def configure(config_path)
52
+ init(config_path)
53
+ mkdir_p_file!(@config.pid_file)
54
+
55
+ configure_credentials
56
+ configure_auto_scaling_queues
57
+ configure_actions
58
+ end
59
+
60
+ # Determine if the daemon is running in debug mode
61
+ # @return [Boolean]
62
+ def debug?
63
+ config.log_level == 'debug'
64
+ end
65
+
66
+ # Log an error and raise an exception
67
+ # @param [String] msg The error and exception message
68
+ # @param [Exception] e The exception to raise
69
+ def logger!(msg, e = RuntimeError)
70
+ logger.error msg
71
+ fail e, msg
72
+ end
73
+
74
+ # Get a logging context and optionally initialize it
75
+ # @param [Hash] attrs a hash of values to +merge+ into the context
76
+ # @return [Cabin::Context]
77
+ def get_context(attrs = {})
78
+ ctx = logger.context
79
+ attrs.each do |k, v|
80
+ ctx[k] = v
81
+ end
82
+ ctx
83
+ end
84
+
85
+ private
86
+
87
+ def configure_credentials
88
+ if config.aws_credentials.nil? || config.aws_credentials.empty?
89
+ creds = default_credentials
90
+ else
91
+ creds = config.aws_credentials.map do |cred|
92
+ cred.options ||= {}
93
+ case cred.type
94
+ when 'assume_role_credentials'
95
+ assume_role_credentials(cred)
96
+ when 'credentials'
97
+ credentials(cred)
98
+ when 'shared_credentials'
99
+ shared_credentials(cred)
100
+ when 'instance_profile_credentials'
101
+ instance_profile_credentials(cred)
102
+ else qurd_logger! "Credential type unknown: '#{cred.type}'"
103
+ end
104
+ end
105
+ end
106
+ config.aws_credentials = Hash[creds]
107
+ end
108
+
109
+ # Convert strings to objects
110
+ def configure_actions
111
+ missing = config.actions.inject([]) do |ary, mod|
112
+ action, klasses = mod
113
+ return ary if klasses.nil?
114
+ ctx = get_context(action: action)
115
+ logger! 'Action types must be an array' unless klasses.is_a?(Array)
116
+ klasses.map! do |klass|
117
+ begin
118
+ k = string2class(klass)
119
+ k.configure(action)
120
+ k
121
+ rescue NameError, LoadError => e
122
+ logger.error(e)
123
+ ary << klass
124
+ end
125
+ end
126
+ ctx.clear
127
+ ary
128
+ end
129
+
130
+ m = missing.uniq.join(', ')
131
+ logger! "Class undefined for actions: #{m}" if missing.any?
132
+ end
133
+
134
+ # Configure Cabin and Aws logging
135
+ def configure_logger
136
+ @logger = Cabin::Channel.new
137
+ if config.log_file || config.daemonize
138
+ path = config.log_file || '/var/log/qurd/qurd.log'
139
+ mkdir_p_file!(path)
140
+ config.log_file_io = open(path, 'w')
141
+ @ruby_logger = Logger.new(config.log_file_io)
142
+ @logger.level = (config.log_level || :info).to_sym
143
+ else
144
+ @logger.level = (config.log_level || :debug).to_sym
145
+ @ruby_logger = Logger.new(STDOUT)
146
+ end
147
+ @logger.subscribe(@ruby_logger)
148
+
149
+ Aws.config[:logger] = @ruby_logger
150
+ Aws.config[:http_wire_trace] = debug?
151
+
152
+ @logger.debug('Logging configured')
153
+ end
154
+
155
+ # Configure sqs clients and queues
156
+ def configure_auto_scaling_queues
157
+ config.listeners = config.auto_scaling_queues.map do |name, monitor|
158
+ if (config.auto_scaling_queues.nil? ||
159
+ config.auto_scaling_queues.empty?) &&
160
+ config.aws_credentials.default
161
+ creds = config.aws_credentials.default
162
+ monitor.credentials = 'default'
163
+ else
164
+ creds = config.aws_credentials[monitor.credentials]
165
+ end
166
+ verify_account!(name, monitor)
167
+ logger!("Undefined credential: '#{monitor.credentials}'") unless creds
168
+ vt = get_or_default(monitor, :visibility_timeout,
169
+ config.visibility_timeout, :to_s)
170
+ wt = get_or_default(monitor, :wait_time, config.wait_time, :to_s)
171
+ Listener.new(
172
+ aws_credentials: creds,
173
+ name: name,
174
+ queues: monitor.queues,
175
+ region: monitor.region,
176
+ visibility_timeout: vt,
177
+ wait_time: wt
178
+ )
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,207 @@
1
+ # rubocop:disable ClassLength
2
+ # Gem module
3
+ module Qurd
4
+ # Provide an interface for interacting with configured queues and AWS.
5
+ class Listener
6
+ include Qurd::Mixins::AwsClients
7
+ include Qurd::Mixins::Configuration
8
+
9
+ # @!attribute aws_credentials [r]
10
+ # The AWS credentials for the account
11
+ # @return [Aws::Credentials]
12
+ # @!attribute message [r]
13
+ # The message reveived
14
+ # @return [Qurd::Message]
15
+ # @!attribute name [r]
16
+ # The name of the executor
17
+ # @return [String]
18
+ # @!attribute queues [r]
19
+ # An array of AWS SQS URLs for the account
20
+ # @return [Array<String>]
21
+ # @!attribute region [r]
22
+ # The AWS region for the account
23
+ # @return [String]
24
+ # @!attribute visibility_timeout [r]
25
+ # @return [String]
26
+ # @!attribute wait_time [r]
27
+ # @return [String]
28
+ attr_reader :aws_credentials,
29
+ :message,
30
+ :name,
31
+ :queues,
32
+ :region,
33
+ :visibility_timeout,
34
+ :wait_time
35
+
36
+ # @param [Hash] attrs
37
+ # @option attrs [Aws::Credentials] :aws_credentials
38
+ # @option attrs [String] :name
39
+ # @option attrs [Array<String|Regexp>] :queues An array of SQS names
40
+ # and Regexps
41
+ # @option attrs [String] :region
42
+ # @option attrs [String] :visibility_timeout
43
+ # @option attrs [String] :wait_time
44
+ def initialize(attrs = {})
45
+ @aws_credentials = attrs[:aws_credentials]
46
+ @name = attrs[:name]
47
+ @region = attrs[:region]
48
+ @visibility_timeout = attrs[:visibility_timeout]
49
+ @wait_time = attrs[:wait_time]
50
+ @queues = convert_queues attrs[:queues]
51
+ configure_queues
52
+ end
53
+
54
+ # Create a thread for each queue URL, a context denoting the listener name
55
+ # and the queue URL.
56
+ # @param [Proc] _block the proc each thread should run
57
+ # @yieldparam [String] url the url of the queue
58
+ # @yieldparam [Cabin::Context] ctx the logging context
59
+ def queue_threads(&_block)
60
+ queues.map do |qurl|
61
+ qurd_logger.debug("Creating thread for #{qurl}")
62
+ Thread.new(qurl) do |url|
63
+ ctx = qurd_config.get_context(name: name, queue_name: url[/([^\/]+)$/])
64
+ qurd_logger.debug('Thread running')
65
+ yield url, ctx
66
+ end
67
+ end
68
+ end
69
+
70
+ # Create one thread per +queue+, receive messages from it and process each
71
+ # message received
72
+ # @return [Array<Thread>]
73
+ def listen
74
+ queue_threads do |qurl, _context|
75
+ loop do
76
+ begin
77
+ msgs = aws_client(:SQS).receive_message(
78
+ queue_url: qurl,
79
+ wait_time_seconds: wait_time,
80
+ visibility_timeout: visibility_timeout
81
+ )
82
+ threads = process_messages(qurl, msgs)
83
+ joins = threads.map do |thread|
84
+ thread.join(qurd_configuration.listen_timeout)
85
+ end
86
+ if joins.compact.count != threads.count
87
+ qurd_logger.warn('Some threads timed out')
88
+ end
89
+ rescue Aws::Errors::ServiceError => e
90
+ qurd_logger.error("Aws raised #{e}")
91
+ end
92
+ end
93
+ end
94
+ end
95
+
96
+ # @private
97
+ def inspect
98
+ format('<Qurd::Listener:%x name:%s>', object_id, name)
99
+ end
100
+
101
+ private
102
+
103
+ def process_messages(qurl, msgs)
104
+ msgs.messages.map do |msg|
105
+ Thread.new(msg) do |m|
106
+ qurd_logger.debug("Found message #{msg}")
107
+ r = Processor.new self, m, name, qurl
108
+ r.process
109
+ end
110
+ end
111
+ end
112
+
113
+ def configure_queues
114
+ threads = configure_queues_threads
115
+ joins = threads.map do |thread|
116
+ thread.join(qurd_configuration.sqs_set_attributes_timeout)
117
+ end
118
+
119
+ qurd_logger! 'One or more threads timed out' \
120
+ if joins.compact.count != threads.count
121
+ end
122
+
123
+ def configure_queues_threads
124
+ queue_threads do |q, _context|
125
+ qurd_logger.debug("Setting wait_time:#{wait_time} " \
126
+ "visibility_timeout:#{visibility_timeout} #{q}")
127
+ begin
128
+ aws_client(:SQS).set_queue_attributes(
129
+ queue_url: q,
130
+ attributes: {
131
+ ReceiveMessageWaitTimeSeconds: wait_time,
132
+ VisibilityTimeout: visibility_timeout
133
+ }
134
+ )
135
+ rescue Aws::SQS::Errors::ServiceError::QueueDoesNotExist => e
136
+ qurd_logger.error("SQS raised #{e}")
137
+ rescue Aws::SQS::Errors::ServiceError => e
138
+ qurd_logger.error("SQS raised #{e}")
139
+ raise e
140
+ end
141
+ end
142
+ end
143
+
144
+ # Convert a regex string to a regex, including modifiers
145
+ # @param [String] r String form of the regex
146
+ # @return [Regexp] The compiled regex
147
+ # @example With modifier
148
+ # Qurd::Configuration.parse_regex("/foo/i")
149
+ def parse_regex(r)
150
+ # /foo/ or /foo/i
151
+ m = r.match %r{\A/(.*)/([a-z]*)\Z}mx
152
+ qurd_logger.debug("Found re: #{m[0]} 1: #{m[1]} 2: #{m[2]}")
153
+ args = modifier2int(m[2])
154
+ regex = Regexp.new(m[1], args)
155
+ qurd_logger.debug("Compiled regex #{regex}")
156
+ queue_url regex
157
+ end
158
+
159
+ def modifier2int(str)
160
+ args = 0
161
+ str.each_byte do |c|
162
+ args |= case c.chr
163
+ when 'i' then Regexp::IGNORECASE
164
+ when 'm' then Regexp::MULTILINE
165
+ when 'x' then Regexp::EXTENDED
166
+ when 'o' then 0
167
+ when 'e' then 16
168
+ else qurd_logger! "Unknown regex modifier #{c.chr}"
169
+ end
170
+ end
171
+ args
172
+ end
173
+
174
+ # Find the SQS URL for a named queue or a regex
175
+ # @overload queue_url(name)
176
+ # @param [String] name The AWS SQS name
177
+ # @return [String] AWS SQS URL
178
+ #
179
+ # @overload queue_url(name)
180
+ # @param [Regexp] name regex of a queue name
181
+ # @return [Array<String>]
182
+ def queue_url(name)
183
+ @sqs_queues ||= aws_client(:SQS).list_queues.queue_urls
184
+
185
+ if name.respond_to?(:upcase)
186
+ url = @sqs_queues.find { |u| u[/([^\/]+$)/] == name }
187
+ else
188
+ url = @sqs_queues.select { |u| u =~ name }
189
+ end
190
+ qurd_logger.debug("Queue #{name} found '#{url}'")
191
+ qurd_logger.warn("No queue found for '#{name}'") if url.nil? || url.empty?
192
+ url
193
+ rescue Aws::SQS::Errors::ServiceError => e
194
+ qurd_logger.error("SQS raised #{e}")
195
+ raise e
196
+ end
197
+
198
+ # Convert regexes to and strings to queue URLs
199
+ # @param [Array<String>] queues An array of queues to monitor
200
+ # @return [Array<String>] SQS URLs
201
+ def convert_queues(queues)
202
+ queues.map do |q|
203
+ q[0] == '/' ? parse_regex(q) : queue_url(q)
204
+ end.flatten.compact
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,231 @@
1
+ # rubocop:disable Metrics/LineLength
2
+ module Qurd
3
+ # Convert an SQS auto scaling message to a more usable object
4
+ # @example SQS auto scaling message
5
+ # {
6
+ # "Type" : "Notification",
7
+ # "MessageId" : "e4379a5a-e119-53f7-b6ef-d7dbd32d31fe",
8
+ # "TopicArn" : "arn:aws:sns:us-east-1:123456890:test-ScalingNotificationsTopic-HPPYDAYSAGAIN",
9
+ # "Subject" : "Auto Scaling: termination for group \"test2-AutoScalingGroup-1QDX3CNO5SU3D\"",
10
+ # "Message" : "{\"StatusCode\":\"InProgress\",\"Service\":\"AWS Auto Scaling\",\"AutoScalingGroupName\":\"test2-AutoScalingGroup-1QDX3CNO5SU3D\",\"Description\":\"Terminating EC2 instance: i-08e58cf8\",\"ActivityId\":\"93faaf3a-28cb-4982-a690-0a73c989ab1f\",\"Event\":\"autoscaling:EC2_INSTANCE_TERMINATE\",\"Details\":{\"Availability Zone\":\"us-east-1a\",\"Subnet ID\":\"subnet-3c3e0e14\"},\"AutoScalingGroupARN\":\"arn:aws:autoscaling:us-east-1:123456890:autoScalingGroup:4edb2535-5015-4b81-b668-88ecb0effcb7:autoScalingGroupName/test2-AutoScalingGroup-1QDX3CNO5SU3D\",\"Progress\":50,\"Time\":\"2015-03-16T19:33:08.181Z\",\"AccountId\":\"123456890\",\"RequestId\":\"93faaf3a-28cb-4982-a690-0a73c989ab1f\",\"StatusMessage\":\"\",\"EndTime\":\"2015-03-16T19:33:08.181Z\",\"EC2InstanceId\":\"i-08e58cf8\",\"StartTime\":\"2015-03-16T19:29:14.911Z\",\"Cause\":\"At 2015-03-16T19:29:14Z an instance was taken out of service in response to a ELB system health check failure.\"}",
11
+ # "Timestamp" : "2015-03-16T19:33:08.242Z",
12
+ # "SignatureVersion" : "1",
13
+ # "Signature" : "I+SE8tMiq13/wDTPTJnJvHYi3jSjChhYByJAsnhY0wGa+0lxXc18vPIn9hIT0tYRNWMcR/Xn1AUNsgHrLjzB93xukyKA2CDff08zIuP0l4Xle/FSEJzfkJ0FDqZnzelFuZ2PMtO3lf5UY7CWZg/wKJv6I9CNJF4Ll9YgvC8Moe/31VwJwNy4TRAWdBhDuRXLjbEHoFNGjaGquiduOGySrgRmm74d0P0zWj7IfWbqO6ReNG2ADrqw+Bhn6dAkkeFH+9vJZeKdUCgsXX8XCBHcWX+yAb4WJH90hdosLN12DCdn2AvNgQfoTdpDPkTHC+QcwfRs52d3MD2WLrUfBMBy0A==",
14
+ # "SigningCertURL" : "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-d6d679a1d18e95c2f9ffcf11f4f9e198.pem",
15
+ # "UnsubscribeURL" : "https://sns.us-east-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-east-1:123456890:test-ScalingNotificationsTopic-HPPYDAYSAGAIN:bd850bb2-1a69-4456-a517-c645a26f54b2"
16
+ # }
17
+ class Message
18
+ extend Qurd::Mixins::Configuration
19
+
20
+ # Add setter and getter instances methods. If the get or set method is
21
+ # already defined, an exception will be raised.
22
+ # @param [Symbol] name the name of the method to add
23
+ def self.add_accessor(name)
24
+ if instance_methods.include?(name) ||
25
+ instance_methods.include?("#{name}=")
26
+ qurd_logger.warn "Can not replace a method! (#{name})"
27
+ end
28
+ attr_accessor name
29
+ end
30
+
31
+ include Qurd::Mixins::Configuration
32
+ include Qurd::Mixins::AwsClients
33
+ # @!attribute aws_credentials [r]
34
+ # @return [Aws::Credentials]
35
+ # @!attribute context [r]
36
+ # Cabin::Channel logs messages as well as context. Context is retained
37
+ # until it is cleared.
38
+ # @return [Cabin::Context] Context data
39
+ # @!attribute exceptions [r]
40
+ # Action exceptions
41
+ # @return [Array<Exception>]
42
+ # @!attribute name [r]
43
+ # The Listener name
44
+ # @return [String]
45
+ # @!attribute queue_url [r]
46
+ # The SQS url the message came from
47
+ # @return [String]
48
+ # @!attribute region [r]
49
+ # AWS region
50
+ # @return [String]
51
+ attr_reader :aws_credentials,
52
+ :context,
53
+ :exceptions,
54
+ :name,
55
+ :queue_url,
56
+ :region
57
+
58
+ #
59
+ # @param [Hash] attrs
60
+ # @option attrs [Aws::Credentials] :aws_credentials
61
+ # @option attrs [String] :name
62
+ # @option attrs [String] :queue_url
63
+ # @option attrs [String] :region msg AWS SQS message
64
+ # @option attrs [Struct] :message AWS SQS message
65
+ def initialize(attrs)
66
+ @aws_credentials = attrs[:aws_credentials]
67
+ @name = attrs[:name]
68
+ @queue_url = attrs[:queue_url]
69
+ @region = attrs[:region]
70
+ @sqs_message = attrs[:message]
71
+
72
+ @exceptions = []
73
+ @failed = false
74
+ @context = qurd_config.get_context(name: @name,
75
+ queue_name: (@queue_url[/[^\/]+$/] rescue nil),
76
+ instance_id: instance_id,
77
+ message_id: message_id,
78
+ action: action)
79
+
80
+ qurd_logger.info "Received #{body.Subject} Cause #{message.Cause} Event #{message.Event}"
81
+ end
82
+
83
+ # Convert the SQS message +body+ to a mash, keys include +Type+, +MessageId+,
84
+ # +TopicArn+, +Subject+, +Message+, +Timestamp+, +SignatureVersion+,
85
+ # +Signature+, +SigningCertURL+, +UnsubscribeURL+
86
+ # @return [Hashie::Mash]
87
+ def body
88
+ @body ||= Hashie::Mash.new JSON.load(@sqs_message.body)
89
+ rescue JSON::ParserError
90
+ @body = Hashie::Mash.new {}
91
+ end
92
+
93
+ # Convert +body.Message+ to a mash, keys include
94
+ # +StatusCode+, +Service+, +AutoScalingGroupName+, +Description+,
95
+ # +ActivityId+, +Event+, +Details+ +AutoScalingGroupARN+ +Progress+ +Time+
96
+ # +AccountId+ +RequestId+, +StatusMessage+ +EndTime+ +EC2InstanceId+
97
+ # +StartTime+ +Cause+
98
+ # @return [Hashie::Mash]
99
+ def message
100
+ @message ||= Hashie::Mash.new JSON.load(body.Message)
101
+ rescue JSON::ParserError
102
+ @message = Hashie::Mash.new {}
103
+ end
104
+
105
+ # The SQS message's +EC2InstanceId+
106
+ # @return [String]
107
+ def instance_id
108
+ @instance_id ||= message.EC2InstanceId
109
+ end
110
+
111
+ # The +body.MessageId+
112
+ # @return [String]
113
+ def message_id
114
+ @message_id ||= body.MessageId
115
+ end
116
+
117
+ # The SQS +receipt_handle+, used to delete a message
118
+ # @return [String]
119
+ def receipt_handle
120
+ @sqs_message.receipt_handle
121
+ end
122
+
123
+ # Record an action failure
124
+ # @param [Exception] e The exception
125
+ def failed!(e = nil)
126
+ qurd_logger.debug 'Failed'
127
+ @exceptions << e if e
128
+ @failed = true
129
+ nil
130
+ end
131
+
132
+ # Has processing the message failed
133
+ # @return [Boolean]
134
+ def failed?
135
+ @failed == true
136
+ end
137
+
138
+ # Memozied EC2 instance. Caller must anticipate +nil+ results, as instances
139
+ # may terminate before the message is received.
140
+ # @param [Fixnum] tries The number of times to retry the Aws API
141
+ # @return [Struct|nil]
142
+ def instance(tries = nil)
143
+ return @instance if @instance
144
+ @instance = aws_instance(tries)
145
+ end
146
+
147
+ # Memoize the instance's +Name+ tag
148
+ def instance_name
149
+ return @instance_name if @instance_name
150
+ @instance_name = instance.tags.find do |t|
151
+ t.key == 'Name'
152
+ end.value
153
+ qurd_logger.debug("Found instance name '#{@instance_name}'")
154
+ @instance_name
155
+ rescue NoMethodError
156
+ qurd_logger.debug('No instance found')
157
+ @instance_name = nil
158
+ end
159
+
160
+ # Convert the +message.Event+ to an action
161
+ # @return [String] +launch+, +launch_error+, +terminate+, +terminate_error+,
162
+ # or +test+
163
+ def action
164
+ case message.Event
165
+ when 'autoscaling:EC2_INSTANCE_LAUNCH' then 'launch'
166
+ when 'autoscaling:EC2_INSTANCE_LAUNCH_ERROR' then 'launch_error'
167
+ when 'autoscaling:EC2_INSTANCE_TERMINATE' then 'terminate'
168
+ when 'autoscaling:EC2_INSTANCE_TERMINATE_ERROR' then 'terminate_error'
169
+ when 'autoscaling:TEST_NOTIFICATION' then 'test'
170
+ else
171
+ qurd_logger.info "Ignoring #{message.Event}"
172
+ failed!
173
+ end
174
+ end
175
+
176
+ # Delete an AWS SQS message
177
+ def delete
178
+ qurd_logger.debug('Preparing to delete message')
179
+ if failed? && qurd_configuration.save_failures
180
+ qurd_logger.error 'Message failed processing, not deleting'
181
+ elsif qurd_configuration.dry_run
182
+ qurd_logger.info 'Dry run'
183
+ else
184
+ delete_message
185
+ end
186
+ context.clear
187
+ end
188
+
189
+ # @private
190
+ def inspect
191
+ format('#<Qurd::Message message_id:%s subject:%s cause:%s ' \
192
+ 'instance_id:%s instance:%s>',
193
+ message_id,
194
+ body.Subject,
195
+ message.Cause,
196
+ instance_id,
197
+ instance)
198
+ end
199
+
200
+ private
201
+
202
+ # Get the Aws EC2 instance, using +instance_id+
203
+ # @param [Fixnum] tries the number of retries
204
+ # @return [Struct|nil]
205
+ # @see instance_id
206
+ # @see instance
207
+ def aws_instance(tries = nil)
208
+ return unless instance_id
209
+ aws_retryable(tries) do
210
+ aws_client(:EC2).describe_instances(
211
+ instance_ids: [instance_id]
212
+ ).reservations.first.instances.first
213
+ end
214
+ rescue NoMethodError
215
+ nil
216
+ end
217
+
218
+ def delete_message
219
+ qurd_logger.info 'Deleting'
220
+ begin
221
+ aws_client(:SQS).delete_message(queue_url: queue_url,
222
+ receipt_handle: receipt_handle)
223
+ rescue Aws::SQS::Errors::ReceiptHandleIsInvalid
224
+ qurd_logger.info('SQS message deleted already or timed out')
225
+ rescue Aws::SQS::Errors::ServiceError => e
226
+ qurd_logger.error("SQS raised #{e}")
227
+ raise e
228
+ end
229
+ end
230
+ end
231
+ end