Skip to content
This repository was archived by the owner on Jun 2, 2021. It is now read-only.

Commit e931079

Browse files
weymanfBrian CunnieMerricdeLauneyreidmitsweinstein22
authored andcommitted
v3: Client can download droplets on the v3 API
- `GET /v3/droplets/:guid/download` - The setup requires a valid `droplet_hash`, which is a SHA1. It's used to create the blobstore key. - We use the world's smallest (29 bytes) .tgz file, and we take the SHA1 from that. - The temporary file must be created in `/tmp/`, otherwise the upload will fail. - Download link is now in the droplet response [finishes #169630882] Co-authored-by: Weyman Fung <wfung@pivotal.io> Co-authored-by: Brian Cunnie <bcunnie@pivotal.io> Co-authored-by: Merric de Launey <mdelauney@pivotal.io> Co-authored-by: Reid Mitchell <rmitchell@pivotal.io> Co-authored-by: Sarah Weinstein <sweinstein@pivotal.io>
1 parent 677863a commit e931079

10 files changed

Lines changed: 194 additions & 4 deletions

File tree

app/controllers/runtime/helpers/blob_dispatcher.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ def send_or_redirect(guid:)
99
raise CloudController::Errors::BlobNotFound unless guid
1010

1111
blob = @blobstore.blob(guid)
12+
1213
raise CloudController::Errors::BlobNotFound unless blob
1314

1415
send_or_redirect_blob(blob)

app/controllers/v3/droplets_controller.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,32 @@ def upload
134134
render status: :accepted, json: Presenters::V3::DropletPresenter.new(droplet)
135135
end
136136

137+
def download
138+
droplet = DropletModel.where(guid: hashed_params[:guid]).eager(:app, :space, space: :organization).first
139+
140+
droplet_not_found! unless droplet && permission_queryer.can_read_from_space?(droplet.space.guid, droplet.space.organization.guid)
141+
142+
unless droplet.state == DropletModel::STAGED_STATE
143+
unprocessable!('Only staged droplets can be downloaded.')
144+
end
145+
146+
VCAP::CloudController::Repositories::DropletEventRepository.record_download(
147+
droplet,
148+
user_audit_info,
149+
droplet.app.name,
150+
droplet.space.guid,
151+
droplet.space.organization.guid,
152+
)
153+
send_droplet_blob(droplet)
154+
end
155+
137156
private
138157

158+
def send_droplet_blob(droplet)
159+
droplet_blobstore = CloudController::DependencyLocator.instance.droplet_blobstore
160+
BlobDispatcher.new(blobstore: droplet_blobstore, controller: self).send_or_redirect(guid: droplet.blobstore_key)
161+
end
162+
139163
def combine_messages(messages)
140164
unprocessable!("Uploaded droplet file is invalid: #{messages.join(', ')}")
141165
end

app/presenters/v3/droplet_presenter.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ def build_links
4747
}.tap do |links|
4848
links[:package] = { href: url_builder.build_url(path: "/v3/packages/#{droplet.package_guid}") } if droplet.package_guid.present?
4949
links[:upload] = { href: url_builder.build_url(path: "/v3/droplets/#{droplet.guid}/upload"), method: 'POST' } if droplet.state == DropletModel::AWAITING_UPLOAD_STATE
50+
links[:download] = { href: url_builder.build_url(path: "/v3/droplets/#{droplet.guid}/download"), experimental: true } if droplet.state == DropletModel::STAGED_STATE
5051
end
5152
end
5253

config/routes.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103
get '/packages/:package_guid/droplets', to: 'droplets#index'
104104
patch '/droplets/:guid', to: 'droplets#update'
105105
post '/droplets/:guid/upload', to: 'droplets#upload'
106+
get '/droplets/:guid/download', to: 'droplets#download'
106107

107108
# errors
108109
match '404', to: 'errors#not_found', via: :all

