cloud_crooner 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,104 @@
1
+ require 'fog'
2
+ require 'rack/mime'
3
+
4
+ module CloudCrooner
5
+ class Storage
6
+
7
+ def initialize
8
+ @bucket_name = CloudCrooner.bucket_name
9
+ @prefix = CloudCrooner.prefix
10
+ @fog_options = CloudCrooner.fog_options
11
+ @manifest = CloudCrooner.manifest
12
+ end
13
+
14
+ def connection
15
+ @connection ||= Fog::Storage.new(@fog_options)
16
+ end
17
+
18
+ def bucket
19
+ @bucket ||= connection.directories.get(@bucket_name, :prefix => @prefix)
20
+ end
21
+
22
+ def local_compiled_assets
23
+ # compiled assets prepended with prefix for comparison against remote
24
+ @manifest.files.keys.map {|f| File.join(@prefix, f)}
25
+ end
26
+
27
+ def exists_on_remote?(file)
28
+ bucket.files.head(file)
29
+ end
30
+
31
+ def upload_files
32
+ files_to_upload = local_compiled_assets.reject { |f| exists_on_remote?(f) }
33
+ files_to_upload.each do |asset|
34
+ upload_file(asset)
35
+ end
36
+ end
37
+
38
+ def log(msg)
39
+ CloudCrooner.log(msg)
40
+ end
41
+
42
+ def upload_file(f)
43
+ # grabs the compiled asset from public_path
44
+ full_file_path = File.join(File.dirname(@manifest.dir), f)
45
+ one_year = 31557600
46
+ mime = Rack::Mime.mime_type(File.extname(f))
47
+ file = {
48
+ :key => f,
49
+ :public => true,
50
+ :content_type => mime,
51
+ :cache_control => "public, max-age=#{one_year}",
52
+ :expires => CGI.rfc1123_date(Time.now + one_year)
53
+ }
54
+
55
+ gzipped = "#{full_file_path}.gz"
56
+
57
+ # if a gzipped version of the file exists and is a smaller file size than the original, upload that in place of the uncompressed file
58
+ if File.exists?(gzipped)
59
+ original_size = File.size(full_file_path)
60
+ gzipped_size = File.size(gzipped)
61
+
62
+ if gzipped_size < original_size
63
+ file.merge!({
64
+ :body => File.open(gzipped),
65
+ :content_encoding => 'gzip'
66
+ })
67
+ log "Uploading #{gzipped} in place of #{f}"
68
+ else
69
+ file.merge!({
70
+ :body => File.open(full_file_path)
71
+ })
72
+ log "Gzip exists but has larger file size, uploading #{f}"
73
+ end
74
+ else
75
+ file.merge!({
76
+ :body => File.open(full_file_path)
77
+ })
78
+ log "Uploading #{f}"
79
+ end
80
+ # put in reduced redundancy option here later if desired
81
+
82
+ file = bucket.files.create( file )
83
+ end
84
+
85
+ def remote_assets
86
+ files = []
87
+ bucket.files.each { |f| files << f.key }
88
+ return files
89
+ end
90
+
91
+ def delete_remote_asset(f)
92
+ log "Deleting #{f.key} from remote"
93
+ f.destroy
94
+ end
95
+
96
+ def clean_remote
97
+ to_delete = remote_assets - local_compiled_assets
98
+ to_delete.each do |f|
99
+ delete_remote_asset(bucket.files.get(f))
100
+ end
101
+ end
102
+
103
+ end
104
+ end
@@ -0,0 +1,3 @@
1
+ module CloudCrooner
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,320 @@
1
+ require 'spec_helper'
2
+
3
+ describe CloudCrooner do
4
+ describe 'default general configuration' do
5
+
6
+ it 'creates a sprockets environment' do
7
+ expect(CloudCrooner.sprockets).to be_an_instance_of(Sprockets::Environment)
8
+ end
9
+
10
+ it 'sets a default prefix' do
11
+ expect(CloudCrooner.prefix).to eq("assets")
12
+ end
13
+
14
+ it 'sets a default public folder in dev' do
15
+ expect(CloudCrooner.public_folder).to eq("public")
16
+ end
17
+
18
+ it 'defaults to remote enabled' do
19
+ expect(CloudCrooner.remote_enabled?).to be_true
20
+ end
21
+
22
+ it "defaults to looking for assets in '/assets'" do
23
+ expect(CloudCrooner.asset_paths).to eq(%w(assets))
24
+ end
25
+
26
+ it 'checks ENV for Amazon credentials' do
27
+ ENV.stub(:[]).with("AWS_ACCESS_KEY_ID").and_return("asdf123")
28
+ ENV.stub(:[]).with("AWS_SECRET_ACCESS_KEY").and_return("secret")
29
+ ENV.stub(:has_key?).with("AWS_ACCESS_KEY_ID").and_return(true)
30
+ ENV.stub(:has_key?).with("AWS_SECRET_ACCESS_KEY").and_return(true)
31
+
32
+ expect(CloudCrooner.aws_access_key_id).to eq("asdf123")
33
+ expect(CloudCrooner.aws_secret_access_key).to eq("secret")
34
+ end # it
35
+
36
+ it "checks ENV for bucket name" do
37
+ ENV.stub(:[]).with("AWS_BUCKET_NAME").and_return("test-bucket")
38
+ ENV.stub(:has_key?).with("AWS_BUCKET_NAME").and_return(true)
39
+
40
+ expect(CloudCrooner.bucket_name).to eq("test-bucket")
41
+ end #it
42
+
43
+ it "checks ENV for region" do
44
+ ENV.stub(:[]).with("AWS_REGION").and_return("eu-west-1")
45
+ ENV.stub(:has_key?).with("AWS_REGION").and_return(true)
46
+
47
+ expect(CloudCrooner.region).to eq("eu-west-1")
48
+ end
49
+
50
+ it "errors if the ENV region is not valid" do
51
+ ENV.stub(:[]).with("AWS_REGION").and_return("shangrila")
52
+ ENV.stub(:has_key?).with("AWS_REGION").and_return(true)
53
+
54
+ expect{CloudCrooner.region}.to raise_error(CloudCrooner::FogSettingError)
55
+ end
56
+
57
+ it 'defaults to keeping 2 backups' do
58
+ expect(CloudCrooner.backups_to_keep).to eq(2)
59
+ end
60
+
61
+ end # describe
62
+
63
+ describe 'errors if missing required settings' do
64
+ it "errors if region is not assigned" do
65
+ ENV.stub(:[]).and_return(nil)
66
+ ENV.stub(:has_key?).with("AWS_REGION").and_return(false)
67
+ expect{CloudCrooner.region}.to raise_error(CloudCrooner::FogSettingError, "AWS Region must be set in ENV or in configure block")
68
+ end
69
+
70
+ it "errors if the bucket is not set" do
71
+ ENV.stub(:[]).and_return(nil)
72
+ ENV.stub(:has_key?).with("AWS_BUCKET_NAME").and_return(false)
73
+ expect{CloudCrooner.bucket_name}.to raise_error(CloudCrooner::FogSettingError, "Bucket name must be set in ENV or configure block")
74
+ end
75
+
76
+ it "errors if aws access key id is unset" do
77
+ ENV.stub(:[]).and_return(nil)
78
+ ENV.stub(:has_key?).with("AWS_ACCESS_KEY_ID").and_return(false)
79
+ expect{CloudCrooner.aws_access_key_id}.to raise_error
80
+ end
81
+
82
+ it "errors if aws secret access key is unset" do
83
+ ENV.stub(:[]).and_return(nil)
84
+ ENV.stub(:has_key?).with("AWS_SECRET_ACCESS_KEY").and_return(false)
85
+ expect{CloudCrooner.aws_secret_access_key}.to raise_error
86
+ end
87
+
88
+ end
89
+
90
+ describe 'default configuration that touches filesystem' do
91
+ # aka: these tests require temp files and constructs
92
+
93
+ it 'creates a manifest' do
94
+ within_construct do |c|
95
+ c.directory 'public/assets'
96
+
97
+ expect(CloudCrooner.manifest).to be_an_instance_of(Sprockets::Manifest)
98
+ expect(CloudCrooner.manifest.dir).to eq(File.join(c, "public/assets"))
99
+ end #construct
100
+ end # it
101
+
102
+ it 'defaults assets to compile to files under prefix' do
103
+ within_construct do |c|
104
+ asset_folder = c.directory 'assets'
105
+ c.file('assets/a.css')
106
+ c.file('assets/b.css')
107
+
108
+ expect(CloudCrooner.assets_to_compile).to eq(%w(a.css b.css))
109
+ end # construct
110
+ end # it
111
+
112
+ it 'adds the default asset path to sprockets load path' do
113
+ within_construct do |c|
114
+ asset_folder = c.directory 'assets'
115
+ c.file('assets/a.css')
116
+
117
+ expect(CloudCrooner.sprockets['a.css']).to be_an_instance_of(Sprockets::BundledAsset)
118
+ expect(CloudCrooner.sprockets['a.css'].pathname.to_s).to eq(File.join(c, 'assets', 'a.css'))
119
+
120
+ end # construct
121
+ end # it
122
+
123
+ it 'initializes sprockets-helpers in development' do
124
+ within_construct do |c|
125
+ c.file 'assets/a.css'
126
+ CloudCrooner.configure_sprockets_helpers
127
+
128
+ expect(Sprockets::Helpers.prefix).to eq('/assets')
129
+ expect(context.stylesheet_tag('a.css')).to eq(%Q(<link rel="stylesheet" href="/assets/a.css">))
130
+
131
+ end # context
132
+ end #it
133
+
134
+ it 'initizalizes sprockets-helpers in production' do
135
+ within_construct do |c|
136
+ c.file 'assets/a.css'
137
+ stub_env_vars
138
+ ENV.stub(:[]).with('RACK_ENV').and_return("production")
139
+ CloudCrooner.configure_sprockets_helpers
140
+ CloudCrooner.manifest.compile('a.css')
141
+
142
+ expect(context.asset_path('a.css')).to eq("http://my-bucket.s3.amazonaws.com/assets/#{CloudCrooner.sprockets['a.css'].digest_path}")
143
+ end # construct
144
+ end # it
145
+
146
+ end # describe
147
+
148
+ describe 'custom configuration' do
149
+
150
+ it 'accepts a custom prefix' do
151
+ within_construct do |c|
152
+ CloudCrooner.configure do |config|
153
+ config.prefix = "meow"
154
+ end
155
+ expect(CloudCrooner.prefix).to eq("meow")
156
+ expect(Sprockets::Helpers.prefix).to eq("/meow")
157
+ expect(CloudCrooner.manifest.dir).to eq(File.join(c, "public/meow"))
158
+ end #context
159
+ end #it
160
+
161
+ it 'adds specified asset paths to load path' do
162
+ within_construct do |c|
163
+ c.file 'foo/bar.css'
164
+ CloudCrooner.configure do |config|
165
+ config.asset_paths = (%w(foo assets))
166
+ end
167
+
168
+ expect(CloudCrooner.sprockets['bar.css']).to be_an_instance_of(Sprockets::BundledAsset)
169
+ end
170
+ end
171
+
172
+ it 'can disable remote asset host' do
173
+ CloudCrooner.remote_enabled = false
174
+ expect(CloudCrooner.remote_enabled?).to be_false
175
+ end
176
+
177
+ it 'initializes sprockets-helpers when remote is disabled' do
178
+ within_construct do |c|
179
+ # compile the manifest and asset in dev
180
+ c.file 'assets/a.css'
181
+ c.directory 'public/assets'
182
+ manifest = Sprockets::Manifest.new(CloudCrooner.sprockets, 'public/assets')
183
+ CloudCrooner.manifest = manifest
184
+ CloudCrooner.manifest.compile('a.css')
185
+
186
+ # reload the app & helpers in production
187
+ reload_crooner
188
+
189
+ ENV.stub(:[]).with('RACK_ENV').and_return('production')
190
+ CloudCrooner.configure do |config|
191
+ config.manifest = manifest
192
+ config.remote_enabled = false
193
+ end
194
+
195
+ expect(context.asset_path('a.css')).to eq("/assets/#{CloudCrooner.manifest.assets['a.css']}")
196
+ end
197
+ end
198
+
199
+ it 'accepts a custom manifest' do
200
+ within_construct do |c|
201
+ manifest = Sprockets::Manifest.new(CloudCrooner.sprockets, 'foo/bar')
202
+ CloudCrooner.configure do |config|
203
+ config.manifest = manifest
204
+ end
205
+
206
+ expect(CloudCrooner.manifest.dir).to eq(File.join(c,'foo/bar'))
207
+ end # construct
208
+ end # it
209
+
210
+ it 'accepts a list of assets to compile' do
211
+ within_construct do |c|
212
+ c.file 'assets/a.css'
213
+ c.file 'assets/b.css'
214
+ c.file 'assets/c.css'
215
+
216
+ CloudCrooner.assets_to_compile = %w(a.css b.css)
217
+ expect(CloudCrooner.assets_to_compile).to eq(%w(a.css b.css))
218
+ end # construct
219
+ end # it
220
+
221
+ it "allows bucket to be set in config and overwrites ENV setting" do
222
+ ENV.stub(:[]).with("AWS_BUCKET_NAME").and_return("test-bucket")
223
+ ENV.stub(:has_key?).with("AWS_BUCKET_NAME").and_return(true)
224
+ CloudCrooner.bucket_name = "foo_bucket"
225
+
226
+ expect(CloudCrooner.bucket_name).to eq("foo_bucket")
227
+ end
228
+
229
+ it "allows bucket to be set in config if none in env" do
230
+ CloudCrooner.bucket_name= "bar_bucket"
231
+
232
+ ENV.stub(:[]).with("AWS_BUCKET_NAME").and_return(nil)
233
+ ENV.stub(:has_key?).with("AWS_BUCKET_NAME").and_return(false)
234
+ expect(CloudCrooner.bucket_name).to eq("bar_bucket")
235
+ end # it
236
+
237
+ it "allows region to be set in config if none in env" do
238
+ CloudCrooner.region = "us-west-2"
239
+
240
+ expect(CloudCrooner.region).to eq("us-west-2")
241
+ end
242
+
243
+ it "allows region to be set in config and overwrites ENV setting" do
244
+ ENV.stub(:[]).with("AWS_REGION").and_return("eu-west-1")
245
+ ENV.stub(:has_key?).with("AWS_REGION").and_return(true)
246
+ CloudCrooner.region = "us-west-2"
247
+
248
+ expect(CloudCrooner.region).to eq("us-west-2")
249
+ end
250
+
251
+ it "errors if config region is not valid" do
252
+ expect{CloudCrooner.region = "el-dorado"}.to raise_error(CloudCrooner::FogSettingError)
253
+ end
254
+
255
+ it "allows aws_access_key_id to be set in config and overwrite ENV" do
256
+ ENV.stub(:[]).with("AWS_ACCESS_KEY_ID").and_return("asdf123")
257
+ ENV.stub(:has_key?).with("AWS_ACCESS_KEY_ID").and_return(true)
258
+ CloudCrooner.aws_access_key_id = "lkjh0987"
259
+
260
+ expect(CloudCrooner.aws_access_key_id).to eq("lkjh0987")
261
+ end
262
+
263
+ it "allows aws_access_key_id to be set in config if none in env" do
264
+ ENV.stub(:[]).with("AWS_ACCESS_KEY_ID").and_return(nil)
265
+ ENV.stub(:has_key?).with("AWS_ACCESS_KEY_ID").and_return(false)
266
+ CloudCrooner.aws_access_key_id = "lkjh0987"
267
+
268
+ expect(CloudCrooner.aws_access_key_id).to eq("lkjh0987")
269
+ end
270
+
271
+ it "allows aws_secret_access_key to be set in config and overwrite ENV" do
272
+ ENV.stub(:[]).with("AWS_SECRET_ACCESS_KEY").and_return("secret")
273
+ ENV.stub(:has_key?).with("AWS_SECRET_ACCESS_KEY").and_return(true)
274
+ CloudCrooner.aws_secret_access_key = "terces"
275
+
276
+ expect(CloudCrooner.aws_secret_access_key).to eq("terces")
277
+ end
278
+
279
+ it "allows secret access key to be set in config when ENV is empty" do
280
+ ENV.stub(:[]).with("AWS_SECRET_ACCESS_KEY").and_return(nil)
281
+ ENV.stub(:has_key?).with("AWS_SECRET_ACCESS_KEY").and_return(false)
282
+ CloudCrooner.aws_secret_access_key = "terces"
283
+
284
+ expect(CloudCrooner.aws_secret_access_key).to eq("terces")
285
+ end
286
+
287
+ it "sets the number of backups to keep" do
288
+ CloudCrooner.configure{|config| config.backups_to_keep= 5}
289
+
290
+ expect(CloudCrooner.backups_to_keep).to eq(5)
291
+ end
292
+
293
+ end # describe
294
+
295
+ it 'compiles assets' do
296
+ within_construct do |c|
297
+ mock_environment(c)
298
+ CloudCrooner.assets_to_compile = ['a.css', 'b.css']
299
+ (CloudCrooner.storage.local_compiled_assets).should == []
300
+ CloudCrooner.compile_sprockets_assets
301
+
302
+ expect(CloudCrooner.storage.local_compiled_assets).to eq(['assets/' + CloudCrooner.sprockets['a.css'].digest_path, 'assets/' + CloudCrooner.sprockets['b.css'].digest_path])
303
+ end # construct
304
+ end # it
305
+
306
+ it 'syncs assets to the cloud', :moo => true do
307
+ within_construct do |c|
308
+ mock_environment(c)
309
+ CloudCrooner.assets_to_compile = ['a.css', 'b.css']
310
+ mock_fog(CloudCrooner.storage)
311
+ CloudCrooner.sync
312
+
313
+ expect(local_equals_remote?(CloudCrooner.storage)).to be_true
314
+ end # construct
315
+ end # it
316
+
317
+ after(:each) do
318
+ reload_crooner
319
+ end
320
+ end #describe
@@ -0,0 +1,122 @@
1
+ require 'sprockets'
2
+ require 'sinatra/base'
3
+ require 'cloud_crooner'
4
+ require 'construct'
5
+ require 'securerandom'
6
+ require 'sprockets-helpers'
7
+
8
+ RSpec.configure do |rconf|
9
+ rconf.include Construct::Helpers
10
+
11
+ # don't pollute stdout with output during tests
12
+ original_stdout = $stdout
13
+ rconf.before(:all) do
14
+ # Redirect stderr and stdout
15
+ $stdout = File.new(File.join(File.dirname(__FILE__), 'rspec_output.txt'), 'w')
16
+ end
17
+ rconf.after(:all) do
18
+ $stdout = original_stdout
19
+ end
20
+
21
+ def reload_crooner
22
+ # need to unset the class instance variables
23
+ Object.send(:remove_const, 'CloudCrooner')
24
+ load 'cloud_crooner/cloud_crooner.rb'
25
+ load 'cloud_crooner/storage.rb'
26
+ Sprockets::Helpers.instance_variables.each do |var|
27
+ Sprockets::Helpers.instance_variable_set var, nil
28
+ end
29
+ end
30
+
31
+ # used for testing sprockets-helpers
32
+ def context(logical_path = 'application.js', pathname = nil)
33
+ pathname ||= Pathname.new(File.join('assets', logical_path)).expand_path
34
+ CloudCrooner.sprockets.context_class.new CloudCrooner.sprockets, logical_path, pathname
35
+ end
36
+
37
+
38
+ def stub_env_vars
39
+ ENV.stub(:has_key?).and_return(false)
40
+ ENV.stub(:[]).and_return(nil)
41
+
42
+ ENV.stub(:[]).with('AWS_BUCKET_NAME').and_return('my-bucket')
43
+ ENV.stub(:has_key?).with('AWS_BUCKET_NAME').and_return(true)
44
+
45
+ ENV.stub(:[]).with('AWS_REGION').and_return('eu-west-1')
46
+ ENV.stub(:has_key?).with('AWS_REGION').and_return(true)
47
+
48
+ ENV.stub(:[]).with('AWS_ACCESS_KEY_ID').and_return('asdf123')
49
+ ENV.stub(:has_key?).with('AWS_ACCESS_KEY_ID').and_return(true)
50
+
51
+ ENV.stub(:[]).with('AWS_SECRET_ACCESS_KEY').and_return('secret')
52
+ ENV.stub(:has_key?).with('AWS_SECRET_ACCESS_KEY').and_return(true)
53
+ end
54
+
55
+ def sample_assets(construct)
56
+ lambda { |c|
57
+ c.file('assets/main.js') do |f|
58
+ f << "//= require a\n"
59
+ f << "//= require b\n"
60
+ end
61
+ c.file('assets/a.js') do |f|
62
+ f << "var pi=3.14;"
63
+ end
64
+ c.file('assets/b.js') do |f|
65
+ f << "var person='John Doe';"
66
+ end
67
+
68
+ c.file('assets/main.css') do |f|
69
+ f << "/*\n"
70
+ f << "*= require a\n"
71
+ f << "*= require b\n"
72
+ f << "*/\n"
73
+ end
74
+ c.file('assets/a.css') do |f|
75
+ f << "p { color: red; }\n"
76
+ end
77
+ c.file('assets/b.css') do |f|
78
+ f << "li { color: pink; }"
79
+ end
80
+ c.file('assets/c.css') do |f|
81
+ f << "h1{color:blue;}\n"
82
+ f << "h2{color:blue;}\n"
83
+ f << "h3{color:blue;}\n"
84
+ end
85
+ }.call(construct)
86
+ end
87
+
88
+ def local_equals_remote?(storage)
89
+ # the remote files are not guaranteed to be ordered
90
+ frequency(storage.local_compiled_assets) == frequency(storage.remote_assets)
91
+ end
92
+
93
+ def frequency(arr)
94
+ # http://stackoverflow.com/questions/9095017/comparing-two-arrays-in-ruby
95
+ p = Hash.new(0)
96
+ arr.each{ |v| p[v] += 1 }
97
+ p
98
+ end
99
+
100
+ def uncompiled_assets_dir(construct)
101
+ "#{construct}" + "/assets"
102
+ end
103
+
104
+ def mock_fog(storage)
105
+ Fog.mock!
106
+ storage.connection.directories.create(
107
+ :key => storage.instance_variable_get(:@bucket_name),
108
+ :public => true
109
+ )
110
+ end
111
+
112
+ def mock_environment(c)
113
+ # requires a construct
114
+ CloudCrooner.configure do |config|
115
+ config.bucket_name = SecureRandom.hex
116
+ end
117
+ stub_env_vars
118
+ sample_assets(c)
119
+ end
120
+
121
+ end
122
+