Skip to content

Commit 0810cda

Browse files
committed
Add state_reason in Stack object
See RFC#0045 (Stack Management) Includes state_reason in Stack Validation Error and Warning Signed-off-by: Rashed Kamal <rashed.kamal@broadcom.com>
1 parent 5c3fcad commit 0810cda

File tree

15 files changed

+513
-7
lines changed

15 files changed

+513
-7
lines changed

app/actions/stack_create.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ def create(message)
1313
stack = VCAP::CloudController::Stack.create(
1414
name: message.name,
1515
description: message.description,
16-
state: message.state
16+
state: message.state,
17+
state_reason: message.state_reason
1718
)
1819

1920
MetadataUpdate.update(stack, message)

app/actions/stack_update.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ def initialize(user_audit_info)
1313
def update(stack, message)
1414
stack.db.transaction do
1515
stack.update(state: message.state) if message.requested?(:state)
16+
stack.update(state_reason: message.state_reason) if message.requested?(:state_reason)
1617
MetadataUpdate.update(stack, message)
1718
Repositories::StackEventRepository.new.record_stack_update(stack, @user_audit_info, message.audit_hash)
1819
end

app/messages/stack_create_message.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33

44
module VCAP::CloudController
55
class StackCreateMessage < MetadataBaseMessage
6-
register_allowed_keys %i[name description state]
6+
register_allowed_keys %i[name description state state_reason]
77

88
validates :name, presence: true, length: { maximum: 250 }
99
validates :description, length: { maximum: 250 }
1010
validates :state, inclusion: { in: StackStates::VALID_STATES, message: "must be one of #{StackStates::VALID_STATES.join(', ')}" }, allow_nil: false, if: :state_requested?
11+
validates :state_reason, length: { maximum: 1000 }, allow_nil: true
1112

1213
def state_requested?
1314
requested?(:state)

app/messages/stack_update_message.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,18 @@
33

44
module VCAP::CloudController
55
class StackUpdateMessage < MetadataBaseMessage
6-
register_allowed_keys [:state]
6+
register_allowed_keys %i[state state_reason]
77

88
validates_with NoAdditionalKeysValidator
99
validates :state, inclusion: { in: StackStates::VALID_STATES, message: "must be one of #{StackStates::VALID_STATES.join(', ')}" }, allow_nil: false, if: :state_requested?
10+
validates :state_reason, length: { maximum: 1000 }, allow_nil: true
1011

1112
def state_requested?
1213
requested?(:state)
1314
end
15+
16+
def state_reason_requested?
17+
requested?(:state_reason)
18+
end
1419
end
1520
end

app/presenters/v3/stack_presenter.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ def to_hash
1313
name: stack.name,
1414
description: stack.description,
1515
state: stack.state,
16+
state_reason: stack.state_reason,
1617
run_rootfs_image: stack.run_rootfs_image,
1718
build_rootfs_image: stack.build_rootfs_image,
1819
default: stack.default?,
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
Sequel.migration do
2+
up do
3+
alter_table :stacks do
4+
add_column :state_reason, String, null: true, size: 1000 unless @db.schema(:stacks).map(&:first).include?(:state_reason)
5+
end
6+
end
7+
8+
down do
9+
alter_table :stacks do
10+
drop_column :state_reason if @db.schema(:stacks).map(&:first).include?(:state_reason)
11+
end
12+
end
13+
end

lib/cloud_controller/stack_state_validator.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,15 @@ def self.validate_for_restaging!(stack)
2626
end
2727

2828
def self.build_stack_error(stack, state)
29-
"ERROR: Staging failed. The stack '#{stack.name}' is '#{state}' and cannot be used for staging."
29+
message = "ERROR: Staging failed. The stack '#{stack.name}' is '#{state}' and cannot be used for staging."
30+
message += " #{stack.state_reason}" if stack.state_reason.present?
31+
message
3032
end
3133

