Skip to content

Commit 12a6644

Browse files
committed
Stack state validation
and integration into app creation and build workflows. Signed-off-by: Rashed Kamal <[email protected]>
1 parent 1b40ad1 commit 12a6644

File tree

8 files changed

+275
-0
lines changed

8 files changed

+275
-0
lines changed

app/actions/build_create.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ def initialize(user_audit_info: UserAuditInfo.from_context(SecurityContext),
4141

4242
def create_and_stage(package:, lifecycle:, metadata: nil, start_after_staging: false)
4343
logger.info("creating build for package #{package.guid}")
44+
warnings = validate_stack_state!(lifecycle, package.app)
4445
staging_in_progress! if package.app.staging_in_progress?
4546
raise InvalidPackage.new('Cannot stage package whose state is not ready.') if package.state != PackageModel::READY_STATE
4647

@@ -60,6 +61,7 @@ def create_and_stage(package:, lifecycle:, metadata: nil, start_after_staging: f
6061
created_by_user_name: @user_audit_info.user_name,
6162
created_by_user_email: @user_audit_info.user_email
6263
)
64+
build.instance_variable_set(:@stack_warnings, warnings)
6365

6466
BuildModel.db.transaction do
6567
build.save
@@ -179,5 +181,29 @@ def stagers
179181
def staging_in_progress!
180182
raise StagingInProgress
181183
end
184+
185+
def validate_stack_state!(lifecycle, app)
186+
return [] if lifecycle.type == Lifecycles::DOCKER
187+
188+
stack = Stack.find(name: lifecycle.staging_stack)
189+
return [] unless stack
190+
191+
warnings = if first_build_for_app?(app)
192+
StackStateValidator.validate_for_new_app!(stack)
193+
else
194+
StackStateValidator.validate_for_restaging!(stack)
195+
end
196+
warnings.each { |warning| logger.warn(warning) }
197+
warnings
198+
rescue StackStateValidator::DisabledStackError, StackStateValidator::RestrictedStackError => e
199+
raise CloudController::Errors::ApiError.new_from_details(
200+
'StackValidationFailed',
201+
e.message
202+
)
203+
end
204+
205+
def first_build_for_app?(app)
206+
app.builds_dataset.count.zero?
207+
end
182208
end
183209
end

app/models/runtime/build_model.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ class BuildModel < Sequel::Model(:builds)
1717
CNBGenericBuildFailed CNBDownloadBuildpackFailed CNBDetectFailed
1818
CNBBuildFailed CNBExportFailed CNBLaunchFailed CNBRestoreFailed].map(&:freeze).freeze
1919

20+
attr_reader :stack_warnings
21+
2022
many_to_one :app,
2123
class: 'VCAP::CloudController::AppModel',
2224
key: :app_guid,

