Merge lp:~therve/txaws/ebs-support into lp:~txawsteam/txaws/trunk
- ebs-support
- Merge into trunk
Proposed by
Thomas Herve
Status: | Merged | ||||
---|---|---|---|---|---|
Merged at revision: | not available | ||||
Proposed branch: | lp:~therve/txaws/ebs-support | ||||
Merge into: | lp:~txawsteam/txaws/trunk | ||||
Diff against target: | None lines | ||||
To merge this branch: | bzr merge lp:~therve/txaws/ebs-support | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Duncan McGreggor | Approve | ||
Review via email: mp+10742@code.launchpad.net |
Commit message
Description of the change
To post a comment you must log in.
Revision history for this message
Thomas Herve (therve) wrote : | # |
Revision history for this message
Duncan McGreggor (oubiwann) wrote : | # |
Looks great -- +1 for merge. Thanks for this new functionality!
review:
Approve
lp:~therve/txaws/ebs-support
updated
- 13. By Thomas Herve
-
Merge separate branches for EBS support.
- 14. By Thomas Herve
-
Add parameter support to describe volumes
- 15. By Thomas Herve
-
Add parameter support to describe snapshots
- 16. By Thomas Herve
-
Merge from trunk
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'txaws/ec2/client.py' |
2 | --- txaws/ec2/client.py 2009-08-21 03:26:35 +0000 |
3 | +++ txaws/ec2/client.py 2009-08-26 13:50:25 +0000 |
4 | @@ -3,7 +3,7 @@ |
5 | |
6 | """EC2 client support.""" |
7 | |
8 | -from base64 import b64encode |
9 | +from datetime import datetime |
10 | from urllib import quote |
11 | |
12 | from twisted.web.client import getPage |
13 | @@ -19,7 +19,7 @@ |
14 | """An Amazon EC2 Reservation. |
15 | |
16 | @attrib reservation_id: Unique ID of the reservation. |
17 | - @attrib owner_id: AWS Access Key ID of the user who owns the reservation. |
18 | + @attrib owner_id: AWS Access Key ID of the user who owns the reservation. |
19 | @attrib groups: A list of security groups. |
20 | """ |
21 | def __init__(self, reservation_id, owner_id, groups=None): |
22 | @@ -72,11 +72,32 @@ |
23 | self.reservation = reservation |
24 | |
25 | |
26 | +class Volume(object): |
27 | + """An EBS volume instance.""" |
28 | + |
29 | + def __init__(self, id, size, status, create_time): |
30 | + self.id = id |
31 | + self.size = size |
32 | + self.status = status |
33 | + self.create_time = create_time |
34 | + self.attachments = [] |
35 | + |
36 | + |
37 | +class Attachment(object): |
38 | + """An attachment of a L{Volume}.""" |
39 | + |
40 | + def __init__(self, instance_id, snapshot_id, availability_zone, status, |
41 | + attach_time): |
42 | + self.instance_id = instance_id |
43 | + self.snapshot_id = snapshot_id |
44 | + self.availability_zone = availability_zone |
45 | + self.status = status |
46 | + self.attach_time = attach_time |
47 | + |
48 | + |
49 | class EC2Client(object): |
50 | """A client for EC2.""" |
51 | |
52 | - name_space = '{http://ec2.amazonaws.com/doc/2008-12-01/}' |
53 | - |
54 | def __init__(self, creds=None, query_factory=None): |
55 | """Create an EC2Client. |
56 | |
57 | @@ -102,7 +123,7 @@ |
58 | """ |
59 | Parse the reservations XML payload that is returned from an AWS |
60 | describeInstances API call. |
61 | - |
62 | + |
63 | Instead of returning the reservations as the "top-most" object, we |
64 | return the object that most developers and their code will be |
65 | interested in: the instances. In instances reservation is available on |
66 | @@ -111,53 +132,38 @@ |
67 | root = XML(xml_bytes) |
68 | results = [] |
69 | # May be a more elegant way to do this: |
70 | - for reservation_data in root.find(self.name_space + 'reservationSet'): |
71 | + for reservation_data in root.find("reservationSet"): |
72 | # Get the security group information. |
73 | groups = [] |
74 | - for group_data in reservation_data.find( |
75 | - self.name_space + 'groupSet'): |
76 | - group_id = group_data.findtext(self.name_space + 'groupId') |
77 | + for group_data in reservation_data.find("groupSet"): |
78 | + group_id = group_data.findtext("groupId") |
79 | groups.append(group_id) |
80 | # Create a reservation object with the parsed data. |
81 | reservation = Reservation( |
82 | - reservation_id=reservation_data.findtext( |
83 | - self.name_space + 'reservationId'), |
84 | - owner_id=reservation_data.findtext( |
85 | - self.name_space + 'ownerId'), |
86 | + reservation_id=reservation_data.findtext("reservationId"), |
87 | + owner_id=reservation_data.findtext("ownerId"), |
88 | groups=groups) |
89 | # Get the list of instances. |
90 | instances = [] |
91 | - for instance_data in reservation_data.find( |
92 | - self.name_space + 'instancesSet'): |
93 | - instance_id = instance_data.findtext( |
94 | - self.name_space + 'instanceId') |
95 | + for instance_data in reservation_data.find("instancesSet"): |
96 | + instance_id = instance_data.findtext("instanceId") |
97 | instance_state = instance_data.find( |
98 | - self.name_space + 'instanceState').findtext( |
99 | - self.name_space + 'name') |
100 | - instance_type = instance_data.findtext( |
101 | - self.name_space + 'instanceType') |
102 | - image_id = instance_data.findtext(self.name_space + 'imageId') |
103 | - private_dns_name = instance_data.findtext( |
104 | - self.name_space + 'privateDnsName') |
105 | - dns_name = instance_data.findtext(self.name_space + 'dnsName') |
106 | - key_name = instance_data.findtext(self.name_space + 'keyName') |
107 | - ami_launch_index = instance_data.findtext( |
108 | - self.name_space + 'amiLaunchIndex') |
109 | - launch_time = instance_data.findtext( |
110 | - self.name_space + 'launchTime') |
111 | - placement = instance_data.find( |
112 | - self.name_space + 'placement').findtext( |
113 | - self.name_space + 'availabilityZone') |
114 | + "instanceState").findtext("name") |
115 | + instance_type = instance_data.findtext("instanceType") |
116 | + image_id = instance_data.findtext("imageId") |
117 | + private_dns_name = instance_data.findtext("privateDnsName") |
118 | + dns_name = instance_data.findtext("dnsName") |
119 | + key_name = instance_data.findtext("keyName") |
120 | + ami_launch_index = instance_data.findtext("amiLaunchIndex") |
121 | + launch_time = instance_data.findtext("launchTime") |
122 | + placement = instance_data.find("placement").findtext( |
123 | + "availabilityZone") |
124 | products = [] |
125 | - for product_data in instance_data.find( |
126 | - self.name_space + 'productCodesSet'): |
127 | - product_code = product_data.findtext( |
128 | - self.name_space + 'productCode') |
129 | + for product_data in instance_data.find("productCodesSet"): |
130 | + product_code = product_data.findtext("productCode") |
131 | products.append(product_code) |
132 | - kernel_id = instance_data.findtext( |
133 | - self.name_space + 'kernelId') |
134 | - ramdisk_id = instance_data.findtext( |
135 | - self.name_space + 'ramdiskId') |
136 | + kernel_id = instance_data.findtext("kernelId") |
137 | + ramdisk_id = instance_data.findtext("ramdiskId") |
138 | instance = Instance( |
139 | instance_id, instance_state, instance_type, image_id, |
140 | private_dns_name, dns_name, key_name, ami_launch_index, |
141 | @@ -169,7 +175,7 @@ |
142 | |
143 | def terminate_instances(self, *instance_ids): |
144 | """Terminate some instances. |
145 | - |
146 | + |
147 | @param instance_ids: The ids of the instances to terminate. |
148 | @return: A deferred which on success gives an iterable of |
149 | (id, old-state, new-state) tuples. |
150 | @@ -185,17 +191,48 @@ |
151 | root = XML(xml_bytes) |
152 | result = [] |
153 | # May be a more elegant way to do this: |
154 | - for instance in root.find(self.name_space + 'instancesSet'): |
155 | - instanceId = instance.findtext(self.name_space + 'instanceId') |
156 | - previousState = instance.find( |
157 | - self.name_space + 'previousState').findtext( |
158 | - self.name_space + 'name') |
159 | - shutdownState = instance.find( |
160 | - self.name_space + 'shutdownState').findtext( |
161 | - self.name_space + 'name') |
162 | + for instance in root.find("instancesSet"): |
163 | + instanceId = instance.findtext("instanceId") |
164 | + previousState = instance.find("previousState").findtext( |
165 | + "name") |
166 | + shutdownState = instance.find("shutdownState").findtext( |
167 | + "name") |
168 | result.append((instanceId, previousState, shutdownState)) |
169 | return result |
170 | |
171 | + def describe_volumes(self): |
172 | + """Describe available volumes.""" |
173 | + q = self.query_factory("DescribeVolumes", self.creds) |
174 | + d = q.submit() |
175 | + return d.addCallback(self._parse_volumes) |
176 | + |
177 | + def _parse_volumes(self, xml_bytes): |
178 | + root = XML(xml_bytes) |
179 | + result = [] |
180 | + for volume_data in root.find("volumeSet"): |
181 | + volume_id = volume_data.findtext("volumeId") |
182 | + size = int(volume_data.findtext("size")) |
183 | + status = volume_data.findtext("status") |
184 | + create_time = volume_data.findtext("createTime") |
185 | + create_time = datetime.strptime( |
186 | + create_time[:19], "%Y-%m-%dT%H:%M:%S") |
187 | + volume = Volume(volume_id, size, status, create_time) |
188 | + result.append(volume) |
189 | + for attachment_data in volume_data.find("attachmentSet"): |
190 | + instance_id = attachment_data.findtext("instanceId") |
191 | + snapshot_id = attachment_data.findtext("snapshotId") |
192 | + availability_zone = attachment_data.findtext( |
193 | + "availabilityZone") |
194 | + status = attachment_data.findtext("status") |
195 | + attach_time = attachment_data.findtext("attachTime") |
196 | + attach_time = datetime.strptime( |
197 | + attach_time[:19], "%Y-%m-%dT%H:%M:%S") |
198 | + attachment = Attachment( |
199 | + instance_id, snapshot_id, availability_zone, status, |
200 | + attach_time) |
201 | + volume.attachments.append(attachment) |
202 | + return result |
203 | + |
204 | |
205 | class Query(object): |
206 | """A query that may be submitted to EC2.""" |
207 | @@ -204,7 +241,7 @@ |
208 | """Create a Query to submit to EC2.""" |
209 | # Require params (2008-12-01 API): |
210 | # Version, SignatureVersion, SignatureMethod, Action, AWSAccessKeyId, |
211 | - # Timestamp || Expires, Signature, |
212 | + # Timestamp || Expires, Signature, |
213 | self.params = {'Version': '2008-12-01', |
214 | 'SignatureVersion': '2', |
215 | 'SignatureMethod': 'HmacSHA1', |
216 | @@ -242,7 +279,7 @@ |
217 | |
218 | def sign(self): |
219 | """Sign this query using its built in credentials. |
220 | - |
221 | + |
222 | This prepares it to be sent, and should be done as the last step before |
223 | submitting the query. Signing is done automatically - this is a public |
224 | method to facilitate testing. |
225 | |
226 | === modified file 'txaws/ec2/tests/test_client.py' |
227 | --- txaws/ec2/tests/test_client.py 2009-08-21 03:26:35 +0000 |
228 | +++ txaws/ec2/tests/test_client.py 2009-08-26 14:31:01 +0000 |
229 | @@ -1,6 +1,7 @@ |
230 | # Copyright (C) 2009 Robert Collins <robertc@robertcollins.net> |
231 | # Licenced under the txaws licence available at /LICENSE in the txaws source. |
232 | |
233 | +from datetime import datetime |
234 | import os |
235 | |
236 | from twisted.internet.defer import succeed |
237 | @@ -86,6 +87,31 @@ |
238 | """ |
239 | |
240 | |
241 | +sample_describe_volumes_result = """<?xml version="1.0"?> |
242 | +<DescribeVolumesResponse xmlns="http://ec2.amazonaws.com/doc/2008-12-01/"> |
243 | + <volumeSet> |
244 | + <item> |
245 | + <volumeId>vol-4282672b</volumeId> |
246 | + <size>800</size> |
247 | + <status>in-use</status> |
248 | + <createTime>2008-05-07T11:51:50.000Z</createTime> |
249 | + <attachmentSet> |
250 | + <item> |
251 | + <volumeId>vol-4282672b</volumeId> |
252 | + <instanceId>i-6058a509</instanceId> |
253 | + <size>800</size> |
254 | + <snapshotId>snap-12345678</snapshotId> |
255 | + <availabilityZone>us-east-1a</availabilityZone> |
256 | + <status>attached</status> |
257 | + <attachTime>2008-05-07T12:51:50.000Z</attachTime> |
258 | + </item> |
259 | + </attachmentSet> |
260 | + </item> |
261 | + </volumeSet> |
262 | +</DescribeVolumesResponse> |
263 | +""" |
264 | + |
265 | + |
266 | class ReservationTestCase(TXAWSTestCase): |
267 | |
268 | def test_reservation_creation(self): |
269 | @@ -118,7 +144,7 @@ |
270 | |
271 | |
272 | class TestEC2Client(TXAWSTestCase): |
273 | - |
274 | + |
275 | def test_init_no_creds(self): |
276 | os.environ['AWS_SECRET_ACCESS_KEY'] = 'foo' |
277 | os.environ['AWS_ACCESS_KEY_ID'] = 'bar' |
278 | @@ -160,7 +186,6 @@ |
279 | self.assertEquals(instance.kernel_id, "aki-b51cf9dc") |
280 | self.assertEquals(instance.ramdisk_id, "ari-b31cf9da") |
281 | |
282 | - |
283 | def test_parse_reservation(self): |
284 | ec2 = client.EC2Client(creds='foo') |
285 | results = ec2._parse_instances(sample_describe_instances_result) |
286 | @@ -286,3 +311,38 @@ |
287 | query.sign() |
288 | self.assertEqual('4hEtLuZo9i6kuG3TOXvRQNOrE/U=', |
289 | query.params['Signature']) |
290 | + |
291 | + |
292 | +class TestEBS(TXAWSTestCase): |
293 | + |
294 | + def test_describe_volumes(self): |
295 | + |
296 | + class StubQuery(object): |
297 | + def __init__(stub, action, creds): |
298 | + self.assertEqual(action, "DescribeVolumes") |
299 | + self.assertEqual("foo", creds) |
300 | + |
301 | + def submit(self): |
302 | + return succeed(sample_describe_volumes_result) |
303 | + |
304 | + def check_parsed_volumes(volumes): |
305 | + self.assertEquals(len(volumes), 1) |
306 | + volume = volumes[0] |
307 | + self.assertEquals(volume.id, "vol-4282672b") |
308 | + self.assertEquals(volume.size, 800) |
309 | + self.assertEquals(volume.status, "in-use") |
310 | + create_time = datetime(2008, 05, 07, 11, 51, 50) |
311 | + self.assertEquals(volume.create_time, create_time) |
312 | + self.assertEquals(len(volume.attachments), 1) |
313 | + attachment = volume.attachments[0] |
314 | + self.assertEquals(attachment.instance_id, "i-6058a509") |
315 | + self.assertEquals(attachment.snapshot_id, "snap-12345678") |
316 | + self.assertEquals(attachment.availability_zone, "us-east-1a") |
317 | + self.assertEquals(attachment.status, "attached") |
318 | + attach_time = datetime(2008, 05, 07, 12, 51, 50) |
319 | + self.assertEquals(attachment.attach_time, attach_time) |
320 | + |
321 | + ec2 = client.EC2Client(creds="foo", query_factory=StubQuery) |
322 | + d = ec2.describe_volumes() |
323 | + d.addCallback(check_parsed_volumes) |
324 | + return d |
325 | |
326 | === modified file 'txaws/storage/client.py' |
327 | --- txaws/storage/client.py 2009-08-20 12:15:12 +0000 |
328 | +++ txaws/storage/client.py 2009-08-26 13:50:25 +0000 |
329 | @@ -19,9 +19,6 @@ |
330 | from txaws.util import XML, calculate_md5 |
331 | |
332 | |
333 | -name_space = '{http://s3.amazonaws.com/doc/2006-03-01/}' |
334 | - |
335 | - |
336 | class S3Request(object): |
337 | |
338 | def __init__(self, verb, bucket=None, object_name=None, data='', |
339 | @@ -114,10 +111,10 @@ |
340 | Parse XML bucket list response. |
341 | """ |
342 | root = XML(response) |
343 | - for bucket in root.find(name_space + 'Buckets'): |
344 | - timeText = bucket.findtext(name_space + 'CreationDate') |
345 | + for bucket in root.find("Buckets"): |
346 | + timeText = bucket.findtext("CreationDate") |
347 | yield { |
348 | - 'name': bucket.findtext(name_space + 'Name'), |
349 | + 'name': bucket.findtext("Name"), |
350 | 'created': Time.fromISO8601TimeAndDate(timeText), |
351 | } |
352 | |
353 | |
354 | === modified file 'txaws/util.py' |
355 | --- txaws/util.py 2009-08-20 12:15:12 +0000 |
356 | +++ txaws/util.py 2009-08-26 13:50:25 +0000 |
357 | @@ -9,14 +9,14 @@ |
358 | import hmac |
359 | import time |
360 | |
361 | -# Import XML from somwhere; here in one place to prevent duplication. |
362 | +# Import XMLTreeBuilder from somwhere; here in one place to prevent duplication. |
363 | try: |
364 | - from xml.etree.ElementTree import XML |
365 | + from xml.etree.ElementTree import XMLTreeBuilder |
366 | except ImportError: |
367 | - from elementtree.ElementTree import XML |
368 | - |
369 | - |
370 | -__all__ = ['hmac_sha1', 'iso8601time'] |
371 | + from elementtree.ElementTree import XMLTreeBuilder |
372 | + |
373 | + |
374 | +__all__ = ["hmac_sha1", "iso8601time", "XML"] |
375 | |
376 | |
377 | def calculate_md5(data): |
378 | @@ -38,3 +38,17 @@ |
379 | return time.strftime("%Y-%m-%dT%H:%M:%SZ", time_tuple) |
380 | else: |
381 | return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) |
382 | + |
383 | + |
384 | +class NamespaceFixXmlTreeBuilder(XMLTreeBuilder): |
385 | + |
386 | + def _fixname(self, key): |
387 | + if "}" in key: |
388 | + key = key.split("}", 1)[1] |
389 | + return key |
390 | + |
391 | + |
392 | +def XML(text): |
393 | + parser = NamespaceFixXmlTreeBuilder() |
394 | + parser.feed(text) |
395 | + return parser.close() |
This is ready to review in the attached branch. I also took the opportunity to remove the need of namespaces, as it feels useless to me.