2 ;;;; Copyright (c) 2009 Zachary Beane, All Rights Reserved
4 ;;;; Redistribution and use in source and binary forms, with or without
5 ;;;; modification, are permitted provided that the following conditions
8 ;;;; * Redistributions of source code must retain the above copyright
9 ;;;; notice, this list of conditions and the following disclaimer.
11 ;;;; * Redistributions in binary form must reproduce the above
12 ;;;; copyright notice, this list of conditions and the following
13 ;;;; disclaimer in the documentation and/or other materials
14 ;;;; provided with the distribution.
16 ;;;; THIS SOFTWARE IS PROVIDED BY THE AUTHOR 'AS IS' AND ANY EXPRESSED
17 ;;;; OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
18 ;;;; WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
19 ;;;; ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
20 ;;;; DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
21 ;;;; DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE
22 ;;;; GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
23 ;;;; INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
24 ;;;; WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
25 ;;;; NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
26 ;;;; SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
32 (defvar *canonical-bucket-name-suffix
*
35 (defparameter *caller-reference-counter
* 8320208)
37 (defparameter *cloudfront-base-url
*
38 "https://cloudfront.amazonaws.com/2010-08-01/distribution")
42 (defparameter *distribution-specific-errors
*
43 (make-hash-table :test
'equal
)
44 "This table is used to signal the most specific error possible for
45 distribution request error responses.")
47 (defparameter *distribution-error-response-binder
*
53 ("Message" (bind :message
))
55 ("Detail" (bind :detail
))))
56 ("RequestId" (bind :request-id
)))))
58 (define-condition distribution-error
()
61 :accessor distribution-error-type
)
64 :accessor distribution-error-code
)
66 :initarg
:http-status-code
67 :accessor distribution-error-http-status-code
)
69 :initarg
:error-message
70 :accessor distribution-error-message
)
72 :initarg
:error-detail
73 :accessor distribution-error-detail
))
74 (:report
(lambda (condition stream
)
75 (format stream
"~A error ~A: ~A"
76 (distribution-error-type condition
)
77 (distribution-error-code condition
)
78 (distribution-error-message condition
)))))
80 (defmacro define-specific-distribution-error
(error-xml-code error-name
)
82 (setf (gethash ,error-xml-code
*distribution-specific-errors
*)
84 (define-condition ,error-name
(distribution-error) ())))
86 (define-specific-distribution-error "InvalidIfMatchVersion"
87 invalid-if-match-version
)
89 (define-specific-distribution-error "PreconditionFailed"
90 distribution-precondition-failed
)
92 (define-specific-distribution-error "DistributionNotDisabled"
93 distribution-not-disabled
)
95 (define-specific-distribution-error "CNAMEAlreadyExists"
98 (define-specific-distribution-error "TooManyDistributions"
99 too-many-distributions
)
101 (defun maybe-signal-distribution-error (http-status-code content
)
103 (plusp (length content
))
104 (string= (xml-document-element content
) "ErrorResponse"))
105 (let* ((bindings (xml-bind *distribution-error-response-binder
*
107 (condition (gethash (bvalue :code bindings
)
108 *distribution-specific-errors
*
109 'distribution-error
)))
111 :http-status-code http-status-code
112 :error-type
(bvalue :type bindings
)
113 :error-code
(bvalue :code bindings
)
114 :error-message
(bvalue :message bindings
)
115 :error-detail
(bvalue :detail bindings
)))))
118 ;;; Distribution objects
120 (defun canonical-distribution-bucket-name (name)
121 (if (ends-with *canonical-bucket-name-suffix
* name
)
123 (concatenate 'string name
*canonical-bucket-name-suffix
*)))
125 (defun generate-caller-reference ()
128 (incf *caller-reference-counter
*)))
130 (defclass distribution
()
132 :initarg
:origin-bucket
133 :accessor origin-bucket
135 "The S3 bucket that acts as the source of objects for the distribution.")
137 :initarg
:caller-reference
138 :accessor caller-reference
139 :initform
(generate-caller-reference)
141 "A unique value provided by the caller to prevent replays. See
142 http://docs.amazonwebservices.com/AmazonCloudFront/2008-06-30/DeveloperGuide/index.html?AboutCreatingDistributions.html")
148 "Whether this distribution should be enabled at creation time or not.")
153 :initarg
:default-root-object
154 :accessor default-root-object
)
160 :initarg
:logging-bucket
162 :accessor logging-bucket
)
164 :initarg
:logging-prefix
166 :accessor logging-prefix
)
171 "Amazon's assigned unique ID.")
173 :initarg
:domain-name
174 :accessor domain-name
176 "Amazon's assigned domain name.")
185 :documentation
"Assigned by Amazon.")
187 :initarg
:last-modified
188 :accessor last-modified
189 :documentation
"Assigned by Amazon.")))
191 (defmethod print-object ((distribution distribution
) stream
)
192 (print-unreadable-object (distribution stream
:type t
)
193 (format stream
"~A for ~S~@[ [~A]~]"
195 (origin-bucket distribution
)
196 (status distribution
))))
198 (defmethod initialize-instance :after
((distribution distribution
)
200 &key
&allow-other-keys
)
201 (declare (ignore initargs
))
202 (setf (origin-bucket distribution
)
203 (canonical-distribution-bucket-name (origin-bucket distribution
))))
206 ;;; Distribution-related requests
208 (defun distribution-document (distribution)
209 (cxml:with-xml-output
(cxml:make-string-sink
)
210 (cxml:with-element
"DistributionConfig"
211 (cxml:attribute
"xmlns" "http://cloudfront.amazonaws.com/doc/2010-08-01/")
212 (cxml:with-element
"Origin"
213 (cxml:text
(origin-bucket distribution
)))
214 (cxml:with-element
"CallerReference"
215 (cxml:text
(caller-reference distribution
)))
216 (dolist (cname (cnames distribution
))
217 (cxml:with-element
"CNAME"
219 (when (comment distribution
)
220 (cxml:with-element
"Comment"
221 (cxml:text
(comment distribution
))))
222 (cxml:with-element
"Enabled"
223 (cxml:text
(if (enabledp distribution
)
226 (cxml:with-element
"DefaultRootObject"
227 (when (default-root-object distribution
)
228 (cxml:text
(default-root-object distribution
))))
229 (let ((logging-bucket (logging-bucket distribution
))
230 (logging-prefix (logging-prefix distribution
)))
231 (when (and logging-bucket logging-prefix
)
232 (cxml:with-element
"Logging"
233 (cxml:with-element
"Bucket" (cxml:text logging-bucket
))
234 (cxml:with-element
"Prefix" (cxml:text logging-prefix
))))))))
236 (defun distribution-request-headers (distribution)
237 (let* ((date (http-date-string))
238 (signature (sign-string (secret-key *credentials
*)
240 (parameters-alist :date date
242 (format nil
"AWS ~A:~A"
243 (access-key *credentials
*)
245 :if-match
(and distribution
(etag distribution
)))))
248 (defun distribution-request (&key distribution
(method :get
)
249 parameters url-suffix content
250 ((:credentials
*credentials
*) *credentials
*))
251 (let ((url (format nil
"~A~@[~A~]" *cloudfront-base-url
* url-suffix
)))
252 (multiple-value-bind (content code headers uri stream must-close-p phrase
)
253 (drakma:http-request url
255 :parameters parameters
259 :content-type
"text/xml"
260 :additional-headers
(distribution-request-headers distribution
)
264 (member method
'(:post
:put
))
265 (distribution-document distribution
))))
266 (declare (ignore uri must-close-p
))
267 (ignore-errors (close stream
))
268 (maybe-signal-distribution-error code content
)
269 (values content headers code phrase
))))
271 (defparameter *distribution-config-form
*
272 '("DistributionConfig"
273 ("Origin" (bind :origin
))
274 ("CallerReference" (bind :caller-reference
))
276 ("CNAME" (bind :cname
)))
277 (optional ("Comment" (bind :comment
)))
278 ("Enabled" (bind :enabled
))
281 ("Bucket" (bind :logging-bucket
))
282 ("Prefix" (bind :logging-prefix
))))
284 ("DefaultRootObject" (bind :default-root-object
)))))
286 (defparameter *distribution-form
*
289 ("Status" (bind :status
))
290 ("LastModifiedTime" (bind :last-modified-time
))
291 ("InProgressInvalidationBatches" (bind :in-progress-invalidation-batches
))
292 ("DomainName" (bind :domain-name
))
293 ,@*distribution-config-form
*))
295 (defparameter *distribution-config-binder
*
296 (make-binder *distribution-config-form
*))
298 (defparameter *distribution-binder
* (make-binder *distribution-form
*))
300 (defun bindings-distribution (bindings)
301 (let ((timestamp (bvalue :last-modified-time bindings
)))
302 (make-instance 'distribution
303 :id
(bvalue :id bindings
)
304 :status
(bvalue :status bindings
)
305 :caller-reference
(bvalue :caller-reference bindings
)
306 :domain-name
(bvalue :domain-name bindings
)
307 :origin-bucket
(bvalue :origin bindings
)
308 :cnames
(mapcar (lambda (b) (bvalue :cname b
))
309 (bvalue :cnames bindings
))
310 :comment
(bvalue :comment bindings
)
311 :logging-bucket
(bvalue :logging-bucket bindings
)
312 :logging-prefix
(bvalue :logging-prefix bindings
)
313 :default-root-object
(bvalue :default-root-object bindings
)
314 :enabledp
(equal (bvalue :enabled bindings
) "true")
315 :last-modified
(and timestamp
316 (parse-amazon-timestamp timestamp
)))))
318 ;;; Distribution queries, creation, and manipulation
320 (defun put-config (distribution)
321 "Post DISTRIBUTION's configuration to AWS. Signals an error and does
322 not retry in the event of an etag match problem."
323 (multiple-value-bind (document headers code
)
324 (distribution-request :distribution distribution
325 :url-suffix
(format nil
"/~A/config"
328 (declare (ignore document headers
))
331 (defun latest-version (distribution)
332 (multiple-value-bind (document headers
)
333 (distribution-request :url-suffix
(format nil
"/~A" (id distribution
)))
334 (let ((new (bindings-distribution (xml-bind *distribution-binder
*
336 (setf (etag new
) (bvalue :etag headers
))
339 (defun merge-into (distribution new
)
340 "Copy slot values from NEW into DISTRIBUTION."
341 (macrolet ((sync (accessor)
342 `(setf (,accessor distribution
) (,accessor new
))))
344 (sync caller-reference
)
349 (sync default-root-object
)
350 (sync logging-bucket
)
351 (sync logging-prefix
)
354 (sync last-modified
))
357 (defgeneric refresh
(distribution)
359 "Pull down the latest data from AWS for DISTRIBUTION and update its slots.")
360 (:method
((distribution distribution
))
361 (merge-into distribution
(latest-version distribution
))))
363 (defun call-with-latest (fun distribution
)
364 "Call FUN on DISTRIBUTION; if there is an ETag-related error,
365 retries after REFRESHing DISTRIBUTION. FUN should not have side
366 effects on anything but the DISTRIBUTION itself, as it may be re-tried
372 (((or invalid-if-match-version distribution-precondition-failed
)
375 (setf distribution
(refresh distribution
))
377 (return (funcall fun distribution
))))))
379 (defun modify-and-save (fun distribution
)
380 "Call the modification function FUN with DISTRIBUTION as its only
381 argument, and push the modified configuration to Cloudfront. May
382 refresh DISTRIBUTION if needed. FUN should not have side effects on
383 anything but the DISTRIBUTION itself, as it may be re-tried multiple
385 (call-with-latest (lambda (distribution)
386 (multiple-value-prog1
387 (funcall fun distribution
)
388 (put-config distribution
)))
391 (defmacro with-saved-modifications
((var distribution
) &body body
)
392 "Make a series of changes to DISTRIBUTION and push the final result
393 to AWS. BODY should not have side-effects on anything but the
394 DISTRIBUTION itself, as it may be re-tried multiple times."
395 `(modify-and-save (lambda (,var
)
399 (defparameter *distribution-list-binder
*
402 ("Marker" (bind :marker
))
404 ("NextMarker" (bind :next-marker
)))
405 ("MaxItems" (bind :max-items
))
406 ("IsTruncated" (bind :is-truncateD
))
407 (sequence :distributions
408 ("DistributionSummary"
410 ("Status" (bind :status
))
411 ("LastModifiedTime" (bind :last-modified-time
))
412 ("DomainName" (bind :domain-name
))
413 ("Origin" (bind :origin
))
414 (sequence :cnames
("CNAME" (bind :cname
)))
415 (optional ("Comment" (bind :comment
)))
416 ("Enabled" (bind :enabled
)))))))
418 (defun all-distributions (&key
((:credentials
*credentials
*) *credentials
*))
419 (let* ((document (distribution-request))
420 (bindings (xml-bind *distribution-list-binder
* document
)))
422 (bindings-distribution b
))
423 (bvalue :distributions bindings
))))
425 (defun create-distribution (bucket-name &key cnames
(enabled t
) comment
)
426 (unless (listp cnames
)
427 (setf cnames
(list cnames
)))
428 (let ((distribution (make-instance 'distribution
429 :origin-bucket bucket-name
433 (let* ((document (distribution-request :method
:post
434 :distribution distribution
))
435 (bindings (xml-bind *distribution-binder
* document
)))
436 (bindings-distribution bindings
))))
438 (defun %delete-distribution
(distribution)
439 (multiple-value-bind (document headers code
)
440 (distribution-request :url-suffix
(format nil
"/~A" (id distribution
))
441 :distribution distribution
443 (declare (ignore document headers
))
446 (defgeneric delete-distribution
(distribution)
447 (:method
((distribution distribution
))
448 (call-with-latest #'%delete-distribution distribution
)))
450 (defgeneric enable
(distribution)
452 "Mark DISTRIBUTION as enabled. Enabling can take time to take
453 effect; the STATUS of DISTRIBUTION will change from \"InProgress\"
454 to \"Deployed\" when fully enabled.")
455 (:method
((distribution distribution
))
456 (with-saved-modifications (d distribution
)
457 (setf (enabledp d
) t
))))
460 (defgeneric disable
(distribution)
462 "Mark DISTRIBUTION as disabled. Like ENABLE, DISABLE may take some
463 time to take effect.")
464 (:method
((distribution distribution
))
465 (with-saved-modifications (d distribution
)
466 (setf (enabledp d
) nil
)
469 (defgeneric ensure-cname
(distribution cname
)
471 "Add CNAME to DISTRIBUTION's list of CNAMEs, if not already
473 (:method
((distribution distribution
) cname
)
474 (with-saved-modifications (d distribution
)
475 (pushnew cname
(cnames d
)
476 :test
#'string-equal
))))
478 (defgeneric remove-cname
(distribution cname
)
479 (:method
(cname (distribution distribution
))
480 (with-saved-modifications (d distribution
)
482 (remove cname
(cnames distribution
)
483 :test
#'string-equal
)))))
485 (defgeneric set-comment
(distribution comment
)
486 (:method
((distribution distribution
) comment
)
487 (with-saved-modifications (d distribution
)
488 (setf (comment d
) comment
))))
490 (defun distributions-for-bucket (bucket-name)
491 "Return a list of distributions that are associated with BUCKET-NAME."
492 (setf bucket-name
(canonical-distribution-bucket-name bucket-name
))
495 :test-not
#'string-equal
496 :key
#'origin-bucket
))
501 (defclass invalidation
()
506 :documentation
"Amazon's assigned unique ID.")
508 :initarg
:distribution
509 :accessor distribution
512 :initarg
:create-time
514 :accessor create-time
)
518 :initform
"InProgress")
520 :initarg
:caller-reference
521 :initform
(generate-caller-reference)
522 :accessor caller-reference
)
528 (defmethod print-object ((invalidation invalidation
) stream
)
529 (print-unreadable-object (invalidation stream
:type t
)
530 (format stream
"~S [~A]"
532 (status invalidation
))))
534 (defparameter *invalidation-batch-form
*
535 '("InvalidationBatch"
536 (sequence :paths
("Path" (bind :path
)))
537 ("CallerReference" (bind :caller-reference
))))
539 (defparameter *invalidation-form
*
542 ("Status" (bind :status
))
543 ("CreateTime" (bind :create-time
))
544 ,*invalidation-batch-form
*))
546 (defparameter *invalidation-batch-binder
*
547 (make-binder *invalidation-batch-form
*))
549 (defparameter *invalidation-binder
*
550 (make-binder *invalidation-form
*))
552 (defmethod merge-bindings ((invalidation invalidation
) bindings
)
553 (setf (id invalidation
) (bvalue :id bindings
)
554 (status invalidation
) (bvalue :status bindings
)
555 (create-time invalidation
) (parse-amazon-timestamp
556 (bvalue :create-time bindings
))
559 (mapcar (bfun :path
) (bvalue :paths bindings
))))
562 (defgeneric distribution-id
(object)
563 (:method
((invalidation invalidation
))
564 (id (distribution invalidation
))))
566 (defun invalidation-request (invalidation &key
(url-suffix "")
567 (method :get
) content
)
568 (distribution-request :method method
569 :url-suffix
(format nil
"/~A/invalidation~A"
570 (distribution-id invalidation
)
574 (defun invalidation-batch-document (invalidation)
575 (cxml:with-xml-output
(cxml:make-string-sink
)
576 (cxml:with-element
"InvalidationBatch"
577 (cxml:attribute
"xmlns" "http://cloudfront.amazonaws.com/doc/2010-08-01/")
578 (dolist (path (paths invalidation
))
579 (cxml:with-element
"Path"
581 (cxml:with-element
"CallerReference"
582 (cxml:text
(caller-reference invalidation
))))))
585 (defun invalidate-paths (distribution paths
)
586 (let* ((invalidation (make-instance 'invalidation
587 :distribution distribution
590 (invalidation-request invalidation
592 :content
(invalidation-batch-document invalidation
))))
593 (merge-bindings invalidation
(xml-bind *invalidation-binder
* response
))))
596 (defmethod refresh ((invalidation invalidation
))
598 (invalidation-request invalidation
599 :url-suffix
(format nil
"/~A"
600 (id invalidation
)))))
601 (merge-bindings invalidation
(xml-bind *invalidation-binder
* document
))))