Browse code

* Merged CloudFront support from branches/s3cmd-airlock See the ChangeLog in that branch for details.

git-svn-id: https://s3tools.svn.sourceforge.net/svnroot/s3tools/s3cmd/trunk@358 830e0280-6d2a-0410-9c65-932aecc39d9d

Michal Ludvig authored on 2009/01/27 10:53:18
Showing 7 changed files
... ...
@@ -1,3 +1,8 @@
1
+2009-01-26  Michal Ludvig  <michal@logix.cz>
2
+
3
+	* Merged CloudFront support from branches/s3cmd-airlock
4
+	  See the ChangeLog in that branch for details.
5
+
1 6
 2009-01-25  W. Tell  <w_tell -at- sourceforge>
2 7
 
3 8
 	* s3cmd: Implemented --include and friends.
4 9
new file mode 100644
... ...
@@ -0,0 +1,487 @@
0
+## Amazon CloudFront support
1
+## Author: Michal Ludvig <michal@logix.cz>
2
+##         http://www.logix.cz/michal
3
+## License: GPL Version 2
4
+
5
+import sys
6
+import base64
7
+import time
8
+import httplib
9
+from logging import debug, info, warning, error
10
+
11
+try:
12
+	from hashlib import md5, sha1
13
+except ImportError:
14
+	from md5 import md5
15
+	import sha as sha1
16
+import hmac
17
+
18
+try:
19
+	import xml.etree.ElementTree as ET
20
+except ImportError:
21
+	import elementtree.ElementTree as ET
22
+
23
+from Config import Config
24
+from Exceptions import *
25
+from Utils import getTreeFromXml, appendXmlTextNode, getDictFromTree, dateS3toPython
26
+from S3Uri import S3Uri, S3UriS3
27
+
28
+def output(message):
29
+	sys.stdout.write(message + "\n")
30
+
31
+def pretty_output(label, message):
32
+	#label = ("%s " % label).ljust(20, ".")
33
+	label = ("%s:" % label).ljust(15)
34
+	output("%s %s" % (label, message))
35
+
36
+class DistributionSummary(object):
37
+	## Example:
38
+	##
39
+	## <DistributionSummary>
40
+	##	<Id>1234567890ABC</Id>
41
+	##	<Status>Deployed</Status>
42
+	##	<LastModifiedTime>2009-01-16T11:49:02.189Z</LastModifiedTime>
43
+	##	<DomainName>blahblahblah.cloudfront.net</DomainName>
44
+	##	<Origin>example.bucket.s3.amazonaws.com</Origin>
45
+	##	<Enabled>true</Enabled>
46
+	## </DistributionSummary>
47
+
48
+	def __init__(self, tree):
49
+		if tree.tag != "DistributionSummary":
50
+			raise ValueError("Expected <DistributionSummary /> xml, got: <%s />" % tree.tag)
51
+		self.parse(tree)
52
+
53
+	def parse(self, tree):
54
+		self.info = getDictFromTree(tree)
55
+		self.info['Enabled'] = (self.info['Enabled'].lower() == "true")
56
+
57
+	def uri(self):
58
+		return S3Uri("cf://%s" % self.info['Id'])
59
+
60
+class DistributionList(object):
61
+	## Example:
62
+	## 
63
+	## <DistributionList xmlns="http://cloudfront.amazonaws.com/doc/2008-06-30/">
64
+	##	<Marker />
65
+	##	<MaxItems>100</MaxItems>
66
+	##	<IsTruncated>false</IsTruncated>
67
+	##	<DistributionSummary>
68
+	##	... handled by DistributionSummary() class ...
69
+	##	</DistributionSummary>
70
+	## </DistributionList>
71
+
72
+	def __init__(self, xml):
73
+		tree = getTreeFromXml(xml)
74
+		if tree.tag != "DistributionList":
75
+			raise ValueError("Expected <DistributionList /> xml, got: <%s />" % tree.tag)
76
+		self.parse(tree)
77
+
78
+	def parse(self, tree):
79
+		self.info = getDictFromTree(tree)
80
+		## Normalise some items
81
+		self.info['IsTruncated'] = (self.info['IsTruncated'].lower() == "true")
82
+
83
+		self.dist_summs = []
84
+		for dist_summ in tree.findall(".//DistributionSummary"):
85
+			self.dist_summs.append(DistributionSummary(dist_summ))
86
+
87
+class Distribution(object):
88
+	## Example:
89
+	##
90
+	## <Distribution xmlns="http://cloudfront.amazonaws.com/doc/2008-06-30/">
91
+	##	<Id>1234567890ABC</Id>
92
+	##	<Status>InProgress</Status>
93
+	##	<LastModifiedTime>2009-01-16T13:07:11.319Z</LastModifiedTime>
94
+	##	<DomainName>blahblahblah.cloudfront.net</DomainName>
95
+	##	<DistributionConfig>
96
+	##	... handled by DistributionConfig() class ...
97
+	##	</DistributionConfig>
98
+	## </Distribution>
99
+
100
+	def __init__(self, xml):
101
+		tree = getTreeFromXml(xml)
102
+		if tree.tag != "Distribution":
103
+			raise ValueError("Expected <Distribution /> xml, got: <%s />" % tree.tag)
104
+		self.parse(tree)
105
+
106
+	def parse(self, tree):
107
+		self.info = getDictFromTree(tree)
108
+		## Normalise some items
109
+		self.info['LastModifiedTime'] = dateS3toPython(self.info['LastModifiedTime'])
110
+
111
+		self.info['DistributionConfig'] = DistributionConfig(tree = tree.find(".//DistributionConfig"))
112
+	
113
+	def uri(self):
114
+		return S3Uri("cf://%s" % self.info['Id'])
115
+
116
+class DistributionConfig(object):
117
+	## Example:
118
+	##
119
+	## <DistributionConfig>
120
+	##	<Origin>somebucket.s3.amazonaws.com</Origin>
121
+	##	<CallerReference>s3://somebucket/</CallerReference>
122
+	##	<Comment>http://somebucket.s3.amazonaws.com/</Comment>
123
+	##	<Enabled>true</Enabled>
124
+	## </DistributionConfig>
125
+
126
+	EMPTY_CONFIG = "<DistributionConfig><Origin/><CallerReference/><Enabled>true</Enabled></DistributionConfig>"
127
+	xmlns = "http://cloudfront.amazonaws.com/doc/2008-06-30/"
128
+	def __init__(self, xml = None, tree = None):
129
+		if not xml:
130
+			xml = DistributionConfig.EMPTY_CONFIG
131
+
132
+		if not tree:
133
+			tree = getTreeFromXml(xml)
134
+
135
+		if tree.tag != "DistributionConfig":
136
+			raise ValueError("Expected <DistributionConfig /> xml, got: <%s />" % tree.tag)
137
+		self.parse(tree)
138
+
139
+	def parse(self, tree):
140
+		self.info = getDictFromTree(tree)
141
+		self.info['Enabled'] = (self.info['Enabled'].lower() == "true")
142
+		if not self.info.has_key("CNAME"):
143
+			self.info['CNAME'] = []
144
+		if type(self.info['CNAME']) != list:
145
+			self.info['CNAME'] = [self.info['CNAME']]
146
+		self.info['CNAME'] = [cname.lower() for cname in self.info['CNAME']]
147
+		if not self.info.has_key("Comment"):
148
+			self.info['Comment'] = ""
149
+
150
+	def __str__(self):
151
+		tree = ET.Element("DistributionConfig")
152
+		tree.attrib['xmlns'] = DistributionConfig.xmlns
153
+
154
+		## Retain the order of the following calls!
155
+		appendXmlTextNode("Origin", self.info['Origin'], tree)
156
+		appendXmlTextNode("CallerReference", self.info['CallerReference'], tree)
157
+		for cname in self.info['CNAME']:
158
+			appendXmlTextNode("CNAME", cname.lower(), tree)
159
+		if self.info['Comment']:
160
+			appendXmlTextNode("Comment", self.info['Comment'], tree)
161
+		appendXmlTextNode("Enabled", str(self.info['Enabled']).lower(), tree)
162
+
163
+		return ET.tostring(tree)
164
+
165
+class CloudFront(object):
166
+	operations = {
167
+		"CreateDist" : { 'method' : "POST", 'resource' : "" },
168
+		"DeleteDist" : { 'method' : "DELETE", 'resource' : "/%(dist_id)s" },
169
+		"GetList" : { 'method' : "GET", 'resource' : "" },
170
+		"GetDistInfo" : { 'method' : "GET", 'resource' : "/%(dist_id)s" },
171
+		"GetDistConfig" : { 'method' : "GET", 'resource' : "/%(dist_id)s/config" },
172
+		"SetDistConfig" : { 'method' : "PUT", 'resource' : "/%(dist_id)s/config" },
173
+	}
174
+
175
+	## Maximum attempts of re-issuing failed requests
176
+	_max_retries = 5
177
+
178
+	def __init__(self, config):
179
+		self.config = config
180
+
181
+	## --------------------------------------------------
182
+	## Methods implementing CloudFront API
183
+	## --------------------------------------------------
184
+
185
+	def GetList(self):
186
+		response = self.send_request("GetList")
187
+		response['dist_list'] = DistributionList(response['data'])
188
+		if response['dist_list'].info['IsTruncated']:
189
+			raise NotImplementedError("List is truncated. Ask s3cmd author to add support.")
190
+		## TODO: handle Truncated 
191
+		return response
192
+	
193
+	def CreateDistribution(self, uri, cnames_add = [], comment = None):
194
+		dist_config = DistributionConfig()
195
+		dist_config.info['Enabled'] = True
196
+		dist_config.info['Origin'] = uri.host_name()
197
+		dist_config.info['CallerReference'] = str(uri)
198
+		if comment == None:
199
+			dist_config.info['Comment'] = uri.public_url()
200
+		else:
201
+			dist_config.info['Comment'] = comment
202
+		for cname in cnames_add:
203
+			if dist_config.info['CNAME'].count(cname) == 0:
204
+				dist_config.info['CNAME'].append(cname)
205
+		request_body = str(dist_config)
206
+		debug("CreateDistribution(): request_body: %s" % request_body)
207
+		response = self.send_request("CreateDist", body = request_body)
208
+		response['distribution'] = Distribution(response['data'])
209
+		return response
210
+	
211
+	def ModifyDistribution(self, cfuri, cnames_add = [], cnames_remove = [],
212
+	                       comment = None, enabled = None):
213
+		if cfuri.type != "cf":
214
+			raise ValueError("Expected CFUri instead of: %s" % cfuri)
215
+		# Get current dist status (enabled/disabled) and Etag
216
+		info("Checking current status of %s" % cfuri)
217
+		response = self.GetDistConfig(cfuri)
218
+		dc = response['dist_config']
219
+		if enabled != None:
220
+			dc.info['Enabled'] = enabled
221
+		if comment != None:
222
+			dc.info['Comment'] = comment
223
+		for cname in cnames_add:
224
+			if dc.info['CNAME'].count(cname) == 0:
225
+				dc.info['CNAME'].append(cname)
226
+		for cname in cnames_remove:
227
+			while dc.info['CNAME'].count(cname) > 0:
228
+				dc.info['CNAME'].remove(cname)
229
+		response = self.SetDistConfig(cfuri, dc, response['headers']['etag'])
230
+		return response
231
+		
232
+	def DeleteDistribution(self, cfuri):
233
+		if cfuri.type != "cf":
234
+			raise ValueError("Expected CFUri instead of: %s" % cfuri)
235
+		# Get current dist status (enabled/disabled) and Etag
236
+		info("Checking current status of %s" % cfuri)
237
+		response = self.GetDistConfig(cfuri)
238
+		if response['dist_config'].info['Enabled']:
239
+			info("Distribution is ENABLED. Disabling first.")
240
+			response['dist_config'].info['Enabled'] = False
241
+			response = self.SetDistConfig(cfuri, response['dist_config'], 
242
+			                              response['headers']['etag'])
243
+			warning("Waiting for Distribution to become disabled.")
244
+			warning("This may take several minutes, please wait.")
245
+			while True:
246
+				response = self.GetDistInfo(cfuri)
247
+				d = response['distribution']
248
+				if d.info['Status'] == "Deployed" and d.info['Enabled'] == False:
249
+					info("Distribution is now disabled")
250
+					break
251
+				warning("Still waiting...")
252
+				time.sleep(10)
253
+		headers = {}
254
+		headers['if-match'] = response['headers']['etag']
255
+		response = self.send_request("DeleteDist", dist_id = cfuri.dist_id(),
256
+		                             headers = headers)
257
+		return response
258
+	
259
+	def GetDistInfo(self, cfuri):
260
+		if cfuri.type != "cf":
261
+			raise ValueError("Expected CFUri instead of: %s" % cfuri)
262
+		response = self.send_request("GetDistInfo", dist_id = cfuri.dist_id())
263
+		response['distribution'] = Distribution(response['data'])
264
+		return response
265
+
266
+	def GetDistConfig(self, cfuri):
267
+		if cfuri.type != "cf":
268
+			raise ValueError("Expected CFUri instead of: %s" % cfuri)
269
+		response = self.send_request("GetDistConfig", dist_id = cfuri.dist_id())
270
+		response['dist_config'] = DistributionConfig(response['data'])
271
+		return response
272
+	
273
+	def SetDistConfig(self, cfuri, dist_config, etag = None):
274
+		if etag == None:
275
+			debug("SetDistConfig(): Etag not set. Fetching it first.")
276
+			etag = self.GetDistConfig(cfuri)['headers']['etag']
277
+		debug("SetDistConfig(): Etag = %s" % etag)
278
+		request_body = str(dist_config)
279
+		debug("SetDistConfig(): request_body: %s" % request_body)
280
+		headers = {}
281
+		headers['if-match'] = etag
282
+		response = self.send_request("SetDistConfig", dist_id = cfuri.dist_id(),
283
+		                             body = request_body, headers = headers)
284
+		return response
285
+
286
+	## --------------------------------------------------
287
+	## Low-level methods for handling CloudFront requests
288
+	## --------------------------------------------------
289
+
290
+	def send_request(self, op_name, dist_id = None, body = None, headers = {}, retries = _max_retries):
291
+		operation = self.operations[op_name]
292
+		if body:
293
+			headers['content-type'] = 'text/plain'
294
+		request = self.create_request(operation, dist_id, headers)
295
+		conn = self.get_connection()
296
+		debug("send_request(): %s %s" % (request['method'], request['resource']))
297
+		conn.request(request['method'], request['resource'], body, request['headers'])
298
+		http_response = conn.getresponse()
299
+		response = {}
300
+		response["status"] = http_response.status
301
+		response["reason"] = http_response.reason
302
+		response["headers"] = dict(http_response.getheaders())
303
+		response["data"] =  http_response.read()
304
+		conn.close()
305
+
306
+		debug("CloudFront: response: %r" % response)
307
+
308
+		if response["status"] >= 500:
309
+			e = CloudFrontError(response)
310
+			if retries:
311
+				warning(u"Retrying failed request: %s" % op_name)
312
+				warning(unicode(e))
313
+				warning("Waiting %d sec..." % self._fail_wait(retries))
314
+				time.sleep(self._fail_wait(retries))
315
+				return self.send_request(op_name, dist_id, body, retries - 1)
316
+			else:
317
+				raise e
318
+
319
+		if response["status"] < 200 or response["status"] > 299:
320
+			raise CloudFrontError(response)
321
+
322
+		return response
323
+
324
+	def create_request(self, operation, dist_id = None, headers = None):
325
+		resource = self.config.cloudfront_resource + (
326
+		           operation['resource'] % { 'dist_id' : dist_id })
327
+
328
+		if not headers:
329
+			headers = {}
330
+
331
+		if headers.has_key("date"):
332
+			if not headers.has_key("x-amz-date"):
333
+				headers["x-amz-date"] = headers["date"]
334
+			del(headers["date"])
335
+		
336
+		if not headers.has_key("x-amz-date"):
337
+			headers["x-amz-date"] = time.strftime("%a, %d %b %Y %H:%M:%S +0000", time.gmtime())
338
+
339
+		signature = self.sign_request(headers)
340
+		headers["Authorization"] = "AWS "+self.config.access_key+":"+signature
341
+
342
+		request = {}
343
+		request['resource'] = resource
344
+		request['headers'] = headers
345
+		request['method'] = operation['method']
346
+
347
+		return request
348
+
349
+	def sign_request(self, headers):
350
+		string_to_sign = headers['x-amz-date']
351
+		signature = base64.encodestring(hmac.new(self.config.secret_key, string_to_sign, sha1).digest()).strip()
352
+		debug(u"CloudFront.sign_request('%s') = %s" % (string_to_sign, signature))
353
+		return signature
354
+
355
+	def get_connection(self):
356
+		if self.config.proxy_host != "":
357
+			raise ParameterError("CloudFront commands don't work from behind a HTTP proxy")
358
+		return httplib.HTTPSConnection(self.config.cloudfront_host)
359
+
360
+	def _fail_wait(self, retries):
361
+		# Wait a few seconds. The more it fails the more we wait.
362
+		return (self._max_retries - retries + 1) * 3
363
+
364
+class Cmd(object):
365
+	"""
366
+	Class that implements CloudFront commands
367
+	"""
368
+	
369
+	class Options(object):
370
+		cf_cnames_add = []
371
+		cf_cnames_remove = []
372
+		cf_comment = None
373
+		cf_enable = None
374
+
375
+		def option_list(self):
376
+			return [opt for opt in dir(self) if opt.startswith("cf_")]
377
+
378
+		def update_option(self, option, value):
379
+			setattr(Cmd.options, option, value)
380
+
381
+	options = Options()
382
+
383
+	@staticmethod
384
+	def info(args):
385
+		cf = CloudFront(Config())
386
+		if not args:
387
+			response = cf.GetList()
388
+			for d in response['dist_list'].dist_summs:
389
+				pretty_output("Origin", S3UriS3.httpurl_to_s3uri(d.info['Origin']))
390
+				pretty_output("DistId", d.uri())
391
+				pretty_output("DomainName", d.info['DomainName'])
392
+				pretty_output("Status", d.info['Status'])
393
+				pretty_output("Enabled", d.info['Enabled'])
394
+				output("")
395
+		else:
396
+			cfuris = []
397
+			for arg in args:
398
+				cfuris.append(S3Uri(arg))
399
+				if cfuris[-1].type != 'cf':
400
+					raise ParameterError("CloudFront URI required instead of: %s" % arg)
401
+			for cfuri in cfuris:
402
+				response = cf.GetDistInfo(cfuri)
403
+				d = response['distribution']
404
+				dc = d.info['DistributionConfig']
405
+				pretty_output("Origin", S3UriS3.httpurl_to_s3uri(dc.info['Origin']))
406
+				pretty_output("DistId", d.uri())
407
+				pretty_output("DomainName", d.info['DomainName'])
408
+				pretty_output("Status", d.info['Status'])
409
+				pretty_output("CNAMEs", ", ".join(dc.info['CNAME']))
410
+				pretty_output("Comment", dc.info['Comment'])
411
+				pretty_output("Enabled", dc.info['Enabled'])
412
+				pretty_output("Etag", response['headers']['etag'])
413
+
414
+	@staticmethod
415
+	def create(args):
416
+		cf = CloudFront(Config())
417
+		buckets = []
418
+		for arg in args:
419
+			uri = S3Uri(arg)
420
+			if uri.type != "s3":
421
+				raise ParameterError("Bucket can only be created from a s3:// URI instead of: %s" % arg)
422
+			if uri.object():
423
+				raise ParameterError("Use s3:// URI with a bucket name only instead of: %s" % arg)
424
+			if not uri.is_dns_compatible():
425
+				raise ParameterError("CloudFront can only handle lowercase-named buckets.")
426
+			buckets.append(uri)
427
+		if not buckets:
428
+			raise ParameterError("No valid bucket names found")
429
+		for uri in buckets:
430
+			info("Creating distribution from: %s" % uri)
431
+			response = cf.CreateDistribution(uri, cnames_add = Cmd.options.cf_cnames_add, 
432
+			                                 comment = Cmd.options.cf_comment)
433
+			d = response['distribution']
434
+			dc = d.info['DistributionConfig']
435
+			output("Distribution created:")
436
+			pretty_output("Origin", S3UriS3.httpurl_to_s3uri(dc.info['Origin']))
437
+			pretty_output("DistId", d.uri())
438
+			pretty_output("DomainName", d.info['DomainName'])
439
+			pretty_output("CNAMEs", ", ".join(dc.info['CNAME']))
440
+			pretty_output("Comment", dc.info['Comment'])
441
+			pretty_output("Status", d.info['Status'])
442
+			pretty_output("Enabled", dc.info['Enabled'])
443
+			pretty_output("Etag", response['headers']['etag'])
444
+
445
+	@staticmethod
446
+	def delete(args):
447
+		cf = CloudFront(Config())
448
+		cfuris = []
449
+		for arg in args:
450
+			cfuris.append(S3Uri(arg))
451
+			if cfuris[-1].type != 'cf':
452
+				raise ParameterError("CloudFront URI required instead of: %s" % arg)
453
+		for cfuri in cfuris:
454
+			response = cf.DeleteDistribution(cfuri)
455
+			if response['status'] >= 400:
456
+				error("Distribution %s could not be deleted: %s" % (cfuri, response['reason']))
457
+			output("Distribution %s deleted" % cfuri)
458
+
459
+	@staticmethod
460
+	def modify(args):
461
+		cf = CloudFront(Config())
462
+		cfuri = S3Uri(args.pop(0))
463
+		if cfuri.type != 'cf':
464
+			raise ParameterError("CloudFront URI required instead of: %s" % arg)
465
+		if len(args):
466
+			raise ParameterError("Too many parameters. Modify one Distribution at a time.")
467
+
468
+		response = cf.ModifyDistribution(cfuri,
469
+		                                 cnames_add = Cmd.options.cf_cnames_add,
470
+		                                 cnames_remove = Cmd.options.cf_cnames_remove,
471
+		                                 comment = Cmd.options.cf_comment,
472
+		                                 enabled = Cmd.options.cf_enable)
473
+		if response['status'] >= 400:
474
+			error("Distribution %s could not be modified: %s" % (cfuri, response['reason']))
475
+		output("Distribution modified: %s" % cfuri)
476
+		response = cf.GetDistInfo(cfuri)
477
+		d = response['distribution']
478
+		dc = d.info['DistributionConfig']
479
+		pretty_output("Origin", S3UriS3.httpurl_to_s3uri(dc.info['Origin']))
480
+		pretty_output("DistId", d.uri())
481
+		pretty_output("DomainName", d.info['DomainName'])
482
+		pretty_output("Status", d.info['Status'])
483
+		pretty_output("CNAMEs", ", ".join(dc.info['CNAME']))
484
+		pretty_output("Comment", dc.info['Comment'])
485
+		pretty_output("Enabled", dc.info['Enabled'])
486
+		pretty_output("Etag", response['headers']['etag'])
... ...
@@ -17,6 +17,8 @@ class Config(object):
17 17
 	host_base = "s3.amazonaws.com"