docs/v3/source/includes/api_resources/_droplets.erb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,10 @@
200200
"assign_current_droplet": {
201201
"href": "https://api.example.org/v3/apps/7b34f1cf-7e73-428a-bb5a-8a17a8058396/relationships/current_droplet",
202202
"method": "PATCH"
203+
},
204+
"download": {
205+
"href": "https://api.example.org/v3/droplets/585bc3c1-3743-497d-88b0-403ad6b56d16/download",
206+
"experimental": true
203207
}
204208
},
205209
"metadata": {
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
### Download droplet bits
2+
3+
```
4+
Example Request
5+
```
6+
7+
```shell
8+
curl "https://api.example.org/v3/droplets/[guid]/download" \
9+
-X GET \
10+
-H "Authorization: bearer [token]" \
11+
```
12+
13+
```
14+
Example Response
15+
```
16+
17+
```http
18+
HTTP/1.1 302 FOUND
19+
Content-Type: application/json
20+
```
21+
22+
Download a gzip compressed tarball file containing a Cloud Foundry compatible droplet. When using a remote blobstore, such as AWS, the response is a redirect to the actual location of the bits. If the client is automatically following redirects, then the OAuth token that was used to communicate with Cloud Controller will be relayed on the new redirect request. Some blobstores may reject the request in that case. Clients may need to follow the redirect without including the OAuth token.
23+
24+
#### Definition
25+
`POST /v3/droplets/:guid/download`
26+
27+
#### Permitted roles
28+
|
29+
--- | ---
30+
Admin |
31+
Admin Read-Only |
32+
Global Auditor |
33+
Org Manager |
34+
Space Auditor |
35+
Space Developer |
36+
Space Manager |

docs/v3/source/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ includes:
123123
- resources/droplets/update
124124
- resources/droplets/delete
125125
- resources/droplets/copy
126+
- resources/droplets/download_bits
126127
- resources/droplets/upload_bits
127128
- resources/environment_variable_groups/header
128129
- resources/environment_variable_groups/object

spec/request/apps_spec.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2223,6 +2223,7 @@
22232223
'self' => { 'href' => "#{link_prefix}/v3/droplets/#{guid}" },
22242224
'package' => { 'href' => "#{link_prefix}/v3/packages/#{package_model.guid}" },
22252225
'app' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}" },
2226+
'download' => { 'href' => "#{link_prefix}/v3/droplets/#{guid}/download", 'experimental' => true },
22262227
'assign_current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/relationships/current_droplet",
22272228
'method' => 'PATCH' },
22282229
},

spec/request/droplets_spec.rb

Lines changed: 101 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
let(:developer_headers) { headers_for(developer, user_name: user_name) }
1212
let(:user_name) { 'sundance kid' }
1313

14+
let(:guid) { droplet_model.guid }
15+
let(:package_model) { VCAP::CloudController::PackageModel.make(app_guid: app_model.guid) }
16+
let(:app_guid) { droplet_model.app_guid }
17+
1418
let(:parsed_response) { MultiJson.load(last_response.body) }
1519

1620
describe 'POST /v3/droplets' do
@@ -189,10 +193,6 @@
189193
end
190194

