Skip to content

Commit 69786e3

Browse files
committed
alioss-storage-cli based blobstore client
1 parent 9fcbac0 commit 69786e3

File tree

5 files changed

+279
-1
lines changed

5 files changed

+279
-1
lines changed

lib/cloud_controller/blobstore/client_provider.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
require 'cloud_controller/blobstore/safe_delete_client'
77
require 'cloud_controller/blobstore/storage_cli/storage_cli_client'
88
require 'cloud_controller/blobstore/storage_cli/azure_storage_cli_client'
9+
require 'cloud_controller/blobstore/storage_cli/alioss_storage_cli_client'
910
require 'google/apis/errors'
1011

1112
module CloudController
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
module CloudController
2+
module Blobstore
3+
class AliStorageCliClient < StorageCliClient
4+
def cli_path
5+
ENV['ALI_STORAGE_CLI_PATH'] || '/var/vcap/packages/ali-storage-cli/bin/ali-storage-cli'
6+
end
7+
8+
CloudController::Blobstore::StorageCliClient.register('aliyun', AliStorageCliClient)
9+
end
10+
end
11+
end

lib/cloud_controller/blobstore/storage_cli/storage_cli_client.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,11 @@ def validate_required_keys!(json, path)
8080
provider = json['provider'].to_s.strip
8181
raise BlobstoreError.new("No provider specified in config file: #{path.inspect}") if provider.empty?
8282

