diff --git a/app/actions/build_create.rb b/app/actions/build_create.rb index 80c6bd79fa4..1bdde8cc14a 100644 --- a/app/actions/build_create.rb +++ b/app/actions/build_create.rb @@ -41,6 +41,7 @@ def initialize(user_audit_info: UserAuditInfo.from_context(SecurityContext), def create_and_stage(package:, lifecycle:, metadata: nil, start_after_staging: false) logger.info("creating build for package #{package.guid}") + warnings = validate_stack_state!(lifecycle, package.app) staging_in_progress! if package.app.staging_in_progress? raise InvalidPackage.new('Cannot stage package whose state is not ready.') if package.state != PackageModel::READY_STATE @@ -60,6 +61,7 @@ def create_and_stage(package:, lifecycle:, metadata: nil, start_after_staging: f created_by_user_name: @user_audit_info.user_name, created_by_user_email: @user_audit_info.user_email ) + build.instance_variable_set(:@stack_warnings, warnings) BuildModel.db.transaction do build.save @@ -179,5 +181,29 @@ def stagers def staging_in_progress! raise StagingInProgress end + + def validate_stack_state!(lifecycle, app) + return [] if lifecycle.type == Lifecycles::DOCKER + + stack = Stack.find(name: lifecycle.staging_stack) + return [] unless stack + + warnings = if first_build_for_app?(app) + StackStateValidator.validate_for_new_app!(stack) + else + StackStateValidator.validate_for_restaging!(stack) + end + warnings.each { |warning| logger.warn(warning) } + warnings + rescue StackStateValidator::DisabledStackError, StackStateValidator::RestrictedStackError => e + raise CloudController::Errors::ApiError.new_from_details( + 'StackValidationFailed', + e.message + ) + end + + def first_build_for_app?(app) + app.builds_dataset.count.zero? + end end end diff --git a/app/actions/stack_create.rb b/app/actions/stack_create.rb index e73ed8622ea..167ae8c3432 100644 --- a/app/actions/stack_create.rb +++ b/app/actions/stack_create.rb @@ -6,7 +6,8 @@ class Error < ::StandardError def create(message) stack = VCAP::CloudController::Stack.create( name: message.name, - description: message.description + description: message.description, + state: message.state ) MetadataUpdate.update(stack, message) diff --git a/app/actions/stack_update.rb b/app/actions/stack_update.rb index ac66eccf5c0..fb70082cb45 100644 --- a/app/actions/stack_update.rb +++ b/app/actions/stack_update.rb @@ -9,6 +9,7 @@ def initialize def update(stack, message) stack.db.transaction do + stack.update(state: message.state) if message.requested?(:state) MetadataUpdate.update(stack, message) end @logger.info("Finished updating metadata on stack #{stack.guid}") diff --git a/app/actions/v2/app_stage.rb b/app/actions/v2/app_stage.rb index 5e30f4b5d3b..968aedfecbf 100644 --- a/app/actions/v2/app_stage.rb +++ b/app/actions/v2/app_stage.rb @@ -1,8 +1,11 @@ module VCAP::CloudController module V2 class AppStage + attr_reader :warnings + def initialize(stagers:) @stagers = stagers + @warnings = [] end def stage(process) @@ -25,6 +28,9 @@ def stage(process) lifecycle: lifecycle, start_after_staging: true ) + + @warnings = build.instance_variable_get(:@stack_warnings) || [] + TelemetryLogger.v2_emit( 'create-build', { diff --git a/app/actions/v2/app_update.rb b/app/actions/v2/app_update.rb index e07279f4f4e..c5b32b756cb 100644 --- a/app/actions/v2/app_update.rb +++ b/app/actions/v2/app_update.rb @@ -4,9 +4,12 @@ module VCAP::CloudController module V2 class AppUpdate + attr_reader :warnings + def initialize(access_validator:, stagers:) @access_validator = access_validator @stagers = stagers + @warnings = [] end def update(app, process, request_attrs) @@ -116,7 +119,9 @@ def prepare_to_stage(app) end def stage(process) - V2::AppStage.new(stagers: @stagers).stage(process) + app_stage = V2::AppStage.new(stagers: @stagers) + app_stage.stage(process) + @warnings = app_stage.warnings end def start_or_stop(app, request_attrs) diff --git a/app/controllers/runtime/apps_controller.rb b/app/controllers/runtime/apps_controller.rb index 5f3a514389c..22157ecd01b 100644 --- a/app/controllers/runtime/apps_controller.rb +++ b/app/controllers/runtime/apps_controller.rb @@ -294,6 +294,7 @@ def update(guid) updater = V2::AppUpdate.new(access_validator: self, stagers: @stagers) updater.update(app, process, request_attrs) + updater.warnings.each { |warning| add_warning(warning) } after_update(process) diff --git a/app/controllers/runtime/restages_controller.rb b/app/controllers/runtime/restages_controller.rb index 189d479cafd..b73e3920a9c 100644 --- a/app/controllers/runtime/restages_controller.rb +++ b/app/controllers/runtime/restages_controller.rb @@ -35,7 +35,10 @@ def restage(guid) process.app.update(droplet_guid: nil) AppStart.start_without_event(process.app, create_revision: false) end - V2::AppStage.new(stagers: @stagers).stage(process) + # V2::AppStage.new(stagers: @stagers).stage(process) + app_stage = V2::AppStage.new(stagers: @stagers) + app_stage.stage(process) + app_stage.warnings.each { |warning| add_warning(warning) } @app_event_repository.record_app_restage(process, UserAuditInfo.from_context(SecurityContext)) diff --git a/app/messages/stack_create_message.rb b/app/messages/stack_create_message.rb index 082dfb4b565..c9f8a91a32c 100644 --- a/app/messages/stack_create_message.rb +++ b/app/messages/stack_create_message.rb @@ -1,10 +1,22 @@ require 'messages/metadata_base_message' +require 'models/helpers/stack_states' module VCAP::CloudController class StackCreateMessage < MetadataBaseMessage - register_allowed_keys %i[name description] + register_allowed_keys %i[name description state] 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? + + def state_requested? + requested?(:state) + end + + def state + return @state if defined?(@state) + + @state = requested?(:state) ? super : StackStates::DEFAULT_STATE + end end end diff --git a/app/messages/stack_update_message.rb b/app/messages/stack_update_message.rb index c9fce2392fb..ecd98baf693 100644 --- a/app/messages/stack_update_message.rb +++ b/app/messages/stack_update_message.rb @@ -1,9 +1,15 @@ require 'messages/metadata_base_message' +require 'models/helpers/stack_states' module VCAP::CloudController class StackUpdateMessage < MetadataBaseMessage - register_allowed_keys [] + register_allowed_keys [:state] 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? + + def state_requested? + requested?(:state) + end end end diff --git a/app/models/helpers/stack_states.rb b/app/models/helpers/stack_states.rb new file mode 100644 index 00000000000..097e7c8e819 --- /dev/null +++ b/app/models/helpers/stack_states.rb @@ -0,0 +1,17 @@ +module VCAP::CloudController + class StackStates + STACK_ACTIVE = 'ACTIVE'.freeze + STACK_RESTRICTED = 'RESTRICTED'.freeze + STACK_DEPRECATED = 'DEPRECATED'.freeze + STACK_DISABLED = 'DISABLED'.freeze + + DEFAULT_STATE = STACK_ACTIVE + + VALID_STATES = [ + STACK_ACTIVE, + STACK_RESTRICTED, + STACK_DEPRECATED, + STACK_DISABLED + ].freeze + end +end diff --git a/app/models/runtime/build_model.rb b/app/models/runtime/build_model.rb index abc5310866d..8dac1c16ef1 100644 --- a/app/models/runtime/build_model.rb +++ b/app/models/runtime/build_model.rb @@ -17,6 +17,8 @@ class BuildModel < Sequel::Model(:builds) CNBGenericBuildFailed CNBDownloadBuildpackFailed CNBDetectFailed CNBBuildFailed CNBExportFailed CNBLaunchFailed CNBRestoreFailed].map(&:freeze).freeze + attr_reader :stack_warnings + many_to_one :app, class: 'VCAP::CloudController::AppModel', key: :app_guid, diff --git a/app/models/runtime/stack.rb b/app/models/runtime/stack.rb index 0637e612e1c..98db03ed298 100644 --- a/app/models/runtime/stack.rb +++ b/app/models/runtime/stack.rb @@ -1,5 +1,6 @@ require 'models/helpers/process_types' require 'models/helpers/stack_config_file' +require 'models/helpers/stack_states' module VCAP::CloudController class Stack < Sequel::Model @@ -43,6 +44,7 @@ def around_save def validate validates_presence :name validates_unique :name + validates_includes StackStates::VALID_STATES, :state, allow_nil: true end def before_destroy @@ -98,5 +100,29 @@ def self.populate_from_hash(hash) create(hash.slice('name', 'description', 'build_rootfs_image', 'run_rootfs_image')) end end + + def active? + state == StackStates::STACK_ACTIVE + end + + def deprecated? + state == StackStates::STACK_DEPRECATED + end + + def restricted? + state == StackStates::STACK_RESTRICTED + end + + def disabled? + state == StackStates::STACK_DISABLED + end + + def can_stage_new_app? + !restricted? && !disabled? + end + + def can_restage_apps? + !disabled? + end end end diff --git a/app/presenters/v3/build_presenter.rb b/app/presenters/v3/build_presenter.rb index 3579faf4364..fa4f861afe7 100644 --- a/app/presenters/v3/build_presenter.rb +++ b/app/presenters/v3/build_presenter.rb @@ -30,6 +30,7 @@ def to_hash }, package: { guid: build.package_guid }, droplet: droplet, + warnings: build_warnings, created_by: { guid: build.created_by_user_guid, name: build.created_by_user_name, @@ -61,6 +62,12 @@ def error e.presence end + def build_warnings + return nil unless build.stack_warnings&.any? + + build.stack_warnings.map { |warning| { detail: warning } } + end + def build_links { self: { href: url_builder.build_url(path: "/v3/builds/#{build.guid}") }, diff --git a/app/presenters/v3/stack_presenter.rb b/app/presenters/v3/stack_presenter.rb index eaff5313bf0..a053eb69f80 100644 --- a/app/presenters/v3/stack_presenter.rb +++ b/app/presenters/v3/stack_presenter.rb @@ -12,6 +12,7 @@ def to_hash updated_at: stack.updated_at, name: stack.name, description: stack.description, + state: stack.state, run_rootfs_image: stack.run_rootfs_image, build_rootfs_image: stack.build_rootfs_image, default: stack.default?, diff --git a/db/migrations/20251117123719_add_state_to_stacks.rb b/db/migrations/20251117123719_add_state_to_stacks.rb new file mode 100644 index 00000000000..29879bb882c --- /dev/null +++ b/db/migrations/20251117123719_add_state_to_stacks.rb @@ -0,0 +1,13 @@ +Sequel.migration do + up do + alter_table :stacks do + add_column :state, String, null: false, default: 'ACTIVE', size: 255 unless @db.schema(:stacks).map(&:first).include?(:state) + end + end + + down do + alter_table :stacks do + drop_column :state if @db.schema(:stacks).map(&:first).include?(:state) + end + end +end diff --git a/docs/v3/source/includes/api_resources/_stacks.erb b/docs/v3/source/includes/api_resources/_stacks.erb index 2e91570c628..4abc6868cd7 100644 --- a/docs/v3/source/includes/api_resources/_stacks.erb +++ b/docs/v3/source/includes/api_resources/_stacks.erb @@ -5,6 +5,7 @@ "updated_at": "2018-11-09T22:43:28Z", "name": "my-stack", "description": "Here is my stack!", + "state": "ACTIVE", "build_rootfs_image": "my-stack", "run_rootfs_image": "my-stack", "default": true, @@ -45,6 +46,7 @@ "build_rootfs_image": "my-stack-1-build", "run_rootfs_image": "my-stack-1-run", "description": "This is my first stack!", + "state": "ACTIVE", "default": true, "metadata": { "labels": {}, @@ -64,6 +66,7 @@ "description": "This is my second stack!", "build_rootfs_image": "my-stack-2-build", "run_rootfs_image": "my-stack-2-run", + "state": "DEPRECATED", "default": false, "metadata": { "labels": {}, @@ -79,3 +82,26 @@ } <% end %> + +<% content_for :single_stack_disabled do | metadata={} | %> +{ + "guid": "11c916c9-c2f9-440e-8e73-102e79c4704d", + "created_at": "2018-11-09T22:43:28Z", + "updated_at": "2018-11-09T22:43:28Z", + "name": "my-stack", + "description": "Here is my stack!", + "state": "ACTIVE", + "build_rootfs_image": "my-stack", + "run_rootfs_image": "my-stack", + "default": true, + "metadata": { + "labels": <%= metadata.fetch(:labels, {}).to_json(space: ' ', object_nl: ' ')%>, + "annotations": <%= metadata.fetch(:annotations, {}).to_json(space: ' ', object_nl: ' ')%> + }, + "links": { + "self": { + "href": "https://api.example.com/v3/stacks/11c916c9-c2f9-440e-8e73-102e79c4704d" + } + } +} +<% end %> diff --git a/docs/v3/source/includes/resources/stacks/_object.md.erb b/docs/v3/source/includes/resources/stacks/_object.md.erb index 09e7280a9f9..cb20ae480b4 100644 --- a/docs/v3/source/includes/resources/stacks/_object.md.erb +++ b/docs/v3/source/includes/resources/stacks/_object.md.erb @@ -14,6 +14,7 @@ Name | Type | Description **updated_at** | _[timestamp](#timestamps)_ | The time with zone when the object was last updated **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` **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 fcca207f005..567fc51c000 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"}}}' + -d '{ "metadata": { "labels": { "key": "value" }, "annotations": {"note": "detailed information"}, "state": "DISABLED" }}' ``` @@ -21,7 +21,7 @@ Example Response HTTP/1.1 200 OK Content-Type: application/json -<%= yield_content :single_stack, labels: { "key" => "value" }, "annotations": {"note" => "detailed information"} %> +<%= yield_content :single_stack_disabled, labels: { "key" => "value" }, "annotations": {"note" => "detailed information"} %> ``` #### Definition @@ -33,6 +33,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` #### Permitted roles | diff --git a/errors/v2.yml b/errors/v2.yml index cf4bf28bea2..1f0d12418cb 100644 --- a/errors/v2.yml +++ b/errors/v2.yml @@ -863,6 +863,11 @@ http_code: 404 message: "The stack could not be found: %s" +250004: + name: StackValidationFailed + http_code: 422 + message: "%s" + 260001: name: ServicePlanVisibilityInvalid http_code: 400 diff --git a/lib/cloud_controller.rb b/lib/cloud_controller.rb index 5875c33462a..ba8297fdc47 100644 --- a/lib/cloud_controller.rb +++ b/lib/cloud_controller.rb @@ -117,3 +117,5 @@ module VCAP::CloudController; end require 'cloud_controller/errands/rotate_database_key' require 'services' + +require 'cloud_controller/stack_state_validator' diff --git a/lib/cloud_controller/stack_state_validator.rb b/lib/cloud_controller/stack_state_validator.rb new file mode 100644 index 00000000000..d8f874e6c2f --- /dev/null +++ b/lib/cloud_controller/stack_state_validator.rb @@ -0,0 +1,28 @@ +module VCAP::CloudController + class StackStateValidator + class StackValidationError < StandardError; end + class DisabledStackError < StackValidationError; end + class RestrictedStackError < StackValidationError; end + def self.validate_for_new_app!(stack) + return [] if stack.active? + + raise DisabledStackError.new("Stack '#{stack.name}' is disabled and cannot be used for staging new applications. #{stack.description}") if stack.disabled? + + raise RestrictedStackError.new("Stack '#{stack.name}' is restricted and cannot be used for staging new applications. #{stack.description}") if stack.restricted? + + stack.deprecated? ? [build_deprecation_warning(stack)] : [] + end + + def self.validate_for_restaging!(stack) + return [] if stack.active? || stack.restricted? + + raise DisabledStackError.new("Stack '#{stack.name}' is disabled and cannot be used for staging new applications. #{stack.description}") if stack.disabled? + + stack.deprecated? ? [build_deprecation_warning(stack)] : [] + end + + def self.build_deprecation_warning(stack) + "Stack '#{stack.name}' is deprecated and will be removed in the future. #{stack.description}" + end + end +end diff --git a/spec/migrations/20251117123719_add_state_to_stacks_spec.rb b/spec/migrations/20251117123719_add_state_to_stacks_spec.rb new file mode 100644 index 00000000000..ce3a56afcae --- /dev/null +++ b/spec/migrations/20251117123719_add_state_to_stacks_spec.rb @@ -0,0 +1,91 @@ +require 'spec_helper' +require 'migrations/helpers/migration_shared_context' + +RSpec.describe 'migration to add state column to stacks table', isolation: :truncation, type: :migration do + include_context 'migration' do + let(:migration_filename) { '20251117123719_add_state_to_stacks.rb' } + end + + describe 'stacks table' do + subject(:run_migration) { Sequel::Migrator.run(db, migrations_path, target: current_migration_index, allow_missing_migration_files: true) } + + describe 'up' do + it 'adds a column `state`' do + expect(db[:stacks].columns).not_to include(:state) + run_migration + expect(db[:stacks].columns).to include(:state) + end + + it 'sets the default value of existing stacks to ACTIVE' do + db[:stacks].insert(guid: SecureRandom.uuid, name: 'existing-stack', description: 'An existing stack') + run_migration + expect(db[:stacks].first(name: 'existing-stack')[:state]).to eq('ACTIVE') + end + + it 'sets the default value of new stacks to ACTIVE' do + run_migration + db[:stacks].insert(guid: SecureRandom.uuid, name: 'new-stack', description: 'A new stack') + expect(db[:stacks].first(name: 'new-stack')[:state]).to eq('ACTIVE') + end + + it 'forbids null values' do + run_migration + expect do + db[:stacks].insert(guid: SecureRandom.uuid, name: 'null-state-stack', description: 'A stack with null state', state: nil) + end.to raise_error(Sequel::NotNullConstraintViolation) + end + + it 'allows valid state values' do + run_migration + %w[ACTIVE DEPRECATED RESTRICTED DISABLED].each do |state| + expect do + db[:stacks].insert(guid: SecureRandom.uuid, name: "stack-#{state.downcase}", description: "A #{state} stack", state: state) + end.not_to raise_error + expect(db[:stacks].first(name: "stack-#{state.downcase}")[:state]).to eq(state) + end + end + + context 'when the column already exists' do + before do + db.alter_table :stacks do + add_column :state, String, null: false, default: 'ACTIVE', size: 255 unless @db.schema(:stacks).map(&:first).include?(:state) + end + end + + it 'does not fail' do + expect(db[:stacks].columns).to include(:state) + expect { run_migration }.not_to raise_error + expect(db[:stacks].columns).to include(:state) + end + end + end + + describe 'down' do + subject(:run_rollback) { Sequel::Migrator.run(db, migrations_path, target: current_migration_index - 1, allow_missing_migration_files: true) } + + before do + run_migration + end + + it 'removes the `state` column' do + expect(db[:stacks].columns).to include(:state) + run_rollback + expect(db[:stacks].columns).not_to include(:state) + end + + context 'when the column does not exist' do + before do + db.alter_table :stacks do + drop_column :state if @db.schema(:stacks).map(&:first).include?(:state) + end + end + + it 'does not fail' do + expect(db[:stacks].columns).not_to include(:state) + expect { run_rollback }.not_to raise_error + expect(db[:stacks].columns).not_to include(:state) + end + end + end + end +end diff --git a/spec/request/apps_spec.rb b/spec/request/apps_spec.rb index 063a2452f7a..14d3101c773 100644 --- a/spec/request/apps_spec.rb +++ b/spec/request/apps_spec.rb @@ -1900,6 +1900,7 @@ 'droplet' => { 'guid' => droplet.guid }, + 'warnings' => nil, 'relationships' => { 'app' => { 'data' => { 'guid' => app_model.guid } } }, 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'links' => { @@ -1929,6 +1930,7 @@ 'droplet' => { 'guid' => second_droplet.guid }, + 'warnings' => nil, 'relationships' => { 'app' => { 'data' => { 'guid' => app_model.guid } } }, 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'links' => { diff --git a/spec/request/builds_spec.rb b/spec/request/builds_spec.rb index 0a72800ec9c..17fa8c30d08 100644 --- a/spec/request/builds_spec.rb +++ b/spec/request/builds_spec.rb @@ -92,6 +92,7 @@ 'guid' => package.guid }, 'droplet' => nil, + 'warnings' => nil, 'relationships' => { 'app' => { 'data' => { 'guid' => app_model.guid } } }, 'links' => { 'self' => { @@ -199,6 +200,130 @@ end end end + + context 'when stack is DISABLED' do + let(:disabled_stack) { VCAP::CloudController::Stack.make(name: 'cflinuxfs3', state: 'DISABLED', description: 'cflinuxfs3 stack is now disabled') } + let(:create_request) do + { + lifecycle: { + type: 'buildpack', + data: { + buildpacks: ['https://github.com/myorg/awesome-buildpack'], + stack: disabled_stack.name + } + }, + package: { + guid: package.guid + } + } + end + + it 'returns 422 and does not create the build' do + post '/v3/builds', create_request.to_json, developer_headers + + expect(last_response.status).to eq(422) + expect(parsed_response['errors'].first['detail']).to include('disabled') + expect(parsed_response['errors'].first['detail']).to include('cannot be used for staging new applications') + expect(VCAP::CloudController::BuildModel.count).to eq(0) + end + end + + context 'when stack is RESTRICTED' do + let(:restricted_stack) { VCAP::CloudController::Stack.make(name: 'cflinuxfs3', state: 'RESTRICTED', description: 'No new apps') } + let(:create_request) do + { + lifecycle: { + type: 'buildpack', + data: { + buildpacks: ['http://github.com/myorg/awesome-buildpack'], + stack: restricted_stack.name + } + }, + package: { + guid: package.guid + } + } + end + + context 'first build for app' do + it 'returns 422 and does not create build' do + expect(app_model.builds_dataset.count).to eq(0) + + post '/v3/builds', create_request.to_json, developer_headers + + expect(last_response.status).to eq(422) + expect(parsed_response['errors'].first['detail']).to include('cannot be used for staging new applications') + expect(VCAP::CloudController::BuildModel.count).to eq(0) + end + end + + context 'app has previous builds' do + before do + VCAP::CloudController::BuildModel.make(app: app_model, state: VCAP::CloudController::BuildModel::STAGED_STATE) + end + + it 'returns 201 and creates build' do + expect(app_model.builds_dataset.count).to eq(1) + + post '/v3/builds', create_request.to_json, developer_headers + + expect(last_response.status).to eq(201) + expect(parsed_response['state']).to eq('STAGING') + expect(app_model.builds_dataset.count).to eq(2) + end + end + end + + context 'when stack is DEPRECATED' do + let(:deprecated_stack) { VCAP::CloudController::Stack.make(name: 'cflinuxfs3', state: 'DEPRECATED', description: 'cflinuxfs3 stack is deprecated. Please migrate your application to cflinuxfs4') } + let(:create_request) do + { + lifecycle: { + type: 'buildpack', + data: { + buildpacks: ['http://github.com/myorg/awesome-buildpack'], + stack: deprecated_stack.name + } + }, + package: { + guid: package.guid + } + } + end + + context 'first build for app' do + it 'returns 201 and does not create the build' do + expect(app_model.builds_dataset.count).to eq(0) + + post '/v3/builds', create_request.to_json, developer_headers + + expect(last_response.status).to eq(201) + expect(parsed_response['state']).to eq('STAGING') + expect(parsed_response['warnings']).to be_present + expect(parsed_response['warnings'][0]['detail']).to include('deprecated') + expect(parsed_response['warnings'][0]['detail']).to include('cflinuxfs3 stack is deprecated') + end + end + + context 'app has previous builds' do + before do + VCAP::CloudController::BuildModel.make(app: app_model, state: VCAP::CloudController::BuildModel::STAGED_STATE) + end + + it 'returns 201 and does not create the build' do + expect(app_model.builds_dataset.count).to eq(1) + + post '/v3/builds', create_request.to_json, developer_headers + + expect(last_response.status).to eq(201) + expect(parsed_response['state']).to eq('STAGING') + expect(parsed_response['warnings']).to be_present + expect(parsed_response['warnings'][0]['detail']).to include('deprecated') + expect(parsed_response['warnings'][0]['detail']).to include('cflinuxfs3 stack is deprecated') + expect(app_model.builds_dataset.count).to eq(2) + end + end + end end describe 'GET /v3/builds' do @@ -349,6 +474,7 @@ 'droplet' => { 'guid' => droplet.guid }, + 'warnings' => nil, 'relationships' => { 'app' => { 'data' => { 'guid' => app_model.guid } } }, 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'links' => { @@ -378,6 +504,7 @@ 'droplet' => { 'guid' => second_droplet.guid }, + 'warnings' => nil, 'relationships' => { 'app' => { 'data' => { 'guid' => app_model.guid } } }, 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'links' => { @@ -480,6 +607,7 @@ 'droplet' => { 'guid' => droplet.guid }, + 'warnings' => nil, 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'relationships' => { 'app' => { 'data' => { 'guid' => app_model.guid } } }, 'links' => { diff --git a/spec/request/stacks_spec.rb b/spec/request/stacks_spec.rb index fd64bcd017e..f348e1bdfab 100644 --- a/spec/request/stacks_spec.rb +++ b/spec/request/stacks_spec.rb @@ -26,6 +26,7 @@ 'run_rootfs_image' => stack1.run_rootfs_image, 'build_rootfs_image' => stack1.build_rootfs_image, 'guid' => stack1.guid, + 'state' => 'ACTIVE', 'default' => false, 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, @@ -42,6 +43,7 @@ 'run_rootfs_image' => stack2.run_rootfs_image, 'build_rootfs_image' => stack2.build_rootfs_image, 'guid' => stack2.guid, + 'state' => 'ACTIVE', 'default' => true, 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, @@ -122,6 +124,7 @@ 'run_rootfs_image' => stack1.run_rootfs_image, 'build_rootfs_image' => stack1.build_rootfs_image, 'guid' => stack1.guid, + 'state' => 'ACTIVE', 'default' => false, 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, @@ -138,6 +141,7 @@ 'run_rootfs_image' => stack2.run_rootfs_image, 'build_rootfs_image' => stack2.build_rootfs_image, 'guid' => stack2.guid, + 'state' => 'ACTIVE', 'default' => true, 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, @@ -177,6 +181,7 @@ 'run_rootfs_image' => stack1.run_rootfs_image, 'build_rootfs_image' => stack1.build_rootfs_image, 'guid' => stack1.guid, + 'state' => 'ACTIVE', 'default' => false, 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, @@ -193,6 +198,7 @@ 'run_rootfs_image' => stack3.run_rootfs_image, 'build_rootfs_image' => stack3.build_rootfs_image, 'guid' => stack3.guid, + 'state' => 'ACTIVE', 'default' => false, 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, @@ -232,6 +238,7 @@ 'run_rootfs_image' => stack2.run_rootfs_image, 'build_rootfs_image' => stack2.build_rootfs_image, 'guid' => stack2.guid, + 'state' => 'ACTIVE', 'default' => true, 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, @@ -287,6 +294,7 @@ 'run_rootfs_image' => stack1.run_rootfs_image, 'build_rootfs_image' => stack1.build_rootfs_image, 'guid' => stack1.guid, + 'state' => 'ACTIVE', 'default' => false, 'metadata' => { 'labels' => { @@ -323,6 +331,7 @@ 'run_rootfs_image' => stack.run_rootfs_image, 'build_rootfs_image' => stack.build_rootfs_image, 'guid' => stack.guid, + 'state' => 'ACTIVE', 'default' => false, 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, @@ -670,6 +679,7 @@ } }, 'guid' => created_stack.guid, + 'state' => 'ACTIVE', 'created_at' => iso8601, 'updated_at' => iso8601, 'links' => { @@ -734,6 +744,7 @@ } }, 'guid' => stack.guid, + 'state' => 'ACTIVE', 'created_at' => iso8601, 'updated_at' => iso8601, 'links' => { diff --git a/spec/request/stacks_state_spec.rb b/spec/request/stacks_state_spec.rb new file mode 100644 index 00000000000..07b3257ec1e --- /dev/null +++ b/spec/request/stacks_state_spec.rb @@ -0,0 +1,215 @@ +require 'spec_helper' +require 'request_spec_shared_examples' + +RSpec.describe 'Stacks State Management' do + let(:stack_config_file) { File.join(Paths::FIXTURES, 'config/stacks.yml') } + let(:user) { make_user(admin: true) } + let(:headers) { admin_headers_for(user) } + + before { VCAP::CloudController::Stack.configure(stack_config_file) } + + describe 'POST /v3/stacks with state' do + context 'when creating stack with explicit state' do + %w[ACTIVE DEPRECATED RESTRICTED DISABLED].each do |state| + it "creates stack with #{state} state" do + request_body = { + name: "stack-#{state.downcase}", + description: 'test stack', + state: state + }.to_json + + post '/v3/stacks', request_body, headers + + expect(last_response.status).to eq(201) + expect(parsed_response['state']).to eq(state) + expect(parsed_response['name']).to eq("stack-#{state.downcase}") + end + end + end + + context 'when creating stack without state' do + it 'defaults to ACTIVE' do + request_body = { + name: 'default-state-stack', + description: 'test stack' + }.to_json + + post '/v3/stacks', request_body, headers + + expect(last_response.status).to eq(201) + expect(parsed_response['state']).to eq('ACTIVE') + end + end + + context 'when creating stack with invalid state' do + it 'returns validation error' do + request_body = { + name: 'invalid-stack', + state: 'INVALID_STATE' + }.to_json + + post '/v3/stacks', request_body, headers + + expect(last_response.status).to eq(422) + expect(parsed_response['errors'].first['detail']).to include('must be one of ACTIVE, RESTRICTED, DEPRECATED, DISABLED') + end + end + + context 'when creating stack with null state' do + it 'returns validation error' do + request_body = { + name: 'stack-null-state', + state: nil + }.to_json + + post '/v3/stacks', request_body, headers + + expect(last_response.status).to eq(422) + expect(parsed_response['errors'].first['detail']).to include('must be one of ACTIVE, RESTRICTED, DEPRECATED, DISABLED') + end + end + + context 'as non-admin user' do + let(:non_admin_user) { make_user } + let(:non_admin_headers) { headers_for(non_admin_user) } + + it 'is unauthorized' do + request_body = { + name: 'test-stack', + state: 'ACTIVE' + }.to_json + + post '/v3/stacks', request_body, non_admin_headers + + expect(last_response.status).to eq(403) + end + end + end + + describe 'PATCH /v3/stacks/:guid with state' do + let!(:stack) { VCAP::CloudController::Stack.make(name: 'test-stack', state: 'ACTIVE') } + + context 'when updating state through lifecycle' do + it 'transitions from ACTIVE to DEPRECATED' do + request_body = { state: 'DEPRECATED' }.to_json + + patch "/v3/stacks/#{stack.guid}", request_body, headers + + expect(last_response.status).to eq(200) + expect(parsed_response['state']).to eq('DEPRECATED') + + stack.reload + expect(stack.state).to eq('DEPRECATED') + end + + it 'transitions from DEPRECATED to RESTRICTED' do + stack.update(state: 'DEPRECATED') + + 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') + end + + it 'transitions from RESTRICTED to DISABLED' do + stack.update(state: 'RESTRICTED') + + request_body = { state: 'DISABLED' }.to_json + + patch "/v3/stacks/#{stack.guid}", request_body, headers + + expect(last_response.status).to eq(200) + expect(parsed_response['state']).to eq('DISABLED') + end + + it 'allows transition back to ACTIVE' do + stack.update(state: 'DEPRECATED') + + request_body = { state: 'ACTIVE' }.to_json + + patch "/v3/stacks/#{stack.guid}", request_body, headers + + expect(last_response.status).to eq(200) + expect(parsed_response['state']).to eq('ACTIVE') + end + end + + context 'when updating with invalid state' do + it 'returns validation error' do + request_body = { state: 'BOGUS_STATE' }.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('must be one of ACTIVE, RESTRICTED, DEPRECATED, DISABLED') + end + end + + context 'when updating metadata without changing state' do + it 'preserves existing state' do + stack.update(state: 'DEPRECATED') + + request_body = { + metadata: { + labels: { test: 'label' } + } + }.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['metadata']['labels']['test']).to eq('label') + end + end + + context 'as non-admin user' do + let(:non_admin_user) { make_user } + let(:non_admin_headers) { headers_for(non_admin_user) } + + it 'is unauthorized' do + request_body = { state: 'DEPRECATED' }.to_json + + patch "/v3/stacks/#{stack.guid}", request_body, non_admin_headers + + expect(last_response.status).to eq(403) + end + end + end + + describe 'GET /v3/stacks/:guid' do + let!(:deprecated_stack) { VCAP::CloudController::Stack.make(state: 'DEPRECATED') } + let(:reader_user) { make_user } + let(:reader_headers) { headers_for(reader_user) } + + it 'returns state field for all users' do + get "/v3/stacks/#{deprecated_stack.guid}", nil, reader_headers + + expect(last_response.status).to eq(200) + expect(parsed_response['state']).to eq('DEPRECATED') + 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!(:restricted_stack) { VCAP::CloudController::Stack.make(name: 'restricted', state: 'RESTRICTED') } + let!(:disabled_stack) { VCAP::CloudController::Stack.make(name: 'disabled', state: 'DISABLED') } + + let(:reader_user) { make_user } + let(:reader_headers) { headers_for(reader_user) } + + it 'includes state for all stacks' do + get '/v3/stacks', nil, reader_headers + + expect(last_response.status).to eq(200) + + resources = parsed_response['resources'] + expect(resources.pluck('state')).to contain_exactly('ACTIVE', 'DEPRECATED', 'RESTRICTED', 'DISABLED') + end + end +end diff --git a/spec/request/v2/apps_spec.rb b/spec/request/v2/apps_spec.rb index 36a1536a3a3..056d73e134f 100644 --- a/spec/request/v2/apps_spec.rb +++ b/spec/request/v2/apps_spec.rb @@ -926,6 +926,58 @@ end end + context 'stack state validation on app update with staging' do + let(:stager) { instance_double(VCAP::CloudController::Diego::Stager, stage: nil) } + + before do + allow_any_instance_of(VCAP::CloudController::Stagers).to receive(:validate_process) + allow_any_instance_of(VCAP::CloudController::Stagers).to receive(:stager_for_build).and_return(stager) + VCAP::CloudController::Buildpack.make + VCAP::CloudController::PackageModel.make(app: process.app, state: VCAP::CloudController::PackageModel::READY_STATE) + end + + context 'when stack is DISABLED' do + let(:disabled_stack) { VCAP::CloudController::Stack.make(name: 'cflinuxfs2', state: 'DISABLED', description: 'Migrate to cflinuxfs4') } + let(:update_params) { Oj.dump({ state: 'STARTED' }) } + + before do + process.app.buildpack_lifecycle_data.update(stack: disabled_stack.name) + process.update(state: 'STOPPED') + end + + it 'returns 422 with stack validation error when starting app' do + put "/v2/apps/#{process.guid}", update_params, headers_for(user) + + expect(last_response.status).to eq(422) + parsed_response = Oj.load(last_response.body) + expect(parsed_response['error_code']).to eq('CF-StackValidationFailed') + expect(parsed_response['description']).to include('disabled') + expect(parsed_response['description']).to include('cflinuxfs2') + end + end + + context 'when stack is DEPRECATED' do + let(:deprecated_stack) { VCAP::CloudController::Stack.make(name: 'cflinuxfs3', state: 'DEPRECATED', description: 'EOL Dec 2025') } + let(:update_params) { Oj.dump({ state: 'STARTED' }) } + + before do + process.app.buildpack_lifecycle_data.update(stack: deprecated_stack.name) + process.app.update(droplet_guid: nil) + process.update(state: 'STOPPED') + end + + it 'allows starting app with deprecation warning' do + put "/v2/apps/#{process.guid}", update_params, headers_for(user) + + expect(last_response.status).to eq(201) + expect(last_response.headers['X-Cf-Warnings']).to be_present + decoded_warning = CGI.unescape(last_response.headers['X-Cf-Warnings']) + expect(decoded_warning).to include('deprecated') + expect(decoded_warning).to include('EOL Dec 2025') + end + end + end + context 'when process memory is being decreased and the new memory allocation is lower than memory of associated sidecars' do let!(:process) do VCAP::CloudController::ProcessModelFactory.make( @@ -1563,6 +1615,138 @@ def make_actual_lrp(instance_guid:, index:, state:, error:, since:) end end end + + context 'stack state validation' do + let(:process) { VCAP::CloudController::ProcessModelFactory.make(name: 'maria', space: space, diego: true) } + let(:stager) { instance_double(VCAP::CloudController::Diego::Stager, stage: nil) } + + before do + allow_any_instance_of(VCAP::CloudController::Stagers).to receive(:validate_process) + allow_any_instance_of(VCAP::CloudController::Stagers).to receive(:stager_for_build).and_return(stager) + VCAP::CloudController::Buildpack.make + end + + context 'when stack is DISABLED' do + let(:disabled_stack) { VCAP::CloudController::Stack.make(name: 'cflinuxfs2', state: 'DISABLED', description: 'Migrate to cflinuxfs4') } + + before do + process.app.buildpack_lifecycle_data.update(stack: disabled_stack.name) + end + + it 'returns 422 with stack validation error' do + post "/v2/apps/#{process.guid}/restage", nil, headers_for(user) + + expect(last_response.status).to eq(422) + parsed_response = Oj.load(last_response.body) + expect(parsed_response['error_code']).to eq('CF-StackValidationFailed') + expect(parsed_response['description']).to include('disabled') + expect(parsed_response['description']).to include('cannot be used for staging') + expect(parsed_response['description']).to include('cflinuxfs2') + expect(parsed_response['description']).to include('Migrate to cflinuxfs4') + end + + it 'does not expose stack state field in error response' do + post "/v2/apps/#{process.guid}/restage", nil, headers_for(user) + + parsed_response = Oj.load(last_response.body) + expect(parsed_response['entity']).to be_nil + expect(parsed_response['error_code']).to eq('CF-StackValidationFailed') + end + end + + context 'when stack is RESTRICTED' do + let(:restricted_stack) { VCAP::CloudController::Stack.make(name: 'cflinuxfs3', state: 'RESTRICTED', description: 'No new apps') } + + before do + process.app.buildpack_lifecycle_data.update(stack: restricted_stack.name) + end + + context 'for first build' do + before do + process.app.builds_dataset.destroy + end + + it 'returns 422 with stack validation error' do + expect(process.app.builds_dataset.count).to eq(0) + + post "/v2/apps/#{process.guid}/restage", nil, headers_for(user) + + expect(last_response.status).to eq(422) + parsed_response = Oj.load(last_response.body) + expect(parsed_response['error_code']).to eq('CF-StackValidationFailed') + expect(parsed_response['description']).to include('cannot be used for staging new applications') + expect(parsed_response['description']).to include('cflinuxfs3') + end + end + + context 'for restaging existing app' do + before do + VCAP::CloudController::BuildModel.make(app: process.app, state: 'STAGED') + end + + it 'allows restaging without errors' do + post "/v2/apps/#{process.guid}/restage", nil, headers_for(user) + + expect(last_response.status).to eq(201) + end + + it 'does not include warnings header' do + post "/v2/apps/#{process.guid}/restage", nil, headers_for(user) + + expect(last_response.headers['X-Cf-Warnings']).to be_nil + end + end + end + + context 'when stack is DEPRECATED' do + let(:deprecated_stack) { VCAP::CloudController::Stack.make(name: 'cflinuxfs3', state: 'DEPRECATED', description: 'EOL Dec 2025') } + + before do + process.app.buildpack_lifecycle_data.update(stack: deprecated_stack.name) + end + + it 'allows restaging with success' do + post "/v2/apps/#{process.guid}/restage", nil, headers_for(user) + + expect(last_response.status).to eq(201) + end + + it 'includes deprecation warning in X-Cf-Warnings header' do + post "/v2/apps/#{process.guid}/restage", nil, headers_for(user) + + expect(last_response.headers['X-Cf-Warnings']).to be_present + decoded_warning = CGI.unescape(last_response.headers['X-Cf-Warnings']) + expect(decoded_warning).to include('deprecated') + expect(decoded_warning).to include('cflinuxfs3') + expect(decoded_warning).to include('EOL Dec 2025') + end + + it 'does not expose stack state field in response body' do + post "/v2/apps/#{process.guid}/restage", nil, headers_for(user) + + parsed_response = Oj.load(last_response.body) + # The response includes process 'state' (STARTED/STOPPED) which is different from stack 'state' + # We verify stack state is not exposed by checking it's not in the stack-related fields + expect(parsed_response['entity']['stack_guid']).to be_present + expect(parsed_response.to_s).not_to match(/DEPRECATED/) + end + end + + context 'when stack is ACTIVE' do + let(:active_stack) { VCAP::CloudController::Stack.make(name: 'cflinuxfs5', state: 'ACTIVE') } + + before do + process.app.buildpack_lifecycle_data.update(stack: active_stack.name) + end + + it 'allows restaging without warnings' do + post "/v2/apps/#{process.guid}/restage", nil, headers_for(user) + + expect(last_response.status).to eq(201) + expect(last_response.headers['X-Cf-Warnings']).to be_nil + end + end + end end describe 'PUT /v2/apps/:guid/bits' do diff --git a/spec/unit/actions/build_create_spec.rb b/spec/unit/actions/build_create_spec.rb index 8b38b4c287c..d0969a88bfb 100644 --- a/spec/unit/actions/build_create_spec.rb +++ b/spec/unit/actions/build_create_spec.rb @@ -452,6 +452,84 @@ module VCAP::CloudController end end + context 'when stack is DISABLED' do + let(:disabled_stack) { Stack.make(name: 'cflinuxfs3', state: 'DISABLED', description: 'Migrate to cflinuxfs4') } + let(:lifecycle_data) do + { + stack: disabled_stack.name, + buildpacks: [buildpack_git_url] + } + end + + it 'raises StackValidationFailed error for staging' do + expect do + action.create_and_stage(package:, lifecycle:) + end.to raise_error(CloudController::Errors::ApiError) do |error| + expect(error.name).to eq('StackValidationFailed') + expect(error.message).to include('disabled') + expect(error.message).to include('cannot be used for staging new applications') + end + end + + it 'does not create any DB records' do + expect do + action.create_and_stage(package:, lifecycle:) + rescue StandardError + nil + end.not_to(change { [BuildModel.count, BuildpackLifecycleDataModel.count, AppUsageEvent.count, Event.count] }) + end + end + + context 'when stack is RESTRICTED' do + let(:restricted_stack) { Stack.make(name: 'cflinuxfs3', state: 'RESTRICTED', description: 'No new apps') } + let(:lifecycle_data) do + { + stack: restricted_stack.name, + buildpacks: [buildpack_git_url] + } + end + + context 'build for new app' do + it 'raises StackValidationFailed error' do + expect(app.builds_dataset.count).to eq(0) + expect do + action.create_and_stage(package:, lifecycle:) + end.to raise_error(CloudController::Errors::ApiError) do |error| + expect(error.name).to eq('StackValidationFailed') + expect(error.message).to include('annot be used for staging new applications') + end + end + + it 'does not create any DB records' do + expect do + action.create_and_stage(package:, lifecycle:) + rescue StandardError + nil + end.not_to(change { [BuildModel.count, BuildpackLifecycleDataModel.count, AppUsageEvent.count, Event.count] }) + end + end + + context 'app has previous builds' do + before do + BuildModel.make(app: app, state: BuildModel::STAGED_STATE) + end + + it 'allows restaging' do + expect(app.builds_dataset.count).to eq(1) + expect do + action.create_and_stage(package:, lifecycle:) + end.not_to raise_error + end + + it 'creates build successfully' do + build = action.create_and_stage(package:, lifecycle:) + expect(build.id).not_to be_nil + expect(build.state).to eq(BuildModel::STAGING_STATE) + expect(app.builds_dataset.count).to eq(2) + end + end + end + context 'when there is already a staging in progress for the app' do it 'raises a StagingInProgress exception' do BuildModel.make(state: BuildModel::STAGING_STATE, app: app) diff --git a/spec/unit/actions/v2/app_stage_spec.rb b/spec/unit/actions/v2/app_stage_spec.rb index 023584d0d5c..0f13b042460 100644 --- a/spec/unit/actions/v2/app_stage_spec.rb +++ b/spec/unit/actions/v2/app_stage_spec.rb @@ -215,6 +215,100 @@ module V2 end end end + + context 'stack state warnings' do + let(:process) { ProcessModelFactory.make } + let(:user_audit_info) do + UserAuditInfo.new(user_email: 'test@example.com', user_name: 'test-user', user_guid: 'test-guid') + end + + before do + allow(UserAuditInfo).to receive(:from_context).and_return(user_audit_info) + allow(BuildCreate).to receive(:new).and_call_original + allow_any_instance_of(Diego::Stager).to receive(:stage).and_return 'staging-complete' + end + + context 'when stack is DEPRECATED' do + let(:deprecated_stack) { Stack.make(name: 'cflinuxfs3', state: 'DEPRECATED', description: 'EOL Dec 2025') } + + before do + process.app.buildpack_lifecycle_data.update(stack: deprecated_stack.name) + end + + it 'captures warnings from BuildCreate' do + action.stage(process) + + expect(action.warnings).not_to be_empty + expect(action.warnings.first).to include('deprecated') + expect(action.warnings.first).to include('cflinuxfs3') + expect(action.warnings.first).to include('EOL Dec 2025') + end + end + + context 'when stack is ACTIVE' do + let(:active_stack) { Stack.make(name: 'cflinuxfs5', state: 'ACTIVE') } + + before do + process.app.buildpack_lifecycle_data.update(stack: active_stack.name) + end + + it 'has no warnings' do + action.stage(process) + + expect(action.warnings).to be_empty + end + end + + context 'when stack is DISABLED' do + let(:disabled_stack) { Stack.make(name: 'cflinuxfs2', state: 'DISABLED', description: 'Migrate to cflinuxfs4') } + + before do + process.app.buildpack_lifecycle_data.update(stack: disabled_stack.name) + end + + it 'raises StackValidationFailed error' do + expect { action.stage(process) }.to raise_error(CloudController::Errors::ApiError) do |error| + expect(error.name).to eq('StackValidationFailed') + expect(error.message).to include('disabled') + expect(error.message).to include('cannot be used for staging') + end + end + end + + context 'when stack is RESTRICTED' do + let(:restricted_stack) { Stack.make(name: 'cflinuxfs3-restricted', state: 'RESTRICTED', description: 'No new apps') } + + before do + process.app.buildpack_lifecycle_data.update(stack: restricted_stack.name) + end + + context 'for first build' do + before do + process.app.builds_dataset.destroy + end + + it 'raises StackValidationFailed error' do + expect(process.app.builds_dataset.count).to eq(0) + + expect { action.stage(process) }.to raise_error(CloudController::Errors::ApiError) do |error| + expect(error.name).to eq('StackValidationFailed') + expect(error.message).to include('cannot be used for staging new applications') + end + end + end + + context 'for restaging existing app' do + before do + BuildModel.make(app: process.app, state: 'STAGED') + end + + it 'allows staging without warnings' do + expect { action.stage(process) }.not_to raise_error + expect(action.warnings).to be_empty + end + end + end + end end end end diff --git a/spec/unit/actions/v2/app_update_spec.rb b/spec/unit/actions/v2/app_update_spec.rb index 7be122232af..778e1bd62fd 100644 --- a/spec/unit/actions/v2/app_update_spec.rb +++ b/spec/unit/actions/v2/app_update_spec.rb @@ -432,7 +432,7 @@ module VCAP::CloudController describe 'updating docker_image' do let(:process) { ProcessModelFactory.make(app: AppModel.make(:docker), docker_image: 'repo/original-image') } let!(:original_package) { process.latest_package } - let(:app_stage) { instance_double(V2::AppStage, stage: nil) } + let(:app_stage) { instance_double(V2::AppStage, stage: nil, warnings: []) } before do FeatureFlag.create(name: 'diego_docker', enabled: true) @@ -507,7 +507,7 @@ module VCAP::CloudController describe 'updating docker_credentials' do let(:process) { ProcessModelFactory.make(app: AppModel.make(:docker), docker_image: 'repo/original-image') } let!(:original_package) { process.latest_package } - let(:app_stage) { instance_double(V2::AppStage, stage: nil) } + let(:app_stage) { instance_double(V2::AppStage, stage: nil, warnings: []) } before do FeatureFlag.create(name: 'diego_docker', enabled: true) @@ -545,7 +545,7 @@ module VCAP::CloudController end describe 'staging' do - let(:app_stage) { instance_double(V2::AppStage, stage: nil) } + let(:app_stage) { instance_double(V2::AppStage, stage: nil, warnings: []) } let(:process) { ProcessModelFactory.make(state: 'STARTED') } let(:app) { process.app } diff --git a/spec/unit/controllers/runtime/apps_controller_spec.rb b/spec/unit/controllers/runtime/apps_controller_spec.rb index 300f736f34b..d3231f87a60 100644 --- a/spec/unit/controllers/runtime/apps_controller_spec.rb +++ b/spec/unit/controllers/runtime/apps_controller_spec.rb @@ -1094,7 +1094,7 @@ def update_app end describe 'staging' do - let(:app_stage) { instance_double(V2::AppStage, stage: nil) } + let(:app_stage) { instance_double(V2::AppStage, stage: nil, warnings: []) } let(:process) { ProcessModelFactory.make } before do diff --git a/spec/unit/controllers/runtime/restages_controller_spec.rb b/spec/unit/controllers/runtime/restages_controller_spec.rb index 854d3d6aaa9..12ca41e2f93 100644 --- a/spec/unit/controllers/runtime/restages_controller_spec.rb +++ b/spec/unit/controllers/runtime/restages_controller_spec.rb @@ -11,7 +11,7 @@ module VCAP::CloudController describe 'POST /v2/apps/:id/restage' do subject(:restage_request) { post "/v2/apps/#{process.app.guid}/restage", {} } let!(:process) { ProcessModelFactory.make } - let(:app_stage) { instance_double(V2::AppStage, stage: nil) } + let(:app_stage) { instance_double(V2::AppStage, stage: nil, warnings: []) } before do allow(V2::AppStage).to receive(:new).and_return(app_stage) diff --git a/spec/unit/lib/cloud_controller/stack_state_validator_spec.rb b/spec/unit/lib/cloud_controller/stack_state_validator_spec.rb new file mode 100644 index 00000000000..f6222220576 --- /dev/null +++ b/spec/unit/lib/cloud_controller/stack_state_validator_spec.rb @@ -0,0 +1,238 @@ +require 'spec_helper' + +module VCAP::CloudController + RSpec.describe StackStateValidator do + describe '.validate_for_new_app!' do + context 'when stack is Active' do + let(:stack) { Stack.make(state: StackStates::STACK_ACTIVE, description: 'My ACTIVE stack') } + + it 'returns empty warnings' do + result = StackStateValidator.validate_for_new_app!(stack) + expect(result).to eq([]) + end + + it 'does not raise an error' do + expect { StackStateValidator.validate_for_new_app!(stack) }.not_to raise_error + end + end + + context 'when stack is DEPRECATED' do + let(:stack) { Stack.make(state: StackStates::STACK_DEPRECATED, description: 'My DEPRECATED stack') } + + it 'returns a warning message' do + result = StackStateValidator.validate_for_new_app!(stack) + expect(result).to be_an(Array) + expect(result.size).to eq(1) + expect(result.first).to include("Stack '#{stack.name}' is deprecated and will be removed in the future. #{stack.description}") + end + + it 'returns a warning message with stack name' do + result = StackStateValidator.validate_for_new_app!(stack) + warning = result.first + expect(warning).to include(stack.name) + expect(warning).to include(stack.description) + expect(warning).to include('deprecated') + end + + it 'does not raise an error' do + expect { StackStateValidator.validate_for_new_app!(stack) }.not_to raise_error + end + end + + context 'when stack is RESTRICTED' do + let(:stack) { Stack.make(state: StackStates::STACK_RESTRICTED, description: 'My RESTRICTED stack') } + + it 'raise RestrictedStackError' do + expect do + StackStateValidator.validate_for_new_app!(stack) + end.to raise_error(StackStateValidator::RestrictedStackError, /Stack '#{stack.name}' is restricted and cannot be used for staging new applications./) + end + + it 'includes stack name in error message' do + expect do + StackStateValidator.validate_for_new_app!(stack) + end.to raise_error(StackStateValidator::RestrictedStackError, /#{stack.name}/) + end + + it 'includes stack description in error message' do + expect do + StackStateValidator.validate_for_new_app!(stack) + end.to raise_error(StackStateValidator::RestrictedStackError, /#{stack.description}/) + end + + it 'raises RestrictedStackError which is a StackStateValidator::Error' do + expect do + StackStateValidator.validate_for_new_app!(stack) + end.to raise_error(StackStateValidator::StackValidationError) + end + end + + context 'when stack is DISABLED' do + let(:stack) { Stack.make(state: StackStates::STACK_DISABLED, description: 'My DEPRECATED stack') } + + it 'returns a disabled error message' do + expect do + StackStateValidator.validate_for_new_app!(stack) + end.to raise_error(StackStateValidator::DisabledStackError, /Stack '#{stack.name}' is disabled and cannot be used for staging new applications./) + end + + it 'includes stack name in error message' do + expect do + StackStateValidator.validate_for_new_app!(stack) + end.to raise_error(StackStateValidator::DisabledStackError, /#{stack.name}/) + end + + it 'includes stack description in error message' do + expect do + StackStateValidator.validate_for_new_app!(stack) + end.to raise_error(StackStateValidator::DisabledStackError, /#{stack.description}/) + end + end + end + + describe '.validate_for_restaging_app!' do + context 'when stack is Active' do + let(:stack) { Stack.make(state: StackStates::STACK_ACTIVE, description: 'My ACTIVE stack') } + + it 'for restaging returns empty warnings' do + result = StackStateValidator.validate_for_restaging!(stack) + expect(result).to eq([]) + end + + it 'does not raise an error' do + expect { StackStateValidator.validate_for_new_app!(stack) }.not_to raise_error + end + end + + context 'when stack is DEPRECATED' do + let(:stack) { Stack.make(state: StackStates::STACK_DEPRECATED, description: 'My DEPRECATED stack') } + + it 'returns a warning message' do + result = StackStateValidator.validate_for_restaging!(stack) + expect(result).to be_an(Array) + expect(result.size).to eq(1) + expect(result.first).to include("Stack '#{stack.name}' is deprecated and will be removed in the future. #{stack.description}") + end + + it 'returns a warning message with stack name' do + result = StackStateValidator.validate_for_restaging!(stack) + warning = result.first + expect(warning).to include(stack.name) + expect(warning).to include(stack.description) + expect(warning).to include('deprecated') + end + + it 'does not raise an error' do + expect { StackStateValidator.validate_for_restaging!(stack) }.not_to raise_error + end + end + + context 'when stack is RESTRICTED' do + let(:stack) { Stack.make(state: StackStates::STACK_RESTRICTED, description: 'My RESTRICTED stack') } + + it 'returns empty warnings' do + result = StackStateValidator.validate_for_restaging!(stack) + expect(result).to eq([]) + end + + it 'does not raise an error' do + expect { StackStateValidator.validate_for_restaging!(stack) }.not_to raise_error + end + end + + context 'when stack is DISABLED' do + let(:stack) { Stack.make(state: StackStates::STACK_DISABLED, description: 'My DEPRECATED stack') } + + it 'returns a disabled error message' do + expect do + StackStateValidator.validate_for_restaging!(stack) + end.to raise_error(StackStateValidator::DisabledStackError, /Stack '#{stack.name}' is disabled and cannot be used for staging new applications./) + end + + it 'includes stack name in error message' do + expect do + StackStateValidator.validate_for_restaging!(stack) + end.to raise_error(StackStateValidator::DisabledStackError, /#{stack.name}/) + end + + it 'includes stack description in error message' do + expect do + StackStateValidator.validate_for_restaging!(stack) + end.to raise_error(StackStateValidator::DisabledStackError, /#{stack.description}/) + end + end + end + + describe '.build_deprecation_warning' do + let(:stack) { Stack.make(name: 'cflinuxfs3', description: 'End of life December 2025') } + + it 'returns formatted warning string' do + warning = StackStateValidator.build_deprecation_warning(stack) + expect(warning).to be_a(String) + expect(warning).to include('cflinuxfs3') + expect(warning).to include('deprecated') + expect(warning).to include('End of life December 2025') + end + + it 'includes stack name when description is empty' do + stack.description = '' + warning = StackStateValidator.build_deprecation_warning(stack) + expect(warning).to include('cflinuxfs3') + end + + it 'handles nil description' do + stack.description = nil + warning = StackStateValidator.build_deprecation_warning(stack) + expect(warning).to include('cflinuxfs3') + 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) } + let(:restricted_stack) { Stack.make(state: StackStates::STACK_RESTRICTED) } + let(:disabled_stack) { Stack.make(state: StackStates::STACK_DISABLED) } + + describe 'new app creation' do + it 'allows ACTIVE without warnings' do + result = StackStateValidator.validate_for_new_app!(active_stack) + expect(result).to be_empty + end + + it 'allows DEPRECATED with warnings' do + result = StackStateValidator.validate_for_new_app!(deprecated_stack) + expect(result).not_to be_empty + end + + it 'rejects RESTRICTED' do + expect { StackStateValidator.validate_for_new_app!(restricted_stack) }.to raise_error(StackStateValidator::RestrictedStackError) + end + + it 'rejects DISABLED' do + expect { StackStateValidator.validate_for_new_app!(disabled_stack) }.to raise_error(StackStateValidator::DisabledStackError) + end + end + + describe 'restaging' do + it 'allows ACTIVE without warnings' do + result = StackStateValidator.validate_for_restaging!(active_stack) + expect(result).to be_empty + end + + it 'allows DEPRECATED with warnings' do + result = StackStateValidator.validate_for_restaging!(deprecated_stack) + expect(result).not_to be_empty + end + + it 'allows RESTRICTED without warnings' do + result = StackStateValidator.validate_for_restaging!(restricted_stack) + expect(result).to be_empty + end + + it 'rejects DISABLED' do + expect { StackStateValidator.validate_for_restaging!(disabled_stack) }.to raise_error(StackStateValidator::DisabledStackError) + end + end + end + end +end diff --git a/spec/unit/messages/stack_create_message_spec.rb b/spec/unit/messages/stack_create_message_spec.rb index d854ecf0b86..a8383b86c3c 100644 --- a/spec/unit/messages/stack_create_message_spec.rb +++ b/spec/unit/messages/stack_create_message_spec.rb @@ -81,5 +81,46 @@ end end end + + describe 'state' do + context 'when it is not provided' do + let(:params) { valid_params } + + it 'defaults to ACTIVE' do + expect(subject.state).to eq('ACTIVE') + end + end + + context 'when it is a valid state' do + %w[ACTIVE DEPRECATED RESTRICTED DISABLED].each do |valid_state| + context "when state is #{valid_state}" do + let(:params) { valid_params.merge({ state: valid_state }) } + + it 'is valid' do + expect(subject).to be_valid + expect(subject.state).to eq(valid_state) + end + end + end + end + + context 'when it is an invalid state' do + let(:params) { valid_params.merge({ state: 'INVALID_STATE' }) } + + it 'returns an error' do + expect(subject).not_to be_valid + expect(subject.errors[:state]).to include('must be one of ACTIVE, RESTRICTED, DEPRECATED, DISABLED') + end + end + + context 'when it is explicitly null' do + let(:params) { valid_params.merge({ state: nil }) } + + it 'returns an error' do + expect(subject).not_to be_valid + expect(subject.errors[:state]).to include('must be one of ACTIVE, RESTRICTED, DEPRECATED, DISABLED') + 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 new file mode 100644 index 00000000000..eae64b1caac --- /dev/null +++ b/spec/unit/messages/stack_update_message_spec.rb @@ -0,0 +1,66 @@ +require 'lightweight_spec_helper' +require 'messages/stack_update_message' + +RSpec.describe VCAP::CloudController::StackUpdateMessage do + describe 'validations' do + subject { described_class.new(params) } + + let(:valid_params) do + { + metadata: { + labels: { + potato: 'mashed' + }, + annotations: { + happy: 'annotation' + } + } + } + end + + it 'is valid with metadata only' do + expect(described_class.new(valid_params)).to be_valid + end + + describe 'state' do + context 'when it is a valid state' do + %w[ACTIVE DEPRECATED RESTRICTED DISABLED].each do |valid_state| + context "when state is #{valid_state}" do + let(:params) { valid_params.merge({ state: valid_state }) } + + it 'is valid' do + expect(subject).to be_valid + expect(subject.state).to eq(valid_state) + end + end + end + end + + context 'when it is an invalid state' do + let(:params) { valid_params.merge({ state: 'INVALID_STATE' }) } + + it 'returns an error' do + expect(subject).not_to be_valid + expect(subject.errors[:state]).to include('must be one of ACTIVE, RESTRICTED, DEPRECATED, DISABLED') + end + end + + context 'when it is not provided' do + let(:params) { valid_params } + + it 'is valid' do + expect(subject).to be_valid + end + end + + context 'when it is explicitly null' do + let(:params) { valid_params.merge({ state: nil }) } + + it 'returns an error' do + expect(subject).not_to be_valid + expect(subject.errors[:state]).to include('must be one of ACTIVE, RESTRICTED, DEPRECATED, DISABLED') + end + end + end + end +end diff --git a/spec/unit/models/runtime/stack_spec.rb b/spec/unit/models/runtime/stack_spec.rb index 48987111025..ac43503f088 100644 --- a/spec/unit/models/runtime/stack_spec.rb +++ b/spec/unit/models/runtime/stack_spec.rb @@ -26,6 +26,29 @@ module VCAP::CloudController it { is_expected.to validate_presence :name } it { is_expected.to validate_uniqueness :name } it { is_expected.to strip_whitespace :name } + + describe 'state validation' do + it 'accepts valid states' do + stack = Stack.make + StackStates::VALID_STATES.each do |valid_state| + stack.state = valid_state + expect(stack).to be_valid + end + end + + it 'rejects invalid states' do + stack = Stack.make + stack.state = 'INVALID' + expect(stack).not_to be_valid + expect(stack.errors[:state]).to include(:includes) + end + + it 'allows nil state' do + stack = Stack.make + stack.state = nil + expect(stack).to be_valid + end + end end describe 'Serialization' do diff --git a/spec/unit/presenters/v3/build_presenter_spec.rb b/spec/unit/presenters/v3/build_presenter_spec.rb index e47de781c56..658103e1e8b 100644 --- a/spec/unit/presenters/v3/build_presenter_spec.rb +++ b/spec/unit/presenters/v3/build_presenter_spec.rb @@ -158,6 +158,33 @@ module VCAP::CloudController::Presenters::V3 expect(result[:package][:guid]).to eq(@package_guid) end end + + context 'when stack has warnings' do + before do + build.instance_variable_set(:@stack_warnings, ['Stack cflinuxfs3 is deprecated. EOL Dec 2025']) + end + + it 'includes warnings in response' do + expect(result[:warnings]).to be_present + expect(result[:warnings]).to eq([{ detail: 'Stack cflinuxfs3 is deprecated. EOL Dec 2025' }]) + end + end + + context 'when stack has no warnings' do + before do + build.instance_variable_set(:@stack_warnings, []) + end + + it 'returns nil for warnings' do + expect(result[:warnings]).to be_nil + end + end + + context 'when stack warnings is nil' do + it 'returns nil for warnings' do + expect(result[:warnings]).to be_nil + end + end end end end