qurd 0.0.1

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.
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
data/lib/hash.rb ADDED
@@ -0,0 +1,6 @@
1
+ # rubocop:disable Documentation
2
+ class Hash
3
+ def inspect
4
+ to_json
5
+ end
6
+ end
data/lib/qurd.rb ADDED
@@ -0,0 +1,49 @@
1
+ require 'rubygems'
2
+ require 'bundler/setup'
3
+ # monkey patch inspect
4
+ require 'hash'
5
+ # add method underscore
6
+ require 'string'
7
+ require 'fileutils'
8
+ require 'singleton'
9
+ require 'json'
10
+ require 'yaml'
11
+ require 'hashie'
12
+ require 'cabin'
13
+ require 'aws-sdk'
14
+ require 'qurd/version'
15
+
16
+ # Ain't that some bullshit
17
+ module Qurd
18
+ autoload :Action, 'qurd/action'
19
+ autoload :Configuration, 'qurd/configuration'
20
+ autoload :Listener, 'qurd/listener'
21
+ autoload :Message, 'qurd/message'
22
+ autoload :Mixins, 'qurd/mixins'
23
+ autoload :Processor, 'qurd/processor'
24
+
25
+ extend Mixins::Configuration
26
+
27
+ class << self
28
+ def start(config = nil)
29
+ qurd_config.configure(config)
30
+ daemonize
31
+ listen_to_queues
32
+ end
33
+
34
+ private
35
+
36
+ def daemonize
37
+ IO.write(qurd_configuration.pid_file, $$)
38
+ end
39
+
40
+ # Iterate over listeners and their queues, listen for messages, and
41
+ # processing them
42
+ def listen_to_queues
43
+ threads = qurd_configuration.listeners.map(&:listen).flatten
44
+ $0 = "qurd [#{threads.count} threads]"
45
+ qurd_logger.debug("Threads #{threads}")
46
+ threads.each(&:join)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,92 @@
1
+ module Qurd
2
+ # Subclass and override {#launch}, {#launch_error}, {#terminate},
3
+ # {#terminate_error}, and {#test}, optionally override class method
4
+ # {#Qurd::Action.configure} and instance methods {#run_before} and
5
+ # {#run_after}
6
+ # @abstract
7
+ class Action
8
+ # Raised if the +message+ is invalid
9
+ class InvalidMessage < StandardError; end
10
+
11
+ autoload :Dummy, 'qurd/action/dummy'
12
+ autoload :Chef, 'qurd/action/chef'
13
+ autoload :Route53, 'qurd/action/route53'
14
+
15
+ extend Qurd::Mixins::Configuration
16
+ include Qurd::Mixins::Configuration
17
+
18
+ extend Qurd::Mixins::AwsClients
19
+ include Qurd::Mixins::AwsClients
20
+
21
+ # @!attribute context [r]
22
+ # The logging context
23
+ # @return [Cabin::Context]
24
+ # @!attribute message [r]
25
+ # @return [Qurd::Message]
26
+ attr_reader :context, :message
27
+
28
+ # Optionally configure the plugin
29
+ # @param [String] _action optionally configure, based on the actions
30
+ # +launch+, +launch_error+, +terminate+, +terminate_error+, or +test+
31
+ def self.configure(_action)
32
+ qurd_logger.debug('Nothing to do')
33
+ end
34
+
35
+ # Run the plugin for a given {#Qurd::Message}
36
+ # @param [Qurd::Message] message message The message
37
+ # @raise [Qurd::Action::InvalidMessage] If the +message+ is not a
38
+ # {#Qurd::Message}
39
+ # @see Qurd::Message
40
+ def initialize(message)
41
+ unless message.is_a?(Qurd::Message)
42
+ qurd_logger!("Message is not a Qurd::Message (#{message.class})",
43
+ Qurd::Action::InvalidMessage)
44
+ end
45
+ @message = message
46
+ @context = message.context
47
+ end
48
+
49
+ # Aws region for the message
50
+ # @return [String]
51
+ def region
52
+ message.region
53
+ end
54
+
55
+ # Aws credentials for the message
56
+ # @return [Aws::Credentials]
57
+ def aws_credentials
58
+ message.aws_credentials
59
+ end
60
+
61
+ # Executed before the processor runs the plugins for an action
62
+ # @see Qurd::Processor
63
+ def run_before
64
+ qurd_logger.debug('Nothing to do')
65
+ end
66
+
67
+ # Run the plugin
68
+ def launch
69
+ qurd_logger!("Override the abstract method #{__method__}")
70
+ end
71
+ alias_method :launch_error, :launch
72
+ alias_method :terminate, :launch
73
+ alias_method :terminate_error, :launch
74
+ alias_method :test, :launch
75
+
76
+ # Executed after the processor runs the plugins for an action
77
+ # @see Qurd::Processor
78
+ def run_after
79
+ qurd_logger.debug('Nothing to do')
80
+ end
81
+
82
+ def inspect
83
+ format('<%s:%x instance_id:%s message_id:%s context:%s>',
84
+ self.class,
85
+ object_id,
86
+ message.instance_id,
87
+ message.message_id,
88
+ context.inspect
89
+ )
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,128 @@
1
+ require 'qurd/action'
2
+ require 'chef'
3
+ require 'chef/search/query'
4
+ module Qurd
5
+ class Action
6
+ # Clean up chef client and node data
7
+ # @example Chef configuration
8
+ # chef_configuration: "/etc/chef/some_knife_config.rb"
9
+ class Chef < Action
10
+ @configure_done = false
11
+
12
+ # Add a setter and getter for {#Qurd::Message} +chef_node+ +chef_client+
13
+ # and configure Chef using {#Qurd::Configuration} +chef_configuration+ or
14
+ # the default +/etc/chef/knife.rb+. If {#Qurd::Configuration} +log_file+
15
+ # is defined, Chef will log to it.
16
+ # @param [String] _action the name of the action being configured
17
+ def self.configure(_action)
18
+ return if @configure_done
19
+ configure_chef
20
+ Qurd::Message.add_accessor(:chef_node)
21
+ Qurd::Message.add_accessor(:chef_client)
22
+ @configure_done = true
23
+ end
24
+
25
+ # Parse Chef's configuration file, as defined in +qurd.yml+, set Chef's
26
+ # +log_level+, and Chef's +log_location+
27
+ def self.configure_chef
28
+ config = File.expand_path(qurd_configuration.chef_configuration ||
29
+ '/etc/chef/knife.rb')
30
+ qurd_logger.debug("Configuring Chef for using #{config}")
31
+ ::Chef::Config.from_file(config)
32
+ ::Chef::Config[:log_level] = qurd_configuration.log_level
33
+ if qurd_configuration.log_file_io
34
+ qurd_logger.debug('Setting chef log file to ' \
35
+ "'#{qurd_configuration.log_file_io.path}'")
36
+ ::Chef::Config[:log_location] = qurd_configuration.log_file_io
37
+ end
38
+ end
39
+
40
+ # Find the node, using the +instance_id+ of the +message+
41
+ def run_before
42
+ find_chef_node
43
+ find_chef_client
44
+ end
45
+
46
+ # Delete the node, if the message did not fail other processing steps and
47
+ # dry_run is not true
48
+ # @see {#Qurd::Message}
49
+ def terminate
50
+ if message.failed?
51
+ qurd_logger.warn('Not deleting, message failed to process')
52
+ elsif qurd_configuration.dry_run
53
+ check_dry_run
54
+ else
55
+ qurd_logger.debug('Deleting')
56
+ message.chef_node.destroy unless message.chef_node.nil?
57
+ message.chef_client.destroy unless message.chef_client.nil?
58
+ end
59
+ end
60
+
61
+ # Respond to test actions
62
+ def test
63
+ qurd_logger.info('Test')
64
+ end
65
+
66
+ private
67
+
68
+ # Set the +message+ +chef_node+ and +context+ +chef_name+
69
+ # @see chef_search_node
70
+ def find_chef_node
71
+ node = chef_search_node
72
+ message.chef_node = node
73
+ message.context[:chef_name] = node.name
74
+ qurd_logger.debug('Chef node found')
75
+ rescue NoMethodError
76
+ qurd_logger.warn('Chef node not found')
77
+ message.chef_node = nil
78
+ message.context[:chef_name] = nil
79
+ end
80
+
81
+ # Set the +message+ +chef_client+ and +context+ +chef_client_name+
82
+ # @see chef_search_client
83
+ def find_chef_client
84
+ client = chef_search_client(message.chef_node.name)
85
+ message.chef_client = client
86
+ message.context[:chef_client_name] = client.name
87
+ qurd_logger.debug('Chef client found')
88
+ rescue NoMethodError
89
+ qurd_logger.warn('Chef client not found')
90
+ message.chef_client = nil
91
+ message.context[:chef_client_name] = nil
92
+ end
93
+
94
+ # Memoize a +Chef::Search::Query+
95
+ # @return [Chef::Search::Query]
96
+ def chef_search
97
+ @chef_search ||= ::Chef::Search::Query.new
98
+ end
99
+
100
+ # Search for a Chef node, based on the +instance_id+
101
+ # @return [Chef::Node|nil]
102
+ # @see instance_id
103
+ def chef_search_node
104
+ res = chef_search.search(:node, "instance_id:#{message.instance_id}")
105
+ res.last == 1 ? res[0][0] : nil
106
+ end
107
+
108
+ # Search for a Chef client, based on the +name+ passed, likely FQDN
109
+ # @param [String] name the client's name
110
+ # @return [Chef::ApiClient|nil]
111
+ def chef_search_client(name)
112
+ res = chef_search.search(:client, "name:#{name}")
113
+ res.last == 1 ? res[0][0] : nil
114
+ end
115
+
116
+ # Print log messages, based on object state
117
+ def check_dry_run
118
+ if !find_chef_node
119
+ qurd_logger.debug('Dry run; missing node')
120
+ elsif !find_chef_client
121
+ qurd_logger.debug('Dry run; missing client')
122
+ else
123
+ qurd_logger.debug('Dry run; would delete')
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,27 @@
1
+ require 'qurd/action'
2
+ module Qurd
3
+ class Action
4
+ # Example of a sub-classed {#Qurd::Action}
5
+ class Dummy < Action
6
+ def self.configure(action)
7
+ case action
8
+ when 'launch' then qurd_logger.debug('launch')
9
+ when 'launch_error' then qurd_logger.debug('launch_error')
10
+ when 'terminate' then qurd_logger.debug('terminate')
11
+ when 'terminate_error' then qurd_logger.debug('terminate_error')
12
+ when 'test' then qurd_logger.debug('test')
13
+ end
14
+ end
15
+
16
+ def launch
17
+ message.context[:dummy] = true
18
+ qurd_logger.debug('Qurd is debugging') if qurd_config.debug?
19
+ qurd_logger.info("Received message #{message.inspect}")
20
+ end
21
+ alias_method :launch_error, :launch
22
+ alias_method :terminate, :launch
23
+ alias_method :terminate_error, :launch
24
+ alias_method :test, :launch
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,168 @@
1
+ require 'qurd/action'
2
+ module Qurd
3
+ class Action
4
+ # Clean up route53 records
5
+ # @example Route53 configuration
6
+ # auto_scaling_queues:
7
+ # staging:
8
+ # credentials: foo
9
+ # region: us-east-1
10
+ # queues:
11
+ # - "/QueueName/i"
12
+ # route53:
13
+ # staging:
14
+ # hosted_zone: "staging.example.com."
15
+ class Route53 < Action
16
+ # Parent class for errors
17
+ class Errors < StandardError
18
+ # Hosted Zone not found
19
+ class ZoneNotFound < Errors; end
20
+ # Resource record set not found
21
+ class ResourceNotFound < Errors; end
22
+ # Hostname not available from EC2 and Chef
23
+ class HostNotFound < Errors; end
24
+ end
25
+
26
+ @configure_done = false
27
+ # Verify each +auto_scaling_queue+ has a corresponding +route53+ key and
28
+ # that each of those keys defines a +hosted_zone+
29
+ # @param [String] _action the action being configured
30
+ # @raise [RuntimeError] if any +auto_scaling_queues+ do not have correctly
31
+ # configured +route53+ keys
32
+ def self.configure(_action)
33
+ return if @configure_done
34
+ check_configuration
35
+ @configure_done = true
36
+ end
37
+
38
+ # Delete the record, if the message did not fail other processing steps and
39
+ # dry_run is not true
40
+ # @see {#Qurd::Message}
41
+ def terminate
42
+ if message.failed?
43
+ qurd_logger.warn('Not deleting, message failed to process')
44
+ elsif qurd_configuration.dry_run
45
+ if !hosted_zone
46
+ qurd_logger.debug('Dry run; missing hosted_zone')
47
+ elsif !hostname
48
+ qurd_logger.debug('Dry run; missing hostname')
49
+ elsif !resource_record
50
+ qurd_logger.debug('Dry run; missing resource_record')
51
+ else
52
+ qurd_logger.debug('Dry run; would delete')
53
+ end
54
+ else
55
+ route53_delete
56
+ end
57
+ end
58
+
59
+ # Respond to test actions
60
+ def test
61
+ qurd_logger.info('Test')
62
+ end
63
+
64
+ private
65
+
66
+ def instance_name
67
+ message.instance_name
68
+ end
69
+
70
+ def route53
71
+ @route53 ||= aws_client(:Route53)
72
+ end
73
+
74
+ def qurd_route53
75
+ @config ||= qurd_configuration.route53[message.name]
76
+ end
77
+
78
+ def chef_node_name
79
+ return @chef_node_name if @chef_node_name
80
+ @chef_node_name = message.chef_node.name
81
+ qurd_logger.debug("Found chef name '#{@chef_node_name}'")
82
+ @chef_node_name
83
+ rescue NoMethodError
84
+ qurd_logger.debug('No node found')
85
+ nil
86
+ end
87
+
88
+ def hostname
89
+ @hostname = instance_name || chef_node_name
90
+ @hostname.sub!(/([^.])$/, '\1.')
91
+ qurd_logger.debug("Using host '#{@hostname}'")
92
+ @hostname
93
+ rescue NoMethodError
94
+ qurd_logger!('No instance or chef information',
95
+ Errors::HostNotFound)
96
+ end
97
+
98
+ def resource_record(tries = nil)
99
+ @rr = aws_retryable(tries) do
100
+ route53.list_resource_record_sets(
101
+ hosted_zone_id: hosted_zone.id,
102
+ start_record_name: hostname,
103
+ max_items: 1
104
+ ).resource_record_sets.first
105
+ end
106
+ @rr || qurd_logger!('Resource record not found',
107
+ Errors::ResourceNotFound)
108
+ end
109
+
110
+ def route53_delete(tries = nil)
111
+ qurd_logger.debug('Deleting')
112
+ aws_retryable(tries) do
113
+ route53.change_resource_record_sets(
114
+ hosted_zone_id: hosted_zone.id,
115
+ change_batch: {
116
+ changes: [
117
+ action: 'DELETE',
118
+ resource_record_set: {
119
+ name: resource_record.name,
120
+ type: resource_record.type,
121
+ ttl: resource_record.ttl,
122
+ resource_records: resource_record.resource_records
123
+ }
124
+ ]
125
+ }
126
+ )
127
+ end
128
+ rescue Qurd::Action::Route53::Errors => e
129
+ qurd_logger.error("Failed to delete: #{e}")
130
+ message.failed!(e)
131
+ end
132
+
133
+ def hosted_zone(tries = nil)
134
+ return @hosted_zone if @hosted_zone
135
+ name = qurd_route53.hosted_zone
136
+ qurd_logger.debug("Looking for zone '#{name}'")
137
+ aws_retryable(tries) do
138
+ @hosted_zone = route53.list_hosted_zones_by_name(
139
+ dns_name: name,
140
+ max_items: 1
141
+ ).hosted_zones.first
142
+ qurd_logger.debug "Found zone '#{@hosted_zone}'"
143
+ end
144
+ @hosted_zone || qurd_logger!("Zone not found: '#{name}'",
145
+ Errors::ZoneNotFound)
146
+ end
147
+
148
+ def self.config_valid?(name)
149
+ if qurd_configuration.route53.nil? || \
150
+ qurd_configuration.route53[name].nil? || \
151
+ qurd_configuration.route53[name].hosted_zone.nil?
152
+ false
153
+ else
154
+ true
155
+ end
156
+ end
157
+
158
+ def self.check_configuration
159
+ missing = []
160
+ qurd_configuration.auto_scaling_queues.each do |name, _monitor|
161
+ missing << name unless config_valid?(name)
162
+ end
163
+ m = missing.join(', ')
164
+ qurd_logger! "Missing configuration for route53: #{m}" unless m.empty?
165
+ end
166
+ end
167
+ end
168
+ end