Browse code

Support for CloudFront invalidation using [sync --cf-invalidate]

* s3cmd, S3/CloudFront.py, S3/Config.py: Support for CloudFront
invalidation using [sync --cf-invalidate] command.

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

Michal Ludvig authored on 2011/04/08 22:45:43
Showing 4 changed files
... ...
@@ -1,5 +1,7 @@
1 1
 2011-04-10  Michal Ludvig  <mludvig@logix.net.nz>
2 2
 
3
+	* s3cmd, S3/CloudFront.py, S3/Config.py: Support for CloudFront
4
+	  invalidation using [sync --cf-invalidate] command.
3 5
 	* S3/Utils.py: getDictFromTree() now recurses into
4 6
 	  sub-trees.
5 7
 
... ...
@@ -6,6 +6,8 @@
6 6
 import sys
7 7
 import time
8 8
 import httplib
9
+import random
10
+from datetime import datetime
9 11
 from logging import debug, info, warning, error
10 12
 
11 13
 try:
... ...
@@ -17,6 +19,10 @@ from Config import Config
17 17
 from Exceptions import *
18 18
 from Utils import getTreeFromXml, appendXmlTextNode, getDictFromTree, dateS3toPython, sign_string, getBucketFromHostname, getHostnameFromBucket
19 19
 from S3Uri import S3Uri, S3UriS3
20
+from FileLists import fetch_remote_list
21
+
22
+cloudfront_api_version = "2010-11-01"
23
+cloudfront_resource = "/%(api_ver)s/distribution" % { 'api_ver' : cloudfront_api_version }
20 24
 
21 25
 def output(message):
22 26
 	sys.stdout.write(message + "\n")
... ...
@@ -34,7 +40,12 @@ class DistributionSummary(object):
34 34
 	##	<Status>Deployed</Status>
35 35
 	##	<LastModifiedTime>2009-01-16T11:49:02.189Z</LastModifiedTime>
36 36
 	##	<DomainName>blahblahblah.cloudfront.net</DomainName>
37
-	##	<Origin>example.bucket.s3.amazonaws.com</Origin>
37
+	##	<S3Origin>
38
+	##     <DNSName>example.bucket.s3.amazonaws.com</DNSName>
39
+	##  </S3Origin>
40
+	##  <CNAME>cdn.example.com</CNAME>
41
+	##  <CNAME>img.example.com</CNAME>
42
+	##  <Comment>What Ever</Comment>
38 43
 	##	<Enabled>true</Enabled>
39 44
 	## </DistributionSummary>
40 45
 
... ...
@@ -46,6 +57,8 @@ class DistributionSummary(object):
46 46
 	def parse(self, tree):
47 47
 		self.info = getDictFromTree(tree)
48 48
 		self.info['Enabled'] = (self.info['Enabled'].lower() == "true")
49
+		if self.info.has_key("CNAME") and type(self.info['CNAME']) != list:
50
+			self.info['CNAME'] = [self.info['CNAME']]
49 51
 
50 52
 	def uri(self):
51 53
 		return S3Uri("cf://%s" % self.info['Id'])
... ...
@@ -121,7 +134,7 @@ class DistributionConfig(object):
121 121
 	## </DistributionConfig>
122 122
 
123 123
 	EMPTY_CONFIG = "<DistributionConfig><Origin/><CallerReference/><Enabled>true</Enabled></DistributionConfig>"
124
-	xmlns = "http://cloudfront.amazonaws.com/doc/2010-07-15/"
124
+	xmlns = "http://cloudfront.amazonaws.com/doc/%(api_ver)s/" % { 'api_ver' : cloudfront_api_version }
125 125
 	def __init__(self, xml = None, tree = None):
126 126
 		if xml is None:
127 127
 			xml = DistributionConfig.EMPTY_CONFIG
... ...
@@ -178,6 +191,45 @@ class DistributionConfig(object):
178 178
 			tree.append(logging_el)
179 179
 		return ET.tostring(tree)
180 180
 