18 18
 	host_bucket = "%(bucket)s.s3.amazonaws.com"
19 19
 	simpledb_host = "sdb.amazonaws.com"
20
+	cloudfront_host = "cloudfront.amazonaws.com"
21
+	cloudfront_resource = "/2008-06-30/distribution"
20 22
 	verbosity = logging.WARNING
21 23
 	progress_meter = True
22 24
 	progress_class = Progress.ProgressCR
... ...
@@ -3,7 +3,7 @@
3 3
 ##         http://www.logix.cz/michal
4 4
 ## License: GPL Version 2
5 5
 
6
-from Utils import getRootTagName, unicodise, deunicodise
6
+from Utils import getTreeFromXml, unicodise, deunicodise
7 7
 from logging import debug, info, warning, error
8 8
 
9 9
 try:
... ...
@@ -38,21 +38,26 @@ class S3Error (S3Exception):
38 38
 		if response.has_key("headers"):
39 39
 			for header in response["headers"]:
40 40
 				debug("HttpHeader: %s: %s" % (header, response["headers"][header]))
41
-		if response.has_key("data") and getRootTagName(response["data"]) == "Error":
42
-			tree = ET.fromstring(response["data"])
43
-			for child in tree.getchildren():
41
+		if response.has_key("data"):
42
+			tree = getTreeFromXml(response["data"])
43
+			error_node = tree
44
+			if not error_node.tag == "Error":
45
+				error_node = tree.find(".//Error")
46
+			for child in error_node.getchildren():
44 47
 				if child.text != "":
