-
-
Save albrow/4385707 to your computer and use it in GitHub Desktop.
require 'rubygems' | |
require 'bundler/setup' | |
require 'openssl' | |
require 'digest/sha1' | |
require 'net/https' | |
require 'base64' | |
require 'aws-sdk' | |
require 'digest/md5' | |
require 'colored' | |
# A convenient wrapper around aws-sdk | |
# Also includes the ability to invalidate cloudfront files | |
# Used for deploying to s3/cloudfront | |
class AWSDeployTools | |
def initialize(config = {}) | |
@access_key_id = config['access_key_id'] | |
@secret_access_key = config['secret_access_key'] | |
# for privacy, allow user to store aws credentials in a shell env variable | |
@access_key_id ||= ENV['AWS_ACCESS_KEY_ID'] | |
@secret_access_key ||= ENV['AWS_SECRET_ACCESS_KEY'] | |
@bucket = config['bucket'] | |
@acl = config['acl'] | |
@cf_distribution_id = config['cf_distribution_id'] | |
@dirty_keys = Set.new # a set of keys that are dirty (have been pushed but not invalidated) | |
if @bucket.nil? | |
raise "ERROR: Must provide bucket name in constructor. (e.g. :bucket => 'bucket_name')" | |
end | |
if @cf_distribution_id.nil? | |
puts "WARNING: cf_distribution_id is nil. (you can include it with :cf_distribution_id => 'id')\n Skipping cf invalidations..." | |
end | |
@s3 = AWS::S3.new(config) | |
end | |
# checks if a local file is in sync with the s3 bucket | |
# file can either be a file object or a string with a | |
# valid file path | |
def synced?(s3_key, file) | |
if file.is_a? String | |
file = File.open(file, 'r') | |
end | |
f_content = file.read | |
obj_etag = "" | |
begin | |
obj = @s3.buckets[@bucket].objects[s3_key] | |
obj_etag = obj.etag # the etag is the md5 of the remote file | |
rescue | |
return false | |
end | |
# the etag is surrounded by quotations. chomp removes them | |
obj_etag = obj_etag.gsub('"', '') | |
# compare the etag to the md5 hash of the local file | |
obj_etag == md5(f_content) | |
end | |
# pushes (writes) the file to the s3 bucket at location | |
# indicated by s3_key. | |
# file can either be a file object or a string with a | |
# valid file path | |
# options are any options that can be passed to the | |
# write method. | |
# See http://docs.aws.amazon.com/AWSRubySDK/latest/frames.html | |
def push(s3_key, file, options = {}) | |
if file.is_a? String | |
file = File.open(file, 'r') | |
end | |
# detect content type | |
require 'mime/types' | |
file_path = file.path | |
# remove the .gz extension so the base extension will | |
# be used to determine content type. E.g. we want the | |
# type of index.html.gz to be text/html | |
if file_path.include? ".gz" | |
file_path.gsub!(".gz", "") | |
end | |
content_type = MIME::Types.type_for(File.extname(file_path)).first.to_s | |
content_type_hash = {:content_type => content_type} | |
options.merge! content_type_hash | |
puts "--> pushing #{file.path} to #{s3_key}...".green | |
obj = @s3.buckets[@bucket].objects[s3_key] | |
obj.write(file, options) | |
@dirty_keys << s3_key | |
# Special cases for index files. | |
# for /index.html we should also invalidate / | |
# for /archive/index.html we should also invalidate /archive/ | |
if (s3_key == "index.html") | |
@dirty_keys << "/" | |
elsif File.basename(s3_key) == "index.html" | |
@dirty_keys << s3_key.chomp(s3_key.split("/").last) | |
end | |
end | |
# batch pushes (writes) the files to the s3 bucket at locations | |
# indicated by s3_keys. (more than one file at a time) | |
# files can either be a file object or a string with a | |
# valid file path | |
# options are any options that can be passed to the | |
# write method. | |
# See http://docs.aws.amazon.com/AWSRubySDK/latest/frames.html | |
def batch_push(s3_keys = [], files = [], options = {}) | |
if (s3_keys.size != files.size) | |
raise "ERROR: There must be a 1-to-1 correspondence of keys to files!" | |
end | |
files.each_with_index do |file, i| | |
s3_key = s3_keys[i] | |
push(s3_key, file, options) | |
end | |
end | |
# for each file, first checks if the file is synced. | |
# If not, it pushes (writes) the file. | |
# options are any options that can be passed to the | |
# write method. | |
# See http://docs.aws.amazon.com/AWSRubySDK/latest/frames.html | |
def sync(s3_keys = [], files = [], options = {}) | |
if (s3_keys.size != files.size) | |
raise "ERROR: There must be a 1-to-1 correspondence of keys to files!" | |
end | |
files.each_with_index do |file, i| | |
s3_key = s3_keys[i] | |
unless synced?(s3_key, file) | |
push(s3_key, file, options) | |
end | |
end | |
end | |
# a convenience method which simply returns the md5 hash of input | |
def md5 (input) | |
Digest::MD5.hexdigest(input) | |
end | |
# invalidates files (accepts an array of keys or a single key) | |
# based heavily on https://gist.github.com/601408 | |
def invalidate(s3_keys) | |
if @cf_distribution_id.nil? | |
puts "WARNING: cf_distribution_id is nil. (you can include it with :cf_distribution_id => 'id')\n--> skipping cf invalidations..." | |
return | |
end | |
if s3_keys.nil? || s3_keys.empty? | |
puts "nothing to invalidate." | |
return | |
elsif s3_keys.is_a? String | |
puts "--> invalidating #{s3_keys}...".yellow | |
# special case for root | |
if s3_keys == '/' | |
paths = '<Path>/</Path>' | |
else | |
paths = '<Path>/' + s3_keys + '</Path>' | |
end | |
elsif s3_keys.length > 0 | |
puts "--> invalidating #{s3_keys.size} file(s)...".yellow | |
paths = '<Path>/' + s3_keys.join('</Path><Path>/') + '</Path>' | |
# special case for root | |
if s3_keys.include?('/') | |
paths.sub!('<Path>//</Path>', '<Path>/</Path>') | |
end | |
end | |
# digest calculation based on http://blog.confabulus.com/2011/05/13/cloudfront-invalidation-from-ruby/ | |
date = Time.now.strftime("%a, %d %b %Y %H:%M:%S %Z") | |
digest = Base64.encode64(OpenSSL::HMAC.digest(OpenSSL::Digest::Digest.new('sha1'), @secret_access_key, date)).strip | |
uri = URI.parse('https://cloudfront.amazonaws.com/2010-08-01/distribution/' + @cf_distribution_id + '/invalidation') | |
if paths != nil | |
req = Net::HTTP::Post.new(uri.path) | |
else | |
req = Net::HTTP::Get.new(uri.path) | |
end | |
req.initialize_http_header({ | |
'x-amz-date' => date, | |
'Content-Type' => 'text/xml', | |
'Authorization' => "AWS %s:%s" % [@access_key_id, digest] | |
}) | |
if paths != nil | |
req.body = "<InvalidationBatch>" + paths + "<CallerReference>ref_#{Time.now.utc.to_i}</CallerReference></InvalidationBatch>" | |
end | |
http = Net::HTTP.new(uri.host, uri.port) | |
http.use_ssl = true | |
http.verify_mode = OpenSSL::SSL::VERIFY_NONE | |
res = http.request(req) | |
if res.code == '201' | |
puts "Cloudfront Invalidation Success [201]. It may take a few minutes for the new files to propagate.".green | |
else | |
puts ("Cloudfront Invalidation Error: \n" + res.body).red | |
end | |
return res.code | |
end | |
# invalidates all the dirty keys and marks them as clean | |
def invalidate_dirty_keys | |
if @cf_distribution_id.nil? | |
puts "WARNING: cf_distribution_id is nil. (you can include it with :cf_distribution_id => 'id')\n--> skipping cf invalidations..." | |
return | |
end | |
res_code = invalidate(@dirty_keys.to_a) | |
# mark the keys as clean iff the invalidation request went through | |
@dirty_keys.clear if res_code == '201' | |
end | |
end |
# ... | |
desc "Deploy website to s3/cloudfront via aws-sdk" | |
task :s3_cloudfront => [:generate, :minify, :gzip, :compress_images] do | |
puts "==================================================" | |
puts " Deploying to Amazon S3 & CloudFront" | |
puts "==================================================" | |
# setup the aws_deploy_tools object | |
config = YAML::load( File.open("_config.yml")) | |
aws_deploy = AWSDeployTools.new(config) | |
# get all files in the public directory | |
all_files = Dir.glob("#{$public_dir}/**/*.*") | |
# we want the gzipped version of the files, not the regular (non-gzipped) version | |
# excluded files contains all the regular versions, which will not be deployed | |
excluded_files = [] | |
$gzip_exts.collect do |ext| | |
excluded_files += Dir.glob("#{$public_dir}/**/*.#{ext}") | |
end | |
# we do gzipped files seperately since they have different metadata (:content_encoding => gzip) | |
puts "--> syncing gzipped files...".yellow | |
gzipped_files = Dir.glob("#{$public_dir}/**/*.gz") | |
gzipped_keys = gzipped_files.collect {|f| (f.split("#{$public_dir}/")[1]).sub(".gz", "")} | |
aws_deploy.sync(gzipped_keys, gzipped_files, | |
:reduced_redundancy => true, | |
:cache_control => "max_age=86400", #24 hours | |
:content_encoding => 'gzip', | |
:acl => config['acl'] | |
) | |
puts "--> syncing all other files...".yellow | |
non_gzipped_files = all_files - gzipped_files - excluded_files | |
non_gzipped_keys = non_gzipped_files.collect {|f| f.split("#{$public_dir}/")[1]} | |
aws_deploy.sync(non_gzipped_keys, non_gzipped_files, | |
:reduced_redundancy => true, | |
:cache_control => "max_age=86400", #24 hours | |
:acl => config['acl'] | |
) | |
# invalidate all the files we just pushed | |
aws_deploy.invalidate_dirty_keys | |
puts "DONE." | |
end | |
desc "Compress all applicable content in public/ using gzip" | |
task :gzip do | |
unless which('gzip') | |
puts "WARNING: gzip is not installed on your system. Skipping gzip..." | |
return | |
end | |
@compressor ||= RedDragonfly.new | |
$gzip_exts.each do |ext| | |
puts "--> gzipping all #{ext}...".yellow | |
files = Dir.glob("#{$gzip_dir}/**/*.#{ext}") | |
files.each do |f| | |
@compressor.gzip(f) | |
end | |
end | |
puts "DONE." | |
end | |
desc "Minify all applicable files in public/ using jitify" | |
task :minify do | |
unless which('jitify') | |
puts "WARNING: jitify is not installed on your system. Skipping minification..." | |
return | |
end | |
@compressor ||= RedDragonfly.new | |
$minify_exts.each do |ext| | |
puts "--> minifying all #{ext}...".yellow | |
files = Dir.glob("#{$minify_dir}/**/*.#{ext}") | |
files.each do |f| | |
@compressor.minify(f) | |
end | |
end | |
puts "DONE." | |
end | |
desc "Compress all images in public/ using ImageMagick" | |
task :compress_images do | |
unless which('convert') | |
puts "WARNING: ImageMagick is not installed on your system. Skipping image compression..." | |
return | |
end | |
@compressor ||= RedDragonfly.new | |
$compress_img_exts.each do |ext| | |
puts "--> compressing all #{ext}...".yellow | |
files = Dir.glob("#{$compress_img_dir}/**/*.#{ext}") | |
files.each do |f| | |
@compressor.compress_img(f) | |
end | |
end | |
puts "DONE." | |
end | |
# ... | |
## | |
# invoke system which to check if a command is supported | |
# from http://stackoverflow.com/questions/2108727/which-in-ruby-checking-if-program-exists-in-path-from-ruby | |
# which('ruby') #=> /usr/bin/ruby | |
def which(cmd) | |
system("which #{ cmd} > /dev/null 2>&1") | |
end |
# A set of tools for minifying and compressing content | |
# | |
# Currently supports: | |
# - miniifying js, css, and html | |
# - gzipping any content | |
# - compressing and shrinking images | |
# | |
# Currently depends on 3 system command line tools: | |
# - ImageMagick (http://www.imagemagick.org/script/index.php) | |
# - gzip (http://www.gzip.org/) | |
# - jitify (http://www.jitify.com/) | |
# | |
# These may be swapped out for gem versions in the future, | |
# but for now you have to manually install command line tools | |
# above on your system if you don't already have them. | |
# | |
# Author: Alex Browne | |
class RedDragonfly | |
def initialize | |
# ~~ gzip ~~ | |
$gzip_options = { | |
:output_ext => "gz" | |
} | |
# ~~ minify (jitify) ~~ | |
# $minify_options = { | |
# | |
# } | |
# ~~ images (ImageMagick) ~~ | |
# exts : files extensions which sould be minified during batch operations | |
# output_ext : file extension of the output file ("" means keep the same extension) | |
# max_width : max width for compressed images | |
# max_height : max height for compressed images | |
# quality : image compression quality (1-100, higher is better quality/bigger files) | |
# compress_type : type of compression to be used (http://www.imagemagick.org/script/command-line-options.php#compress) | |
$img_options = { | |
:output_ext => "jpg", | |
:max_width => "600", | |
:max_height => "1200", | |
:quality => "65", | |
:compress_type => "JPEG" | |
} | |
end | |
# accepts a single file or an array of files | |
# accepts a file object or the path to a file (a string) | |
# perserves the original file | |
# the output is (e.g.) .html.gz | |
def gzip (files = []) | |
unless which('gzip') | |
puts "WARNING: gzip is not installed on your system. Skipping gzip..." | |
return | |
end | |
unless files.is_a? Array | |
files = [files] | |
end | |
files.each do |file| | |
fname = get_filename(file) | |
# invoke system gzip | |
system("gzip -cn9 #{fname} > #{fname + '.' + $gzip_options[:output_ext]}") | |
end | |
end | |
# accepts a single file or an array of files | |
# accepts a file object or the path to a file | |
# overwrites the original file with the minified version | |
# html, css, and js supported only | |
def minify (files = []) | |
unless which('jitify') | |
puts "WARNING: jitify is not installed on your system. Skipping minification..." | |
return | |
end | |
unless files.is_a? Array | |
files = [files] | |
end | |
files.each do |file| | |
fname = get_filename(file) | |
# invoke system jitify | |
system("jitify --minify #{fname} > #{fname + '.min'}") | |
# remove the .min extension | |
system("mv #{fname + '.min'} #{fname}") | |
end | |
end | |
# compresses an image file using the options | |
# specified at the top | |
# accepts either a single file or an array of files | |
# accepts either a file object or a path to a file | |
def compress_img (files = []) | |
unless which('convert') | |
puts "WARNING: ImageMagick is not installed on your system. Skipping image compression..." | |
return | |
end | |
unless files.is_a? Array | |
files = [files] | |
end | |
files.each do |file| | |
fname = get_filename(file) | |
compress_cmd = "convert #{fname} -resize #{$img_options[:max_width]}x#{$img_options[:max_height]}\\>" + | |
" -compress #{$img_options[:compress_type]} -quality #{$img_options[:quality]}" + | |
" #{get_raw_filename(fname) + '.' + $img_options[:output_ext]}" | |
# invoke system ImageMagick | |
system(compress_cmd) | |
# remove the old file (if applicable) | |
if (get_ext(fname) != ("." + $img_options[:output_ext])) | |
system("rm #{fname}") | |
end | |
end | |
end | |
# returns the filename (including path and ext) if the input is a file | |
# if the input is a string, returns the same string | |
def get_filename (file) | |
if file.is_a? File | |
file = file.path | |
end | |
return file | |
end | |
# returns the extension of a file | |
# accepts either a file object or a string path | |
def get_ext (file) | |
if file.is_a? String | |
return File.extname(file) | |
elsif file.is_a? File | |
return File.extname(file.path) | |
end | |
end | |
# returns the raw filename (minus extension) of a file | |
# accepts either a file object or a string path | |
def get_raw_filename (file) | |
# convert to string | |
file = get_filename(file) | |
# remove extension | |
file.sub(get_ext(file), "") | |
end | |
## | |
# invoke system which to check if a command is supported | |
# from http://stackoverflow.com/questions/2108727/which-in-ruby-checking-if-program-exists-in-path-from-ruby | |
# which('ruby') #=> /usr/bin/ruby | |
def which(cmd) | |
system("which #{ cmd} > /dev/null 2>&1") | |
end | |
end |
Turns out the content type problem was causing problems on certain versions of IE, plus I can imagine that specifying an incorrect content type might have a performance penalty. I added a few lines to aws_deploy_tools.rb to automatically detect the content type based on the file extension. It uses part of Actionpack, which means you have to add rails to your gemfile.
I've also fixed a problem with cloudfront invalidations. It turns out that they work with paths instead of files. So, e.g., it's necessary to post invalidation requests for both "/index.html" and "/"
By the way– Anyone interested in seeing some of this converted to a gem?
This would make a useful gem.
I'll note that I had to replace "$public_dir" in the Rakefile excerpt with "public_dir"; otherwise the Dir.glob line tried to scan my entire hard drive since $public_dir is unset.
I would really like this into a gem! :)
I think there could be an issue with
def compress_img (files = [])
in that $img_options[:output_ext] is being forced to jpg by
$img_options = {
:output_ext => "jpg",
:max_width => "600",
:max_height => "1200",
:quality => "65",
:compress_type => "JPEG"
}
This means png files are being converted to jpg and deleted.
Good catch. Turns out S3 is setting the content-type for everything to be "image/jpeg." It doesn't seem to break anything, but I'll probably still fix it at some point for the sanity's sake.