branchable_cdn_assets 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 6ea31a15245850a8679508ce6256859f872e8e22
4
+ data.tar.gz: 34645624f7e366189c37c46c2ccd1841e032652c
5
+ SHA512:
6
+ metadata.gz: d724f92695a224e3fc88dca3ca8075b18cdb420a6a0817205ceeceb9a3b3cc789907b4fcf9c0247221e9a913a2977fc6ea987c3547171865874f72435e68174c
7
+ data.tar.gz: fd962e24f6d4b906f7507d6636d6cd11f82a782112e418dc0421a67e66801a4ac9c11ed66a7d8ff9e697746f6129ae26f0fa1560470f6cfab6f44372155954bc
@@ -0,0 +1,19 @@
1
+ require 'tempfile'
2
+ require 'yaml'
3
+
4
+ require 'asgit'
5
+ require 'here_or_there'
6
+ require 'colorize'
7
+ require 'fog'
8
+
9
+ require_relative 'branchable_cdn_assets/version'
10
+ require_relative 'branchable_cdn_assets/check_before'
11
+ require_relative 'branchable_cdn_assets/shell'
12
+
13
+ require_relative 'branchable_cdn_assets/file_manager'
14
+ require_relative 'branchable_cdn_assets/manifest'
15
+ require_relative 'branchable_cdn_assets/config'
16
+ require_relative 'branchable_cdn_assets/cloudfront'
17
+
18
+ module BranchableCDNAssets
19
+ end
@@ -0,0 +1,49 @@
1
+ module BranchableCDNAssets
2
+
3
+ # this abstracts creating filter methods
4
+ # not offering inheritance currently
5
+ #
6
+ # check_before :check, params={}
7
+ #
8
+ # to only check for specific methods,
9
+ # pass an array of methods to the :methods key
10
+ #
11
+ # check_before :check, methods: ["foo", "bar"]
12
+ #
13
+ module CheckBefore
14
+
15
+ def before_checks
16
+ @_before_checks ||= []
17
+ end
18
+
19
+ # define a before check for a specific method
20
+ #
21
+ # @param method [Symbol] the method to check on
22
+ # @param check [Symbol] the check to run
23
+ # @param params [Hash] additional parameters for the check
24
+ # @return [Void]
25
+ def check_before check, params={}
26
+ before_checks << params.merge( check: check )
27
+ end
28
+
29
+
30
+ def self.extended base
31
+ base.class_eval do
32
+
33
+ # @param action [Symbol] the method to execute
34
+ # @param *args [Array] arguments to pass to the check method
35
+ def with_check action, *args
36
+ self.class.before_checks.select do |c|
37
+ c[:methods].nil? || c[:methods].include?(action)
38
+ end.each do |c|
39
+ send c[:check]
40
+ end
41
+
42
+ public_send(action, *args)
43
+ end
44
+
45
+ end
46
+ end
47
+
48
+ end
49
+ end
@@ -0,0 +1,28 @@
1
+ module BranchableCDNAssets
2
+ class Cloudfront
3
+
4
+ attr_reader :access_key, :secret_key, :distribution_id,
5
+ :cdn
6
+
7
+ # handles communication with cloudfront
8
+ def initialize keys
9
+ @distribution_id = keys.fetch :distribution_id
10
+ @access_key = keys.fetch :access_key
11
+ @secret_key = keys.fetch :secret_key
12
+
13
+ @cdn = ::Fog::CDN.new provider: 'AWS',
14
+ aws_access_key_id: access_key,
15
+ aws_secret_access_key: secret_key
16
+ end
17
+
18
+ # invalidate a batch of files on fog
19
+ # @param files [Array]
20
+ def invalidate_files files
21
+ resp = cdn.post_invalidation( distribution_id, Array(files) )
22
+ resp.body["InvalidationBatch"]["Path"].each do |file|
23
+ puts "Posted an invalidation for #{file}".colorize( :green )
24
+ end
25
+ end
26
+
27
+ end
28
+ end
@@ -0,0 +1,80 @@
1
+ require_relative 'config/environment_attribute_reader'
2
+
3
+ module BranchableCDNAssets
4
+ class Config
5
+ extend EnvironmentAttributeReader
6
+
7
+ attr_reader :raw_data, :branch,
8
+ :production_branch, :cloudfront, :cdn_dir,
9
+ :env, :environments
10
+ env_attr_reader :host, :root, :url
11
+
12
+ def initialize data, branch=Asgit.current_branch
13
+ @raw_data = normalize_data data
14
+ @branch = branch
15
+
16
+ @production_branch = raw_data.fetch :production_branch, 'master'
17
+ @cloudfront = raw_data.fetch :cloudfront, {}
18
+ @cdn_dir = raw_data.fetch :dir, 'cdn'
19
+
20
+ @env = env_for_branch
21
+ @environments = add_env_path_modifications_to_env_data raw_data.fetch(:environments, {})
22
+ end
23
+
24
+ private
25
+
26
+ def add_env_path_modifications_to_env_data environments
27
+ Hash[*environments.map do |env,data|
28
+ data = data.dup
29
+ unless env == :production
30
+ data[:root] = File.join( data[:root], branch, '/' )
31
+ data[:url] = File.join( data[:url], branch )
32
+ end
33
+
34
+ [env,data]
35
+ end.flatten]
36
+ end
37
+
38
+ def env_for_branch
39
+ if branch == production_branch
40
+ return :production
41
+ elsif raw_data.fetch(:environments).has_key?( branch.to_sym )
42
+ return branch.to_sym
43
+ else
44
+ return raw_data.fetch(:default_env, 'staging').to_sym
45
+ end
46
+ end
47
+
48
+ def normalize_data data
49
+ if data.respond_to?(:to_hash)
50
+ symbolize_keys data.to_hash
51
+ else
52
+ symbolize_keys read_config_file(data)
53
+ end
54
+ end
55
+
56
+ def read_config_file path
57
+ if File.exists?(path)
58
+ YAML.load IO.read(path)
59
+ else
60
+ raise "config file not found at #{path}"
61
+ end
62
+ end
63
+
64
+ def symbolize_keys(hash)
65
+ hash.inject({}) do |out, (key, value)|
66
+ k = case key
67
+ when String then key.to_sym
68
+ else key
69
+ end
70
+ v = case value
71
+ when Hash then symbolize_keys(value)
72
+ else value
73
+ end
74
+ out[k] = v
75
+ out
76
+ end
77
+ end
78
+
79
+ end
80
+ end
@@ -0,0 +1,17 @@
1
+ module BranchableCDNAssets
2
+ class Config
3
+
4
+ module EnvironmentAttributeReader
5
+ def env_attr_reader *keys
6
+ keys.each do |key|
7
+ define_method key do
8
+ environments[env.to_sym].fetch(key) {
9
+ raise ArgumentError, "No key '#{key.to_s}' exists for '#{env}'"
10
+ }
11
+ end
12
+ end
13
+ end
14
+ end
15
+
16
+ end
17
+ end
@@ -0,0 +1,222 @@
1
+ require_relative 'file_manager/checks'
2
+
3
+ module BranchableCDNAssets
4
+ class FileManager
5
+ extend CheckBefore
6
+ include Checks
7
+
8
+ check_before :manifest_list_clean_for_master, methods: [ :push!, :pull!, :prune! ]
9
+ check_before :local_file_conflict, methods: [ :push!, :pull! ]
10
+ check_before :ready_for_production, methods: [ :move_to_production ]
11
+
12
+ attr_reader :config
13
+ attr_reader :root
14
+ attr_reader :branch
15
+ attr_reader :manifest
16
+
17
+ # handles moving files to/from our various cdn locations
18
+ def initialize config, branch=nil
19
+ @config = config
20
+ @root = config.cdn_dir
21
+ @branch = branch || config.branch
22
+ @manifest = Manifest.new( File.join( @root, "#{@branch}.manifest" ) )
23
+ end
24
+
25
+ # @param where [Symbol] :local, :remote, :all (default)
26
+ # @return [Array]
27
+ def list where=:all
28
+ case where
29
+ when :local then
30
+ return list_local_files
31
+ when :remote then
32
+ return list_remote_files
33
+ when :both then
34
+ return list_local_files & list_remote_files
35
+ else
36
+ return list_local_files + list_remote_files
37
+ end
38
+ end
39
+
40
+ # destructive pull, removes references to remote
41
+ # files once they're pulled
42
+ # @return [Array] the pulled files
43
+ def pull!
44
+ pull_list = pull
45
+ @manifest.remove_files( pull_list )
46
+ @manifest.update_source_file!
47
+ return pull_list
48
+ end
49
+
50
+ # destructive push, removes the local versions of
51
+ # the pushed files and adds them to the manifest
52
+ # @return [Array] the pushed files
53
+ def push!
54
+ setup_remote
55
+
56
+ push_list = push
57
+ invalidate( push_list ) if config.env.to_sym == :production
58
+
59
+ @manifest.merge_files( push_list )
60
+ @manifest.update_source_file!
61
+
62
+ return push_list
63
+ end
64
+
65
+ # remove local files that exist on the remote
66
+ # @return [Array]
67
+ def prune!
68
+ puts "files to be removed:\n#{list(:both).join("\n")}"
69
+ abort unless Shell.get_input("do you want to continue? (y|n) ") == "y"
70
+
71
+ list(:both).each do |f|
72
+ Shell.run_local "rm #{File.join( root, f )}"
73
+ end
74
+ remove_empty_directories
75
+ end
76
+
77
+ # move the current cdn files to the production cdn
78
+ # guarded to only run from prod
79
+ def move_to_production branch
80
+ branch_cdn = FileManager.new( Config.new( config.raw_data, branch ) )
81
+ if branch_cdn.manifest.files.empty?
82
+ puts "no files to move from #{branch}"
83
+ abort
84
+ end
85
+ branch_cdn.pull!
86
+ push!
87
+ end
88
+
89
+ def remove_empty_directories
90
+ empty_directories.each do |dir|
91
+ Dir.rmdir( dir )
92
+ end
93
+ end
94
+
95
+ def find file
96
+ return :local if list(:local).include?(file)
97
+ return File.join( config.url, file ) if list(:remote).include?(file)
98
+
99
+ unless config.env == :production
100
+ production_config = Config.new( config.raw_data, config.production_branch )
101
+ production_manifest = Manifest.new( File.join( root, "#{config.production_branch}.manifest" ) )
102
+ return File.join( production_config.url, file ) if production_manifest.files.include?(file)
103
+ end
104
+ end
105
+
106
+ private
107
+
108
+ def invalidate files
109
+ to_invalidate = ( files & list(:local) ).map do |f|
110
+ File.join( config.cloudfront[:path_prefix], f )
111
+ end
112
+
113
+ Cloudfront.new( config.cloudfront )
114
+ .invalidate_files( to_invalidate )
115
+ end
116
+
117
+ # create the root dir on remote
118
+ def setup_remote
119
+ resp = Shell.run_remote "mkdir -p #{config.root}", hostname: config.host
120
+ raise resp.stderr unless resp.success?
121
+ end
122
+
123
+ def empty_directories
124
+ Dir[ File.join( Dir.pwd, root, '**/*' ) ].select do |d|
125
+ File.directory?(d) && (Dir.entries(d) - ['.','..']).empty?
126
+ end
127
+ end
128
+
129
+ # @return [Array] the pushed files (only those updated on remote)
130
+ def push
131
+ list_file = create_list_file( list(:local).join("\n") )
132
+ ensure_local_file_permissions
133
+ push_list = map_rsync_output_to_list rsync_files_to_remote(list_file)
134
+ list_file.unlink
135
+
136
+ push_list = resolve_conflicts(push_list)
137
+ puts "#{push_list.length} files pushed to #{branch}"
138
+ return push_list
139
+ end
140
+
141
+ def rsync_files_to_remote list_file
142
+ resp = Shell.run_local "rsync -aviz --files-from=#{list_file.path} " +
143
+ "-e ssh #{root}/ #{config.host}:#{config.root}"
144
+
145
+ raise "rsync failed\n#{resp.stderr}" unless resp.success?
146
+ return resp.stdout
147
+ end
148
+
149
+ def ensure_local_file_permissions
150
+ list_local_files.each do |file|
151
+ Shell.run_local "chmod 644 #{File.join( root, file )}"
152
+ end
153
+ end
154
+
155
+ # Take files from the cdn and move them locally
156
+ # @return [Array] list of files pulled
157
+ def pull
158
+ # create list for rsync
159
+ list_file = create_list_file( list(:remote).join("\n") )
160
+ pull_list = map_rsync_output_to_list rsync_files_from_remote(list_file)
161
+ list_file.unlink
162
+
163
+ puts "#{pull_list.length} files pulled from #{branch}"
164
+ return pull_list
165
+ end
166
+
167
+ def rsync_files_from_remote list_file
168
+ resp = Shell.run_local "rsync -aviz --files-from=#{list_file.path}" +
169
+ " -e ssh #{config.host}:#{config.root} #{root}/"
170
+
171
+ raise "rsync failed\n#{resp.stderr}" unless resp.success?
172
+ return resp.stdout
173
+ end
174
+
175
+ # @return [Array] resolved list
176
+ def resolve_conflicts push_list
177
+ conflicts = list(:local) - push_list
178
+ return push_list if conflicts.empty?
179
+
180
+ puts "these local files were not pushed by rsync:"
181
+ puts conflicts.join("\n")
182
+ if Shell.get_input("add to the #{branch} manifest? (y|n) ") == "y"
183
+ push_list.push(*conflicts)
184
+ end
185
+ return push_list
186
+ end
187
+
188
+ # creates a temp file with contens and returns it's path
189
+ # @param contents [String] content for the file
190
+ # @return [String] file path
191
+ def create_list_file contents
192
+ file = Tempfile.new( "list_file" )
193
+ file.write contents
194
+ file.close
195
+
196
+ return file
197
+ end
198
+
199
+ # @param output [String]
200
+ # @return [Array]
201
+ def map_rsync_output_to_list output
202
+ return output.split("\n").keep_if do |f|
203
+ f.match( /^(?:<|>)f/ ) && !f.match(/\/$/ )
204
+ end.map do |f|
205
+ f.split()[1]
206
+ end
207
+ end
208
+
209
+ def list_local_files
210
+ Dir[ File.join( Dir.pwd, root, '**/*' ) ].keep_if do |f|
211
+ !f.match(/\.manifest$/) && !File.directory?(f)
212
+ end.map do |f|
213
+ f.sub( "#{File.join( Dir.pwd, root )}/", "" )
214
+ end
215
+ end
216
+
217
+ def list_remote_files
218
+ manifest.files
219
+ end
220
+
221
+ end
222
+ end
@@ -0,0 +1,41 @@
1
+ module BranchableCDNAssets
2
+ class FileManager
3
+ module Checks
4
+
5
+ def manifest_list_clean_for_master
6
+ manifests = Dir[ File.join( root, "*.manifest" ) ]
7
+ if Asgit.current_branch == "master" && manifests.length > 1
8
+ raise "there should only be a production manifest on the master branch\n" +
9
+ "consider cleaning up with rake cdn:move_to_production[branch_name]"
10
+ end
11
+ end
12
+
13
+ def local_file_conflict
14
+ intersection = list(:both)
15
+ if !intersection.empty?
16
+ puts "#{intersection.length} files are duplicated locally and on the remote"
17
+ unless Shell.get_input("do you want to continue? (y|n)") == "y"
18
+ puts "the conflicting files: \n#{intersection.join("\n")}"
19
+ abort
20
+ end
21
+ end
22
+ end
23
+
24
+ def ready_for_production
25
+ if Asgit.current_branch != "master"
26
+ puts "you shouldn't move to production except from master"
27
+ abort
28
+ end
29
+ if !Asgit.remote_up_to_date?
30
+ puts "make sure you've pulled all remote changes"
31
+ abort
32
+ end
33
+ if !list(:local).empty?
34
+ puts "push all your local files and commit first"
35
+ abort
36
+ end
37
+ end
38
+
39
+ end
40
+ end
41
+ end