45 48
 					debug("ErrorXML: " + child.tag + ": " + repr(child.text))
46 49
 					self.info[child.tag] = child.text
47 50
 
48 51
 	def __unicode__(self):
49
-		retval = "%d (%s)" % (self.status, self.reason)
50
-		try:
51
-			retval += (": %s" % self.info["Code"])
52
-		except (AttributeError, KeyError):
53
-			pass
52
+		retval = u"%d " % (self.status)
53
+		retval += (u"(%s)" % (self.info.has_key("Code") and self.info["Code"] or self.reason))
54
+		if self.info.has_key("Message"):
55
+			retval += (u": %s" % self.info["Message"])
54 56
 		return retval
55 57
 
58
+class CloudFrontError(S3Error):
59
+	pass
60
+		
56 61
 class S3UploadError(S3Exception):
57 62
 	pass
58 63
 
... ...
@@ -72,16 +72,48 @@ class S3UriS3(S3Uri):
72 72
 	def uri(self):
73 73
 		return "/".join(["s3:/", self._bucket, self._object])
74 74
 	
75
+	def is_dns_compatible(self):
76
+		return S3.check_bucket_name_dns_conformity(self._bucket)
77
+
75 78
 	def public_url(self):
76
-		if S3.check_bucket_name_dns_conformity(self._bucket):
79
+		if self.is_dns_compatible():
77 80
 			return "http://%s.s3.amazonaws.com/%s" % (self._bucket, self._object)
