|
| 1 | +#!/usr/bin/env ruby |
| 2 | + |
| 3 | +require 'net/http' |
| 4 | +require 'net/https' |
| 5 | +require 'openssl' |
| 6 | +require 'base64' |
| 7 | +require 'rexml/document' |
| 8 | +# We have enough of a minimal parser to get by without the JSON gem. |
| 9 | +# However, if it's available we may as well use it. |
| 10 | +begin |
| 11 | + require 'json' |
| 12 | +rescue LoadError |
| 13 | +end |
| 14 | + |
| 15 | +# Escape URI form components. |
| 16 | +# URI.encode_www_form_component doesn't exist on Ruby 1.8, so we fall back to |
| 17 | +# URI.encode + gsub |
| 18 | +def uri_encode(component) |
| 19 | + if URI.respond_to? :encode_www_form_component |
| 20 | + URI.encode_www_form_component(component) |
| 21 | + else |
| 22 | + URI.encode(component).gsub('=', '%3D').gsub(':', '%3A').gsub('/', '%2F').gsub('+', '%2B') |
| 23 | + end |
| 24 | +end |
| 25 | + |
| 26 | +# If the JSON gem was loaded, use JSON.parse otherwise use the inbuilt mini parser |
| 27 | +def parse_json_object(input) |
| 28 | + if defined? JSON |
| 29 | + JSON.parse(input) |
| 30 | + else |
| 31 | + json_objparse(input) |
| 32 | + end |
| 33 | +end |
| 34 | + |
| 35 | +# A mini parser capable of parsing unnested JSON objects. |
| 36 | +# A couple of the http://169.254.169.254/ pages return simple JSON |
| 37 | +# in the form of: |
| 38 | +# { |
| 39 | +# "KeyA" : "ValueA", |
| 40 | +# "KeyB" : "ValueB" |
| 41 | +# } |
| 42 | +# Which this should be able to handle. We should still use the JSON library if it's available though |
| 43 | +def json_objparse(input) |
| 44 | + input = input.strip # Copy, don't strip! the source string |
| 45 | + |
| 46 | + unless input.start_with?('{') && input.end_with?('}') |
| 47 | + raise "not an object" |
| 48 | + end |
| 49 | + |
| 50 | + body = input[1..-2].gsub("\n", ' ') # Easier than dealing with newlines in regexen |
| 51 | + if body.empty? |
| 52 | + return {} |
| 53 | + end |
| 54 | + obj = {} |
| 55 | + |
| 56 | + until body.nil? || body =~ /^\s*$/ |
| 57 | + next if body.match(/^\s*"([^"]*)"\s*:\s*("[^"]*"|\d+\.\d+|\d+|null|true|false)\s*(?:,(.*)|($))/) do |md| |
| 58 | + key = md[1] |
| 59 | + if obj.has_key? key |
| 60 | + raise "Duplicate key #{key}" |
| 61 | + end |
| 62 | + |
| 63 | + case md[2] |
| 64 | + when 'null' |
| 65 | + obj[key] = nil |
| 66 | + when 'true' |
| 67 | + obj[key] = true |
| 68 | + when 'false' |
| 69 | + obj[key] = false |
| 70 | + when /^"[^"]*"$/ |
| 71 | + obj[key] = md[2][1..-2] |
| 72 | + when /^\d+\.\d+$/ |
| 73 | + obj[key] = md[2].to_f |
| 74 | + when /^\d+$/ |
| 75 | + obj[key] = md[2].to_i |
| 76 | + end |
| 77 | + body = md[3] |
| 78 | + true |
| 79 | + end |
| 80 | + |
| 81 | + raise "Parsing failed at #{body.strip.inspect}" |
| 82 | + end |
| 83 | + |
| 84 | + obj |
| 85 | +end |
| 86 | +# The instance document tells us our instance id, region, etc |
| 87 | +def get_instance_document |
| 88 | + url = URI.parse('http://169.254.169.254/latest/dynamic/instance-identity/document') |
| 89 | + response = Net::HTTP.get_response(url) |
| 90 | + |
| 91 | + return nil if response.code != "200" |
| 92 | + |
| 93 | + return parse_json_object(response.body) |
| 94 | +end |
| 95 | + |
| 96 | +# If an IAM role is available for the instance, we will attempt to query it. |
| 97 | +# We return an empty Hash on failure. Keys may be available via enviroment |
| 98 | +# variables as a fallback. |
| 99 | +def get_instance_role |
| 100 | + url = URI.parse('http://169.254.169.254/latest/meta-data/iam/security-credentials/') |
| 101 | + response = Net::HTTP.get_response(url) |
| 102 | + |
| 103 | + return {} if response.code != "200" |
| 104 | + |
| 105 | + body = response.body |
| 106 | + |
| 107 | + role = body.lines.first |
| 108 | + response = Net::HTTP::get_response(url+role) |
| 109 | + |
| 110 | + return {} if response.code != "200" |
| 111 | + |
| 112 | + role = parse_json_object(response.body) |
| 113 | +end |
| 114 | + |
| 115 | +# Sign and send a request to the AWS REST API. Method is defined as an "Action" parameter. |
| 116 | +# parameters is an array because order is important for the signing process. |
| 117 | +def query(parameters, endpoint, access_key, secret_key, token = nil) |
| 118 | + timestamp = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ') |
| 119 | + |
| 120 | + parameters += [ |
| 121 | + ['AWSAccessKeyId', access_key ], |
| 122 | + ['SignatureVersion', '2' ], |
| 123 | + ['SignatureMethod', 'HmacSHA256' ], |
| 124 | + ['Timestamp', timestamp ], |
| 125 | + ] |
| 126 | + if token |
| 127 | + parameters.push(['SecurityToken', token]) |
| 128 | + end |
| 129 | + |
| 130 | + sorted_parameters = parameters.sort_by {|k,v| k } |
| 131 | + sorted_params_string = sorted_parameters.map {|k,v| "#{uri_encode(k)}=#{uri_encode(v)}" }.join('&') |
| 132 | + params_string = parameters.map {|k,v| "#{uri_encode(k)}=#{uri_encode(v)}" }.join('&') |
| 133 | + |
| 134 | + canonical_query = [ |
| 135 | + 'GET', |
| 136 | + endpoint, |
| 137 | + '/', |
| 138 | + sorted_params_string |
| 139 | + ].join("\n") |
| 140 | + |
| 141 | + sha256 = OpenSSL::Digest::Digest.new('sha256') |
| 142 | + signature = OpenSSL::HMAC.digest(sha256, secret_key, canonical_query) |
| 143 | + signature = Base64.encode64(signature).strip |
| 144 | + signature = uri_encode(signature) |
| 145 | + |
| 146 | + req_path = "/?#{params_string}&Signature=#{signature}" |
| 147 | + req = Net::HTTP::Get.new(req_path) |
| 148 | + |
| 149 | + http = Net::HTTP.new(endpoint, 443) |
| 150 | + http.use_ssl = true |
| 151 | + |
| 152 | + response = http.start { http.request(req) } |
| 153 | + |
| 154 | + response |
| 155 | +end |
| 156 | + |
| 157 | +# Queries the tags from the provided EC2 instance id. |
| 158 | +def query_instance_tags(instance_id, endpoint, access_key, secret_key, token = nil) |
| 159 | + parameters = [ |
| 160 | + ['Action', 'DescribeInstances' ], |
| 161 | + ['InstanceId.1', instance_id ], |
| 162 | + ['Version', '2014-10-01' ], |
| 163 | + ] |
| 164 | + response = query(parameters, endpoint, access_key, secret_key, token) |
| 165 | + |
| 166 | + if response.code != "200" |
| 167 | + Facter.debug("DescribeInstances returned #{response.code} #{response.message}") |
| 168 | + return {} |
| 169 | + end |
| 170 | + |
| 171 | + doc = REXML::Document.new(response.body) |
| 172 | + |
| 173 | + tags = {} |
| 174 | + doc.get_elements('//tagSet/item').each do |item| |
| 175 | + key = item.get_elements('key')[0].text |
| 176 | + value = item.get_elements('value')[0].text |
| 177 | + tags[key] = value |
| 178 | + end |
| 179 | + |
| 180 | + return tags |
| 181 | +end |
| 182 | + |
| 183 | +# Queries the min/max/desired size of the provided autoscaling group. |
| 184 | +def query_autoscale_group(group_id, endpoint, access_key, secret_key, token) |
| 185 | + parameters = [ |
| 186 | + ['Action', 'DescribeAutoScalingGroups' ], |
| 187 | + ['AutoScalingGroupNames.member.1', group_id ], |
| 188 | + ['Version', '2011-01-01' ], |
| 189 | + ] |
| 190 | + response = query(parameters, endpoint, access_key, secret_key, token) |
| 191 | + |
| 192 | + if response.code != "200" |
| 193 | + Facter.debug("DescribeAutoScalingGroups returned #{response.code} #{response.message}") |
| 194 | + return {} |
| 195 | + end |
| 196 | + |
| 197 | + doc = REXML::Document.new(response.body) |
| 198 | + |
| 199 | + # Note: These params get merged into the facts Hash, so the keys should match the fact names |
| 200 | + params = {} |
| 201 | + |
| 202 | + min_size_elem = doc.get_elements('//MinSize')[0] |
| 203 | + if min_size_elem.nil? |
| 204 | + Facter.debug("No MinSize found for autoscaling group #{group_id}") |
| 205 | + return nil |
| 206 | + else |
| 207 | + params['autoscaling_min_size'] = min_size_elem.text |
| 208 | + end |
| 209 | + |
| 210 | + max_size_elem = doc.get_elements('//MinSize')[0] |
| 211 | + if max_size_elem.nil? |
| 212 | + Facter.debug("No MinSize found for autoscaling group #{group_id}") |
| 213 | + return nil |
| 214 | + else |
| 215 | + params['autoscaling_max_size'] = max_size_elem.text |
| 216 | + end |
| 217 | + |
| 218 | + desired_cap_elem = doc.get_elements('//MinSize')[0] |
| 219 | + if desired_cap_elem.nil? |
| 220 | + Facter.debug("No MinSize found for autoscaling group #{group_id}") |
| 221 | + return nil |
| 222 | + else |
| 223 | + params['autoscaling_desired_capacity'] = desired_cap_elem.text |
| 224 | + end |
| 225 | + |
| 226 | + return params |
| 227 | +end |
| 228 | + |
| 229 | +# Look for our EC2 instance ID, then query the tags assigned to the instance. |
| 230 | +# If there is an autoscaling group tag (aws:autoscaling:groupName), also query |
| 231 | +# the autoscaling group min/max/desired size parameters. |
| 232 | +def check_facts |
| 233 | + facts = {} |
| 234 | + open('/etc/ec2_version', 'r') do |io| |
| 235 | + facts['ec2_version'] = io.read.strip |
| 236 | + end |
| 237 | + |
| 238 | + instance = get_instance_document |
| 239 | + if instance.nil? |
| 240 | + Facter.debug("Didn't get instance document from http://169.254.169.254/latest/dynamic/instance-identity/document") |
| 241 | + return |
| 242 | + end |
| 243 | + |
| 244 | + instance_id = instance['instanceId'] |
| 245 | + region = instance['region'] |
| 246 | + role = get_instance_role |
| 247 | + |
| 248 | + access_key = role['AccessKeyId'] || ENV['AWS_ACCESS_KEY_ID'] |
| 249 | + secret_key = role['SecretAccessKey'] || ENV['AWS_SECRET_ACCESS_KEY'] |
| 250 | + token = role['Token'] |
| 251 | + |
| 252 | + if access_key.nil? || secret_key.nil? |
| 253 | + Facter.debug("No authentication key available") |
| 254 | + return |
| 255 | + end |
| 256 | + |
| 257 | + tags = query_instance_tags(instance_id, "ec2.#{region}.amazonaws.com", access_key, secret_key, token) |
| 258 | + tags.each do |tag, value| |
| 259 | + next if tag.start_with?('aws:') |
| 260 | + |
| 261 | + facts["ec2_tag_#{tag}"] = value |
| 262 | + end |
| 263 | + |
| 264 | + if tags.has_key? 'aws:autoscaling:groupName' |
| 265 | + autoscale_group = tags['aws:autoscaling:groupName'] |
| 266 | + |
| 267 | + facts['autoscaling_group_name'] = autoscale_group |
| 268 | + |
| 269 | + asg_params = query_autoscale_group(autoscale_group, "autoscaling.#{region}.amazonaws.com", access_key, secret_key, token) |
| 270 | + facts = facts.merge(asg_params) |
| 271 | + end |
| 272 | + |
| 273 | + if tags.has_key? 'aws:cloudformation:stack-name' |
| 274 | + facts['cloudformation_stack_name'] = tags['aws:cloudformation:stack-name'] |
| 275 | + end |
| 276 | + |
| 277 | + facts.each do |fact, value| |
| 278 | + Facter.add(fact) do |
| 279 | + setcode { value } |
| 280 | + end |
| 281 | + end |
| 282 | +rescue StandardError => e |
| 283 | + Facter.debug("Unhandled #{e.class}: #{e.message}") |
| 284 | +end |
| 285 | + |
| 286 | +# This file seems to exist on our EC2 instances. There may be a better way to |
| 287 | +# determine if we are running on EC2. |
| 288 | +# We mostly want to avoid waiting for http://169.254.169.254/ to time out if we are not on EC2. |
| 289 | +if File.exists?('/etc/ec2_version') |
| 290 | + check_facts |
| 291 | +end |
0 commit comments