Skip to content

Commit 1aa7353

Browse files
author
David Baggerman
committed
Apparently facts.d is the new lib/facter
1 parent c316065 commit 1aa7353

File tree

1 file changed

+291
-0
lines changed

1 file changed

+291
-0
lines changed

facts.d/ec2facts.rb

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
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

Comments
 (0)