Skip to content

Commit 17a73f9

Browse files
authored
Merge pull request #339 from wordpress-mobile/add/s3-binary-upload
Add Upload to S3 Action
2 parents ae15f11 + 99e6ece commit 17a73f9

File tree

5 files changed

+295
-22
lines changed

5 files changed

+295
-22
lines changed

.rubocop_todo.yml

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -126,28 +126,7 @@ RSpec/DescribedClass:
126126
# Include: **/*_spec*rb*, **/spec/**/*
127127
RSpec/FilePath:
128128
Exclude:
129-
- 'spec/android_localize_helper_spec.rb'
130-
- 'spec/android_merge_translators_strings_spec.rb'
131-
- 'spec/android_version_helper_spec.rb'
132-
- 'spec/an_localize_libs_action_spec.rb'
133-
- 'spec/an_metadata_update_helper_spec.rb'
134-
- 'spec/an_update_metadata_source_spec.rb'
135-
- 'spec/configuration_spec.rb'
136-
- 'spec/configure_helper_spec.rb'
137-
- 'spec/encryption_helper_spec.rb'
138-
- 'spec/git_helper_spec.rb'
139-
- 'spec/github_helper_spec.rb'
140-
- 'spec/ios_git_helper_spec.rb'
141-
- 'spec/ios_lint_localizations_spec.rb'
142-
- 'spec/ios_merge_translators_strings_spec.rb'
143-
- 'spec/release_notes_helper_spec.rb'
144-
- 'spec/check_localization_progress_spec.rb'
145-
- 'spec/ios_generate_strings_file_from_code_spec.rb'
146-
- 'spec/ios_l10n_helper_spec.rb'
147-
- 'spec/ios_merge_strings_files_spec.rb'
148-
- 'spec/gp_update_metadata_source_spec.rb'
149-
- 'spec/ios_download_strings_files_from_glotpress_spec.rb'
150-
- 'spec/ios_extract_keys_from_strings_files_spec.rb'
129+
- 'spec/*_spec.rb'
151130

152131
# Offense count: 8
153132
# Cop supports --auto-correct.

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ _None_
1111
### New Features
1212

