From cbfdf80680e066df73dd5b4af4bc44584c7f09a5 Mon Sep 17 00:00:00 2001 From: Rashed Kamal Date: Mon, 2 Feb 2026 15:23:26 -0500 Subject: [PATCH 1/5] 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 --- app/actions/stack_create.rb | 3 +- app/actions/stack_update.rb | 1 + app/messages/stack_create_message.rb | 3 +- app/messages/stack_update_message.rb | 7 +- app/presenters/v3/stack_presenter.rb | 1 + ...260202105042_add_state_reason_to_stacks.rb | 13 ++ lib/cloud_controller/stack_state_validator.rb | 8 +- spec/request/stacks_spec.rb | 11 ++ spec/request/stacks_state_spec.rb | 170 +++++++++++++++++- spec/unit/actions/stack_create_spec.rb | 25 +++ spec/unit/actions/stack_update_spec.rb | 40 +++++ .../stack_state_validator_spec.rb | 106 +++++++++++ .../messages/stack_create_message_spec.rb | 47 +++++ .../messages/stack_update_message_spec.rb | 58 ++++++ .../presenters/v3/stack_presenter_spec.rb | 27 +++ 15 files changed, 513 insertions(+), 7 deletions(-) create mode 100644 db/migrations/20260202105042_add_state_reason_to_stacks.rb diff --git a/app/actions/stack_create.rb b/app/actions/stack_create.rb index 142d780da42..88dce7dcd39 100644 --- a/app/actions/stack_create.rb +++ b/app/actions/stack_create.rb @@ -13,7 +13,8 @@ def create(message) stack = VCAP::CloudController::Stack.create( name: message.name, description: message.description, - state: message.state + state: message.state, + state_reason: message.state_reason ) MetadataUpdate.update(stack, message) diff --git a/app/actions/stack_update.rb b/app/actions/stack_update.rb index 41fb244724c..91fcf2d3ec2 100644 --- a/app/actions/stack_update.rb +++ b/app/actions/stack_update.rb @@ -13,6 +13,7 @@ def initialize(user_audit_info) def update(stack, message) stack.db.transaction do stack.update(state: message.state) if message.requested?(:state) + stack.update(state_reason: message.state_reason) if message.requested?(:state_reason) MetadataUpdate.update(stack, message) Repositories::StackEventRepository.new.record_stack_update(stack, @user_audit_info, message.audit_hash) end diff --git a/app/messages/stack_create_message.rb b/app/messages/stack_create_message.rb index c9f8a91a32c..4c5fb5bca1c 100644 --- a/app/messages/stack_create_message.rb +++ b/app/messages/stack_create_message.rb @@ -3,11 +3,12 @@ module VCAP::CloudController class StackCreateMessage < MetadataBaseMessage - register_allowed_keys %i[name description state] + register_allowed_keys %i[name description state state_reason] validates :name, presence: true, length: { maximum: 250 } validates :description, length: { maximum: 250 } validates :state, inclusion: { in: StackStates::VALID_STATES, message: "must be one of #{StackStates::VALID_STATES.join(', ')}" }, allow_nil: false, if: :state_requested? + validates :state_reason, length: { maximum: 1000 }, allow_nil: true def state_requested? requested?(:state) diff --git a/app/messages/stack_update_message.rb b/app/messages/stack_update_message.rb index ecd98baf693..c078fb88773 100644 --- a/app/messages/stack_update_message.rb +++ b/app/messages/stack_update_message.rb @@ -3,13 +3,18 @@ module VCAP::CloudController class StackUpdateMessage < MetadataBaseMessage - register_allowed_keys [:state] + register_allowed_keys %i[state state_reason] validates_with NoAdditionalKeysValidator validates :state, inclusion: { in: StackStates::VALID_STATES, message: "must be one of #{StackStates::VALID_STATES.join(', ')}" }, allow_nil: false, if: :state_requested? + validates :state_reason, length: { maximum: 1000 }, allow_nil: true def state_requested? requested?(:state) end + + def state_reason_requested? + requested?(:state_reason) + end end end diff --git a/app/presenters/v3/stack_presenter.rb b/app/presenters/v3/stack_presenter.rb index a053eb69f80..f12113af033 100644 --- a/app/presenters/v3/stack_presenter.rb +++ b/app/presenters/v3/stack_presenter.rb @@ -13,6 +13,7 @@ def to_hash name: stack.name, description: stack.description, state: stack.state, + state_reason: stack.state_reason, run_rootfs_image: stack.run_rootfs_image, build_rootfs_image: stack.build_rootfs_image, default: stack.default?, diff --git a/db/migrations/20260202105042_add_state_reason_to_stacks.rb b/db/migrations/20260202105042_add_state_reason_to_stacks.rb new file mode 100644 index 00000000000..f06e64e40ae --- /dev/null +++ b/db/migrations/20260202105042_add_state_reason_to_stacks.rb @@ -0,0 +1,13 @@ +Sequel.migration do + up do + alter_table :stacks do + add_column :state_reason, String, null: true, size: 1000 unless @db.schema(:stacks).map(&:first).include?(:state_reason) + end + end + + down do + alter_table :stacks do + drop_column :state_reason if @db.schema(:stacks).map(&:first).include?(:state_reason) + end + end +end diff --git a/lib/cloud_controller/stack_state_validator.rb b/lib/cloud_controller/stack_state_validator.rb index b3a56391c3c..33f0a48bd42 100644 --- a/lib/cloud_controller/stack_state_validator.rb +++ b/lib/cloud_controller/stack_state_validator.rb @@ -26,11 +26,15 @@ def self.validate_for_restaging!(stack) end def self.build_stack_error(stack, state) - "ERROR: Staging failed. The stack '#{stack.name}' is '#{state}' and cannot be used for staging." + message = "ERROR: Staging failed. The stack '#{stack.name}' is '#{state}' and cannot be used for staging." + message += " #{stack.state_reason}" if stack.state_reason.present? + message end def self.build_stack_warning(stack, state) - "WARNING: The stack '#{stack.name}' is '#{state}' and will be removed in the future." + message = "WARNING: The stack '#{stack.name}' is '#{state}' and will be removed in the future." + message += " #{stack.state_reason}" if stack.state_reason.present? + message end end end diff --git a/spec/request/stacks_spec.rb b/spec/request/stacks_spec.rb index f348e1bdfab..644ce145ea7 100644 --- a/spec/request/stacks_spec.rb +++ b/spec/request/stacks_spec.rb @@ -27,6 +27,7 @@ 'build_rootfs_image' => stack1.build_rootfs_image, 'guid' => stack1.guid, 'state' => 'ACTIVE', + 'state_reason' => nil, 'default' => false, 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, @@ -44,6 +45,7 @@ 'build_rootfs_image' => stack2.build_rootfs_image, 'guid' => stack2.guid, 'state' => 'ACTIVE', + 'state_reason' => nil, 'default' => true, 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, @@ -125,6 +127,7 @@ 'build_rootfs_image' => stack1.build_rootfs_image, 'guid' => stack1.guid, 'state' => 'ACTIVE', + 'state_reason' => nil, 'default' => false, 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, @@ -142,6 +145,7 @@ 'build_rootfs_image' => stack2.build_rootfs_image, 'guid' => stack2.guid, 'state' => 'ACTIVE', + 'state_reason' => nil, 'default' => true, 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, @@ -182,6 +186,7 @@ 'build_rootfs_image' => stack1.build_rootfs_image, 'guid' => stack1.guid, 'state' => 'ACTIVE', + 'state_reason' => nil, 'default' => false, 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, @@ -199,6 +204,7 @@ 'build_rootfs_image' => stack3.build_rootfs_image, 'guid' => stack3.guid, 'state' => 'ACTIVE', + 'state_reason' => nil, 'default' => false, 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, @@ -239,6 +245,7 @@ 'build_rootfs_image' => stack2.build_rootfs_image, 'guid' => stack2.guid, 'state' => 'ACTIVE', + 'state_reason' => nil, 'default' => true, 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, @@ -295,6 +302,7 @@ 'build_rootfs_image' => stack1.build_rootfs_image, 'guid' => stack1.guid, 'state' => 'ACTIVE', + 'state_reason' => nil, 'default' => false, 'metadata' => { 'labels' => { @@ -332,6 +340,7 @@ 'build_rootfs_image' => stack.build_rootfs_image, 'guid' => stack.guid, 'state' => 'ACTIVE', + 'state_reason' => nil, 'default' => false, 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, @@ -680,6 +689,7 @@ }, 'guid' => created_stack.guid, 'state' => 'ACTIVE', + 'state_reason' => nil, 'created_at' => iso8601, 'updated_at' => iso8601, 'links' => { @@ -745,6 +755,7 @@ }, 'guid' => stack.guid, 'state' => 'ACTIVE', + 'state_reason' => nil, 'created_at' => iso8601, 'updated_at' => iso8601, 'links' => { diff --git a/spec/request/stacks_state_spec.rb b/spec/request/stacks_state_spec.rb index 07b3257ec1e..9c753d2ca72 100644 --- a/spec/request/stacks_state_spec.rb +++ b/spec/request/stacks_state_spec.rb @@ -27,6 +27,48 @@ end end + context 'when creating stack with state_reason' do + it 'creates stack with state_reason' do + request_body = { + name: 'deprecated-with-reason', + state: 'DEPRECATED', + state_reason: 'This stack will be removed on 2026-12-31' + }.to_json + + post '/v3/stacks', request_body, headers + + expect(last_response.status).to eq(201) + expect(parsed_response['state']).to eq('DEPRECATED') + expect(parsed_response['state_reason']).to eq('This stack will be removed on 2026-12-31') + end + + it 'creates stack without state_reason' do + request_body = { + name: 'active-no-reason', + state: 'ACTIVE' + }.to_json + + post '/v3/stacks', request_body, headers + + expect(last_response.status).to eq(201) + expect(parsed_response['state']).to eq('ACTIVE') + expect(parsed_response['state_reason']).to be_nil + end + + it 'rejects state_reason exceeding maximum length' do + request_body = { + name: 'long-reason-stack', + state: 'DEPRECATED', + state_reason: 'A' * 1001 + }.to_json + + post '/v3/stacks', request_body, headers + + expect(last_response.status).to eq(422) + expect(parsed_response['errors'].first['detail']).to include('is too long') + end + end + context 'when creating stack without state' do it 'defaults to ACTIVE' do request_body = { @@ -165,6 +207,79 @@ end end + context 'when updating state_reason' do + it 'updates state_reason along with state' do + request_body = { + state: 'DEPRECATED', + state_reason: 'Stack will be removed on 2026-12-31' + }.to_json + + patch "/v3/stacks/#{stack.guid}", request_body, headers + + expect(last_response.status).to eq(200) + expect(parsed_response['state']).to eq('DEPRECATED') + expect(parsed_response['state_reason']).to eq('Stack will be removed on 2026-12-31') + + stack.reload + expect(stack.state_reason).to eq('Stack will be removed on 2026-12-31') + end + + it 'updates state_reason independently' do + stack.update(state: 'DEPRECATED') + + request_body = { + state_reason: 'Updated reason for deprecation' + }.to_json + + patch "/v3/stacks/#{stack.guid}", request_body, headers + + expect(last_response.status).to eq(200) + expect(parsed_response['state']).to eq('DEPRECATED') + expect(parsed_response['state_reason']).to eq('Updated reason for deprecation') + end + + it 'clears state_reason when set to null' do + stack.update(state: 'DEPRECATED', state_reason: 'Initial reason') + + request_body = { + state_reason: nil + }.to_json + + patch "/v3/stacks/#{stack.guid}", request_body, headers + + expect(last_response.status).to eq(200) + expect(parsed_response['state_reason']).to be_nil + + stack.reload + expect(stack.state_reason).to be_nil + end + + it 'preserves state_reason when not included in request' do + stack.update(state: 'DEPRECATED', state_reason: 'Existing reason') + + request_body = { + state: 'RESTRICTED' + }.to_json + + patch "/v3/stacks/#{stack.guid}", request_body, headers + + expect(last_response.status).to eq(200) + expect(parsed_response['state']).to eq('RESTRICTED') + expect(parsed_response['state_reason']).to eq('Existing reason') + end + + it 'rejects state_reason exceeding maximum length' do + request_body = { + state_reason: 'A' * 1001 + }.to_json + + patch "/v3/stacks/#{stack.guid}", request_body, headers + + expect(last_response.status).to eq(422) + expect(parsed_response['errors'].first['detail']).to include('is too long') + end + end + context 'as non-admin user' do let(:non_admin_user) { make_user } let(:non_admin_headers) { headers_for(non_admin_user) } @@ -190,15 +305,46 @@ expect(last_response.status).to eq(200) expect(parsed_response['state']).to eq('DEPRECATED') end + + context 'when stack has state_reason' do + let!(:stack_with_reason) do + VCAP::CloudController::Stack.make( + state: 'DEPRECATED', + state_reason: 'EOL on 2026-12-31' + ) + end + + it 'returns state_reason in response' do + get "/v3/stacks/#{stack_with_reason.guid}", nil, reader_headers + + expect(last_response.status).to eq(200) + expect(parsed_response['state']).to eq('DEPRECATED') + expect(parsed_response['state_reason']).to eq('EOL on 2026-12-31') + end + end + + context 'when stack has no state_reason' do + let!(:stack_without_reason) do + VCAP::CloudController::Stack.make(state: 'ACTIVE', state_reason: nil) + end + + it 'returns null state_reason in response' do + get "/v3/stacks/#{stack_without_reason.guid}", nil, reader_headers + + expect(last_response.status).to eq(200) + expect(parsed_response['state']).to eq('ACTIVE') + expect(parsed_response['state_reason']).to be_nil + end + end end describe 'GET /v3/stacks' do before { VCAP::CloudController::Stack.dataset.destroy } let!(:active_stack) { VCAP::CloudController::Stack.make(name: 'active', state: 'ACTIVE') } - let!(:deprecated_stack) { VCAP::CloudController::Stack.make(name: 'deprecated', state: 'DEPRECATED') } + let!(:deprecated_stack) { VCAP::CloudController::Stack.make(name: 'deprecated', state: 'DEPRECATED', state_reason: 'Deprecated reason') } let!(:restricted_stack) { VCAP::CloudController::Stack.make(name: 'restricted', state: 'RESTRICTED') } - let!(:disabled_stack) { VCAP::CloudController::Stack.make(name: 'disabled', state: 'DISABLED') } + let!(:disabled_stack) { VCAP::CloudController::Stack.make(name: 'disabled', state: 'DISABLED', state_reason: 'Disabled reason') } let(:reader_user) { make_user } let(:reader_headers) { headers_for(reader_user) } @@ -211,5 +357,25 @@ resources = parsed_response['resources'] expect(resources.pluck('state')).to contain_exactly('ACTIVE', 'DEPRECATED', 'RESTRICTED', 'DISABLED') end + + it 'includes state_reason for stacks that have it' do + get '/v3/stacks', nil, reader_headers + + expect(last_response.status).to eq(200) + + resources = parsed_response['resources'] + + deprecated = resources.find { |r| r['name'] == 'deprecated' } + expect(deprecated['state_reason']).to eq('Deprecated reason') + + disabled = resources.find { |r| r['name'] == 'disabled' } + expect(disabled['state_reason']).to eq('Disabled reason') + + active = resources.find { |r| r['name'] == 'active' } + expect(active['state_reason']).to be_nil + + restricted = resources.find { |r| r['name'] == 'restricted' } + expect(restricted['state_reason']).to be_nil + end end end diff --git a/spec/unit/actions/stack_create_spec.rb b/spec/unit/actions/stack_create_spec.rb index e554e431da8..156019fd567 100644 --- a/spec/unit/actions/stack_create_spec.rb +++ b/spec/unit/actions/stack_create_spec.rb @@ -41,6 +41,31 @@ module VCAP::CloudController ) end + it 'creates a stack with state_reason' do + message = VCAP::CloudController::StackCreateMessage.new( + name: 'deprecated-stack', + state: 'DEPRECATED', + state_reason: 'This stack will be removed on 2026-12-31' + ) + stack = stack_create.create(message) + + expect(stack.name).to eq('deprecated-stack') + expect(stack.state).to eq('DEPRECATED') + expect(stack.state_reason).to eq('This stack will be removed on 2026-12-31') + end + + it 'creates a stack without state_reason' do + message = VCAP::CloudController::StackCreateMessage.new( + name: 'active-stack', + state: 'ACTIVE' + ) + stack = stack_create.create(message) + + expect(stack.name).to eq('active-stack') + expect(stack.state).to eq('ACTIVE') + expect(stack.state_reason).to be_nil + end + it 'creates an audit event' do message = VCAP::CloudController::StackCreateMessage.new( name: 'my-stack', diff --git a/spec/unit/actions/stack_update_spec.rb b/spec/unit/actions/stack_update_spec.rb index 383d4eb1179..59718985458 100644 --- a/spec/unit/actions/stack_update_spec.rb +++ b/spec/unit/actions/stack_update_spec.rb @@ -55,5 +55,45 @@ module VCAP::CloudController expect(stack_update_event.timestamp).to be end end + + context 'when updating state_reason' do + let(:stack) { Stack.make(state: 'ACTIVE') } + + it 'updates state_reason when provided' do + message = StackUpdateMessage.new({ + state: 'DEPRECATED', + state_reason: 'This stack will be removed on 2026-12-31' + }) + + stack_update.update(stack, message) + stack.reload + + expect(stack.state).to eq('DEPRECATED') + expect(stack.state_reason).to eq('This stack will be removed on 2026-12-31') + end + + it 'clears state_reason when set to nil' do + stack.update(state_reason: 'Old reason') + + message = StackUpdateMessage.new({ state_reason: nil }) + + stack_update.update(stack, message) + stack.reload + + expect(stack.state_reason).to be_nil + end + + it 'preserves state_reason when not requested' do + stack.update(state_reason: 'Existing reason') + + message = StackUpdateMessage.new({ state: 'RESTRICTED' }) + + stack_update.update(stack, message) + stack.reload + + expect(stack.state).to eq('RESTRICTED') + expect(stack.state_reason).to eq('Existing reason') + end + end end end diff --git a/spec/unit/lib/cloud_controller/stack_state_validator_spec.rb b/spec/unit/lib/cloud_controller/stack_state_validator_spec.rb index d0ef7315836..ebb239b89c7 100644 --- a/spec/unit/lib/cloud_controller/stack_state_validator_spec.rb +++ b/spec/unit/lib/cloud_controller/stack_state_validator_spec.rb @@ -168,6 +168,112 @@ module VCAP::CloudController end end + describe 'state_reason in messages' do + describe '.build_stack_warning' do + context 'when state_reason is present' do + let(:stack) { Stack.make(name: 'old-stack', state: StackStates::STACK_DEPRECATED, state_reason: 'EOL on 2026-12-31') } + + it 'includes state_reason in warning message' do + warning = StackStateValidator.build_stack_warning(stack, StackStates::STACK_DEPRECATED) + expect(warning).to include('old-stack') + expect(warning).to include('DEPRECATED') + expect(warning).to include('EOL on 2026-12-31') + end + end + + context 'when state_reason is nil' do + let(:stack) { Stack.make(name: 'old-stack', state: StackStates::STACK_DEPRECATED, state_reason: nil) } + + it 'does not append state_reason to warning message' do + warning = StackStateValidator.build_stack_warning(stack, StackStates::STACK_DEPRECATED) + expect(warning).to include('old-stack') + expect(warning).to include('DEPRECATED') + expect(warning).to end_with('will be removed in the future.') + end + end + + context 'when state_reason is empty string' do + let(:stack) { Stack.make(name: 'old-stack', state: StackStates::STACK_DEPRECATED, state_reason: '') } + + it 'does not append state_reason to warning message' do + warning = StackStateValidator.build_stack_warning(stack, StackStates::STACK_DEPRECATED) + expect(warning).to end_with('will be removed in the future.') + end + end + end + + describe '.build_stack_error' do + context 'when state_reason is present' do + let(:stack) { Stack.make(name: 'disabled-stack', state: StackStates::STACK_DISABLED, state_reason: 'Security vulnerability') } + + it 'includes state_reason in error message' do + error = StackStateValidator.build_stack_error(stack, StackStates::STACK_DISABLED) + expect(error).to include('disabled-stack') + expect(error).to include('DISABLED') + expect(error).to include('Security vulnerability') + end + end + + context 'when state_reason is nil' do + let(:stack) { Stack.make(name: 'disabled-stack', state: StackStates::STACK_DISABLED, state_reason: nil) } + + it 'does not append state_reason to error message' do + error = StackStateValidator.build_stack_error(stack, StackStates::STACK_DISABLED) + expect(error).to include('disabled-stack') + expect(error).to include('DISABLED') + expect(error).to end_with('cannot be used for staging.') + end + end + end + + describe 'integration with validation methods' do + context 'when deprecated stack has state_reason' do + let(:stack) { Stack.make(state: StackStates::STACK_DEPRECATED, state_reason: 'Use cflinuxfs5 instead') } + + it 'includes state_reason in warning for new app' do + warnings = StackStateValidator.validate_for_new_app!(stack) + expect(warnings.first).to include('Use cflinuxfs5 instead') + end + + it 'includes state_reason in warning for restaging' do + warnings = StackStateValidator.validate_for_restaging!(stack) + expect(warnings.first).to include('Use cflinuxfs5 instead') + end + end + + context 'when disabled stack has state_reason' do + let(:stack) { Stack.make(state: StackStates::STACK_DISABLED, state_reason: 'Critical security issue') } + + it 'includes state_reason in error for new app' do + expect do + StackStateValidator.validate_for_new_app!(stack) + end.to raise_error(StackStateValidator::DisabledStackError, /Critical security issue/) + end + + it 'includes state_reason in error for restaging' do + expect do + StackStateValidator.validate_for_restaging!(stack) + end.to raise_error(StackStateValidator::DisabledStackError, /Critical security issue/) + end + end + + context 'when restricted stack has state_reason' do + let(:stack) { Stack.make(state: StackStates::STACK_RESTRICTED, state_reason: 'Limited availability') } + + it 'includes state_reason in error for new app' do + expect do + StackStateValidator.validate_for_new_app!(stack) + end.to raise_error(StackStateValidator::RestrictedStackError, /Limited availability/) + end + + it 'includes state_reason in warning for restaging' do + warnings = StackStateValidator.validate_for_restaging!(stack) + expect(warnings.first).to include('Limited availability') + end + end + end + end + describe 'state behavior matrix' do let(:active_stack) { Stack.make(state: StackStates::STACK_ACTIVE) } let(:deprecated_stack) { Stack.make(state: StackStates::STACK_DEPRECATED) } diff --git a/spec/unit/messages/stack_create_message_spec.rb b/spec/unit/messages/stack_create_message_spec.rb index a8383b86c3c..17548aa3261 100644 --- a/spec/unit/messages/stack_create_message_spec.rb +++ b/spec/unit/messages/stack_create_message_spec.rb @@ -122,5 +122,52 @@ end end end + + describe 'state_reason' do + MAX_STATE_REASON_LENGTH = 1000 + + context 'when it is not provided' do + let(:params) { valid_params } + + it 'is valid' do + expect(subject).to be_valid + expect(subject.state_reason).to be_nil + end + end + + context 'when it is provided' do + let(:params) { valid_params.merge({ state_reason: 'Stack will be removed on 2026-12-31' }) } + + it 'is valid' do + expect(subject).to be_valid + expect(subject.state_reason).to eq('Stack will be removed on 2026-12-31') + end + end + + context 'when it is explicitly null' do + let(:params) { valid_params.merge({ state_reason: nil }) } + + it 'is valid' do + expect(subject).to be_valid + end + end + + context 'when it is at maximum length' do + let(:params) { valid_params.merge({ state_reason: 'A' * MAX_STATE_REASON_LENGTH }) } + + it 'is valid' do + expect(subject).to be_valid + end + end + + context 'when it exceeds maximum length' do + let(:params) { valid_params.merge({ state_reason: 'A' * (MAX_STATE_REASON_LENGTH + 1) }) } + + it 'returns an error' do + expect(subject).not_to be_valid + expect(subject.errors[:state_reason]).to eq ["is too long (maximum is #{MAX_STATE_REASON_LENGTH} characters)"] + end + end + end end end diff --git a/spec/unit/messages/stack_update_message_spec.rb b/spec/unit/messages/stack_update_message_spec.rb index eae64b1caac..342a57a36b1 100644 --- a/spec/unit/messages/stack_update_message_spec.rb +++ b/spec/unit/messages/stack_update_message_spec.rb @@ -62,5 +62,63 @@ end end end + + describe 'state_reason' do + MAX_STATE_REASON_LENGTH = 1000 + + context 'when it is not provided' do + let(:params) { valid_params } + + it 'is valid' do + expect(subject).to be_valid + end + + it 'reports state_reason not requested' do + expect(subject.state_reason_requested?).to be false + end + end + + context 'when it is provided' do + let(:params) { valid_params.merge({ state_reason: 'Stack will be removed on 2026-12-31' }) } + + it 'is valid' do + expect(subject).to be_valid + expect(subject.state_reason).to eq('Stack will be removed on 2026-12-31') + end + + it 'reports state_reason requested' do + expect(subject.state_reason_requested?).to be true + end + end + + context 'when it is explicitly null' do + let(:params) { valid_params.merge({ state_reason: nil }) } + + it 'is valid' do + expect(subject).to be_valid + end + + it 'reports state_reason requested' do + expect(subject.state_reason_requested?).to be true + end + end + + context 'when it is at maximum length' do + let(:params) { valid_params.merge({ state_reason: 'A' * MAX_STATE_REASON_LENGTH }) } + + it 'is valid' do + expect(subject).to be_valid + end + end + + context 'when it exceeds maximum length' do + let(:params) { valid_params.merge({ state_reason: 'A' * (MAX_STATE_REASON_LENGTH + 1) }) } + + it 'returns an error' do + expect(subject).not_to be_valid + expect(subject.errors[:state_reason]).to eq ["is too long (maximum is #{MAX_STATE_REASON_LENGTH} characters)"] + end + end + end end end diff --git a/spec/unit/presenters/v3/stack_presenter_spec.rb b/spec/unit/presenters/v3/stack_presenter_spec.rb index 3bee41c03d6..29cebac77ad 100644 --- a/spec/unit/presenters/v3/stack_presenter_spec.rb +++ b/spec/unit/presenters/v3/stack_presenter_spec.rb @@ -52,6 +52,8 @@ expect(result[:updated_at]).to eq(stack.updated_at) expect(result[:name]).to eq(stack.name) expect(result[:description]).to eq(stack.description) + expect(result[:state]).to eq(stack.state) + expect(result[:state_reason]).to eq(stack.state_reason) expect(result[:run_rootfs_image]).to eq(stack.run_rootfs_image) expect(result[:build_rootfs_image]).to eq(stack.build_rootfs_image) expect(result[:default]).to be(false) @@ -61,6 +63,31 @@ end end + context 'when state_reason is present' do + let(:stack) do + VCAP::CloudController::Stack.make( + state: 'DEPRECATED', + state_reason: 'This stack will be removed on 2026-12-31' + ) + end + + it 'presents the state_reason' do + expect(result[:state]).to eq('DEPRECATED') + expect(result[:state_reason]).to eq('This stack will be removed on 2026-12-31') + end + end + + context 'when state_reason is nil' do + let(:stack) do + VCAP::CloudController::Stack.make(state: 'ACTIVE', state_reason: nil) + end + + it 'presents state_reason as nil' do + expect(result[:state]).to eq('ACTIVE') + expect(result[:state_reason]).to be_nil + end + end + context 'when optional fields are missing' do before do stack.description = nil From 713bcffd4e099fb9741d7b720121bc5f45a5d1ea Mon Sep 17 00:00:00 2001 From: Rashed Kamal Date: Tue, 3 Feb 2026 10:22:01 -0500 Subject: [PATCH 2/5] Update API docs with state_reason Signed-off-by: Rashed Kamal --- docs/v3/source/includes/api_resources/_stacks.erb | 4 ++++ docs/v3/source/includes/resources/stacks/_create.md.erb | 2 ++ docs/v3/source/includes/resources/stacks/_object.md.erb | 1 + docs/v3/source/includes/resources/stacks/_update.md.erb | 3 ++- 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/v3/source/includes/api_resources/_stacks.erb b/docs/v3/source/includes/api_resources/_stacks.erb index 34946fe29cc..db389be0e91 100644 --- a/docs/v3/source/includes/api_resources/_stacks.erb +++ b/docs/v3/source/includes/api_resources/_stacks.erb @@ -6,6 +6,7 @@ "name": "my-stack", "description": "Here is my stack!", "state": "ACTIVE", + "state_reason": null, "build_rootfs_image": "my-stack", "run_rootfs_image": "my-stack", "default": true, @@ -47,6 +48,7 @@ "run_rootfs_image": "my-stack-1-run", "description": "This is my first stack!", "state": "ACTIVE", + "state_reason": null, "default": true, "metadata": { "labels": {}, @@ -67,6 +69,7 @@ "build_rootfs_image": "my-stack-2-build", "run_rootfs_image": "my-stack-2-run", "state": "DEPRECATED", + "state_reason": "Stack deprecated and will be removed in future release", "default": false, "metadata": { "labels": {}, @@ -91,6 +94,7 @@ "name": "my-stack", "description": "Here is my stack!", "state": "DISABLED", + "state_reason": "Stack disabled and cannot be used for staging new application", "build_rootfs_image": "my-stack", "run_rootfs_image": "my-stack", "default": true, diff --git a/docs/v3/source/includes/resources/stacks/_create.md.erb b/docs/v3/source/includes/resources/stacks/_create.md.erb index 5d73609de96..55163509056 100644 --- a/docs/v3/source/includes/resources/stacks/_create.md.erb +++ b/docs/v3/source/includes/resources/stacks/_create.md.erb @@ -13,6 +13,7 @@ curl "https://api.example.org/v3/stacks" \ "name": "my-stack", "description": "Here is my stack!", "state": "ACTIVE", + "state_reason": "", }' ``` @@ -44,6 +45,7 @@ Name | Type | Description | Default **metadata.labels** | [_label object_](#labels) | Labels applied to the stack **metadata.annotations** | [_annotation object_](#annotations) | Annotations applied to the stack **state** | string | The state of the stack; valid states are: `ACTIVE`, `RESTRICTED`, `DEPRECATED`, `DISABLED` +**state_reason** | string | Optional plain text describing the stack change #### Permitted roles | diff --git a/docs/v3/source/includes/resources/stacks/_object.md.erb b/docs/v3/source/includes/resources/stacks/_object.md.erb index cb20ae480b4..08c77a9f3aa 100644 --- a/docs/v3/source/includes/resources/stacks/_object.md.erb +++ b/docs/v3/source/includes/resources/stacks/_object.md.erb @@ -15,6 +15,7 @@ Name | Type | Description **name** | _string_ | The name of the stack **description** | _string_ | The description of the stack **state** | string | The state of the stack; valid states are: `ACTIVE`, `RESTRICTED`, `DEPRECATED`, `DISABLED` +**state_reason** | string | Optional plain text describing the stack change **build_rootfs_image** | _string | The name of the stack image associated with staging/building Apps. If a stack does not have unique images, this will be the same as the stack name. **run_rootfs_image** | _string | The name of the stack image associated with running Apps + Tasks. If a stack does not have unique images, this will be the same as the stack name. **default** | _boolean_ | Whether the stack is configured to be the default stack for new applications. diff --git a/docs/v3/source/includes/resources/stacks/_update.md.erb b/docs/v3/source/includes/resources/stacks/_update.md.erb index 6a79a0451ea..680b2824864 100644 --- a/docs/v3/source/includes/resources/stacks/_update.md.erb +++ b/docs/v3/source/includes/resources/stacks/_update.md.erb @@ -9,7 +9,7 @@ curl "https://api.example.org/v3/stacks/[guid]" \ -X PATCH \ -H "Authorization: bearer [token]" \ -H "Content-Type: application/json" \ - -d '{ "metadata": { "labels": { "key": "value" }, "annotations": {"note": "detailed information"}},"state":"ACTIVE"}' + -d '{ "metadata": { "labels": { "key": "value" }, "annotations": {"note": "detailed information"}},"state":"ACTIVE", "state_reason": "Optional plain text describing the stack change" }' ``` @@ -34,6 +34,7 @@ Name | Type | Description **metadata.labels** | [_label object_](#labels) | Labels applied to the stack **metadata.annotations** | [_annotation object_](#annotations) | Annotations applied to the stack **state** | string | The state of the stack; valid states are: `ACTIVE`, `RESTRICTED`, `DEPRECATED`, `DISABLED` +**state_reason** | string | Optional plain text describing the stack change #### Permitted roles | From 6864feead1846c1467be74b44c5ebe90c0ad0b70 Mon Sep 17 00:00:00 2001 From: Rashed Kamal Date: Tue, 3 Feb 2026 18:02:10 -0500 Subject: [PATCH 3/5] Build a hash when updating stack state and state_reason Signed-off-by: Rashed Kamal --- app/actions/stack_update.rb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/actions/stack_update.rb b/app/actions/stack_update.rb index 91fcf2d3ec2..720dea7329b 100644 --- a/app/actions/stack_update.rb +++ b/app/actions/stack_update.rb @@ -12,8 +12,11 @@ def initialize(user_audit_info) def update(stack, message) stack.db.transaction do - stack.update(state: message.state) if message.requested?(:state) - stack.update(state_reason: message.state_reason) if message.requested?(:state_reason) + stack_updates = {} + stack_updates[:state] = message.state if message.requested?(:state) + stack_updates[:state_reason] = message.state_reason if message.requested?(:state_reason) + stack.update(stack_updates) if stack_updates.any? + MetadataUpdate.update(stack, message) Repositories::StackEventRepository.new.record_stack_update(stack, @user_audit_info, message.audit_hash) end From 4df5b0a3daeb4a349b986391196c567a0ac54fa7 Mon Sep 17 00:00:00 2001 From: Rashed Kamal Date: Wed, 4 Feb 2026 17:04:09 -0500 Subject: [PATCH 4/5] Updated API docs for stack state change Signed-off-by: Rashed Kamal --- docs/v3/source/includes/resources/stacks/_create.md.erb | 2 +- docs/v3/source/includes/resources/stacks/_object.md.erb | 2 +- docs/v3/source/includes/resources/stacks/_update.md.erb | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/v3/source/includes/resources/stacks/_create.md.erb b/docs/v3/source/includes/resources/stacks/_create.md.erb index 55163509056..b46bd6ae994 100644 --- a/docs/v3/source/includes/resources/stacks/_create.md.erb +++ b/docs/v3/source/includes/resources/stacks/_create.md.erb @@ -45,7 +45,7 @@ Name | Type | Description | Default **metadata.labels** | [_label object_](#labels) | Labels applied to the stack **metadata.annotations** | [_annotation object_](#annotations) | Annotations applied to the stack **state** | string | The state of the stack; valid states are: `ACTIVE`, `RESTRICTED`, `DEPRECATED`, `DISABLED` -**state_reason** | string | Optional plain text describing the stack change +**state_reason** | string | Optional plain text describing the stack state change #### Permitted roles | diff --git a/docs/v3/source/includes/resources/stacks/_object.md.erb b/docs/v3/source/includes/resources/stacks/_object.md.erb index 08c77a9f3aa..cac7202df1b 100644 --- a/docs/v3/source/includes/resources/stacks/_object.md.erb +++ b/docs/v3/source/includes/resources/stacks/_object.md.erb @@ -15,7 +15,7 @@ Name | Type | Description **name** | _string_ | The name of the stack **description** | _string_ | The description of the stack **state** | string | The state of the stack; valid states are: `ACTIVE`, `RESTRICTED`, `DEPRECATED`, `DISABLED` -**state_reason** | string | Optional plain text describing the stack change +**state_reason** | string | Optional plain text describing the stack state change **build_rootfs_image** | _string | The name of the stack image associated with staging/building Apps. If a stack does not have unique images, this will be the same as the stack name. **run_rootfs_image** | _string | The name of the stack image associated with running Apps + Tasks. If a stack does not have unique images, this will be the same as the stack name. **default** | _boolean_ | Whether the stack is configured to be the default stack for new applications. diff --git a/docs/v3/source/includes/resources/stacks/_update.md.erb b/docs/v3/source/includes/resources/stacks/_update.md.erb index 680b2824864..e950a4c6089 100644 --- a/docs/v3/source/includes/resources/stacks/_update.md.erb +++ b/docs/v3/source/includes/resources/stacks/_update.md.erb @@ -9,7 +9,7 @@ curl "https://api.example.org/v3/stacks/[guid]" \ -X PATCH \ -H "Authorization: bearer [token]" \ -H "Content-Type: application/json" \ - -d '{ "metadata": { "labels": { "key": "value" }, "annotations": {"note": "detailed information"}},"state":"ACTIVE", "state_reason": "Optional plain text describing the stack change" }' + -d '{ "metadata": { "labels": { "key": "value" }, "annotations": {"note": "detailed information"}},"state":"ACTIVE", "state_reason": "Optional plain text describing the stack state change" }' ``` @@ -34,7 +34,7 @@ Name | Type | Description **metadata.labels** | [_label object_](#labels) | Labels applied to the stack **metadata.annotations** | [_annotation object_](#annotations) | Annotations applied to the stack **state** | string | The state of the stack; valid states are: `ACTIVE`, `RESTRICTED`, `DEPRECATED`, `DISABLED` -**state_reason** | string | Optional plain text describing the stack change +**state_reason** | string | Optional plain text describing the stack state change #### Permitted roles | From 23cb715f29a94bf9e3c190d1a44c7676cf5aec31 Mon Sep 17 00:00:00 2001 From: Rashed Kamal Date: Fri, 6 Feb 2026 09:40:59 -0500 Subject: [PATCH 5/5] Extended state_reason field size to 5k Signed-off-by: Rashed Kamal --- app/messages/stack_create_message.rb | 2 +- app/messages/stack_update_message.rb | 2 +- db/migrations/20260202105042_add_state_reason_to_stacks.rb | 2 +- spec/request/stacks_state_spec.rb | 4 ++-- spec/unit/messages/stack_create_message_spec.rb | 2 +- spec/unit/messages/stack_update_message_spec.rb | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/messages/stack_create_message.rb b/app/messages/stack_create_message.rb index 4c5fb5bca1c..9c08739142c 100644 --- a/app/messages/stack_create_message.rb +++ b/app/messages/stack_create_message.rb @@ -8,7 +8,7 @@ class StackCreateMessage < MetadataBaseMessage validates :name, presence: true, length: { maximum: 250 } validates :description, length: { maximum: 250 } validates :state, inclusion: { in: StackStates::VALID_STATES, message: "must be one of #{StackStates::VALID_STATES.join(', ')}" }, allow_nil: false, if: :state_requested? - validates :state_reason, length: { maximum: 1000 }, allow_nil: true + validates :state_reason, length: { maximum: 5000 }, allow_nil: true def state_requested? requested?(:state) diff --git a/app/messages/stack_update_message.rb b/app/messages/stack_update_message.rb index c078fb88773..57afeea42c1 100644 --- a/app/messages/stack_update_message.rb +++ b/app/messages/stack_update_message.rb @@ -7,7 +7,7 @@ class StackUpdateMessage < MetadataBaseMessage validates_with NoAdditionalKeysValidator validates :state, inclusion: { in: StackStates::VALID_STATES, message: "must be one of #{StackStates::VALID_STATES.join(', ')}" }, allow_nil: false, if: :state_requested? - validates :state_reason, length: { maximum: 1000 }, allow_nil: true + validates :state_reason, length: { maximum: 5000 }, allow_nil: true def state_requested? requested?(:state) diff --git a/db/migrations/20260202105042_add_state_reason_to_stacks.rb b/db/migrations/20260202105042_add_state_reason_to_stacks.rb index f06e64e40ae..f1f0f7d7bd5 100644 --- a/db/migrations/20260202105042_add_state_reason_to_stacks.rb +++ b/db/migrations/20260202105042_add_state_reason_to_stacks.rb @@ -1,7 +1,7 @@ Sequel.migration do up do alter_table :stacks do - add_column :state_reason, String, null: true, size: 1000 unless @db.schema(:stacks).map(&:first).include?(:state_reason) + add_column :state_reason, String, null: true, size: 5000 unless @db.schema(:stacks).map(&:first).include?(:state_reason) end end diff --git a/spec/request/stacks_state_spec.rb b/spec/request/stacks_state_spec.rb index 9c753d2ca72..9959e16909e 100644 --- a/spec/request/stacks_state_spec.rb +++ b/spec/request/stacks_state_spec.rb @@ -59,7 +59,7 @@ request_body = { name: 'long-reason-stack', state: 'DEPRECATED', - state_reason: 'A' * 1001 + state_reason: 'A' * 5001 }.to_json post '/v3/stacks', request_body, headers @@ -270,7 +270,7 @@ it 'rejects state_reason exceeding maximum length' do request_body = { - state_reason: 'A' * 1001 + state_reason: 'A' * 5001 }.to_json patch "/v3/stacks/#{stack.guid}", request_body, headers diff --git a/spec/unit/messages/stack_create_message_spec.rb b/spec/unit/messages/stack_create_message_spec.rb index 17548aa3261..aadf7b9191b 100644 --- a/spec/unit/messages/stack_create_message_spec.rb +++ b/spec/unit/messages/stack_create_message_spec.rb @@ -124,7 +124,7 @@ end describe 'state_reason' do - MAX_STATE_REASON_LENGTH = 1000 + MAX_STATE_REASON_LENGTH = 5000 context 'when it is not provided' do let(:params) { valid_params } diff --git a/spec/unit/messages/stack_update_message_spec.rb b/spec/unit/messages/stack_update_message_spec.rb index 342a57a36b1..a069f73d470 100644 --- a/spec/unit/messages/stack_update_message_spec.rb +++ b/spec/unit/messages/stack_update_message_spec.rb @@ -64,7 +64,7 @@ end describe 'state_reason' do - MAX_STATE_REASON_LENGTH = 1000 + MAX_STATE_REASON_LENGTH = 5000 context 'when it is not provided' do let(:params) { valid_params }