|
| 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 |
0 commit comments