78 81
 		else:
79 82
 			return "http://s3.amazonaws.com/%s/%s" % (self._bucket, self._object)
80 83
 
84
+	def host_name(self):
85
+		if self.is_dns_compatible():
86
+			return "%s.s3.amazonaws.com" % (self._bucket)
87
+		else:
88
+			return "s3.amazonaws.com"
89
+
81 90
 	@staticmethod
82 91
 	def compose_uri(bucket, object = ""):
83 92
 		return "s3://%s/%s" % (bucket, object)
84 93
 
94
+	@staticmethod
95
+	def httpurl_to_s3uri(http_url):
96
+		m=re.match("(https?://)?([^/]+)/?(.*)", http_url, re.IGNORECASE)
97
+		hostname, object = m.groups()[1:]
98
+		hostname = hostname.lower()
99
+		if hostname == "s3.amazonaws.com":
100
+			## old-style url: http://s3.amazonaws.com/bucket/object
101
+			if object.count("/") == 0:
102
+				## no object given
103
+				bucket = object
104
+				object = ""
105
+			else:
106
+				## bucket/object
107
+				bucket, object = object.split("/", 1)
108
+		elif hostname.endswith(".s3.amazonaws.com"):
109
+			## new-style url: http://bucket.s3.amazonaws.com/object
110
+			bucket = hostname[:-(len(".s3.amazonaws.com"))]
111
+		else:
112
+			raise ValueError("Unable to parse URL: %s" % http_url)
113
+		return S3Uri("s3://%(bucket)s/%(object)s" % { 
114
+			'bucket' : bucket,
115
+			'object' : object })
116
+
85 117
 class S3UriS3FS(S3Uri):