app/presenters/v3/build_presenter.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def to_hash
3030
},
3131
package: { guid: build.package_guid },
3232
droplet: droplet,
33+
warnings: build_warnings,
3334
created_by: {
3435
guid: build.created_by_user_guid,
3536
name: build.created_by_user_name,
@@ -61,6 +62,12 @@ def error
6162
e.presence
6263
end
6364

65+
def build_warnings
66+
return nil unless build.stack_warnings&.any?
67+
68+
build.stack_warnings.map { |warning| { detail: warning } }
69+
end
70+
6471
def build_links
6572
{
6673
self: { href: url_builder.build_url(path: "/v3/builds/#{build.guid}") },

errors/v2.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -863,6 +863,11 @@
863863
http_code: 404
864864
message: "The stack could not be found: %s"
865865

866+
250004:
867+
name: StackValidationFailed
868+
http_code: 422
869+
message: "%s"
870+
866871
260001:
867872
name: ServicePlanVisibilityInvalid
868873
http_code: 400

spec/request/apps_spec.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1900,6 +1900,7 @@
19001900
'droplet' => {
19011901
'guid' => droplet.guid
19021902
},
1903+
'warnings' => nil,
19031904
'relationships' => { 'app' => { 'data' => { 'guid' => app_model.guid } } },
19041905
'metadata' => { 'labels' => {}, 'annotations' => {} },
19051906
'links' => {
@@ -1929,6 +1930,7 @@
19291930
'droplet' => {
19301931
'guid' => second_droplet.guid
19311932
},
1933+
'warnings' => nil,
19321934
'relationships' => { 'app' => { 'data' => { 'guid' => app_model.guid } } },
19331935
'metadata' => { 'labels' => {}, 'annotations' => {} },
19341936
'links' => {

spec/request/builds_spec.rb

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
'guid' => package.guid
9393
},
9494
'droplet' => nil,
95+
'warnings' => nil,
9596
'relationships' => { 'app' => { 'data' => { 'guid' => app_model.guid } } },
9697
'links' => {
9798
'self' => {
@@ -199,6 +200,130 @@
199200
end
200201
end
201202
end
203+
204+
context 'when stack is DISABLED' do
205+
let(:disabled_stack) { VCAP::CloudController::Stack.make(name: 'cflinuxfs3', state: 'DISABLED', description: 'cflinuxfs3 stack is now disabled') }
206+
let(:create_request) do
207+
{
208+
lifecycle: {
209+
type: 'buildpack',
210+
data: {
211+
buildpacks: ['https://github.com/myorg/awesome-buildpack'],
212+
stack: disabled_stack.name
213+
}
214+
},
215+
package: {
216+
guid: package.guid
217+
}
218+
}
219+
end
220+
221+
it 'returns 422 and does not create the build' do
222+
post '/v3/builds', create_request.to_json, developer_headers
223+
224+
expect(last_response.status).to eq(422)
225+
expect(parsed_response['errors'].first['detail']).to include('disabled')
226+
expect(parsed_response['errors'].first['detail']).to include('cannot be used for staging new applications')
227+
expect(VCAP::CloudController::BuildModel.count).to eq(0)
228+
end
229+
end
230+
231+
context 'when stack is RESTRICTED' do
232+
let(:restricted_stack) { VCAP::CloudController::Stack.make(name: 'cflinuxfs3', state: 'RESTRICTED', description: 'No new apps') }
233+
let(:create_request) do
234+
{
235+
lifecycle: {
236+
type: 'buildpack',
237+
data: {
238+
buildpacks: ['http://github.com/myorg/awesome-buildpack'],
239+
stack: restricted_stack.name
240+
}
241+
},
242+
package: {
243+
guid: package.guid
244+
}
245+
}
246+
end
247+
248+
context 'first build for app' do
249+
it 'returns 422 and does not create build' do
250+
expect(app_model.builds_dataset.count).to eq(0)
251+
252+
post '/v3/builds', create_request.to_json, developer_headers
253+
254+
expect(last_response.status).to eq(422)
255+
expect(parsed_response['errors'].first['detail']).to include('cannot be used for staging new applications')
256+
expect(VCAP::CloudController::BuildModel.count).to eq(0)
257+
end
258+
end
259+
260+
context 'app has previous builds' do
261+
before do
262+
VCAP::CloudController::BuildModel.make(app: app_model, state: VCAP::CloudController::BuildModel::STAGED_STATE)
263+
end
264+
265+
it 'returns 201 and creates build' do
266+
expect(app_model.builds_dataset.count).to eq(1)
267+
268+
post '/v3/builds', create_request.to_json, developer_headers
269+
270+
expect(last_response.status).to eq(201)
271+
expect(parsed_response['state']).to eq('STAGING')
272+
expect(app_model.builds_dataset.count).to eq(2)
273+
end
274+
end
275+
end
276+
277+
context 'when stack is DEPRECATED' do
278+
let(:deprecated_stack) { VCAP::CloudController::Stack.make(name: 'cflinuxfs3', state: 'DEPRECATED', description: 'cflinuxfs3 stack is deprecated. Please migrate your application to cflinuxfs4') }
279+
let(:create_request) do
280+
{
281+
lifecycle: {
282+
type: 'buildpack',
283+
data: {
284+
buildpacks: ['http://github.com/myorg/awesome-buildpack'],
285+
stack: deprecated_stack.name
286+
}
287+
},
288+
package: {
289+
guid: package.guid
290+
}
291+
}
292+
end
293+
294+
context 'first build for app' do
295+
it 'returns 201 and does not create the build' do
296+
expect(app_model.builds_dataset.count).to eq(0)
297+
298+
post '/v3/builds', create_request.to_json, developer_headers
299+
300+
expect(last_response.status).to eq(201)
301+
expect(parsed_response['state']).to eq('STAGING')
302+
expect(parsed_response['warnings']).to be_present
303+
expect(parsed_response['warnings'][0]['detail']).to include('deprecated')
304+
expect(parsed_response['warnings'][0]['detail']).to include('cflinuxfs3 stack is deprecated')
305+
end
306+
end
307+
308+
context 'app has previous builds' do
309+
before do
310+
VCAP::CloudController::BuildModel.make(app: app_model, state: VCAP::CloudController::BuildModel::STAGED_STATE)
311+
end
312+
313+
it 'returns 201 and does not create the build' do
314+
expect(app_model.builds_dataset.count).to eq(1)
315+
316+
post '/v3/builds', create_request.to_json, developer_headers
317+
318+
expect(last_response.status).to eq(201)
319+
expect(parsed_response['state']).to eq('STAGING')
320+
expect(parsed_response['warnings']).to be_present
321+
expect(parsed_response['warnings'][0]['detail']).to include('deprecated')
322+
expect(parsed_response['warnings'][0]['detail']).to include('cflinuxfs3 stack is deprecated')
323+
expect(app_model.builds_dataset.count).to eq(2)
324+
end
325+
end
326+
end
202327
end
203328

204329
describe 'GET /v3/builds' do
@@ -349,6 +474,7 @@
349474
'droplet' => {
350475
'guid' => droplet.guid
351476
},
477+
'warnings' => nil,
352478
'relationships' => { 'app' => { 'data' => { 'guid' => app_model.guid } } },
353479
'metadata' => { 'labels' => {}, 'annotations' => {} },
354480
'links' => {
@@ -378,6 +504,7 @@
378504
'droplet' => {
379505
'guid' => second_droplet.guid
380506
},
507+
'warnings' => nil,
381508
'relationships' => { 'app' => { 'data' => { 'guid' => app_model.guid } } },
382509
'metadata' => { 'labels' => {}, 'annotations' => {} },
383510
'links' => {
@@ -480,6 +607,7 @@
480607
'droplet' => {
481608
'guid' => droplet.guid
482609
},
610+
'warnings' => nil,
483611
'metadata' => { 'labels' => {}, 'annotations' => {} },
484612
'relationships' => { 'app' => { 'data' => { 'guid' => app_model.guid } } },
485613
'links' => {

spec/unit/actions/build_create_spec.rb

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,84 @@ module VCAP::CloudController
452452
end
453453
end
454454

455+
context 'when stack is DISABLED' do
456+
let(:disabled_stack) { Stack.make(name: 'cflinuxfs3', state: 'DISABLED', description: 'Migrate to cflinuxfs4') }
457+
let(:lifecycle_data) do
458+
{
459+
stack: disabled_stack.name,
460+
buildpacks: [buildpack_git_url]
461+
}
462+
end
463+
464+
it 'raises StackValidationFailed error for staging' do
465+
expect do
466+
action.create_and_stage(package:, lifecycle:)
467+
end.to raise_error(CloudController::Errors::ApiError) do |error|
468+
expect(error.name).to eq('StackValidationFailed')
469+
expect(error.message).to include('disabled')
470+
expect(error.message).to include('cannot be used for staging new applications')
471+
end
472+
end
473+
474+
it 'does not create any DB records' do
475+
expect do
476+
action.create_and_stage(package:, lifecycle:)
477+
rescue StandardError
478+
nil
479+
end.not_to(change { [BuildModel.count, BuildpackLifecycleDataModel.count, AppUsageEvent.count, Event.count] })
480+
end
481+
end
482+
483+
context 'when stack is RESTRICTED' do
484+
let(:restricted_stack) { Stack.make(name: 'cflinuxfs3', state: 'RESTRICTED', description: 'No new apps') }
485+
let(:lifecycle_data) do
486+
{
487+
stack: restricted_stack.name,
488+
buildpacks: [buildpack_git_url]
489+
}
490+
end
491+
492+
context 'build for new app' do
493+
it 'raises StackValidationFailed error' do
494+
expect(app.builds_dataset.count).to eq(0)
495+
expect do
496+
action.create_and_stage(package:, lifecycle:)
497+
end.to raise_error(CloudController::Errors::ApiError) do |error|
498+
expect(error.name).to eq('StackValidationFailed')
499+
expect(error.message).to include('annot be used for staging new applications')
500+
end
501+
end
502+
503+
it 'does not create any DB records' do
504+
expect do
505+
action.create_and_stage(package:, lifecycle:)
506+
rescue StandardError
507+
nil
508+
end.not_to(change { [BuildModel.count, BuildpackLifecycleDataModel.count, AppUsageEvent.count, Event.count] })
509+
end
510+
end
511+
512+
context 'app has previous builds' do
513+
before do
514+
BuildModel.make(app: app, state: BuildModel::STAGED_STATE)
515+
end
516+
517+
it 'allows restaging' do
518+
expect(app.builds_dataset.count).to eq(1)
519+
expect do
520+
action.create_and_stage(package:, lifecycle:)
521+
end.not_to raise_error
522+
end
523+
524+
it 'creates build successfully' do
525+
build = action.create_and_stage(package:, lifecycle:)
526+
expect(build.id).not_to be_nil
527+
expect(build.state).to eq(BuildModel::STAGING_STATE)
528+
expect(app.builds_dataset.count).to eq(2)
529+
end
530+
end
531+
end
532+
455533
context 'when there is already a staging in progress for the app' do
456534
it 'raises a StagingInProgress exception' do
457535
BuildModel.make(state: BuildModel::STAGING_STATE, app: app)

spec/unit/presenters/v3/build_presenter_spec.rb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,33 @@ module VCAP::CloudController::Presenters::V3
158158
expect(result[:package][:guid]).to eq(@package_guid)
159159
end
160160
end
161+
162+
context 'when stack has warnings' do
163+
before do
164+
build.instance_variable_set(:@stack_warnings, ['Stack cflinuxfs3 is deprecated. EOL Dec 2025'])
165+
end
166+
167+
it 'includes warnings in response' do
168+
expect(result[:warnings]).to be_present
169+
expect(result[:warnings]).to eq([{ detail: 'Stack cflinuxfs3 is deprecated. EOL Dec 2025' }])
170+
end
171+
end
172+
173+
context 'when stack has no warnings' do
174+
before do
175+
build.instance_variable_set(:@stack_warnings, [])
176+
end
177+
178+
it 'returns nil for warnings' do
179+
expect(result[:warnings]).to be_nil
180+
end
181+
end
182+
183+
context 'when stack warnings is nil' do
184+
it 'returns nil for warnings' do
185+
expect(result[:warnings]).to be_nil
186+
end
187+
end
161188
end
162189
end
163190
end

0 commit comments

Comments
 (0)