191195
describe 'GET /v3/droplets/:guid' do
192-
let(:guid) { droplet_model.guid }
193-
let(:package_model) { VCAP::CloudController::PackageModel.make(app_guid: app_model.guid) }
194-
let(:app_guid) { droplet_model.app_guid }
195-
196196
context 'when the droplet has a buildpack lifecycle' do
197197
let!(:droplet_model) do
198198
VCAP::CloudController::DropletModel.make(
@@ -239,6 +239,7 @@
239239
'self' => { 'href' => "#{link_prefix}/v3/droplets/#{guid}" },
240240
'package' => { 'href' => "#{link_prefix}/v3/packages/#{package_model.guid}" },
241241
'app' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}" },
242+
'download' => { 'href' => "#{link_prefix}/v3/droplets/#{guid}/download", 'experimental' => true },
242243
'assign_current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/relationships/current_droplet", 'method' => 'PATCH' },
243244
},
244245
'metadata' => {
@@ -304,6 +305,7 @@
304305
'self' => { 'href' => "#{link_prefix}/v3/droplets/#{guid}" },
305306
'package' => { 'href' => "#{link_prefix}/v3/packages/#{package_model.guid}" },
306307
'app' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}" },
308+
'download' => { 'href' => "#{link_prefix}/v3/droplets/#{guid}/download", 'experimental' => true },
307309
'assign_current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/relationships/current_droplet", 'method' => 'PATCH' },
308310
},
309311
'metadata' => {
@@ -315,6 +317,98 @@
315317
end
316318
end
317319

320+
describe 'GET /v3/droplets/:guid/download' do
321+
let(:worlds_smallest_tgz_file) { "\x1f\x8b\x08\x00\x5e\xc2\xc6\x5e\x00\x03\x63\x60\x18\x05\xa3\x60\x14\x8c\x54\x00\x00\x2e\xaf\xb5\xef\x00\x04\x00\x00" }
322+
let!(:droplet_model) do
323+
VCAP::CloudController::DropletModel.make(
324+
state: VCAP::CloudController::DropletModel::AWAITING_UPLOAD_STATE,
325+
app_guid: app_model.guid,
326+
package_guid: package_model.guid,
327+
buildpack_receipt_buildpack: 'http://buildpack.git.url.com',
328+
error_description: 'example error',
329+
execution_metadata: 'some-data',
330+
droplet_hash: Digest::SHA1.hexdigest(worlds_smallest_tgz_file),
331+
sha256_checksum: 'some-sha-256',
332+
process_types: { 'web' => 'start-command' },
333+
)
334+
end
335+
336+
let(:droplet_file) do
337+
File.join(Dir.mktmpdir(nil, '/tmp'), 'droplet.tgz')
338+
end
339+
let(:upload_body) do
340+
{
341+
bits_name: 'droplet.tgz',
342+
bits_path: droplet_file,
343+
}
344+
end
345+
let(:bits_download_url) { CloudController::DependencyLocator.instance.blobstore_url_generator.droplet_download_url(droplet_model) }
346+
347+
context 'when the droplet is uploaded' do
348+
let(:api_call) { lambda { |user_headers| get "/v3/droplets/#{guid}/download", nil, user_headers } }
349+
let(:expected_codes_and_responses) do
350+
h = Hash.new(
351+
code: 302
352+
)
353+
h['org_auditor'] = {
354+
code: 404
355+
}
356+
h['org_billing_manager'] = {
357+
code: 404
358+
}
359+
h['no_role'] = {
360+
code: 404
361+
}
362+
h.freeze
363+
end
364+
365+
before do
366+
File.write(droplet_file, worlds_smallest_tgz_file)
367+
post "/v3/droplets/#{guid}/upload", upload_body.to_json, developer_headers
368+
expect(last_response).to have_status_code(202)
369+
successes, failures = Delayed::Worker.new.work_off
370+
expect(successes).to eq(1)
371+
expect(failures).to eq(0)
372+
end
373+
374+
it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS
375+
376+
it 'downloads the bit(s) for a droplet' do
377+
get "/v3/droplets/#{guid}/download", nil, developer_headers
378+
379+
expect(last_response.status).to eq(302)
380+
expect(last_response.headers['Location']).to eq(bits_download_url)
381+
382+
expected_metadata = { droplet_guid: droplet_model.guid }.to_json
383+
384+
event = VCAP::CloudController::Event.last
385+
expect(event.values).to include({
386+
type: 'audit.app.droplet.download',
387+
actor_username: user_name,
388+
metadata: expected_metadata,
389+
space_guid: space.guid,
390+
organization_guid: space.organization.guid
391+
})
392+
end
393+
end
394+
395+
context 'when the droplet cannot be found' do
396+
it 'returns 404 for the droplet' do
397+
get '/v3/droplets/some-bogus-guid/download', nil, developer_headers
398+
expect(last_response.status).to eq(404)
399+
expect(last_response.body).to include('Droplet not found')
400+
end
401+
end
402+
403+
context "when the droplet hasn't finished uploading/processing" do
404+
it 'returns a 422 with a helpful error message' do
405+
get "/v3/droplets/#{guid}/download", nil, developer_headers
406+
expect(last_response.status).to eq(422)
407+
expect(last_response.body).to include('Only staged droplets can be downloaded.')
408+
end
409+
end
410+
end
411+
318412
describe 'GET /v3/droplets' do
319413
let(:buildpack) { VCAP::CloudController::Buildpack.make }
320414
let(:package_model) do
@@ -449,6 +543,7 @@
449543
'self' => { 'href' => "#{link_prefix}/v3/droplets/#{droplet2.guid}" },
450544
'package' => { 'href' => "#{link_prefix}/v3/packages/#{package_model.guid}" },
451545
'app' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" },
546+
'download' => { 'href' => "#{link_prefix}/v3/droplets/#{droplet2.guid}/download", 'experimental' => true },
452547
'assign_current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/relationships/current_droplet", 'method' => 'PATCH' },
453548
},
454549
'metadata' => {
@@ -845,6 +940,7 @@
845940
'self' => { 'href' => "#{link_prefix}/v3/droplets/#{droplet2.guid}" },
846941
'package' => { 'href' => "#{link_prefix}/v3/packages/#{package_model.guid}" },
847942
'app' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" },
943+
'download' => { 'href' => "#{link_prefix}/v3/droplets/#{droplet2.guid}/download", 'experimental' => true },
848944
'assign_current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/relationships/current_droplet", 'method' => 'PATCH' },
849945
},
850946
'metadata' => {
@@ -1032,6 +1128,7 @@
10321128
'self' => { 'href' => "#{link_prefix}/v3/droplets/#{droplet2.guid}" },
10331129
'package' => { 'href' => "#{link_prefix}/v3/packages/#{package_model.guid}" },
10341130
'app' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" },
1131+
'download' => { 'href' => "#{link_prefix}/v3/droplets/#{droplet2.guid}/download", 'experimental' => true },
10351132
'assign_current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/relationships/current_droplet", 'method' => 'PATCH' },
10361133
},
10371134
'metadata' => {

spec/unit/presenters/v3/droplet_presenter_spec.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ module VCAP::CloudController::Presenters::V3
3737
self: { href: "#{link_prefix}/v3/droplets/#{droplet.guid}" },
3838
package: { href: "#{link_prefix}/v3/packages/#{droplet.package_guid}" },
3939
app: { href: "#{link_prefix}/v3/apps/#{droplet.app_guid}" },
40+
download: { href: "#{link_prefix}/v3/droplets/#{droplet.guid}/download", experimental: true },
4041
assign_current_droplet: { href: "#{link_prefix}/v3/apps/#{droplet.app_guid}/relationships/current_droplet", method: 'PATCH' }
4142
}
4243