86 118
 	type = "s3fs"
87 119
 	_re = re.compile("^s3fs://([^/]*)/?(.*)", re.IGNORECASE)
... ...
@@ -124,6 +156,22 @@ class S3UriFile(S3Uri):
124 124
 	def dirname(self):
125 125
 		return os.path.dirname(self.path())
126 126
 
127
+class S3UriCloudFront(S3Uri):
128
+	type = "cf"
129
+	_re = re.compile("^cf://([^/]*)/?", re.IGNORECASE)
130
+	def __init__(self, string):
131
+		match = self._re.match(string)
132
+		if not match:
133
+			raise ValueError("%s: not a CloudFront URI" % string)
134
+		groups = match.groups()
135
+		self._dist_id = groups[0]
136
+
137
+	def dist_id(self):
138
+		return self._dist_id
139
+
140
+	def uri(self):
141
+		return "/".join(["cf:/", self.dist_id()])
142
+
127 143
 if __name__ == "__main__":
128 144
 	uri = S3Uri("s3://bucket/object")
129 145
 	print "type()  =", type(uri)
... ...
@@ -153,3 +201,11 @@ if __name__ == "__main__":
153 153
 	print "uri.type=", uri.type
154 154
 	print "path    =", uri.path()
155 155
 	print
156
+
157
+	uri = S3Uri("cf://1234567890ABCD/")
158
+	print "type()  =", type(uri)
159
+	print "uri     =", uri
160
+	print "uri.type=", uri.type
161
+	print "dist_id =", uri.dist_id()
162
+	print
163
+
... ...
@@ -63,7 +63,21 @@ def getListFromXml(xml, node):
63 63
 	tree = getTreeFromXml(xml)