181
+class InvalidationBatch(object):
182
+	## Example:
183
+	##
184
+	## <InvalidationBatch>
185
+	##   <Path>/image1.jpg</Path>
186
+	##   <Path>/image2.jpg</Path>
187
+	##   <Path>/videos/movie.flv</Path>
188
+	##   <Path>/sound%20track.mp3</Path>
189
+	##   <CallerReference>my-batch</CallerReference>
190
+	## </InvalidationBatch>
191
+
192
+	def __init__(self, reference = None, distribution = None, paths = []):
193
+		if reference:
194
+			self.reference = reference
195
+		else:
196
+			if not distribution:
197
+				distribution="0"
198
+			self.reference = "%s.%s.%s" % (distribution,
199
+				datetime.strftime(datetime.now(),"%Y%m%d%H%M%S"),
200
+				random.randint(1000,9999))
201
+		self.paths = []
202
+		self.add_objects(paths)
203
+
204
+	def add_objects(self, paths):
205
+		self.paths.extend(paths)
206
+
207
+	def get_reference(self):
208
+		return self.reference
209
+
210
+	def __str__(self):
211
+		tree = ET.Element("InvalidationBatch")
212
+
213
+		for path in self.paths:
214
+			if path[0] != "/":
215
+				path = "/" + path
216
+			appendXmlTextNode("Path", path, tree)
217
+		appendXmlTextNode("CallerReference", self.reference, tree)
218
+		return ET.tostring(tree)
219
+
181 220
 class CloudFront(object):
182 221
 	operations = {
183 222
 		"CreateDist" : { 'method' : "POST", 'resource' : "" },
... ...
@@ -186,6 +238,9 @@ class CloudFront(object):
186 186
 		"GetDistInfo" : { 'method' : "GET", 'resource' : "/%(dist_id)s" },
187 187
 		"GetDistConfig" : { 'method' : "GET", 'resource' : "/%(dist_id)s/config" },
188 188
 		"SetDistConfig" : { 'method' : "PUT", 'resource' : "/%(dist_id)s/config" },
189
+		"Invalidate" : { 'method' : "POST", 'resource' : "/%(dist_id)s/invalidation" },
190
+		"GetInvalList" : { 'method' : "GET", 'resource' : "/%(dist_id)s/invalidation" },
191
+		"GetInvalStatus" : { 'method' : "GET", 'resource' : "/%(dist_id)s/invalidation/%(invalidation_id)s" },
189 192
 	}
190 193
 
191 194
 	## Maximum attempts of re-issuing failed requests
... ...
@@ -311,6 +366,27 @@ class CloudFront(object):
311 311
 		                             body = request_body, headers = headers)
312 312
 		return response
313 313
 
314
+	def InvalidateObjects(self, uri, paths):
315
+		# uri could be either cf:// or s3:// uri
316
+		cfuri = self.get_dist_name_for_bucket(uri)
317
+		if len(paths) > 999:
318
+			try:
319
+				tmp_filename = Utils.mktmpfile()
320
+				f = open(tmp_filename, "w")
321
+				f.write("\n".join(paths)+"\n")
322
+				f.close()
323
+				warning("Request to invalidate %d paths (max 999 supported)" % len(paths))
324
+				warning("All the paths are now saved in: %s" % tmp_filename)
325
+			except:
326
+				pass
327
+			raise ParameterError("Too many paths to invalidate")
328
+		invalbatch = InvalidationBatch(distribution = cfuri.dist_id(), paths = paths)
329
+		debug("InvalidateObjects(): request_body: %s" % invalbatch)
330
+		response = self.send_request("Invalidate", dist_id = cfuri.dist_id(),
331
+		                             body = str(invalbatch))
332
+		debug("InvalidateObjects(): response: %s" % response)
333
+		return response, invalbatch.get_reference()
334
+
314 335
 	## --------------------------------------------------
315 336
 	## Low-level methods for handling CloudFront requests
316 337
 	## --------------------------------------------------
... ...
@@ -350,7 +426,7 @@ class CloudFront(object):
350 350
 		return response
351 351
 
352 352
 	def create_request(self, operation, dist_id = None, headers = None):
