s3_deployer 0.6.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: 844de0917a0347758d662fe6dc799217dc7d16c5
4
+ data.tar.gz: 3ea7e007d1c486ed7186fc76703de849a138b984
5
+ SHA512:
6
+ metadata.gz: 3b7f997173a27444996997c954c2d75c3c6dc9ee8766841d9ffb146da5d7fb588d15bcabf9420118a1a6f9548327fb7d536db96832227019b9fe38591a870453
7
+ data.tar.gz: 6d8cc55576a7c732503dfa014b6a69f3c3e3c880d6499a7208cc5578694c98afbbf6cc6007710915d0e15c5569ac1708ad38a67b4d37a51cc6f7aa265b92381a
data/.gitignore ADDED
@@ -0,0 +1,18 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
18
+ vendor/
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in s3_deployer.gemspec
4
+ gemspec
data/README.md ADDED
@@ -0,0 +1,142 @@
1
+ # S3Deployer
2
+
3
+ Tool for versioned deploying of client-side apps (or literally anything) to S3
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ gem 's3_deployer'
10
+
11
+ And then execute:
12
+
13
+ $ bundle
14
+
15
+ Or install it yourself as:
16
+
17
+ $ gem install s3_deployer
18
+
19
+ ## Usage
20
+
21
+ You need to specify s3_deployer_config.rb file in your home directory, which may look like this:
22
+
23
+ ```ruby
24
+ S3Deployer.configure do
25
+ bucket "some-bucket"
26
+ region 'us-east-1'
27
+ app_name "devastator"
28
+ app_path "path/to/#{app_name}#{"-#{version}" if version && version != ""}"
29
+ dist_dir "dist"
30
+ gzip [/\.js$/, /\.css$/] # or just use 'true' to gzip everything
31
+ colorize true
32
+ time_zone "America/Los_Angeles" # Useful when you develop from different timezones (e.g. for distributed team,
33
+ # or when deploy from some build server), to be consistent with revision numbers
34
+
35
+ before_stage ->(version) do
36
+ # Some custom code to execute before deploy or stage
37
+ end
38
+
39
+ after_stage ->(version) do
40
+ # Some custom code to execute after deploy or stage
41
+ end
42
+
43
+ before_switch ->(version) do
44
+ # Some custom code to execute before deploy or switch
45
+ end
46
+
47
+ after_switch ->(version) do
48
+ # Some custom code to execute after deploy or switch
49
+ end
50
+
51
+ before_deploy ->(version) do
52
+ # Some custom code to execute before deploy
53
+ end
54
+
55
+ after_deploy ->(version) do
56
+ # Some custom code to execute after deploy
57
+ end
58
+
59
+ # You also can specify environment-specific settings, the default environment is 'production'
60
+ environment(:development) do
61
+ bucket "some-bucket-dev"
62
+ end
63
+
64
+ access_key_id 'your S3 access key id'
65
+ secret_access_key 'your S3 secret access key'
66
+ end
67
+ ```
68
+
69
+ Note the 'dist_dir' setting, you should put all the necessary files to there, which should be sent to S3, before deploy.
70
+
71
+ Then, you need to include Deployer's tasks to your Rakefile, like:
72
+
73
+ ```ruby
74
+ require 'rubygems'
75
+ require 'bundler'
76
+ Bundler.setup
77
+
78
+ require 's3_deployer/tasks'
79
+ require './s3_deployer_config'
80
+ ```
81
+
82
+ There are 3 main tasks - for deploy, switch and stage. When you stage, it gets all the files from the dist_dir,
83
+ and copies them to S3. It creates a directories structure on S3, like:
84
+
85
+ ```
86
+ /path
87
+ /to
88
+ /app
89
+ /20130809134509
90
+ /20130809140328
91
+ ...
92
+ SHAS
93
+ ```
94
+
95
+ These '20130809134509'-like directories are actually 'staged' versions of the app. Directory name is just
96
+ a revision name, in the format "%Y%m%d%H%M%S".
97
+
98
+ Then, you have to do 'switch', which just copies the selected revision directory into 'current'.
99
+ So, after 'switch' e.g. to 20130809134509, the directory structure will be like
100
+
101
+ ```
102
+ /path
103
+ /to
104
+ /app
105
+ /20130809134509
106
+ /20130809140328
107
+ ...
108
+ /current
109
+ CURRENT_REVISION
110
+ SHAS
111
+ ```
112
+
113
+ 'current' contains the currently used copy of the app. Your app should use files from this directory.
114
+
115
+ You also could do "deploy", it is basically "stage", and then "switch" to just staged revision.
116
+
117
+ So, use it like this:
118
+
119
+ ```bash
120
+ $ rake s3_deployer:stage # only creates timestamp dir, like 20130809134509, but doesn't override the 'current' dir
121
+ $ rake s3_deployer:switch REVISION=20130809140330
122
+ $ rake s3_deployer:deploy
123
+ $ rake s3_deployer:deploy VERSION=new-stuff # check the example of deployer.rb above to see how it is used
124
+ $ rake s3_deployer:current # get the currently deployed revision
125
+ $ rake s3_deployer:list # get the list of all deployed revisions and their SHAs and commit subjects
126
+ ```
127
+
128
+ If you want to run s3_deployer in some specific environment, use ENV variable:
129
+
130
+ ```bash
131
+ $ ENV=development rake s3_deployer:deploy
132
+ ```
133
+
134
+ Default environment is 'production'
135
+
136
+ ## Contributing
137
+
138
+ 1. Clone it
139
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
140
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
141
+ 4. Push to the branch (`git push origin my-new-feature`)
142
+ 5. Create new Pull Request pointing to master
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
@@ -0,0 +1,22 @@
1
+ class S3Deployer
2
+ class Color
3
+ class << self
4
+ def green(text)
5
+ self.new(32).wrap(text)
6
+ end
7
+
8
+ def yellow(text)
9
+ self.new(33).wrap(text)
10
+ end
11
+ end
12
+
13
+ def initialize(color)
14
+ @color = color
15
+ end
16
+
17
+ def wrap(text)
18
+ "\e[#{@color}m#{text}\e[0m"
19
+ end
20
+ end
21
+ end
22
+
@@ -0,0 +1,36 @@
1
+ class S3Deployer
2
+ class Config
3
+ attr_reader :version, :revision, :env
4
+
5
+ def initialize
6
+ @version = ENV["VERSION"] || ""
7
+ @revision = ENV["REVISION"] || ""
8
+ @env = ENV["ENV"] || "production"
9
+ @env_settings = {}
10
+ colorize true
11
+ time_zone "GMT"
12
+ current_path "current"
13
+ end
14
+
15
+ %w{
16
+ region bucket app_name app_path mixbook_host dist_dir access_key_id secret_access_key
17
+ gzip colorize time_zone current_path cache_control
18
+ before_deploy after_deploy before_stage after_stage before_switch after_switch
19
+ }.each do |method|
20
+ define_method method do |value = :omitted|
21
+ instance_variable_set("@#{method}", value) unless value == :omitted
22
+ instance_variable_get("@#{method}")
23
+ end
24
+ end
25
+
26
+ def environment(name, &block)
27
+ @env_settings[name.to_s] = block
28
+ end
29
+
30
+ def apply_environment_settings!
31
+ if @env_settings[@env.to_s]
32
+ instance_eval(&@env_settings[@env.to_s])
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,28 @@
1
+ require 's3_deployer'
2
+
3
+ namespace :s3_deployer do
4
+ desc "Deploy"
5
+ task :deploy do
6
+ S3Deployer.deploy!
7
+ end
8
+
9
+ desc "Deploy the revision, but don't change it to the 'current' revision"
10
+ task :stage do
11
+ S3Deployer.stage!
12
+ end
13
+
14
+ desc "Switch"
15
+ task :switch do
16
+ S3Deployer.switch!
17
+ end
18
+
19
+ desc "Get current revision number"
20
+ task :current do
21
+ S3Deployer.current
22
+ end
23
+
24
+ desc "Get the list of deployed revisions"
25
+ task :list do
26
+ S3Deployer.list
27
+ end
28
+ end
@@ -0,0 +1,3 @@
1
+ class S3Deployer
2
+ VERSION = "0.6.0"
3
+ end
@@ -0,0 +1,251 @@
1
+ require 'json'
2
+ require 'zlib'
3
+ require 'stringio'
4
+ require 'tzinfo'
5
+ require 'rexml/document'
6
+ require 'parallel'
7
+ require 'aws-sdk'
8
+
9
+ require "s3_deployer/config"
10
+ require "s3_deployer/color"
11
+ require "s3_deployer/version"
12
+
13
+ class S3Deployer
14
+ DATE_FORMAT = "%Y%m%d%H%M%S"
15
+ CURRENT_REVISION = "CURRENT_REVISION"
16
+ class << self
17
+ attr_reader :config
18
+
19
+ def configure(&block)
20
+ @config = Config.new
21
+ @config.instance_eval(&block)
22
+ @config.apply_environment_settings!
23
+
24
+ Aws.config.update({
25
+ region: config.region,
26
+ credentials: Aws::Credentials.new(config.access_key_id, config.secret_access_key),
27
+ })
28
+ end
29
+
30
+ def execute(cmd)
31
+ puts "Running '#{cmd}'"
32
+ system(cmd, out: $stdout, err: :out)
33
+ end
34
+
35
+ def deploy!
36
+ revision = time_zone.now.strftime(DATE_FORMAT)
37
+ config.before_deploy[revision] if config.before_deploy
38
+ stage!(revision)
39
+ switch!(revision)
40
+ config.after_deploy[revision] if config.after_deploy
41
+ end
42
+
43
+ def stage!(revision = time_zone.now.strftime(DATE_FORMAT))
44
+ puts "Staging #{colorize(:green, revision)}"
45
+ config.before_stage[revision] if config.before_stage
46
+ copy_files_to_s3(revision)
47
+ store_git_hash(revision)
48
+ config.after_stage[revision] if config.after_stage
49
+ end
50
+
51
+ def switch!(revision = config.revision)
52
+ current_revision = get_current_revision
53
+ current_sha = sha_of_revision(current_revision)
54
+ sha = sha_of_revision(revision)
55
+ puts "Switching from #{colorize(:green, current_revision)} (#{colorize(:yellow, current_sha && current_sha[0..7])}) " +
56
+ "to #{colorize(:green, revision)} (#{colorize(:yellow, sha && sha[0..7])})"
57
+ if !revision || revision.strip.empty?
58
+ warn "You must specify the revision by REVISION env variable"
59
+ exit(1)
60
+ end
61
+ revision = normalize_revision(revision)
62
+ config.before_switch[current_revision, revision] if config.before_switch
63
+ prefix = config.app_path.empty? ? revision : File.join(config.app_path, revision)
64
+ list_of_objects = []
65
+ Aws::S3::Resource.new.bucket(config.bucket).objects(prefix: prefix).each do |object_summary|
66
+ list_of_objects << object_summary
67
+ end
68
+ Parallel.each(list_of_objects, in_threads: 20) do |object_summary|
69
+ object = object_summary.object
70
+ target_path = config.app_path.empty? ? @config.current_path : File.join(config.app_path, @config.current_path)
71
+ path = object.key.gsub(prefix, target_path)
72
+ value = object.get.body.read
73
+ value = object.content_encoding == "gzip" ? decompress(value) : value
74
+ store_value(path, value)
75
+ end
76
+ store_current_revision(revision)
77
+ config.after_switch[current_revision, revision] if config.after_switch
78
+ end
79
+
80
+ def current
81
+ current_revision = get_current_revision
82
+ if current_revision
83
+ puts "Current revision: #{current_revision} - #{get_datetime_from_revision(current_revision)}"
84
+ else
85
+ puts "There is no information about the current revision"
86
+ end
87
+ end
88
+
89
+ def normalize_revision(revision)
90
+ if revision && !revision.empty?
91
+ datetime = get_datetime_from_revision(revision)
92
+ if datetime
93
+ revision
94
+ else
95
+ shas_by_revisions.detect { |k, v| v.start_with?(revision) }.first
96
+ end
97
+ end
98
+ end
99
+
100
+ def list
101
+ puts "Getting the list of deployed revisions..."
102
+ current_revision = get_current_revision
103
+ get_list_of_revisions.each do |rev|
104
+ datetime = get_datetime_from_revision(rev)
105
+ sha = shas_by_revisions[rev]
106
+ title = sha ? `git show -s --format=%s #{sha}`.strip : nil
107
+ string = "#{rev} - #{datetime} #{sha ? " - #{sha[0..7]}" : ""} #{title ? "(#{title})" : ""} #{" <= current" if rev == current_revision}"
108
+ puts string
109
+ end
110
+ end
111
+
112
+ def changes(from, to)
113
+ from_sha = sha_of_revision(from)
114
+ to_sha = sha_of_revision(to)
115
+ if from_sha && to_sha
116
+ `git log --oneline --reverse #{from_sha}...#{to_sha}`.split("\n").map(&:strip)
117
+ else
118
+ []
119
+ end
120
+ end
121
+
122
+ def sha_of_revision(revision)
123
+ shas_by_revisions[normalize_revision(revision)]
124
+ end
125
+
126
+ private
127
+
128
+ def copy_files_to_s3(rev)
129
+ dir = File.join(config.app_path, rev)
130
+ Parallel.each(source_files_list, in_threads: 20) do |file|
131
+ s3_file = Pathname.new(file).relative_path_from(Pathname.new(config.dist_dir)).to_s
132
+ store_value(File.join(dir, s3_file), File.read(file))
133
+ end
134
+ end
135
+
136
+ def get_list_of_revisions
137
+ prefix = File.join(config.app_path)
138
+ body = Aws::S3::Client.new.list_objects({bucket: config.bucket, delimiter: '/', prefix: prefix + "/"})
139
+ body.common_prefixes.map(&:prefix).map { |e| e.gsub(prefix, "").gsub("/", "") }.select do |dir|
140
+ !!(Time.strptime(dir, DATE_FORMAT) rescue nil)
141
+ end.sort
142
+ end
143
+
144
+ def app_path_with_bucket
145
+ File.join(config.bucket, config.app_path)
146
+ end
147
+
148
+ def get_datetime_from_revision(revision)
149
+ date = Time.strptime(revision, DATE_FORMAT) rescue nil
150
+ date.strftime("%m/%d/%Y %H:%M") if date
151
+ end
152
+
153
+ def shas_by_revisions
154
+ @shas_by_revisions ||= get_value(File.join(config.app_path, "SHAS")).split("\n").inject({}) do |memo, line|
155
+ revision, sha = line.split(" - ").map(&:strip)
156
+ memo[revision] = sha
157
+ memo
158
+ end
159
+ rescue Aws::S3::Errors::NoSuchKey
160
+ {}
161
+ end
162
+
163
+ def current_revision_path
164
+ File.join(config.app_path, CURRENT_REVISION)
165
+ end
166
+
167
+ def get_value(key)
168
+ puts "Retrieving value #{key} on S3"
169
+ Aws::S3::Resource.new.bucket(config.bucket).object(key).get.body.read
170
+ end
171
+
172
+ def store_current_revision(revision)
173
+ store_value(current_revision_path, revision)
174
+ end
175
+
176
+ def store_git_hash(time)
177
+ value = shas_by_revisions.
178
+ merge(time => `git rev-parse HEAD`.strip).
179
+ map { |sha, rev| "#{sha} - #{rev}" }.join("\n")
180
+ store_value(File.join(config.app_path, "SHAS"), value)
181
+ @shas_by_revisions = nil
182
+ end
183
+
184
+ def get_current_revision
185
+ get_value(current_revision_path)
186
+ rescue Aws::S3::Errors::NoSuchKey
187
+ nil
188
+ end
189
+
190
+ def store_value(key, value)
191
+ puts "Storing value #{colorize(:yellow, key)} on S3#{", #{colorize(:green, 'gzipped')}" if should_compress?(key)}"
192
+ options = {acl: "public-read"}
193
+ if config.cache_control && !config.cache_control.empty?
194
+ options[:cache_control] = config.cache_control
195
+ end
196
+ if should_compress?(key)
197
+ options[:content_encoding] = "gzip"
198
+ value = compress(value)
199
+ end
200
+ Aws::S3::Resource.new.bucket(config.bucket).object(key).put(options.merge(body: value))
201
+ end
202
+
203
+ def should_compress?(key)
204
+ if [true, false, nil].include?(config.gzip)
205
+ !!config.gzip
206
+ else
207
+ key != CURRENT_REVISION && Array(config.gzip).any? { |regexp| key.match(regexp) }
208
+ end
209
+ end
210
+
211
+ def compress(source)
212
+ output = Stream.new
213
+ gz = Zlib::GzipWriter.new(output, Zlib::DEFAULT_COMPRESSION, Zlib::DEFAULT_STRATEGY)
214
+ gz.write(source)
215
+ gz.close
216
+ output.string
217
+ end
218
+
219
+ def decompress(source)
220
+ begin
221
+ Zlib::GzipReader.new(StringIO.new(source)).read
222
+ rescue Zlib::GzipFile::Error
223
+ source
224
+ end
225
+ end
226
+
227
+ def source_files_list
228
+ Dir.glob(File.join(config.dist_dir, "**/*")).select { |f| File.file?(f) }
229
+ end
230
+
231
+ def colorize(color, text)
232
+ config.colorize ? Color.send(color, text) : text
233
+ end
234
+
235
+ def time_zone
236
+ TZInfo::Timezone.get(config.time_zone)
237
+ end
238
+
239
+ class Stream < StringIO
240
+ def initialize(*)
241
+ super
242
+ set_encoding "BINARY"
243
+ end
244
+
245
+ def close
246
+ rewind
247
+ end
248
+ end
249
+
250
+ end
251
+ end
@@ -0,0 +1,28 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 's3_deployer/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "s3_deployer"
8
+ spec.version = S3Deployer::VERSION
9
+ spec.authors = ["Anton Astashov"]
10
+ spec.email = ["anton.astashov@gmail.com"]
11
+ spec.description = "Simple gem for deploying client apps to S3"
12
+ spec.summary = "Simple gem for deploying client apps to S3"
13
+ spec.homepage = ""
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files`.split($/)
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_dependency 'tzinfo'
22
+ spec.add_dependency 'json'
23
+ spec.add_dependency 'parallel'
24
+ spec.add_dependency 'aws-sdk'
25
+
26
+ spec.add_development_dependency "bundler", "~> 1.3"
27
+ spec.add_development_dependency "rake"
28
+ end
metadata ADDED
@@ -0,0 +1,138 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: s3_deployer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.6.0
5
+ platform: ruby
6
+ authors:
7
+ - Anton Astashov
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-10-30 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: tzinfo
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: json
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: parallel
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: aws-sdk
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: bundler
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.3'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.3'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rake
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ description: Simple gem for deploying client apps to S3
98
+ email:
99
+ - anton.astashov@gmail.com
100
+ executables: []
101
+ extensions: []
102
+ extra_rdoc_files: []
103
+ files:
104
+ - ".gitignore"
105
+ - Gemfile
106
+ - README.md
107
+ - Rakefile
108
+ - lib/s3_deployer.rb
109
+ - lib/s3_deployer/color.rb
110
+ - lib/s3_deployer/config.rb
111
+ - lib/s3_deployer/tasks.rb
112
+ - lib/s3_deployer/version.rb
113
+ - s3_deployer.gemspec
114
+ homepage: ''
115
+ licenses:
116
+ - MIT
117
+ metadata: {}
118
+ post_install_message:
119
+ rdoc_options: []
120
+ require_paths:
121
+ - lib
122
+ required_ruby_version: !ruby/object:Gem::Requirement
123
+ requirements:
124
+ - - ">="
125
+ - !ruby/object:Gem::Version
126
+ version: '0'
127
+ required_rubygems_version: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ requirements: []
133
+ rubyforge_project:
134
+ rubygems_version: 2.2.2
135
+ signing_key:
136
+ specification_version: 4
137
+ summary: Simple gem for deploying client apps to S3
138
+ test_files: []