64 64
 	nodes = tree.findall('.//%s' % (node))
65 65
 	return parseNodes(nodes)
66
-	
66
+
67
+def getDictFromTree(tree):
68
+	ret_dict = {}
69
+	for child in tree.getchildren():
70
+		if child.getchildren():
71
+			## Complex-type child. We're not interested
72
+			continue
73
+		if ret_dict.has_key(child.tag):
74
+			if not type(ret_dict[child.tag]) == list:
75
+				ret_dict[child.tag] = [ret_dict[child.tag]]
76
+			ret_dict[child.tag].append(child.text or "")
77
+		else:
78
+			ret_dict[child.tag] = child.text or ""
79
+	return ret_dict
80
+
67 81
 def getTextFromXml(xml, xpath):
68 82
 	tree = getTreeFromXml(xml)
69 83
 	if tree.tag.endswith(xpath):
... ...
@@ -75,6 +89,20 @@ def getRootTagName(xml):
75 75
 	tree = getTreeFromXml(xml)
76 76
 	return tree.tag
77 77
 
78
+def xmlTextNode(tag_name, text):
79
+	el = ET.Element(tag_name)
80
+	el.text = unicode(text)
81
+	return el
82
+
83
+def appendXmlTextNode(tag_name, text, parent):
84
+	"""
85
+	Creates a new <tag_name> Node and sets
86
+	its content to 'text'. Then appends the
87
+	created Node to 'parent' element if given.
88
+	Returns the newly created Node.
89
+	"""
90
+	parent.append(xmlTextNode(tag_name, text))
91
+
78 92
 def dateS3toPython(date):