83-
required = %w[account_key account_name container_name environment]
83+
if provider == 'AzureRM'
84+
required = %w[account_key account_name container_name environment]
85+
elsif provider == 'aliyun'
86+
required = %w[access_key_id access_key_secret endpoint bucket_name]
87+
end
8488
missing = required.reject { |k| json.key?(k) && !json[k].to_s.strip.empty? }
8589
return if missing.empty?
8690

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
require 'spec_helper'
2+
require 'tempfile'
3+
require 'json'
4+
require_relative '../client_shared'
5+
require 'cloud_controller/blobstore/storage_cli/azure_storage_cli_client'
6+
require 'cloud_controller/blobstore/storage_cli/storage_cli_blob'
7+
8+
module CloudController
9+
module Blobstore
10+
RSpec.describe AzureStorageCliClient do
11+
let!(:tmp_cfg) do
12+
f = Tempfile.new(['storage_cli_config', '.json'])
13+
f.write({ provider: 'AzureRM',
14+
account_name: 'some-account-name',
15+
account_key: 'some-access-key',
16+
container_name: directory_key,
17+
environment: 'AzureCloud' }.to_json)
18+
f.flush
19+
f
20+
end
21+
22+
before do
23+
cc_cfg = instance_double(VCAP::CloudController::Config)
24+
allow(VCAP::CloudController::Config).to receive(:config).and_return(cc_cfg)
25+
26+
allow(cc_cfg).to receive(:get) do |key, *_|
27+
case key
28+
when :storage_cli_config_file_droplets,
29+
:storage_cli_config_file_buildpacks,
30+
:storage_cli_config_file_packages,
31+
:storage_cli_config_file_resource_pool
32+
tmp_cfg.path
33+
end
34+
end
35+
allow(Steno).to receive(:logger).and_return(double(info: nil, error: nil))
36+
end
37+
38+
after { tmp_cfg.close! }
39+
40+
subject(:client) { AzureStorageCliClient.new(provider: 'AzureRM', directory_key: directory_key, resource_type: resource_type, root_dir: 'bommel', config_path: 'path') }
41+
let(:directory_key) { 'my-bucket' }
42+
let(:resource_type) { 'resource_pool' }
43+
let(:downloaded_file) do
44+
Tempfile.open('') do |tmpfile|
45+
tmpfile.write('downloaded file content')
46+
tmpfile
47+
end
48+
end
49+
50+
let(:deletable_blob) { StorageCliBlob.new('deletable-blob') }
51+
let(:dest_path) { File.join(Dir.mktmpdir, SecureRandom.uuid) }
52+
53+
describe 'conforms to the blobstore client interface' do
54+
before do
55+
allow(client).to receive(:run_cli).with('exists', anything, allow_exit_code_three: true).and_return([nil, instance_double(Process::Status, exitstatus: 0)])
56+
allow(client).to receive(:run_cli).with('get', anything, anything).and_wrap_original do |_original_method, _cmd, _source, dest_path|
57+
File.write(dest_path, 'downloaded content')
58+
[nil, instance_double(Process::Status, exitstatus: 0)]
59+
end
60+
allow(client).to receive(:run_cli).with('put', anything, anything).and_return([nil, instance_double(Process::Status, exitstatus: 0)])
61+
allow(client).to receive(:run_cli).with('copy', anything, anything).and_return([nil, instance_double(Process::Status, exitstatus: 0)])
62+
allow(client).to receive(:run_cli).with('delete', anything).and_return([nil, instance_double(Process::Status, exitstatus: 0)])
63+
allow(client).to receive(:run_cli).with('delete-recursive', anything).and_return([nil, instance_double(Process::Status, exitstatus: 0)])
64+
allow(client).to receive(:run_cli).with('list', anything).and_return(["aa/bb/blob1\ncc/dd/blob2\n", instance_double(Process::Status, exitstatus: 0)])
65+
allow(client).to receive(:run_cli).with('ensure-bucket-exists').and_return([nil, instance_double(Process::Status, exitstatus: 0)])
66+
allow(client).to receive(:run_cli).with('properties', anything).and_return(['{"dummy": "json"}', instance_double(Process::Status, exitstatus: 0)])
67+
allow(client).to receive(:run_cli).with('sign', anything, 'get', '3600s').and_return(['some-url', instance_double(Process::Status, exitstatus: 0)])
68+
end
69+
70+
it_behaves_like 'a blobstore client'
71+
end
72+
73+
describe '#local?' do
74+
it 'returns false' do
75+
expect(client.local?).to be false
76+
end
77+
end
78+
79+
describe '#cli_path' do
80+
it 'returns the default CLI path' do
81+
expect(client.cli_path).to eq('/var/vcap/packages/azure-storage-cli/bin/azure-storage-cli')
82+
end
83+
84+
it 'can be overridden by an environment variable' do
85+
allow(ENV).to receive(:[]).with('AZURE_STORAGE_CLI_PATH').and_return('/custom/path/to/AzureRM-storage-cli')
86+
expect(client.cli_path).to eq('/custom/path/to/AzureRM-storage-cli')
87+
end
88+
end
89+
90+
describe '#exists?' do
91+
context 'when the blob exists' do
92+
before { allow(client).to receive(:run_cli).with('exists', any_args).and_return([nil, instance_double(Process::Status, exitstatus: 0)]) }
93+
94+
it('returns true') { expect(client.exists?('some-blob-key')).to be true }
95+
end
96+
97+
context 'when the blob does not exist' do
98+
before { allow(client).to receive(:run_cli).with('exists', any_args).and_return([nil, instance_double(Process::Status, exitstatus: 3)]) }
99+
100+
it('returns false') { expect(client.exists?('some-blob-key')).to be false }
101+
end
102+
end
103+
104+
describe '#files_for' do
105+
context 'when CLI returns multiple files' do
106+
let(:cli_output) { "aa/bb/blob1\ncc/dd/blob2\n" }
107+
108+
before do
109+
allow(client).to receive(:run_cli).
110+
with('list', 'some-prefix').
111+
and_return([cli_output, instance_double(Process::Status, success?: true)])
112+
end
113+
114+
it 'returns StorageCliBlob instances for each file' do
115+
blobs = client.files_for('some-prefix')
116+
expect(blobs.map(&:key)).to eq(['aa/bb/blob1', 'cc/dd/blob2'])
117+
expect(blobs).to all(be_a(StorageCliBlob))
118+
end
119+
end
120+
121+
context 'when CLI returns empty output' do
122+
before do
123+
allow(client).to receive(:run_cli).
124+
with('list', 'some-prefix').
125+
and_return(["\n", instance_double(Process::Status, success?: true)])
126+
end
127+
128+
it 'returns an empty array' do
129+
expect(client.files_for('some-prefix')).to eq([])
130+
end
131+
end
132+
133+
context 'when CLI output has extra whitespace' do
134+
let(:cli_output) { "aa/bb/blob1 \n \ncc/dd/blob2\n" }
135+
136+
before do
137+
allow(client).to receive(:run_cli).
138+
with('list', 'some-prefix').
139+
and_return([cli_output, instance_double(Process::Status, success?: true)])
140+
end
141+
142+
it 'strips and rejects empty lines' do
143+
blobs = client.files_for('some-prefix')
144+
expect(blobs.map(&:key)).to eq(['aa/bb/blob1', 'cc/dd/blob2'])
145+
end
146+
end
147+
end
148+
149+
describe '#blob' do
150+
let(:properties_json) { '{"etag": "test-etag", "last_modified": "2024-10-01T00:00:00Z", "content_length": 1024}' }
151+
152+
it 'returns a list of StorageCliBlob instances for a given key' do
153+
allow(client).to receive(:run_cli).with('properties', 'bommel/va/li/valid-blob').and_return([properties_json, instance_double(Process::Status, exitstatus: 0)])
154+
allow(client).to receive(:run_cli).with('sign', 'bommel/va/li/valid-blob', 'get', '3600s').and_return(['some-url', instance_double(Process::Status, exitstatus: 0)])
155+
156+
blob = client.blob('valid-blob')
157+
expect(blob).to be_a(StorageCliBlob)
158+
expect(blob.key).to eq('valid-blob')
159+
expect(blob.attributes(:etag, :last_modified, :content_length)).to eq({
160+
etag: 'test-etag',
161+
last_modified: '2024-10-01T00:00:00Z',
162+
content_length: 1024
163+
})
164+
expect(blob.internal_download_url).to eq('some-url')
165+
expect(blob.public_download_url).to eq('some-url')
166+
end
167+
168+
it 'raises an error if the cli output is empty' do
169+
allow(client).to receive(:run_cli).with('properties', 'bommel/no/ne/nonexistent-blob').and_return([nil, instance_double(Process::Status, exitstatus: 0)])
170+
expect { client.blob('nonexistent-blob') }.to raise_error(BlobstoreError, /Properties command returned empty output/)
171+
end
172+
173+
it 'raises an error if the cli output is not valid JSON' do
174+
allow(client).to receive(:run_cli).with('properties', 'bommel/in/va/invalid-json').and_return(['not a json', instance_double(Process::Status, exitstatus: 0)])
175+
expect { client.blob('invalid-json') }.to raise_error(BlobstoreError, /Failed to parse json properties/)
176+
end
177+
end
178+
179+
describe '#run_cli' do
180+
it 'returns output and status on success' do
181+
status = instance_double(Process::Status, success?: true, exitstatus: 0)
182+
allow(Open3).to receive(:capture3).with(anything, '-c', anything, 'list', 'arg1').and_return(['ok', '', status])
183+
184+
output, returned_status = client.send(:run_cli, 'list', 'arg1')
185+
expect(output).to eq('ok')
186+
expect(returned_status).to eq(status)
187+
end
188+
189+
it 'raises an error on failure' do
190+
status = instance_double(Process::Status, success?: false, exitstatus: 1)
191+
allow(Open3).to receive(:capture3).with(anything, '-c', anything, 'list', 'arg1').and_return(['', 'error message', status])
192+
193+
expect do
194+
client.send(:run_cli, 'list', 'arg1')
195+
end.to raise_error(RuntimeError, /storage-cli list failed with exit code 1/)
196+
end
197+
198+
it 'allows exit code 3 if specified' do
199+
status = instance_double(Process::Status, success?: false, exitstatus: 3)
200+
allow(Open3).to receive(:capture3).with(anything, '-c', anything, 'list', 'arg1').and_return(['', 'error message', status])
201+
202+
output, returned_status = client.send(:run_cli, 'list', 'arg1', allow_exit_code_three: true)
203+
expect(output).to eq('')
204+
expect(returned_status).to eq(status)
205+
end
206+
207+
it 'raises BlobstoreError on Open3 failure' do
208+
allow(Open3).to receive(:capture3).and_raise(StandardError.new('Open3 error'))
209+
210+
expect { client.send(:run_cli, 'list', 'arg1') }.to raise_error(BlobstoreError, /Open3 error/)
211+
end
212+
end
213+
end
214+
end
215+
end