353
-		resource = self.config.cloudfront_resource + (
353
+		resource = cloudfront_resource + (
354 354
 		           operation['resource'] % { 'dist_id' : dist_id })
355 355
 
356 356
 		if not headers:
... ...
@@ -400,9 +476,13 @@ class CloudFront(object):
400 400
 			response = self.GetList()
401 401
 			CloudFront.dist_list = {}
402 402
 			for d in response['dist_list'].dist_summs:
403
-				CloudFront.dist_list[getBucketFromHostname(d.info['Origin'])[0]] = d.uri()
403
+				CloudFront.dist_list[getBucketFromHostname(d.info['S3Origin']['DNSName'])[0]] = d.uri()
404 404
 			debug("dist_list: %s" % CloudFront.dist_list)
405
-		return CloudFront.dist_list[uri.bucket()]
405
+		try:
406
+			return CloudFront.dist_list[uri.bucket()]
407
+		except Exception, e:
408
+			debug(e)
409
+			raise ParameterError("Unable to translate S3 URI to CloudFront distribution name: %s" % arg)
406 410
 
407 411
 class Cmd(object):
408 412
 	"""
... ...
@@ -430,11 +510,7 @@ class Cmd(object):
430 430
 		cf = CloudFront(Config())
431 431
 		cfuris = []
432 432
 		for arg in args:
433
-			try:
434
-				uri = cf.get_dist_name_for_bucket(S3Uri(arg))
435
-			except Exception, e:
436
-				debug(e)
437
-				raise ParameterError("Unable to translate S3 URI to CloudFront distribution name: %s" % arg)
433
+			uri = cf.get_dist_name_for_bucket(S3Uri(arg))
438 434
 			cfuris.append(uri)
439 435
 		return cfuris
440 436
 
... ...
@@ -444,9 +520,17 @@ class Cmd(object):
444 444
 		if not args:
445 445
 			response = cf.GetList()
446 446
 			for d in response['dist_list'].dist_summs:
447
-				pretty_output("Origin", S3UriS3.httpurl_to_s3uri(d.info['Origin']))
447
+				if d.info.has_key("S3Origin"):
448
+					origin = S3UriS3.httpurl_to_s3uri(d.info['S3Origin']['DNSName'])
449
+				elif d.info.has_key("CustomOrigin"):
450
+					origin = "http://%s/" % d.info['CustomOrigin']['DNSName']
451
+				else:
452
+					origin = "<unknown>"
453
+				pretty_output("Origin", origin)
448 454
 				pretty_output("DistId", d.uri())
449 455
 				pretty_output("DomainName", d.info['DomainName'])
456
+				if d.info.has_key("CNAME"):
457
+					pretty_output("CNAMEs", ", ".join(d.info['CNAME']))
450 458
 				pretty_output("Status", d.info['Status'])
451 459
 				pretty_output("Enabled", d.info['Enabled'])
452 460
 				output("")
... ...
@@ -456,11 +540,18 @@ class Cmd(object):
456 456
 				response = cf.GetDistInfo(cfuri)
457 457
 				d = response['distribution']
458 458
 				dc = d.info['DistributionConfig']
459
-				pretty_output("Origin", S3UriS3.httpurl_to_s3uri(dc.info['Origin']))
459
+				if dc.info.has_key("S3Origin"):
460
+					origin = S3UriS3.httpurl_to_s3uri(dc.info['S3Origin']['DNSName'])
461
+				elif dc.info.has_key("CustomOrigin"):
462
+					origin = "http://%s/" % dc.info['CustomOrigin']['DNSName']
463
+				else:
464
+					origin = "<unknown>"
465
+				pretty_output("Origin", origin)
460 466
 				pretty_output("DistId", d.uri())
461 467
 				pretty_output("DomainName", d.info['DomainName'])
468
+				if dc.info.has_key("CNAME"):
469
+					pretty_output("CNAMEs", ", ".join(dc.info['CNAME']))
462 470
 				pretty_output("Status", d.info['Status'])
463
-				pretty_output("CNAMEs", ", ".join(dc.info['CNAME']))
464 471
 				pretty_output("Comment", dc.info['Comment'])
465 472
 				pretty_output("Enabled", dc.info['Enabled'])
466 473
 				pretty_output("DfltRootObject", dc.info['DefaultRootObject'])
... ...
@@ -542,3 +633,7 @@ class Cmd(object):
542 542
 		pretty_output("Enabled", dc.info['Enabled'])
543 543
 		pretty_output("DefaultRootObject", dc.info['DefaultRootObject'])
544 544
 		pretty_output("Etag", response['headers']['etag'])
545
+
546
+	@staticmethod
547
+	def invalidate(args):
548
+		cf = CloudFront(Config())
... ...
@@ -19,7 +19,6 @@ class Config(object):
19 19
 	host_bucket = "%(bucket)s.s3.amazonaws.com"
20 20
 	simpledb_host = "sdb.amazonaws.com"
21 21
 	cloudfront_host = "cloudfront.amazonaws.com"
22
-	cloudfront_resource = "/2010-07-15/distribution"
23 22
 	verbosity = logging.WARNING
24 23
 	progress_meter = True
25 24
 	progress_class = Progress.ProgressCR
... ...
@@ -76,6 +75,7 @@ class Config(object):
76 76
 	reduced_redundancy = False
77 77
 	follow_symlinks = False
78 78
 	socket_timeout = 10
79
+	invalidate_on_cf = False
79 80
 
80 81
 	## Creating a singleton
81 82
 	def __new__(self, configfile = None):
... ...
@@ -841,6 +841,7 @@ def cmd_sync_local2remote(args):
841 841
 			s3.object_delete(uri)
842 842
 			output(u"deleted: '%s'" % uri)
843 843
 
844
+	uploaded_objects_list = []
844 845
 	total_size = 0
845 846
 	total_elapsed = 0.0
846 847
 	timestamp_start = time.time()
... ...
@@ -872,6 +873,7 @@ def cmd_sync_local2remote(args):
872 872
 				(item['full_name_unicode'], uri, response["size"], response["elapsed"], 
873 873
 				speed_fmt[0], speed_fmt[1], seq_label))
874 874
 		total_size += response["size"]
875
+		uploaded_objects_list.append(uri.object())
875 876
 
876 877
 	total_elapsed = time.time() - timestamp_start
877 878
 	total_speed = total_elapsed and total_size/total_elapsed or 0.0
... ...
@@ -885,6 +887,16 @@ def cmd_sync_local2remote(args):
885 885
 	else:
886 886
 		info(outstr)
887 887
 
888
+	if cfg.invalidate_on_cf:
889
+		if len(uploaded_objects_list) == 0:
890
+			info("Nothing to invalidate in CloudFront")
891
+		else:
892
+			# 'uri' from the last iteration is still valid at this point
893
+			cf = CloudFront(cfg)
894
+			result, inval_id = cf.InvalidateObjects(uri, uploaded_objects_list)
895
+			print result
896
+			output("Created invalidation request: %s" % inval_id)
897
+
888 898
 def cmd_sync(args):
889 899
 	if (len(args) < 2):
890 900
 		raise ParameterError("Too few parameters! Expected: %s" % commands['sync']['param'])
... ...
@@ -1438,6 +1450,7 @@ def main():
1438 1438
 	optparser.add_option(      "--no-progress", dest="progress_meter", action="store_false", help="Don't display progress meter (default on non-TTY).")
1439 1439
 	optparser.add_option(      "--enable", dest="enable", action="store_true", help="Enable given CloudFront distribution (only for [cfmodify] command)")
1440 1440
 	optparser.add_option(      "--disable", dest="enable", action="store_false", help="Enable given CloudFront distribution (only for [cfmodify] command)")
1441
+	optparser.add_option(      "--cf-invalidate", dest="invalidate_on_cf", action="store_true", help="Invalidate the uploaded filed in CloudFront. Also see [cfinval] command.")
1441 1442
 	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)")
1442 1443
 	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)")
1443 1444
 	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)")
... ...
@@ -1684,6 +1697,7 @@ if __name__ == '__main__':
1684 1684
 		from S3.Utils import *
1685 1685
 		from S3.Progress import Progress
1686 1686
 		from S3.CloudFront import Cmd as CfCmd
1687
+		from S3.CloudFront import CloudFront
1687 1688
 		from S3.FileLists import *
1688 1689
 
1689 1690
 		main()