79 93
 	date = re.compile("\.\d\d\dZ").sub(".000Z", date)
80 94
 	return time.strptime(date, "%Y-%m-%dT%H:%M:%S.000Z")
... ...
@@ -1230,8 +1230,8 @@ def process_patterns(patterns_list, patterns_from, is_glob, option_txt = ""):
1230 1230
 
1231 1231
 	return patterns_compiled, patterns_textual
1232 1232
 
1233
-commands = {}
1234
-commands_list = [
1233
+def get_commands_list():
1234
+	return [
1235 1235
 	{"cmd":"mb", "label":"Make bucket", "param":"s3://BUCKET", "func":cmd_bucket_create, "argc":1},
1236 1236
 	{"cmd":"rb", "label":"Remove bucket", "param":"s3://BUCKET", "func":cmd_bucket_delete, "argc":1},
1237 1237
 	{"cmd":"ls", "label":"List objects or buckets", "param":"[s3://BUCKET[/PREFIX]]", "func":cmd_ls, "argc":0},
... ...
@@ -1246,9 +1246,15 @@ commands_list = [
1246 1246
 	{"cmd":"cp", "label":"Copy object", "param":"s3://BUCKET1/OBJECT1 s3://BUCKET2[/OBJECT2]", "func":cmd_cp, "argc":2},
1247 1247
 	{"cmd":"mv", "label":"Move object", "param":"s3://BUCKET1/OBJECT1 s3://BUCKET2[/OBJECT2]", "func":cmd_mv, "argc":2},
1248 1248
 	{"cmd":"setacl", "label":"Modify Access control list for Bucket or Object", "param":"s3://BUCKET[/OBJECT]", "func":cmd_setacl, "argc":1},
1249
+	## CloudFront commands
1250
+	{"cmd":"cflist", "label":"List CloudFront distribution points", "param":"", "func":CfCmd.info, "argc":0},
1251
+	{"cmd":"cfinfo", "label":"Display CloudFront distribution point parameters", "param":"[cf://DIST_ID]", "func":CfCmd.info, "argc":0},
1252
+	{"cmd":"cfcreate", "label":"Create CloudFront distribution point", "param":"s3://BUCKET", "func":CfCmd.create, "argc":1},
1253
+	{"cmd":"cfdelete", "label":"Delete CloudFront distribution point", "param":"cf://DIST_ID", "func":CfCmd.delete, "argc":1},
1254
+	{"cmd":"cfmodify", "label":"Change CloudFront distribution point parameters", "param":"cf://DIST_ID", "func":CfCmd.modify, "argc":1},
1249 1255
 	]
1250 1256
 
1251
-def format_commands(progname):
1257
+def format_commands(progname, commands_list):
1252 1258
 	help = "Commands:\n"
1253 1259
 	for cmd in commands_list:
1254 1260
 		help += "  %s\n      %s %s %s\n" % (cmd["label"], progname, cmd["cmd"], cmd["param"])
... ...
@@ -1277,6 +1283,9 @@ def main():
1277 1277
 		sys.stderr.write("ERROR: Python 2.4 or higher required, sorry.\n")
1278 1278
 		sys.exit(1)
1279 1279
 
1280
+	commands_list = get_commands_list()
1281
+	commands = {}
1282
+
1280 1283
 	## Populate "commands" from "commands_list"
1281 1284
 	for cmd in commands_list:
1282 1285
 		if cmd.has_key("cmd"):
... ...
@@ -1337,6 +1346,11 @@ def main():
1337 1337
 
1338 1338
 	optparser.add_option(      "--progress", dest="progress_meter", action="store_true", help="Display progress meter (default on TTY).")
1339 1339
 	optparser.add_option(      "--no-progress", dest="progress_meter", action="store_false", help="Don't display progress meter (default on non-TTY).")
1340
+	optparser.add_option(      "--enable", dest="cf_enable", action="store_true", help="Enable given CloudFront distribution (only for [cfmodify] command)")
1341
+	optparser.add_option(      "--disable", dest="cf_enable", action="store_false", help="Enable given CloudFront distribution (only for [cfmodify] command)")
1342
+	optparser.add_option(      "--cf-add-cname", dest="cf_cnames_add", action="append", metavar="CNAME", help="Add given CNAME to a CloudFront distribution (only for [cfcreate] and [cfmodify] commands)")
1343
+	optparser.add_option(      "--cf-remove-cname", dest="cf_cnames_remove", action="append", metavar="CNAME", help="Remove given CNAME from a CloudFront distribution (only for [cfmodify] command)")
1344
+	optparser.add_option(      "--cf-comment", dest="cf_comment", action="store", metavar="COMMENT", help="Set COMMENT for a given CloudFront distribution (only for [cfcreate] and [cfmodify] commands)")
1340 1345
 	optparser.add_option("-v", "--verbose", dest="verbosity", action="store_const", const=logging.INFO, help="Enable verbose output.")
1341 1346
 	optparser.add_option("-d", "--debug", dest="verbosity", action="store_const", const=logging.DEBUG, help="Enable debug output.")
1342 1347
 	optparser.add_option(      "--version", dest="show_version", action="store_true", help="Show s3cmd version (%s) and exit." % (PkgInfo.version))
... ...
@@ -1346,7 +1360,7 @@ def main():
1346 1346
 		'Amazon S3 storage. It allows for making and removing '+
1347 1347
 		'"buckets" and uploading, downloading and removing '+
1348 1348
 		'"objects" from these buckets.')
1349
-	optparser.epilog = format_commands(optparser.get_prog_name())
1349
+	optparser.epilog = format_commands(optparser.get_prog_name(), commands_list)
1350 1350
 	optparser.epilog += ("\nSee program homepage for more information at\n%s\n" % PkgInfo.url)
1351 1351
 
1352 1352
 	(options, args) = optparser.parse_args()
... ...
@@ -1400,11 +1414,21 @@ def main():
1400 1400
 	for option in cfg.option_list():
1401 1401
 		try:
1402 1402
 			if getattr(options, option) != None:
1403
-				debug(u"Updating %s -> %s" % (option, getattr(options, option)))
1403
+				debug(u"Updating Config.Config %s -> %s" % (option, getattr(options, option)))
1404 1404
 				cfg.update_option(option, getattr(options, option))
1405 1405
 		except AttributeError:
1406 1406
 			## Some Config() options are not settable from command line
1407 1407
 			pass
1408
+	
1409
+	## Update CloudFront options if some were set
1410
+	for option in CfCmd.options.option_list():
1411
+		try:
1412
+			if getattr(options, option) != None:
1413
+				debug(u"Updating CloudFront.Cmd %s -> %s" % (option, getattr(options, option)))
1414
+				CfCmd.options.update_option(option, getattr(options, option))
1415
+		except AttributeError:
1416
+			## Some CloudFront.Cmd.Options() options are not settable from command line
1417
+			pass
1408 1418
 
1409 1419
 	## Set output and filesystem encoding for printing out filenames.
1410 1420
 	sys.stdout = codecs.getwriter(cfg.encoding)(sys.stdout, "replace")
... ...
@@ -1469,8 +1493,6 @@ def main():
1469 1469
 		cmd_func(args)
1470 1470
 	except S3Error, e:
1471 1471
 		error(u"S3 error: %s" % e)
1472
-		if e.info.has_key("Message"):
1473
-			error(e.info['Message'])
1474 1472
 		sys.exit(1)
1475 1473
 	except ParameterError, e:
1476 1474
 		error(u"Parameter problem: %s" % e)
... ...
@@ -1489,6 +1511,7 @@ if __name__ == '__main__':
1489 1489
 		from S3.Exceptions import *
1490 1490
 		from S3.Utils import unicodise
1491 1491
 		from S3.Progress import Progress
1492
+		from S3.CloudFront import Cmd as CfCmd
1492 1493
 
1493 1494
 		main()
1494 1495
 		sys.exit(0)