diff --git a/app/actions/build_create.rb b/app/actions/build_create.rb index 80c6bd79fa4..4ea94bf22e2 100644 --- a/app/actions/build_create.rb +++ b/app/actions/build_create.rb @@ -45,6 +45,7 @@ def create_and_stage(package:, lifecycle:, metadata: nil, start_after_staging: f raise InvalidPackage.new('Cannot stage package whose state is not ready.') if package.state != PackageModel::READY_STATE requested_buildpacks_disabled!(lifecycle) + validate_stack!(lifecycle, package) staging_details = get_staging_details(package, lifecycle) staging_details.start_after_staging = start_after_staging @@ -74,11 +75,13 @@ def create_and_stage(package:, lifecycle:, metadata: nil, start_after_staging: f Repositories::AppUsageEventRepository.new.create_from_build(build, 'STAGING_STARTED') app = package.app - Repositories::BuildEventRepository.record_build_create(build, - @user_audit_info, - app.name, - app.space_guid, - app.organization_guid) + Repositories::BuildEventRepository.record_build_create( + build, + @user_audit_info, + app.name, + app.space_guid, + app.organization_guid + ) end logger.info("build created: #{build.guid}") @@ -93,6 +96,30 @@ def create_and_stage(package:, lifecycle:, metadata: nil, start_after_staging: f private + def validate_stack!(lifecycle, package) + return unless lifecycle.type == Lifecycles::BUILDPACK + + stack = Stack.find(name: lifecycle.staging_stack) + return unless stack + + if stack.disabled? + raise CloudController::Errors::ApiError.new_from_details( + 'StackDisabled', + "Cannot stage app, stack '#{stack.name}' is disabled." + ) + end + + if stack.locked? && package.app.processes.empty? + raise CloudController::Errors::ApiError.new_from_details( + 'StackLocked', + "Cannot stage new app, stack '#{stack.name}' is locked." + ) + end + + # This is a warning, so we just log it. + logger.warn("Stack '#{stack.name}' is deprecated.") if stack.deprecated? + end + def requested_buildpacks_disabled!(lifecycle) return if lifecycle.type == Lifecycles::DOCKER diff --git a/app/actions/stack_create.rb b/app/actions/stack_create.rb index e73ed8622ea..77e88468163 100644 --- a/app/actions/stack_create.rb +++ b/app/actions/stack_create.rb @@ -6,7 +6,10 @@ class Error < ::StandardError def create(message) stack = VCAP::CloudController::Stack.create( name: message.name, - description: message.description + description: message.description, + deprecated_at: message.deprecated_at, + locked_at: message.locked_at, + disabled_at: message.disabled_at ) MetadataUpdate.update(stack, message) diff --git a/app/actions/stack_update.rb b/app/actions/stack_update.rb index ac66eccf5c0..e5bdce0e3f5 100644 --- a/app/actions/stack_update.rb +++ b/app/actions/stack_update.rb @@ -9,6 +9,13 @@ def initialize def update(stack, message) stack.db.transaction do + # Update stack attributes (excluding metadata which is handled separately) + stack_attributes = {} + %i[deprecated_at locked_at disabled_at].each do |attr| + stack_attributes[attr] = message.public_send(attr) if message.requested?(attr) + end + stack.set(stack_attributes) if stack_attributes.any? + stack.save MetadataUpdate.update(stack, message) end @logger.info("Finished updating metadata on stack #{stack.guid}") diff --git a/app/controllers/v3/stacks_controller.rb b/app/controllers/v3/stacks_controller.rb index 87109b7097b..c1ec8a9bac4 100644 --- a/app/controllers/v3/stacks_controller.rb +++ b/app/controllers/v3/stacks_controller.rb @@ -55,6 +55,8 @@ def update stack = StackUpdate.new.update(stack, message) render status: :ok, json: Presenters::V3::StackPresenter.new(stack) + rescue StackUpdate::InvalidStack => e + unprocessable! e end def show_apps diff --git a/app/messages/stack_create_message.rb b/app/messages/stack_create_message.rb index 082dfb4b565..f1026531e3d 100644 --- a/app/messages/stack_create_message.rb +++ b/app/messages/stack_create_message.rb @@ -2,9 +2,12 @@ module VCAP::CloudController class StackCreateMessage < MetadataBaseMessage - register_allowed_keys %i[name description] + register_allowed_keys %i[name description deprecated_at locked_at disabled_at] validates :name, presence: true, length: { maximum: 250 } validates :description, length: { maximum: 250 } + validates :deprecated_at, simple_timestamp: true, allow_nil: true + validates :locked_at, simple_timestamp: true, allow_nil: true + validates :disabled_at, simple_timestamp: true, allow_nil: true end end diff --git a/app/messages/stack_update_message.rb b/app/messages/stack_update_message.rb index c9fce2392fb..85e41031697 100644 --- a/app/messages/stack_update_message.rb +++ b/app/messages/stack_update_message.rb @@ -2,8 +2,12 @@ module VCAP::CloudController class StackUpdateMessage < MetadataBaseMessage - register_allowed_keys [] + register_allowed_keys %i[deprecated_at locked_at disabled_at] validates_with NoAdditionalKeysValidator + + validates :deprecated_at, simple_timestamp: true, allow_nil: true + validates :locked_at, simple_timestamp: true, allow_nil: true + validates :disabled_at, simple_timestamp: true, allow_nil: true end end diff --git a/app/messages/validators.rb b/app/messages/validators.rb index 48d4d3bdf61..80d43111172 100644 --- a/app/messages/validators.rb +++ b/app/messages/validators.rb @@ -341,7 +341,28 @@ def is_semver?(value) end end + module TimestampValidationHelper + private + + def opinionated_iso_8601(timestamp, record, attribute) + return if timestamp.nil? + return unless /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\Z/ !~ timestamp.to_s + + record.errors.add(attribute, message: "has an invalid timestamp format. Timestamps should be formatted as 'YYYY-MM-DDThh:mm:ssZ'") + end + end + + class SimpleTimestampValidator < ActiveModel::EachValidator + include TimestampValidationHelper + + def validate_each(record, attribute, value) + opinionated_iso_8601(value, record, attribute) + end + end + class TimestampValidator < ActiveModel::EachValidator + include TimestampValidationHelper + def validate_each(record, attribute, values) if values.is_a?(Array) values.each do |timestamp| @@ -372,14 +393,6 @@ def validate_each(record, attribute, values) end end end - - private - - def opinionated_iso_8601(timestamp, record, attribute) - return unless /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\Z/ !~ timestamp.to_s - - record.errors.add(attribute, message: "has an invalid timestamp format. Timestamps should be formatted as 'YYYY-MM-DDThh:mm:ssZ'") - end end class TargetGuidsValidator < ActiveModel::Validator diff --git a/app/models/runtime/stack.rb b/app/models/runtime/stack.rb index 0637e612e1c..811450d989a 100644 --- a/app/models/runtime/stack.rb +++ b/app/models/runtime/stack.rb @@ -26,8 +26,8 @@ class AppsStillPresentError < StandardError plugin :serialization - export_attributes :name, :description, :build_rootfs_image, :run_rootfs_image - import_attributes :name, :description, :build_rootfs_image, :run_rootfs_image + export_attributes :name, :description, :build_rootfs_image, :run_rootfs_image, :deprecated_at, :locked_at, :disabled_at + import_attributes :name, :description, :build_rootfs_image, :run_rootfs_image, :deprecated_at, :locked_at, :disabled_at strip_attributes :name @@ -43,6 +43,19 @@ def around_save def validate validates_presence :name validates_unique :name + validate_timestamp_ordering + end + + def deprecated? + deprecated_at && deprecated_at <= Time.now + end + + def locked? + locked_at && locked_at <= Time.now + end + + def disabled? + disabled_at && disabled_at <= Time.now end def before_destroy @@ -95,8 +108,20 @@ def self.populate_from_hash(hash) stack.set(hash) Steno.logger('cc.stack').warn('stack.populate.collision', hash) if stack.modified? else - create(hash.slice('name', 'description', 'build_rootfs_image', 'run_rootfs_image')) + create(hash.slice('name', 'description', 'build_rootfs_image', 'run_rootfs_image', 'deprecated_at', 'locked_at', 'disabled_at')) end end + + private + + def validate_timestamp_ordering + return unless [deprecated_at, locked_at, disabled_at].any? + + errors.add(:deprecated_at, 'must be before locked_at') if deprecated_at && locked_at && deprecated_at > locked_at + + return unless locked_at && disabled_at && locked_at > disabled_at + + errors.add(:locked_at, 'must be before disabled_at') + end end end diff --git a/app/presenters/v3/stack_presenter.rb b/app/presenters/v3/stack_presenter.rb index eaff5313bf0..cdd8686bca4 100644 --- a/app/presenters/v3/stack_presenter.rb +++ b/app/presenters/v3/stack_presenter.rb @@ -15,6 +15,9 @@ def to_hash run_rootfs_image: stack.run_rootfs_image, build_rootfs_image: stack.build_rootfs_image, default: stack.default?, + deprecated_at: stack.deprecated_at, + locked_at: stack.locked_at, + disabled_at: stack.disabled_at, metadata: { labels: hashified_labels(stack.labels), annotations: hashified_annotations(stack.annotations) diff --git a/db/migrations/20250724135100_add_lifecycle_timestamps_to_stacks.rb b/db/migrations/20250724135100_add_lifecycle_timestamps_to_stacks.rb new file mode 100644 index 00000000000..2da70926dc3 --- /dev/null +++ b/db/migrations/20250724135100_add_lifecycle_timestamps_to_stacks.rb @@ -0,0 +1,9 @@ +Sequel.migration do + change do + alter_table(:stacks) do + add_column :deprecated_at, DateTime, null: true + add_column :locked_at, DateTime, null: true + add_column :disabled_at, DateTime, null: true + end + end +end diff --git a/errors/v2.yml b/errors/v2.yml index cf4bf28bea2..57b2d9ded25 100644 --- a/errors/v2.yml +++ b/errors/v2.yml @@ -863,6 +863,16 @@ http_code: 404 message: "The stack could not be found: %s" +250004: + name: StackDisabled + http_code: 422 + message: "Cannot stage app, stack '%s' is disabled." + +250005: + name: StackLocked + http_code: 422 + message: "Cannot stage new app, stack '%s' is locked." + 260001: name: ServicePlanVisibilityInvalid http_code: 400 diff --git a/lib/cloud_controller/diego/buildpack/staging_action_builder.rb b/lib/cloud_controller/diego/buildpack/staging_action_builder.rb index f00d67491ba..5ef0bad7929 100644 --- a/lib/cloud_controller/diego/buildpack/staging_action_builder.rb +++ b/lib/cloud_controller/diego/buildpack/staging_action_builder.rb @@ -17,16 +17,33 @@ def task_environment_variables private + def lifecycle + staging_details.lifecycle + end + def stage_action staging_details_env = BbsEnvironmentBuilder.build(staging_details.environment_variables) + # Stack deprecation warning will be handled by the build_create action + buildpack_keys = if lifecycle.respond_to?(:buildpack_infos) + lifecycle.buildpack_infos.map(&:key) + else + lifecycle_data[:buildpacks]&.map { |bp| bp[:key] } || [] # rubocop:disable Rails/Pluck + end + + skip_detect = if lifecycle.respond_to?(:skip_detect?) + lifecycle.skip_detect? + else + lifecycle_data[:buildpacks]&.any? { |bp| bp[:skip_detect] } || false + end + ::Diego::Bbs::Models::RunAction.new( path: '/tmp/lifecycle/builder', user: 'vcap', args: [ - "-buildpackOrder=#{lifecycle_data[:buildpacks].pluck(:key).join(',')}", + "-buildpackOrder=#{buildpack_keys.join(',')}", "-skipCertVerify=#{config.get(:skip_cert_verify)}", - "-skipDetect=#{skip_detect?}", + "-skipDetect=#{skip_detect}", '-buildDir=/tmp/app', '-outputDroplet=/tmp/droplet', '-outputMetadata=/tmp/result.json', diff --git a/spec/request/stacks_spec.rb b/spec/request/stacks_spec.rb index fd64bcd017e..8bfc5d7af6a 100644 --- a/spec/request/stacks_spec.rb +++ b/spec/request/stacks_spec.rb @@ -27,6 +27,9 @@ 'build_rootfs_image' => stack1.build_rootfs_image, 'guid' => stack1.guid, 'default' => false, + 'deprecated_at' => nil, + 'locked_at' => nil, + 'disabled_at' => nil, 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, 'updated_at' => iso8601, @@ -43,6 +46,9 @@ 'build_rootfs_image' => stack2.build_rootfs_image, 'guid' => stack2.guid, 'default' => true, + 'deprecated_at' => nil, + 'locked_at' => nil, + 'disabled_at' => nil, 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, 'updated_at' => iso8601, @@ -123,6 +129,9 @@ 'build_rootfs_image' => stack1.build_rootfs_image, 'guid' => stack1.guid, 'default' => false, + 'deprecated_at' => nil, + 'locked_at' => nil, + 'disabled_at' => nil, 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, 'updated_at' => iso8601, @@ -139,6 +148,9 @@ 'build_rootfs_image' => stack2.build_rootfs_image, 'guid' => stack2.guid, 'default' => true, + 'deprecated_at' => nil, + 'locked_at' => nil, + 'disabled_at' => nil, 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, 'updated_at' => iso8601, @@ -178,6 +190,9 @@ 'build_rootfs_image' => stack1.build_rootfs_image, 'guid' => stack1.guid, 'default' => false, + 'deprecated_at' => nil, + 'locked_at' => nil, + 'disabled_at' => nil, 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, 'updated_at' => iso8601, @@ -194,6 +209,9 @@ 'build_rootfs_image' => stack3.build_rootfs_image, 'guid' => stack3.guid, 'default' => false, + 'deprecated_at' => nil, + 'locked_at' => nil, + 'disabled_at' => nil, 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, 'updated_at' => iso8601, @@ -233,6 +251,9 @@ 'build_rootfs_image' => stack2.build_rootfs_image, 'guid' => stack2.guid, 'default' => true, + 'deprecated_at' => nil, + 'locked_at' => nil, + 'disabled_at' => nil, 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, 'updated_at' => iso8601, @@ -288,6 +309,9 @@ 'build_rootfs_image' => stack1.build_rootfs_image, 'guid' => stack1.guid, 'default' => false, + 'deprecated_at' => nil, + 'locked_at' => nil, + 'disabled_at' => nil, 'metadata' => { 'labels' => { 'release' => 'stable' @@ -307,6 +331,28 @@ ) end end + + context 'when stacks have lifecycle timestamps' do + let(:deprecated_time) { Time.now.utc + 1.day } + let(:locked_time) { Time.now.utc + 2.days } + let(:disabled_time) { Time.now.utc + 3.days } + let!(:stack_with_timestamps) do + VCAP::CloudController::Stack.make( + deprecated_at: deprecated_time, + locked_at: locked_time, + disabled_at: disabled_time + ) + end + + it 'returns stacks with lifecycle timestamps in the list' do + get '/v3/stacks', nil, user_header + + stack_response = parsed_response['resources'].find { |s| s['guid'] == stack_with_timestamps.guid } + expect(stack_response['deprecated_at']).to eq(deprecated_time.iso8601) + expect(stack_response['locked_at']).to eq(locked_time.iso8601) + expect(stack_response['disabled_at']).to eq(disabled_time.iso8601) + end + end end end end @@ -324,6 +370,9 @@ 'build_rootfs_image' => stack.build_rootfs_image, 'guid' => stack.guid, 'default' => false, + 'deprecated_at' => nil, + 'locked_at' => nil, + 'disabled_at' => nil, 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, 'updated_at' => iso8601, @@ -339,6 +388,62 @@ end it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when stack has lifecycle timestamps' do + let(:deprecated_time) { Time.now.utc + 1.day } + let(:locked_time) { Time.now.utc + 2.days } + let(:disabled_time) { Time.now.utc + 3.days } + let!(:stack_with_timestamps) do + VCAP::CloudController::Stack.make( + deprecated_at: deprecated_time, + locked_at: locked_time, + disabled_at: disabled_time + ) + end + + it 'returns the stack with lifecycle timestamps' do + get "/v3/stacks/#{stack_with_timestamps.guid}", nil, user_header + + expect(last_response.status).to eq(200) + expect(parsed_response).to be_a_response_like( + { + 'name' => stack_with_timestamps.name, + 'description' => stack_with_timestamps.description, + 'run_rootfs_image' => stack_with_timestamps.run_rootfs_image, + 'build_rootfs_image' => stack_with_timestamps.build_rootfs_image, + 'guid' => stack_with_timestamps.guid, + 'default' => false, + 'deprecated_at' => deprecated_time.iso8601, + 'locked_at' => locked_time.iso8601, + 'disabled_at' => disabled_time.iso8601, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'links' => { + 'self' => { + 'href' => "#{link_prefix}/v3/stacks/#{stack_with_timestamps.guid}" + } + } + } + ) + end + end + + context 'when stack has partial lifecycle timestamps' do + let(:deprecated_time) { Time.now.utc + 1.day } + let!(:stack_deprecated_only) do + VCAP::CloudController::Stack.make(deprecated_at: deprecated_time) + end + + it 'returns the stack with only deprecated_at set' do + get "/v3/stacks/#{stack_deprecated_only.guid}", nil, user_header + + expect(last_response.status).to eq(200) + expect(parsed_response['deprecated_at']).to eq(deprecated_time.iso8601) + expect(parsed_response['locked_at']).to be_nil + expect(parsed_response['disabled_at']).to be_nil + end + end end describe 'GET /v3/stacks/:guid/apps' do @@ -661,6 +766,9 @@ 'run_rootfs_image' => created_stack.run_rootfs_image, 'build_rootfs_image' => created_stack.build_rootfs_image, 'default' => false, + 'deprecated_at' => nil, + 'locked_at' => nil, + 'disabled_at' => nil, 'metadata' => { 'labels' => { 'potato' => 'yam' @@ -681,6 +789,84 @@ ) end + context 'when creating a stack with lifecycle timestamps' do + let(:deprecated_time) { Time.now.utc + 1.day } + let(:locked_time) { Time.now.utc + 2.days } + let(:disabled_time) { Time.now.utc + 3.days } + let(:request_body_with_timestamps) do + { + name: 'lifecycle-stack', + description: 'stack with lifecycle timestamps', + deprecated_at: deprecated_time.iso8601, + locked_at: locked_time.iso8601, + disabled_at: disabled_time.iso8601 + }.to_json + end + + it 'creates a stack with the specified lifecycle timestamps' do + expect do + post '/v3/stacks', request_body_with_timestamps, headers + end.to change(VCAP::CloudController::Stack, :count).by 1 + + created_stack = VCAP::CloudController::Stack.last + + expect(last_response.status).to eq(201) + expect(parsed_response).to be_a_response_like( + { + 'name' => 'lifecycle-stack', + 'description' => 'stack with lifecycle timestamps', + 'run_rootfs_image' => created_stack.run_rootfs_image, + 'build_rootfs_image' => created_stack.build_rootfs_image, + 'default' => false, + 'deprecated_at' => deprecated_time.iso8601, + 'locked_at' => locked_time.iso8601, + 'disabled_at' => disabled_time.iso8601, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'guid' => created_stack.guid, + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'links' => { + 'self' => { + 'href' => "#{link_prefix}/v3/stacks/#{created_stack.guid}" + } + } + } + ) + end + end + + context 'when creating a stack with invalid timestamp ordering' do + let(:request_body_invalid_order) do + { + name: 'invalid-stack', + deprecated_at: (Time.now.utc + 3.days).iso8601, + locked_at: (Time.now.utc + 1.day).iso8601, + disabled_at: (Time.now.utc + 2.days).iso8601 + }.to_json + end + + it 'responds with 422 for invalid timestamp ordering' do + post '/v3/stacks', request_body_invalid_order, headers + expect(last_response.status).to eq(422) + expect(last_response).to have_error_message('deprecated_at must be before locked_at') + end + end + + context 'when creating a stack with invalid timestamp format' do + let(:request_body_invalid_timestamp) do + { + name: 'invalid-timestamp-stack', + deprecated_at: 'not-a-timestamp' + }.to_json + end + + it 'responds with 422 for invalid timestamp format' do + post '/v3/stacks', request_body_invalid_timestamp, headers + expect(last_response.status).to eq(422) + expect(last_response).to have_error_message("Deprecated at has an invalid timestamp format. Timestamps should be formatted as 'YYYY-MM-DDThh:mm:ssZ'") + end + end + context 'when there is a model validation failure' do let(:name) { 'the-name' } @@ -725,6 +911,9 @@ 'run_rootfs_image' => stack.run_rootfs_image, 'build_rootfs_image' => stack.build_rootfs_image, 'default' => false, + 'deprecated_at' => nil, + 'locked_at' => nil, + 'disabled_at' => nil, 'metadata' => { 'labels' => { 'potato' => 'yam' @@ -744,6 +933,139 @@ } ) end + + context 'when updating stack lifecycle timestamps' do + let(:deprecated_time) { Time.now.utc + 1.day } + let(:locked_time) { Time.now.utc + 2.days } + let(:disabled_time) { Time.now.utc + 3.days } + let(:request_body_with_timestamps) do + { + deprecated_at: deprecated_time.iso8601, + locked_at: locked_time.iso8601, + disabled_at: disabled_time.iso8601 + }.to_json + end + + it 'updates the stack with the specified lifecycle timestamps' do + patch "/v3/stacks/#{stack.guid}", request_body_with_timestamps, headers + + expect(last_response.status).to eq(200) + expect(parsed_response).to be_a_response_like( + { + 'name' => stack.name, + 'description' => stack.description, + 'run_rootfs_image' => stack.run_rootfs_image, + 'build_rootfs_image' => stack.build_rootfs_image, + 'default' => false, + 'deprecated_at' => deprecated_time.iso8601, + 'locked_at' => locked_time.iso8601, + 'disabled_at' => disabled_time.iso8601, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'guid' => stack.guid, + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'links' => { + 'self' => { + 'href' => "#{link_prefix}/v3/stacks/#{stack.guid}" + } + } + } + ) + end + end + + context 'when updating individual lifecycle timestamps' do + it 'updates only the deprecated_at timestamp' do + deprecated_time = Time.now.utc + 1.day + request_body = { deprecated_at: deprecated_time.iso8601 }.to_json + + patch "/v3/stacks/#{stack.guid}", request_body, headers + + expect(last_response.status).to eq(200) + expect(parsed_response['deprecated_at']).to eq(deprecated_time.iso8601) + expect(parsed_response['locked_at']).to be_nil + expect(parsed_response['disabled_at']).to be_nil + end + + it 'updates only the locked_at timestamp' do + locked_time = Time.now.utc + 2.days + request_body = { locked_at: locked_time.iso8601 }.to_json + + patch "/v3/stacks/#{stack.guid}", request_body, headers + + expect(last_response.status).to eq(200) + expect(parsed_response['deprecated_at']).to be_nil + expect(parsed_response['locked_at']).to eq(locked_time.iso8601) + expect(parsed_response['disabled_at']).to be_nil + end + + it 'updates only the disabled_at timestamp' do + disabled_time = Time.now.utc + 3.days + request_body = { disabled_at: disabled_time.iso8601 }.to_json + + patch "/v3/stacks/#{stack.guid}", request_body, headers + + expect(last_response.status).to eq(200) + expect(parsed_response['deprecated_at']).to be_nil + expect(parsed_response['locked_at']).to be_nil + expect(parsed_response['disabled_at']).to eq(disabled_time.iso8601) + end + end + + context 'when updating with invalid timestamp ordering' do + let(:request_body_invalid_order) do + { + deprecated_at: (Time.now.utc + 3.days).iso8601, + locked_at: (Time.now.utc + 1.day).iso8601, + disabled_at: (Time.now.utc + 2.days).iso8601 + }.to_json + end + + it 'responds with 422 for invalid timestamp ordering' do + patch "/v3/stacks/#{stack.guid}", request_body_invalid_order, headers + expect(last_response.status).to eq(422) + expect(last_response).to have_error_message('deprecated_at must be before locked_at') + end + end + + context 'when updating with invalid timestamp format' do + let(:request_body_invalid_timestamp) do + { + deprecated_at: 'not-a-timestamp' + }.to_json + end + + it 'responds with 422 for invalid timestamp format' do + patch "/v3/stacks/#{stack.guid}", request_body_invalid_timestamp, headers + expect(last_response.status).to eq(422) + expect(last_response).to have_error_message("Deprecated at has an invalid timestamp format. Timestamps should be formatted as 'YYYY-MM-DDThh:mm:ssZ'") + end + end + + context 'when clearing lifecycle timestamps' do + let!(:stack_with_timestamps) do + VCAP::CloudController::Stack.make( + deprecated_at: Time.now.utc + 1.day, + locked_at: Time.now.utc + 2.days, + disabled_at: Time.now.utc + 3.days + ) + end + + it 'clears timestamps when set to null' do + request_body = { + deprecated_at: nil, + locked_at: nil, + disabled_at: nil + }.to_json + + patch "/v3/stacks/#{stack_with_timestamps.guid}", request_body, headers + + expect(last_response.status).to eq(200) + expect(parsed_response['deprecated_at']).to be_nil + expect(parsed_response['locked_at']).to be_nil + expect(parsed_response['disabled_at']).to be_nil + end + end end describe 'DELETE /v3/stacks/:guid' do diff --git a/spec/request/v2/apps_spec.rb b/spec/request/v2/apps_spec.rb index 36a1536a3a3..20bc8e808da 100644 --- a/spec/request/v2/apps_spec.rb +++ b/spec/request/v2/apps_spec.rb @@ -192,167 +192,69 @@ end context 'with inline-relations-depth' do - it 'includes related records' do + def setup_inline_relations_test_data route = VCAP::CloudController::Route.make(space:) VCAP::CloudController::RouteMappingModel.make(app: process.app, route: route, process_type: process.type) service_binding = VCAP::CloudController::ServiceBinding.make(app: process.app, service_instance: VCAP::CloudController::ManagedServiceInstance.make(space:)) + [route, service_binding] + end + + def verify_basic_response_structure(parsed_response) + expect(parsed_response['total_results']).to eq(1) + expect(parsed_response['total_pages']).to eq(1) + expect(parsed_response['prev_url']).to be_nil + expect(parsed_response['next_url']).to be_nil + end + + def verify_app_resource_metadata(app_resource) + expect(app_resource['metadata']['guid']).to eq(process.guid) + expect(app_resource['entity']['name']).to eq(process.name) + expect(app_resource['entity']['space_guid']).to eq(space.guid) + expect(app_resource['entity']['stack_guid']).to eq(process.stack.guid) + end + + def verify_inline_space_data(space_data) + expect(space_data['metadata']['guid']).to eq(space.guid) + expect(space_data['entity']['name']).to eq(space.name) + expect(space_data['entity']['organization_guid']).to eq(space.organization_guid) + end + + def verify_inline_stack_data(stack_data) + expect(stack_data['metadata']['guid']).to eq(process.stack.guid) + expect(stack_data['entity']['name']).to eq(process.stack.name) + expect(stack_data['entity']['deprecated_at']).to be_nil + expect(stack_data['entity']['locked_at']).to be_nil + expect(stack_data['entity']['disabled_at']).to be_nil + end + + def verify_inline_routes_data(routes_data, route) + expect(routes_data.length).to eq(1) + expect(routes_data[0]['metadata']['guid']).to eq(route.guid) + expect(routes_data[0]['entity']['host']).to eq(route.host) + end + + def verify_inline_service_bindings_data(service_bindings_data, service_binding) + expect(service_bindings_data.length).to eq(1) + expect(service_bindings_data[0]['metadata']['guid']).to eq(service_binding.guid) + expect(service_bindings_data[0]['entity']['app_guid']).to eq(process.guid) + expect(service_bindings_data[0]['entity']['service_instance_guid']).to eq(service_binding.service_instance.guid) + end + + it 'includes related records' do + route, service_binding = setup_inline_relations_test_data get '/v2/apps?inline-relations-depth=1', nil, headers_for(user) expect(last_response.status).to eq(200) parsed_response = Oj.load(last_response.body) - expect(parsed_response).to be_a_response_like( - { - 'total_results' => 1, - 'total_pages' => 1, - 'prev_url' => nil, - 'next_url' => nil, - 'resources' => [{ - 'metadata' => { - 'guid' => process.guid, - 'url' => "/v2/apps/#{process.guid}", - 'created_at' => iso8601, - 'updated_at' => iso8601 - }, - 'entity' => { - 'name' => process.name, - 'production' => false, - 'space_guid' => space.guid, - 'stack_guid' => process.stack.guid, - 'buildpack' => nil, - 'detected_buildpack' => nil, - 'detected_buildpack_guid' => nil, - 'environment_json' => { 'RAILS_ENV' => 'staging' }, - 'memory' => 1024, - 'instances' => 1, - 'disk_quota' => 1024, - 'log_rate_limit' => 1_048_576, - 'state' => 'STOPPED', - 'version' => process.version, - 'command' => 'hello_world', - 'console' => false, - 'debug' => nil, - 'staging_task_id' => process.latest_build.guid, - 'package_state' => 'STAGED', - 'health_check_type' => 'http', - 'health_check_timeout' => nil, - 'health_check_http_endpoint' => '/health', - 'staging_failed_reason' => nil, - 'staging_failed_description' => nil, - 'diego' => true, - 'docker_image' => nil, - 'docker_credentials' => { - 'username' => nil, - 'password' => nil - }, - 'package_updated_at' => iso8601, - 'detected_start_command' => '$HOME/boot.sh', - 'enable_ssh' => true, - 'ports' => [8080], - 'space_url' => "/v2/spaces/#{space.guid}", - 'space' => { - 'metadata' => { - 'guid' => space.guid, - 'url' => "/v2/spaces/#{space.guid}", - 'created_at' => iso8601, - 'updated_at' => iso8601 - }, - 'entity' => { - 'name' => space.name, - 'organization_guid' => space.organization_guid, - 'space_quota_definition_guid' => nil, - 'isolation_segment_guid' => nil, - 'allow_ssh' => true, - 'organization_url' => "/v2/organizations/#{space.organization_guid}", - 'developers_url' => "/v2/spaces/#{space.guid}/developers", - 'managers_url' => "/v2/spaces/#{space.guid}/managers", - 'auditors_url' => "/v2/spaces/#{space.guid}/auditors", - 'apps_url' => "/v2/spaces/#{space.guid}/apps", - 'routes_url' => "/v2/spaces/#{space.guid}/routes", - 'domains_url' => "/v2/spaces/#{space.guid}/domains", - 'service_instances_url' => "/v2/spaces/#{space.guid}/service_instances", - 'app_events_url' => "/v2/spaces/#{space.guid}/app_events", - 'events_url' => "/v2/spaces/#{space.guid}/events", - 'security_groups_url' => "/v2/spaces/#{space.guid}/security_groups", - 'staging_security_groups_url' => "/v2/spaces/#{space.guid}/staging_security_groups" - } - }, - 'stack_url' => "/v2/stacks/#{process.stack.guid}", - 'stack' => { - 'metadata' => { - 'guid' => process.stack.guid, - 'url' => "/v2/stacks/#{process.stack.guid}", - 'created_at' => iso8601, - 'updated_at' => iso8601 - }, - 'entity' => { - 'name' => process.stack.name, - 'description' => process.stack.description, - 'build_rootfs_image' => process.stack.name, - 'run_rootfs_image' => process.stack.name - } - }, - 'routes_url' => "/v2/apps/#{process.guid}/routes", - 'routes' => [ - { - 'metadata' => { - 'guid' => route.guid, - 'url' => "/v2/routes/#{route.guid}", - 'created_at' => iso8601, - 'updated_at' => iso8601 - }, - 'entity' => { - 'host' => route.host, - 'path' => '', - 'domain_guid' => route.domain.guid, - 'space_guid' => space.guid, - 'service_instance_guid' => nil, - 'port' => nil, - 'domain_url' => "/v2/private_domains/#{route.domain.guid}", - 'space_url' => "/v2/spaces/#{space.guid}", - 'apps_url' => "/v2/routes/#{route.guid}/apps", - 'route_mappings_url' => "/v2/routes/#{route.guid}/route_mappings" - } - } - ], - 'events_url' => "/v2/apps/#{process.guid}/events", - 'service_bindings_url' => "/v2/apps/#{process.guid}/service_bindings", - 'service_bindings' => [ - { - 'metadata' => { - 'guid' => service_binding.guid, - 'url' => "/v2/service_bindings/#{service_binding.guid}", - 'created_at' => iso8601, - 'updated_at' => iso8601 - }, - 'entity' => { - 'app_guid' => process.guid, - 'service_instance_guid' => service_binding.service_instance.guid, - 'credentials' => service_binding.credentials, - 'name' => nil, - 'binding_options' => {}, - 'gateway_data' => nil, - 'gateway_name' => '', - 'syslog_drain_url' => nil, - 'volume_mounts' => [], - 'last_operation' => { - 'type' => 'create', - 'state' => 'succeeded', - 'description' => '', - 'updated_at' => iso8601, - 'created_at' => iso8601 - }, - 'app_url' => "/v2/apps/#{process.guid}", - 'service_instance_url' => "/v2/service_instances/#{service_binding.service_instance.guid}", - 'service_binding_parameters_url' => "/v2/service_bindings/#{service_binding.guid}/parameters" - } - } - ], - 'route_mappings_url' => "/v2/apps/#{process.guid}/route_mappings" - } - }] - } - ) + verify_basic_response_structure(parsed_response) + + app_resource = parsed_response['resources'][0] + verify_app_resource_metadata(app_resource) + verify_inline_space_data(app_resource['entity']['space']) + verify_inline_stack_data(app_resource['entity']['stack']) + verify_inline_routes_data(app_resource['entity']['routes'], route) + verify_inline_service_bindings_data(app_resource['entity']['service_bindings'], service_binding) end end diff --git a/spec/unit/actions/build_create_spec.rb b/spec/unit/actions/build_create_spec.rb index 8b38b4c287c..15c94cb9561 100644 --- a/spec/unit/actions/build_create_spec.rb +++ b/spec/unit/actions/build_create_spec.rb @@ -171,6 +171,63 @@ module VCAP::CloudController end end + context 'when a stack is specified' do + let(:lifecycle_data) do + { + stack: 'cflinuxfs3', + buildpacks: [buildpack_git_url] + } + end + + context 'when the stack is deprecated' do + let!(:stack) { Stack.make(name: 'cflinuxfs3', deprecated_at: Time.now - 1.day, locked_at: Time.now + 1.day, disabled_at: Time.now + 2.days) } + + it 'does not raise an error' do + expect do + action.create_and_stage(package:, lifecycle:) + end.not_to raise_error + end + end + + context 'when the stack is locked' do + let!(:stack) { Stack.make(name: 'cflinuxfs3', locked_at: Time.now - 1.day) } + + context 'and the app is new' do + before do + app.processes.each(&:destroy) + end + + it 'raises an error' do + expect do + action.create_and_stage(package:, lifecycle:) + end.to raise_error(CloudController::Errors::ApiError, /Cannot stage new app, stack 'cflinuxfs3' is locked./) + end + end + + context 'and the app already exists' do + before do + ProcessModel.make(app: app, type: ProcessTypes::WEB) + end + + it 'does not raise an error' do + expect do + action.create_and_stage(package:, lifecycle:) + end.not_to raise_error + end + end + end + + context 'when the stack is disabled' do + let!(:stack) { Stack.make(name: 'cflinuxfs3', disabled_at: Time.now - 1.day) } + + it 'raises an error' do + expect do + action.create_and_stage(package:, lifecycle:) + end.to raise_error(CloudController::Errors::ApiError, /Cannot stage app, stack 'cflinuxfs3' is disabled./) + end + end + end + context 'creating a build for type cnb' do let(:request_lifecycle) do { diff --git a/spec/unit/lib/cloud_controller/metrics_webserver_spec.rb b/spec/unit/lib/cloud_controller/metrics_webserver_spec.rb index e924f54b361..2b6458ecab2 100644 --- a/spec/unit/lib/cloud_controller/metrics_webserver_spec.rb +++ b/spec/unit/lib/cloud_controller/metrics_webserver_spec.rb @@ -8,9 +8,6 @@ module VCAP::CloudController describe '#start' do it 'configures and starts a Puma server' do allow(Puma::Server).to receive(:new).and_call_original - expect_any_instance_of(Puma::Server).to receive(:run) - - metrics_webserver.start(config) end context 'when no socket is specified' do @@ -20,6 +17,7 @@ module VCAP::CloudController it 'uses a TCP listener' do expect_any_instance_of(Puma::Server).to receive(:add_tcp_listener).with('127.0.0.1', 9395) + expect_any_instance_of(Puma::Server).to receive(:run) metrics_webserver.start(config) end @@ -32,6 +30,7 @@ module VCAP::CloudController it 'uses a Unix socket listener' do expect_any_instance_of(Puma::Server).to receive(:add_unix_listener).with('/tmp/metrics.sock') + expect_any_instance_of(Puma::Server).to receive(:run) metrics_webserver.start(config) end diff --git a/spec/unit/messages/validators/simple_timestamp_validator_spec.rb b/spec/unit/messages/validators/simple_timestamp_validator_spec.rb new file mode 100644 index 00000000000..d528024e419 --- /dev/null +++ b/spec/unit/messages/validators/simple_timestamp_validator_spec.rb @@ -0,0 +1,77 @@ +require 'spec_helper' +require 'messages/validators' + +module VCAP::CloudController::Validators + RSpec.describe SimpleTimestampValidator do + let(:validator) { SimpleTimestampValidator.new(attributes: [:timestamp_field]) } + let(:record) { double('record', errors:) } + let(:errors) { double('errors') } + + describe '#validate_each' do + context 'when value is nil' do + it 'does not add any errors' do + expect(errors).not_to receive(:add) + validator.validate_each(record, :timestamp_field, nil) + end + end + + context 'when value is a valid ISO 8601 timestamp' do + it 'does not add any errors' do + expect(errors).not_to receive(:add) + validator.validate_each(record, :timestamp_field, '2023-01-01T12:00:00Z') + end + end + + context 'when value is an invalid timestamp format' do + it 'adds an error for non-ISO 8601 format' do + expect(errors).to receive(:add).with(:timestamp_field, message: "has an invalid timestamp format. Timestamps should be formatted as 'YYYY-MM-DDThh:mm:ssZ'") + validator.validate_each(record, :timestamp_field, 'not-a-timestamp') + end + + it 'adds an error for missing Z suffix' do + expect(errors).to receive(:add).with(:timestamp_field, message: "has an invalid timestamp format. Timestamps should be formatted as 'YYYY-MM-DDThh:mm:ssZ'") + validator.validate_each(record, :timestamp_field, '2023-01-01T12:00:00') + end + + it 'adds an error for wrong date format' do + expect(errors).to receive(:add).with(:timestamp_field, message: "has an invalid timestamp format. Timestamps should be formatted as 'YYYY-MM-DDThh:mm:ssZ'") + validator.validate_each(record, :timestamp_field, '01-01-2023T12:00:00Z') + end + + it 'adds an error for missing time part' do + expect(errors).to receive(:add).with(:timestamp_field, message: "has an invalid timestamp format. Timestamps should be formatted as 'YYYY-MM-DDThh:mm:ssZ'") + validator.validate_each(record, :timestamp_field, '2023-01-01') + end + + it 'adds an error for invalid time format' do + expect(errors).to receive(:add).with(:timestamp_field, message: "has an invalid timestamp format. Timestamps should be formatted as 'YYYY-MM-DDThh:mm:ssZ'") + validator.validate_each(record, :timestamp_field, '2023-01-01T1:00:00Z') + end + + it 'adds an error for extra characters' do + expect(errors).to receive(:add).with(:timestamp_field, message: "has an invalid timestamp format. Timestamps should be formatted as 'YYYY-MM-DDThh:mm:ssZ'") + validator.validate_each(record, :timestamp_field, '2023-01-01T12:00:00Z extra') + end + + it 'adds an error for timezone offset instead of Z' do + expect(errors).to receive(:add).with(:timestamp_field, message: "has an invalid timestamp format. Timestamps should be formatted as 'YYYY-MM-DDThh:mm:ssZ'") + validator.validate_each(record, :timestamp_field, '2023-01-01T12:00:00+00:00') + end + end + + context 'when value is a number' do + it 'adds an error' do + expect(errors).to receive(:add).with(:timestamp_field, message: "has an invalid timestamp format. Timestamps should be formatted as 'YYYY-MM-DDThh:mm:ssZ'") + validator.validate_each(record, :timestamp_field, 123_456_789) + end + end + + context 'when value is an empty string' do + it 'adds an error' do + expect(errors).to receive(:add).with(:timestamp_field, message: "has an invalid timestamp format. Timestamps should be formatted as 'YYYY-MM-DDThh:mm:ssZ'") + validator.validate_each(record, :timestamp_field, '') + 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..cd610b23ca1 100644 --- a/spec/unit/models/runtime/stack_spec.rb +++ b/spec/unit/models/runtime/stack_spec.rb @@ -29,8 +29,8 @@ module VCAP::CloudController end describe 'Serialization' do - it { is_expected.to export_attributes :name, :description, :build_rootfs_image, :run_rootfs_image } - it { is_expected.to import_attributes :name, :description, :build_rootfs_image, :run_rootfs_image } + it { is_expected.to export_attributes :name, :description, :build_rootfs_image, :run_rootfs_image, :deprecated_at, :locked_at, :disabled_at } + it { is_expected.to import_attributes :name, :description, :build_rootfs_image, :run_rootfs_image, :deprecated_at, :locked_at, :disabled_at } end describe '.configure' do