1313
* Introduce new `ios_extract_keys_from_strings_files` action. [#338]
14+
* Add Upload to S3 Action. [#339]
1415

1516
### Bug Fixes
1617

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
require 'fastlane/action'
2+
require 'digest/sha1'
3+
4+
module Fastlane
5+
module Actions
6+
module SharedValues
7+
S3_UPLOADED_FILE_PATH = :S3_UPLOADED_FILE_PATH
8+
end
9+
10+
class UploadToS3Action < Action
11+
def self.run(params)
12+
file_path = params[:file]
13+
file_name = File.basename(file_path)
14+
15+
bucket = params[:bucket]
16+
key = params[:key] || file_name
17+
18+
if params[:auto_prefix] == true
19+
file_name_hash = Digest::SHA1.hexdigest(file_name)
20+
key = [file_name_hash, key].join('/')
21+
end
22+
23+
UI.user_error!("File already exists in S3 bucket #{bucket} at #{key}") if file_is_already_uploaded?(bucket, key)
24+
25+
UI.message("Uploading #{file_path} to: #{key}")
26+
27+
File.open(file_path, 'rb') do |file|
28+
Aws::S3::Client.new().put_object(
29+
body: file,
30+
bucket: bucket,
31+
key: key
32+
)
33+
rescue Aws::S3::Errors::ServiceError => e
34+
UI.crash!("Unable to upload file to S3: #{e.message}")
35+
end
36+
37+
UI.success('Upload Complete')
38+
39+
Actions.lane_context[SharedValues::S3_UPLOADED_FILE_PATH] = key
40+
41+
return key
42+
end
43+
44+
def self.file_is_already_uploaded?(bucket, key)
45+
response = Aws::S3::Client.new().head_object(
46+
bucket: bucket,
47+
key: key
48+
)
49+
return response[:content_length].positive?
50+
rescue Aws::S3::Errors::NotFound
51+
return false
52+
end
53+
54+
def self.description
55+
'Uploads a given file to S3'
56+
end
57+
58+
def self.authors
59+
['Automattic']
60+
end
61+
62+
def self.return_value
63+
'Returns the object\'s derived S3 key'
64+
end
65+
66+
def self.details
67+
'Uploads a file to S3, and makes a pre-signed URL available in the lane context'
68+
end
69+
70+
def self.available_options
71+
[
72+
FastlaneCore::ConfigItem.new(
73+
key: :bucket,
74+
description: 'The bucket that will store the file',
75+
optional: false,
76+
type: String,
77+
verify_block: proc { |bucket| UI.user_error!('You must provide a valid bucket name') if bucket.empty? }
78+
),
79+
FastlaneCore::ConfigItem.new(
80+
key: :key,
81+
description: 'The path to the file within the bucket. If `nil`, will default to the `file\'s basename',
82+
optional: true,
83+
type: String,
84+
verify_block: proc { |key|
85+
next if key.is_a?(String) && !key.empty?
86+
87+
UI.user_error!('The provided key must not be empty. Use nil instead if you want to default to the file basename')
88+
}
89+
),
90+
FastlaneCore::ConfigItem.new(
91+
key: :file,
92+
description: 'The path to the local file on disk',
93+
optional: false,
94+
type: String,
95+
verify_block: proc { |f| UI.user_error!("Path `#{f}` does not exist.") unless File.file?(f) }
96+
),
97+
FastlaneCore::ConfigItem.new(
98+
key: :auto_prefix,
99+
description: 'Generate a derived prefix based on the filename that makes it harder to guess the URL of the uploaded object',
100+
optional: true,
101+
default_value: true,
102+
type: Boolean
103+
),
104+
]
105+
end
106+
107+
def self.is_supported?(platform)
108+
true
109+
end
110+
end
111+
end
112+
end

spec/spec_helper.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ def allow_fastlane_action_sh
3232
allow(FastlaneCore::Helper).to receive(:sh_enabled?).and_return(true)
3333
end
3434

35+
# Allow us to do `.with` matching against a `File` instance to a particular path in RSpec expectations
36+
# (Because `File.open(path)` returns a different instance of `File` for the same path on each call)
37+
RSpec::Matchers.define :file_instance_of do |path|
38+
match { |actual| actual.is_a?(File) && actual.path == path }
39+
end
40+
3541
# Allows to assert if an `Action.sh` command has been triggered by the action under test.
3642
# Requires `allow_fastlane_action_sh` to have been called so that `Action.sh` actually calls `Open3.popen2e`
3743
#
@@ -75,3 +81,16 @@ def in_tmp_dir
7581
end
7682
end
7783
end
84+
85+
# Executes the given block with a temporary file with the given `file_name`
86+
def with_tmp_file(named: nil, content: '')
87+
in_tmp_dir do |tmp_dir|
88+
file_name = named || ('a'..'z').to_a.sample(8).join # 8-character random file name if nil
89+
file_path = File.join(tmp_dir, file_name)
90+
91+
File.write(file_path, content)
92+
yield file_path
93+
ensure
94+
File.delete(file_path)
95+
end
96+
end

spec/upload_to_s3_spec.rb

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
require_relative './spec_helper'
2+
3+
describe Fastlane::Actions::UploadToS3Action do
4+
let(:client) { instance_double(Aws::S3::Client) }
5+
let(:test_bucket) { 'a8c-wpmrt-unit-tests-bucket' }
6+
7+
before do
8+
allow(Aws::S3::Client).to receive(:new).and_return(client)
9+
end
10+
11+
# Stub head_object to return a specific content_length
12+
def stub_s3_response_for_file(key, exists: true)
13+
content_length = exists == true ? 1 : 0
14+
allow(client).to(receive(:head_object))
15+
.with(bucket: test_bucket, key: key)
16+
.and_return(Aws::S3::Types::HeadObjectOutput.new(content_length: content_length))
17+
end
18+
19+
describe 'uploading a file with valid parameters' do
20+
it 'generates a prefix for the key by default' do
21+
expected_key = '939c39398db2405e791e205778ff70f85dff620e/a8c-key1'
22+
stub_s3_response_for_file(expected_key, exists: false)
23+
24+
with_tmp_file(named: 'input_file_1') do |file_path|
25+
expect(client).to receive(:put_object).with(body: file_instance_of(file_path), bucket: test_bucket, key: expected_key)
26+
27+
return_value = run_described_fastlane_action(
28+
bucket: test_bucket,
29+
key: 'a8c-key1',
30+
file: file_path
31+
)
32+
33+
expect(return_value).to eq(expected_key)
34+
expect(Fastlane::Actions.lane_context[Fastlane::Actions::SharedValues::S3_UPLOADED_FILE_PATH]).to eq(expected_key)
35+
end
36+
end
37+
38+
it 'generates a prefix for the key when using auto_prefix:true' do
39+
expected_key = '8bde1a7a04300df27b52f4383dc997e5fbbff180/a8c-key2'
40+
stub_s3_response_for_file(expected_key, exists: false)
41+
42+
with_tmp_file(named: 'input_file_2') do |file_path|
43+
expect(client).to receive(:put_object).with(body: file_instance_of(file_path), bucket: test_bucket, key: expected_key)
44+
45+
return_value = run_described_fastlane_action(
46+
bucket: test_bucket,
47+
key: 'a8c-key2',
48+
file: file_path,
49+
auto_prefix: true
50+
)
51+
52+
expect(return_value).to eq(expected_key)
53+
expect(Fastlane::Actions.lane_context[Fastlane::Actions::SharedValues::S3_UPLOADED_FILE_PATH]).to eq(expected_key)
54+
end
55+
end
56+
57+
it 'uses the provided key verbatim when using auto_prefix:false' do
58+
expected_key = 'a8c-key1'
59+
stub_s3_response_for_file(expected_key, exists: false)
60+
61+
with_tmp_file do |file_path|
62+
expect(client).to receive(:put_object).with(body: file_instance_of(file_path), bucket: test_bucket, key: expected_key)
63+
64+
return_value = run_described_fastlane_action(
65+
bucket: test_bucket,
66+
key: 'a8c-key1',
67+
file: file_path,
68+
auto_prefix: false
69+
)
70+
71+
expect(return_value).to eq(expected_key)
72+
expect(Fastlane::Actions.lane_context[Fastlane::Actions::SharedValues::S3_UPLOADED_FILE_PATH]).to eq(expected_key)
73+
end
74+
end
75+
76+
it 'correctly appends the key if it contains subdirectories' do
77+
expected_key = '939c39398db2405e791e205778ff70f85dff620e/subdir/a8c-key1'
78+
stub_s3_response_for_file(expected_key, exists: false)
79+
80+
with_tmp_file(named: 'input_file_1') do |file_path|
81+
expect(client).to receive(:put_object).with(body: file_instance_of(file_path), bucket: test_bucket, key: expected_key)
82+
83+
return_value = run_described_fastlane_action(
84+
bucket: test_bucket,
85+
key: 'subdir/a8c-key1',
86+
file: file_path
87+
)
88+
89+
expect(return_value).to eq(expected_key)
90+
expect(Fastlane::Actions.lane_context[Fastlane::Actions::SharedValues::S3_UPLOADED_FILE_PATH]).to eq(expected_key)
91+
end
92+
end
93+
94+
it 'uses the filename as the key if one is not provided' do
95+
expected_key = 'c125bd799c6aad31092b02e440a8fae25b45a2ad/test_file_1'
96+
stub_s3_response_for_file(expected_key, exists: false)
97+
98+
with_tmp_file(named: 'test_file_1') do |file_path|
99+
expect(client).to receive(:put_object).with(body: file_instance_of(file_path), bucket: test_bucket, key: expected_key)
100+
101+
return_value = run_described_fastlane_action(
102+
bucket: test_bucket,
103+
file: file_path
104+
)
105+
106+
expect(return_value).to eq(expected_key)
107+
expect(Fastlane::Actions.lane_context[Fastlane::Actions::SharedValues::S3_UPLOADED_FILE_PATH]).to eq(expected_key)
108+
end
109+
end
110+
end
111+
112+
describe 'uploading a file with invalid parameters' do
113+
it 'fails if bucket is empty or nil' do
114+
expect do
115+
with_tmp_file do |file_path|
116+
run_described_fastlane_action(
117+
bucket: '',
118+
key: 'key',
119+
file: file_path
120+
)
121+
end
122+
end.to raise_error(FastlaneCore::Interface::FastlaneError, 'You must provide a valid bucket name')
123+
end
124+
125+
it 'fails if an empty key is provided' do
126+
expect do
127+
with_tmp_file do |file_path|
128+
run_described_fastlane_action(
129+
bucket: test_bucket,
130+
key: '',
131+
file: file_path
132+
)
133+
end
134+
end.to raise_error(FastlaneCore::Interface::FastlaneError, 'The provided key must not be empty. Use nil instead if you want to default to the file basename')
135+
end
136+
137+
it 'fails if local file does not exist' do
138+
expect do
139+
run_described_fastlane_action(
140+
bucket: test_bucket,
141+
key: 'key',
142+
file: 'this-file-does-not-exist.txt'
143+
)
144+
end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Path `this-file-does-not-exist.txt` does not exist.')
145+
end
146+
147+
it 'fails if the file already exists on S3' do
148+
expected_key = 'a62f2225bf70bfaccbc7f1ef2a397836717377de/key'
149+
stub_s3_response_for_file(expected_key)
150+
151+
with_tmp_file(named: 'key') do |file_path|
152+
expect do
153+
run_described_fastlane_action(
154+
bucket: test_bucket,
155+
key: 'key',
156+
file: file_path
157+
)
158+
end.to raise_error(FastlaneCore::Interface::FastlaneError, "File already exists in S3 bucket #{test_bucket} at #{expected_key}")
159+
end
160+
end
161+
end
162+
end

0 commit comments

Comments
 (0)