spec/unit/lib/cloud_controller/blobstore/storage_cli/storage_cli_client_spec.rb

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
require 'spec_helper'
22
require 'cloud_controller/blobstore/storage_cli/azure_storage_cli_client'
3+
require 'cloud_controller/blobstore/storage_cli/alioss_storage_cli_client'
34

45
module CloudController
56
module Blobstore
@@ -28,6 +29,31 @@ module Blobstore
2829
droplets_cfg.close!
2930
end
3031

32+
it 'builds the correct client when JSON has provider aliyun' do
33+
droplets_cfg = Tempfile.new(['droplets', '.json'])
34+
droplets_cfg.write({
35+
provider: 'aliyun',
36+
access_key_id: 'ali-id',
37+
access_key_secret: 'ali-secret',
38+
endpoint: 'oss-example.aliyuncs.com',
39+
bucket_name: 'ali-bucket'
40+
}.to_json)
41+
droplets_cfg.flush
42+
43+
config_double = instance_double(VCAP::CloudController::Config)
44+
allow(VCAP::CloudController::Config).to receive(:config).and_return(config_double)
45+
allow(config_double).to receive(:get).with(:storage_cli_config_file_droplets).and_return(droplets_cfg.path)
46+
47+
client_from_registry = StorageCliClient.build(
48+
directory_key: 'dummy-key',
49+
root_dir: 'dummy-root',
50+
resource_type: 'droplets'
51+
)
52+
expect(client_from_registry).to be_a(AliStorageCliClient)
53+
54+
droplets_cfg.close!
55+
end
56+
3157
it 'raises an error for an unregistered provider' do
3258
droplets_cfg = Tempfile.new(['droplets', '.json'])
3359
droplets_cfg.write(
@@ -195,6 +221,27 @@ def build_client(resource_type)
195221
end.to raise_error(CloudController::Blobstore::BlobstoreError, /Missing required keys.*#{k}/)
196222
end
197223
end
224+
225+
%w[access_key_id access_key_secret endpoint bucket_name].each do |k|
226+
it "raises when #{k} missing for aliyun" do
227+
cfg = {
228+
'provider' => 'aliyun',
229+
'access_key_id' => 'ali-id',
230+
'access_key_secret' => 'ali-secret',
231+
'endpoint' => 'oss-example.aliyuncs.com',
232+
'bucket_name' => 'ali-bucket'
233+
}
234+
cfg.delete(k)
235+
File.write(droplets_cfg.path, cfg.to_json)
236+
237+
expect do
238+
StorageCliClient.build(directory_key: 'dir', root_dir: 'root', resource_type: 'droplets')
239+
end.to raise_error(
240+
CloudController::Blobstore::BlobstoreError,
241+
/Missing required keys.*#{k}/
242+
)
243+
end
244+
end
198245
end
199246

200247
describe '#exists? exit code handling' do

0 commit comments

Comments
 (0)