3234
def self.build_stack_warning(stack, state)
33-
"WARNING: The stack '#{stack.name}' is '#{state}' and will be removed in the future."
35+
message = "WARNING: The stack '#{stack.name}' is '#{state}' and will be removed in the future."
36+
message += " #{stack.state_reason}" if stack.state_reason.present?
37+
message
3438
end
3539
end
3640
end

spec/request/stacks_spec.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
'build_rootfs_image' => stack1.build_rootfs_image,
2828
'guid' => stack1.guid,
2929
'state' => 'ACTIVE',
30+
'state_reason' => nil,
3031
'default' => false,
3132
'metadata' => { 'labels' => {}, 'annotations' => {} },
3233
'created_at' => iso8601,
@@ -44,6 +45,7 @@
4445
'build_rootfs_image' => stack2.build_rootfs_image,
4546
'guid' => stack2.guid,
4647
'state' => 'ACTIVE',
48+
'state_reason' => nil,
4749
'default' => true,
4850
'metadata' => { 'labels' => {}, 'annotations' => {} },
4951
'created_at' => iso8601,
@@ -125,6 +127,7 @@
125127
'build_rootfs_image' => stack1.build_rootfs_image,
126128
'guid' => stack1.guid,
127129
'state' => 'ACTIVE',
130+
'state_reason' => nil,
128131
'default' => false,
129132
'metadata' => { 'labels' => {}, 'annotations' => {} },
130133
'created_at' => iso8601,
@@ -142,6 +145,7 @@
142145
'build_rootfs_image' => stack2.build_rootfs_image,
143146
'guid' => stack2.guid,
144147
'state' => 'ACTIVE',
148+
'state_reason' => nil,
145149
'default' => true,
146150
'metadata' => { 'labels' => {}, 'annotations' => {} },
147151
'created_at' => iso8601,
@@ -182,6 +186,7 @@
182186
'build_rootfs_image' => stack1.build_rootfs_image,
183187
'guid' => stack1.guid,
184188
'state' => 'ACTIVE',
189+
'state_reason' => nil,
185190
'default' => false,
186191
'metadata' => { 'labels' => {}, 'annotations' => {} },
187192
'created_at' => iso8601,
@@ -199,6 +204,7 @@
199204
'build_rootfs_image' => stack3.build_rootfs_image,
200205
'guid' => stack3.guid,
201206
'state' => 'ACTIVE',
207+
'state_reason' => nil,
202208
'default' => false,
203209
'metadata' => { 'labels' => {}, 'annotations' => {} },
204210
'created_at' => iso8601,
@@ -239,6 +245,7 @@
239245
'build_rootfs_image' => stack2.build_rootfs_image,
240246
'guid' => stack2.guid,
241247
'state' => 'ACTIVE',
248+
'state_reason' => nil,
242249
'default' => true,
243250
'metadata' => { 'labels' => {}, 'annotations' => {} },
244251
'created_at' => iso8601,
@@ -295,6 +302,7 @@
295302
'build_rootfs_image' => stack1.build_rootfs_image,
296303
'guid' => stack1.guid,
297304
'state' => 'ACTIVE',
305+
'state_reason' => nil,
298306
'default' => false,
299307
'metadata' => {
300308
'labels' => {
@@ -332,6 +340,7 @@
332340
'build_rootfs_image' => stack.build_rootfs_image,
333341
'guid' => stack.guid,
334342
'state' => 'ACTIVE',
343+
'state_reason' => nil,
335344
'default' => false,
336345
'metadata' => { 'labels' => {}, 'annotations' => {} },
337346
'created_at' => iso8601,
@@ -680,6 +689,7 @@
680689
},
681690
'guid' => created_stack.guid,
682691
'state' => 'ACTIVE',
692+
'state_reason' => nil,
683693
'created_at' => iso8601,
684694
'updated_at' => iso8601,
685695
'links' => {
@@ -745,6 +755,7 @@
745755
},
746756
'guid' => stack.guid,
747757
'state' => 'ACTIVE',
758+
'state_reason' => nil,
748759
'created_at' => iso8601,
749760
'updated_at' => iso8601,
750761
'links' => {

spec/request/stacks_state_spec.rb

Lines changed: 168 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,48 @@
2727
end
2828
end
2929

30+
context 'when creating stack with state_reason' do
31+
it 'creates stack with state_reason' do
32+
request_body = {
33+
name: 'deprecated-with-reason',
34+
state: 'DEPRECATED',
35+
state_reason: 'This stack will be removed on 2026-12-31'
36+
}.to_json
37+
38+
post '/v3/stacks', request_body, headers
39+
40+
expect(last_response.status).to eq(201)
41+
expect(parsed_response['state']).to eq('DEPRECATED')
42+
expect(parsed_response['state_reason']).to eq('This stack will be removed on 2026-12-31')
43+
end
44+
45+
it 'creates stack without state_reason' do
46+
request_body = {
47+
name: 'active-no-reason',
48+
state: 'ACTIVE'
49+
}.to_json
50+
51+
post '/v3/stacks', request_body, headers
52+
53+
expect(last_response.status).to eq(201)
54+
expect(parsed_response['state']).to eq('ACTIVE')
55+
expect(parsed_response['state_reason']).to be_nil
56+
end
57+
58+
it 'rejects state_reason exceeding maximum length' do
59+
request_body = {
60+
name: 'long-reason-stack',
61+
state: 'DEPRECATED',
62+
state_reason: 'A' * 1001
63+
}.to_json
64+
65+
post '/v3/stacks', request_body, headers
66+
67+
expect(last_response.status).to eq(422)
68+
expect(parsed_response['errors'].first['detail']).to include('is too long')
69+
end
70+
end
71+
3072
context 'when creating stack without state' do
3173
it 'defaults to ACTIVE' do
3274
request_body = {
@@ -165,6 +207,79 @@
165207
end
166208
end
167209

210+
context 'when updating state_reason' do
211+
it 'updates state_reason along with state' do
212+
request_body = {
213+
state: 'DEPRECATED',
214+
state_reason: 'Stack will be removed on 2026-12-31'
215+
}.to_json
216+
217+
patch "/v3/stacks/#{stack.guid}", request_body, headers
218+
219+
expect(last_response.status).to eq(200)
220+
expect(parsed_response['state']).to eq('DEPRECATED')
221+
expect(parsed_response['state_reason']).to eq('Stack will be removed on 2026-12-31')
222+
223+
stack.reload
224+
expect(stack.state_reason).to eq('Stack will be removed on 2026-12-31')
225+
end
226+
227+
it 'updates state_reason independently' do
228+
stack.update(state: 'DEPRECATED')
229+
230+
request_body = {
231+
state_reason: 'Updated reason for deprecation'
232+
}.to_json
233+
234+
patch "/v3/stacks/#{stack.guid}", request_body, headers
235+
236+
expect(last_response.status).to eq(200)
237+
expect(parsed_response['state']).to eq('DEPRECATED')
238+
expect(parsed_response['state_reason']).to eq('Updated reason for deprecation')
239+
end
240+
241+
it 'clears state_reason when set to null' do
242+
stack.update(state: 'DEPRECATED', state_reason: 'Initial reason')
243+
244+
request_body = {
245+
state_reason: nil
246+
}.to_json
247+
248+
patch "/v3/stacks/#{stack.guid}", request_body, headers
249+
250+
expect(last_response.status).to eq(200)
251+
expect(parsed_response['state_reason']).to be_nil
252+
253+
stack.reload
254+
expect(stack.state_reason).to be_nil
255+
end
256+
257+
it 'preserves state_reason when not included in request' do
258+
stack.update(state: 'DEPRECATED', state_reason: 'Existing reason')
259+
260+
request_body = {
261+
state: 'RESTRICTED'
262+
}.to_json
263+
264+
patch "/v3/stacks/#{stack.guid}", request_body, headers
265+
266+
expect(last_response.status).to eq(200)
267+
expect(parsed_response['state']).to eq('RESTRICTED')
268+
expect(parsed_response['state_reason']).to eq('Existing reason')
269+
end
270+
271+
it 'rejects state_reason exceeding maximum length' do
272+
request_body = {
273+
state_reason: 'A' * 1001
274+
}.to_json
275+
276+
patch "/v3/stacks/#{stack.guid}", request_body, headers
277+
278+
expect(last_response.status).to eq(422)
279+
expect(parsed_response['errors'].first['detail']).to include('is too long')
280+
end
281+
end
282+
168283
context 'as non-admin user' do
169284
let(:non_admin_user) { make_user }
170285
let(:non_admin_headers) { headers_for(non_admin_user) }
@@ -190,15 +305,46 @@
190305
expect(last_response.status).to eq(200)
191306
expect(parsed_response['state']).to eq('DEPRECATED')
192307
end
308+
309+
context 'when stack has state_reason' do
310+
let!(:stack_with_reason) do
311+
VCAP::CloudController::Stack.make(
312+
state: 'DEPRECATED',
313+
state_reason: 'EOL on 2026-12-31'
314+
)
315+
end
316+
317+
it 'returns state_reason in response' do
318+
get "/v3/stacks/#{stack_with_reason.guid}", nil, reader_headers
319+
320+
expect(last_response.status).to eq(200)
321+
expect(parsed_response['state']).to eq('DEPRECATED')
322+
expect(parsed_response['state_reason']).to eq('EOL on 2026-12-31')
323+
end
324+
end
325+
326+
context 'when stack has no state_reason' do
327+
let!(:stack_without_reason) do
328+
VCAP::CloudController::Stack.make(state: 'ACTIVE', state_reason: nil)
329+
end
330+
331+
it 'returns null state_reason in response' do
332+
get "/v3/stacks/#{stack_without_reason.guid}", nil, reader_headers
333+
334+
expect(last_response.status).to eq(200)
335+
expect(parsed_response['state']).to eq('ACTIVE')
336+
expect(parsed_response['state_reason']).to be_nil
337+
end
338+
end
193339
end
194340

195341
describe 'GET /v3/stacks' do
196342
before { VCAP::CloudController::Stack.dataset.destroy }
197343

198344
let!(:active_stack) { VCAP::CloudController::Stack.make(name: 'active', state: 'ACTIVE') }
199-
let!(:deprecated_stack) { VCAP::CloudController::Stack.make(name: 'deprecated', state: 'DEPRECATED') }
345+
let!(:deprecated_stack) { VCAP::CloudController::Stack.make(name: 'deprecated', state: 'DEPRECATED', state_reason: 'Deprecated reason') }
200346
let!(:restricted_stack) { VCAP::CloudController::Stack.make(name: 'restricted', state: 'RESTRICTED') }
201-
let!(:disabled_stack) { VCAP::CloudController::Stack.make(name: 'disabled', state: 'DISABLED') }
347+
let!(:disabled_stack) { VCAP::CloudController::Stack.make(name: 'disabled', state: 'DISABLED', state_reason: 'Disabled reason') }
202348

203349
let(:reader_user) { make_user }
204350
let(:reader_headers) { headers_for(reader_user) }
@@ -211,5 +357,25 @@
211357
resources = parsed_response['resources']
212358
expect(resources.pluck('state')).to contain_exactly('ACTIVE', 'DEPRECATED', 'RESTRICTED', 'DISABLED')
213359
end
360+
361+
it 'includes state_reason for stacks that have it' do
362+
get '/v3/stacks', nil, reader_headers
363+
364+
expect(last_response.status).to eq(200)
365+
366+
resources = parsed_response['resources']
367+
368+
deprecated = resources.find { |r| r['name'] == 'deprecated' }
369+
expect(deprecated['state_reason']).to eq('Deprecated reason')
370+
371+
disabled = resources.find { |r| r['name'] == 'disabled' }
372+
expect(disabled['state_reason']).to eq('Disabled reason')
373+
374+
active = resources.find { |r| r['name'] == 'active' }
375+
expect(active['state_reason']).to be_nil
376+
377+
restricted = resources.find { |r| r['name'] == 'restricted' }
378+
expect(restricted['state_reason']).to be_nil
379+
end
214380
end
215381
end

0 commit comments

Comments
 (0)