@@ -169,6 +170,7 @@ module VCAP::CloudController::Presenters::V3
169170
self: { href: "#{link_prefix}/v3/droplets/#{droplet.guid}" },
170171
package: { href: "#{link_prefix}/v3/packages/#{droplet.package_guid}" },
171172
app: { href: "#{link_prefix}/v3/apps/#{droplet.app_guid}" },
173+
download: { href: "#{link_prefix}/v3/droplets/#{droplet.guid}/download", experimental: true },
172174
assign_current_droplet: { href: "#{link_prefix}/v3/apps/#{droplet.app_guid}/relationships/current_droplet", method: 'PATCH' }
173175
}
174176

@@ -282,6 +284,28 @@ module VCAP::CloudController::Presenters::V3
282284
expect(result[:links]).to eq(links)
283285
end
284286
end
287+
context 'when the droplet is STAGED' do
288+
before do
289+
droplet.state = VCAP::CloudController::DropletModel::STAGED_STATE
290+
droplet.save
291+
end
292+
293+
it 'adds the upload link' do
294+
links = {
295+
self: { href: "#{link_prefix}/v3/droplets/#{droplet.guid}" },
296+
package: { href: "#{link_prefix}/v3/packages/#{droplet.package_guid}" },
297+
app: { href: "#{link_prefix}/v3/apps/#{droplet.app_guid}" },
298+
assign_current_droplet: { href: "#{link_prefix}/v3/apps/#{droplet.app_guid}/relationships/current_droplet", method: 'PATCH' },
299+
download: { href: "#{link_prefix}/v3/droplets/#{droplet.guid}/download", experimental: true }
300+
}
301+
302+
expect(result[:guid]).to eq(droplet.guid)
303+
expect(result[:state]).to eq('STAGED')
304+
expect(result[:created_at]).to be_a(Time)
305+
expect(result[:updated_at]).to be_a(Time)
306+
expect(result[:links]).to eq(links)
307+
end
308+
end
285309
end
286310
end
287311
end

0 commit comments

Comments
 (0)