Merge lp:~harlowja/cloud-init/boto-ec2-replace into lp:~cloud-init-dev/cloud-init/trunk

Proposed by Joshua Harlow
Status: Merged
Merged at revision: 910
Proposed branch: lp:~harlowja/cloud-init/boto-ec2-replace
Merge into: lp:~cloud-init-dev/cloud-init/trunk
Diff against target: 366 lines (+296/-48)
3 files modified
Requires (+3/-3)
cloudinit/ec2_utils.py (+160/-45)
tests/unittests/test_ec2_util.py (+133/-0)
To merge this branch: bzr merge lp:~harlowja/cloud-init/boto-ec2-replace
Reviewer Review Type Date Requested Status
cloud-init Commiters Pending
Review via email: mp+201269@code.launchpad.net
To post a comment you must log in.
906. By Joshua Harlow

Add ec2 utils tests and httpretty requirement for http mocking

907. By Joshua Harlow

Add a maybe_json helper function

908. By Joshua Harlow

Only check for json objects instead of also arrays

909. By Joshua Harlow

Updated non-json message

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'Requires'
2--- Requires 2013-09-09 05:36:28 +0000
3+++ Requires 2014-01-11 01:39:20 +0000
4@@ -29,8 +29,8 @@
5 # Requests handles ssl correctly!
6 requests
7
8-# Boto for ec2
9-boto
10-
11 # For patching pieces of cloud-config together
12 jsonpatch
13+
14+# For http testing (only needed for testing)
15+httpretty>=0.7.1
16
17=== modified file 'cloudinit/ec2_utils.py'
18--- cloudinit/ec2_utils.py 2013-03-20 12:34:19 +0000
19+++ cloudinit/ec2_utils.py 2014-01-11 01:39:20 +0000
20@@ -16,48 +16,163 @@
21 # You should have received a copy of the GNU General Public License
22 # along with this program. If not, see <http://www.gnu.org/licenses/>.
23
24-import boto.utils as boto_utils
25-
26-# Versions of boto >= 2.6.0 (and possibly 2.5.2)
27-# try to lazily load the metadata backing, which
28-# doesn't work so well in cloud-init especially
29-# since the metadata is serialized and actions are
30-# performed where the metadata server may be blocked
31-# (thus the datasource will start failing) resulting
32-# in url exceptions when fields that do exist (or
33-# would have existed) do not exist due to the blocking
34-# that occurred.
35-
36-# TODO(harlowja): https://github.com/boto/boto/issues/1401
37-# When boto finally moves to using requests, we should be able
38-# to provide it ssl details, it does not yet, so we can't provide them...
39-
40-
41-def _unlazy_dict(mp):
42- if not isinstance(mp, (dict)):
43- return mp
44- # Walk over the keys/values which
45- # forces boto to unlazy itself and
46- # has no effect on dictionaries that
47- # already have there items.
48- for (_k, v) in mp.items():
49- _unlazy_dict(v)
50- return mp
51-
52-
53-def get_instance_userdata(api_version, metadata_address):
54- # Note: boto.utils.get_instance_metadata returns '' for empty string
55- # so the change from non-true to '' is not specifically necessary, but
56- # this way cloud-init will get consistent behavior even if boto changed
57- # in the future to return a None on "no user-data provided".
58- ud = boto_utils.get_instance_userdata(api_version, None, metadata_address)
59- if not ud:
60- ud = ''
61- return ud
62-
63-
64-def get_instance_metadata(api_version, metadata_address):
65- metadata = boto_utils.get_instance_metadata(api_version, metadata_address)
66- if not isinstance(metadata, (dict)):
67- metadata = {}
68- return _unlazy_dict(metadata)
69+from urlparse import (urlparse, urlunparse)
70+
71+import functools
72+import json
73+import urllib
74+
75+from cloudinit import log as logging
76+from cloudinit import util
77+
78+LOG = logging.getLogger(__name__)
79+
80+
81+def maybe_json_object(text):
82+ if not text:
83+ return False
84+ text = text.strip()
85+ if text.startswith("{") and text.endswith("}"):
86+ return True
87+ return False
88+
89+
90+def combine_url(base, add_on):
91+ base_parsed = list(urlparse(base))
92+ path = base_parsed[2]
93+ if path and not path.endswith("/"):
94+ path += "/"
95+ path += urllib.quote(str(add_on), safe="/:")
96+ base_parsed[2] = path
97+ return urlunparse(base_parsed)
98+
99+
100+# See: http://bit.ly/TyoUQs
101+#
102+# Since boto metadata reader uses the old urllib which does not
103+# support ssl, we need to ahead and create our own reader which
104+# works the same as the boto one (for now).
105+class MetadataMaterializer(object):
106+ def __init__(self, blob, base_url, caller):
107+ self._blob = blob
108+ self._md = None
109+ self._base_url = base_url
110+ self._caller = caller
111+
112+ def _parse(self, blob):
113+ leaves = {}
114+ children = []
115+ if not blob:
116+ return (leaves, children)
117+
118+ def has_children(item):
119+ if item.endswith("/"):
120+ return True
121+ else:
122+ return False
123+
124+ def get_name(item):
125+ if item.endswith("/"):
126+ return item.rstrip("/")
127+ return item
128+
129+ for field in blob.splitlines():
130+ field = field.strip()
131+ field_name = get_name(field)
132+ if not field or not field_name:
133+ continue
134+ if has_children(field):
135+ if field_name not in children:
136+ children.append(field_name)
137+ else:
138+ contents = field.split("=", 1)
139+ resource = field_name
140+ if len(contents) > 1:
141+ # What a PITA...
142+ (ident, sub_contents) = contents
143+ ident = util.safe_int(ident)
144+ if ident is not None:
145+ resource = "%s/openssh-key" % (ident)
146+ field_name = sub_contents
147+ leaves[field_name] = resource
148+ return (leaves, children)
149+
150+ def materialize(self):
151+ if self._md is not None:
152+ return self._md
153+ self._md = self._materialize(self._blob, self._base_url)
154+ return self._md
155+
156+ def _decode_leaf_blob(self, field, blob):
157+ if not blob:
158+ return blob
159+ if maybe_json_object(blob):
160+ try:
161+ # Assume it's json, unless it fails parsing...
162+ return json.loads(blob)
163+ except (ValueError, TypeError) as e:
164+ LOG.warn("Field %s looked like a json object, but it was"
165+ " not: %s", field, e)
166+ if blob.find("\n") != -1:
167+ return blob.splitlines()
168+ return blob
169+
170+ def _materialize(self, blob, base_url):
171+ (leaves, children) = self._parse(blob)
172+ child_contents = {}
173+ for c in children:
174+ child_url = combine_url(base_url, c)
175+ if not child_url.endswith("/"):
176+ child_url += "/"
177+ child_blob = str(self._caller(child_url))
178+ child_contents[c] = self._materialize(child_blob, child_url)
179+ leaf_contents = {}
180+ for (field, resource) in leaves.items():
181+ leaf_url = combine_url(base_url, resource)
182+ leaf_blob = str(self._caller(leaf_url))
183+ leaf_contents[field] = self._decode_leaf_blob(field, leaf_blob)
184+ joined = {}
185+ joined.update(child_contents)
186+ for field in leaf_contents.keys():
187+ if field in joined:
188+ LOG.warn("Duplicate key found in results from %s", base_url)
189+ else:
190+ joined[field] = leaf_contents[field]
191+ return joined
192+
193+
194+def get_instance_userdata(api_version='latest',
195+ metadata_address='http://169.254.169.254',
196+ ssl_details=None, timeout=5, retries=5):
197+ ud_url = combine_url(metadata_address, api_version)
198+ ud_url = combine_url(ud_url, 'user-data')
199+ try:
200+ response = util.read_file_or_url(ud_url,
201+ ssl_details=ssl_details,
202+ timeout=timeout,
203+ retries=retries)
204+ return str(response)
205+ except Exception:
206+ util.logexc(LOG, "Failed fetching userdata from url %s", ud_url)
207+ return ''
208+
209+
210+def get_instance_metadata(api_version='latest',
211+ metadata_address='http://169.254.169.254',
212+ ssl_details=None, timeout=5, retries=5):
213+ md_url = combine_url(metadata_address, api_version)
214+ md_url = combine_url(md_url, 'meta-data')
215+ caller = functools.partial(util.read_file_or_url,
216+ ssl_details=ssl_details, timeout=timeout,
217+ retries=retries)
218+
219+ try:
220+ response = caller(md_url)
221+ materializer = MetadataMaterializer(str(response), md_url, caller)
222+ md = materializer.materialize()
223+ if not isinstance(md, (dict)):
224+ md = {}
225+ return md
226+ except Exception:
227+ util.logexc(LOG, "Failed fetching metadata from url %s", md_url)
228+ return {}
229
230=== added file 'tests/unittests/test_ec2_util.py'
231--- tests/unittests/test_ec2_util.py 1970-01-01 00:00:00 +0000
232+++ tests/unittests/test_ec2_util.py 2014-01-11 01:39:20 +0000
233@@ -0,0 +1,133 @@
234+from tests.unittests import helpers
235+
236+from cloudinit import ec2_utils as eu
237+
238+import httpretty as hp
239+
240+
241+class TestEc2Util(helpers.TestCase):
242+ VERSION = 'latest'
243+
244+ @hp.activate
245+ def test_userdata_fetch(self):
246+ hp.register_uri(hp.GET,
247+ 'http://169.254.169.254/%s/user-data' % (self.VERSION),
248+ body='stuff',
249+ status=200)
250+ userdata = eu.get_instance_userdata(self.VERSION)
251+ self.assertEquals('stuff', userdata)
252+
253+ @hp.activate
254+ def test_userdata_fetch_fail_not_found(self):
255+ hp.register_uri(hp.GET,
256+ 'http://169.254.169.254/%s/user-data' % (self.VERSION),
257+ status=404)
258+ userdata = eu.get_instance_userdata(self.VERSION, retries=0)
259+ self.assertEquals('', userdata)
260+
261+
262+ @hp.activate
263+ def test_userdata_fetch_fail_server_dead(self):
264+ hp.register_uri(hp.GET,
265+ 'http://169.254.169.254/%s/user-data' % (self.VERSION),
266+ status=500)
267+ userdata = eu.get_instance_userdata(self.VERSION, retries=0)
268+ self.assertEquals('', userdata)
269+
270+ @hp.activate
271+ def test_metadata_fetch_no_keys(self):
272+ base_url = 'http://169.254.169.254/%s/meta-data' % (self.VERSION)
273+ hp.register_uri(hp.GET, base_url, status=200,
274+ body="\n".join(['hostname',
275+ 'instance-id',
276+ 'ami-launch-index']))
277+ hp.register_uri(hp.GET, eu.combine_url(base_url, 'hostname'),
278+ status=200, body='ec2.fake.host.name.com')
279+ hp.register_uri(hp.GET, eu.combine_url(base_url, 'instance-id'),
280+ status=200, body='123')
281+ hp.register_uri(hp.GET, eu.combine_url(base_url, 'ami-launch-index'),
282+ status=200, body='1')
283+ md = eu.get_instance_metadata(self.VERSION, retries=0)
284+ self.assertEquals(md['hostname'], 'ec2.fake.host.name.com')
285+ self.assertEquals(md['instance-id'], '123')
286+ self.assertEquals(md['ami-launch-index'], '1')
287+
288+ @hp.activate
289+ def test_metadata_fetch_key(self):
290+ base_url = 'http://169.254.169.254/%s/meta-data' % (self.VERSION)
291+ hp.register_uri(hp.GET, base_url, status=200,
292+ body="\n".join(['hostname',
293+ 'instance-id',
294+ 'public-keys/']))
295+ hp.register_uri(hp.GET, eu.combine_url(base_url, 'hostname'),
296+ status=200, body='ec2.fake.host.name.com')
297+ hp.register_uri(hp.GET, eu.combine_url(base_url, 'instance-id'),
298+ status=200, body='123')
299+ hp.register_uri(hp.GET, eu.combine_url(base_url, 'public-keys/'),
300+ status=200, body='0=my-public-key')
301+ hp.register_uri(hp.GET, eu.combine_url(base_url, 'public-keys/'),
302+ status=200, body='0=my-public-key')
303+ hp.register_uri(hp.GET,
304+ eu.combine_url(base_url, 'public-keys/0/openssh-key'),
305+ status=200, body='ssh-rsa AAAA.....wZEf my-public-key')
306+ md = eu.get_instance_metadata(self.VERSION, retries=0, timeout=0.1)
307+ self.assertEquals(md['hostname'], 'ec2.fake.host.name.com')
308+ self.assertEquals(md['instance-id'], '123')
309+ self.assertEquals(1, len(md['public-keys']))
310+
311+ @hp.activate
312+ def test_metadata_fetch_key(self):
313+ base_url = 'http://169.254.169.254/%s/meta-data' % (self.VERSION)
314+ hp.register_uri(hp.GET, base_url, status=200,
315+ body="\n".join(['hostname',
316+ 'instance-id',
317+ 'public-keys/']))
318+ hp.register_uri(hp.GET, eu.combine_url(base_url, 'hostname'),
319+ status=200, body='ec2.fake.host.name.com')
320+ hp.register_uri(hp.GET, eu.combine_url(base_url, 'instance-id'),
321+ status=200, body='123')
322+ hp.register_uri(hp.GET, eu.combine_url(base_url, 'public-keys/'),
323+ status=200,
324+ body="\n".join(['0=my-public-key', '1=my-other-key']))
325+ hp.register_uri(hp.GET,
326+ eu.combine_url(base_url, 'public-keys/0/openssh-key'),
327+ status=200, body='ssh-rsa AAAA.....wZEf my-public-key')
328+ hp.register_uri(hp.GET,
329+ eu.combine_url(base_url, 'public-keys/1/openssh-key'),
330+ status=200, body='ssh-rsa AAAA.....wZEf my-other-key')
331+ md = eu.get_instance_metadata(self.VERSION, retries=0, timeout=0.1)
332+ self.assertEquals(md['hostname'], 'ec2.fake.host.name.com')
333+ self.assertEquals(md['instance-id'], '123')
334+ self.assertEquals(2, len(md['public-keys']))
335+
336+ @hp.activate
337+ def test_metadata_fetch_bdm(self):
338+ base_url = 'http://169.254.169.254/%s/meta-data' % (self.VERSION)
339+ hp.register_uri(hp.GET, base_url, status=200,
340+ body="\n".join(['hostname',
341+ 'instance-id',
342+ 'block-device-mapping/']))
343+ hp.register_uri(hp.GET, eu.combine_url(base_url, 'hostname'),
344+ status=200, body='ec2.fake.host.name.com')
345+ hp.register_uri(hp.GET, eu.combine_url(base_url, 'instance-id'),
346+ status=200, body='123')
347+ hp.register_uri(hp.GET,
348+ eu.combine_url(base_url, 'block-device-mapping/'),
349+ status=200,
350+ body="\n".join(['ami', 'ephemeral0']))
351+ hp.register_uri(hp.GET,
352+ eu.combine_url(base_url, 'block-device-mapping/ami'),
353+ status=200,
354+ body="sdb")
355+ hp.register_uri(hp.GET,
356+ eu.combine_url(base_url,
357+ 'block-device-mapping/ephemeral0'),
358+ status=200,
359+ body="sdc")
360+ md = eu.get_instance_metadata(self.VERSION, retries=0, timeout=0.1)
361+ self.assertEquals(md['hostname'], 'ec2.fake.host.name.com')
362+ self.assertEquals(md['instance-id'], '123')
363+ bdm = md['block-device-mapping']
364+ self.assertEquals(2, len(bdm))
365+ self.assertEquals(bdm['ami'], 'sdb')
366+ self.assertEquals(bdm['ephemeral0'], 'sdc')