diff --git a/app/actions/build_create.rb b/app/actions/build_create.rb index 80c6bd79fa4..a5af0d77597 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,24 @@ 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 + + case stack.state + when 'DEPRECATED' + logger.warn("Stack '#{stack.name}' is deprecated. #{stack.description}") + when 'LOCKED' + # Check if this is a new app (no existing processes) vs an existing app being updated + has_existing_processes = ProcessModel.where(app_guid: package.app.guid).count > 0 + raise CloudController::Errors::ApiError.new_from_details('StackLocked', stack.name, stack.description) unless has_existing_processes + when 'DISABLED' + raise CloudController::Errors::ApiError.new_from_details('StackDisabled', stack.name, stack.description) + end + 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..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..5305daecfb9 100644 --- a/app/actions/stack_update.rb +++ b/app/actions/stack_update.rb @@ -9,6 +9,9 @@ def initialize def update(stack, message) stack.db.transaction do + stack.state = message.state if message.requested?(:state) + stack.description = message.description if message.requested?(:description) + 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..dfb7e517372 100644 --- a/app/messages/stack_create_message.rb +++ b/app/messages/stack_create_message.rb @@ -2,9 +2,11 @@ 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: %w[ACTIVE DEPRECATED LOCKED DISABLED], + message: 'must be one of [ACTIVE, DEPRECATED, LOCKED, DISABLED]' }, allow_nil: true end end diff --git a/app/messages/stack_update_message.rb b/app/messages/stack_update_message.rb index c9fce2392fb..15b575f8fcf 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 [:state, :description] validates_with NoAdditionalKeysValidator + + validates :state, inclusion: { in: %w[ACTIVE DEPRECATED LOCKED DISABLED], + message: 'must be one of [ACTIVE, DEPRECATED, LOCKED, DISABLED]' }, allow_nil: true + validates :description, length: { maximum: 250 } end end diff --git a/app/models/runtime/stack.rb b/app/models/runtime/stack.rb index 0637e612e1c..0e51e8f3932 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, :state + import_attributes :name, :description, :build_rootfs_image, :run_rootfs_image, :state strip_attributes :name @@ -43,6 +43,7 @@ def around_save def validate validates_presence :name validates_unique :name + validates_includes %w[ACTIVE DEPRECATED LOCKED DISABLED], :state, allow_missing: true end def before_destroy @@ -95,7 +96,7 @@ 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', 'state')) end end end diff --git a/app/presenters/v3/stack_presenter.rb b/app/presenters/v3/stack_presenter.rb index eaff5313bf0..e421e73116e 100644 --- a/app/presenters/v3/stack_presenter.rb +++ b/app/presenters/v3/stack_presenter.rb @@ -14,6 +14,7 @@ def to_hash description: stack.description, run_rootfs_image: stack.run_rootfs_image, build_rootfs_image: stack.build_rootfs_image, + state: stack.state, default: stack.default?, metadata: { labels: hashified_labels(stack.labels), diff --git a/db/migrations/20250724135100_add_state_to_stacks.rb b/db/migrations/20250724135100_add_state_to_stacks.rb new file mode 100644 index 00000000000..b0f710acfcc --- /dev/null +++ b/db/migrations/20250724135100_add_state_to_stacks.rb @@ -0,0 +1,7 @@ +Sequel.migration do + change do + alter_table(:stacks) do + add_column :state, String, null: false, default: 'ACTIVE', size: 255 + end + end +end diff --git a/devenv.sh b/devenv.sh index c9ea501bcb5..314ef02542d 100755 --- a/devenv.sh +++ b/devenv.sh @@ -18,27 +18,27 @@ help_command() { # Create a clean development environment create_command(){ - docker-compose -p "" down + docker compose -p "" down docker buildx bake -f docker-compose.yml & - docker-compose -p "" pull & + docker compose -p "" pull & wait $(jobs -p) - docker-compose -p "" up -d --build + docker compose -p "" up -d --build ./.devcontainer/scripts/setupDevelopmentEnvironment.sh } # Start containers start_command(){ - docker-compose -p "" start + docker compose -p "" start } # Stop containers stop_command(){ - docker-compose -p "" stop + docker compose -p "" stop } # Remove containers destroy_command(){ - docker-compose -p "" down + docker compose -p "" down } # Call Setup IDEs Script @@ -72,7 +72,7 @@ fi # Check Prerequisites export should_exit=0 # Check Path Exists -for p in docker docker-compose ruby bundle mysql psql yq; do +for p in docker ruby bundle mysql psql yq; do if ! command -v "${p}" >/dev/null 2>&1; then echo "Error: Dependency \"$p\" is not installed" && export should_exit=1 fi diff --git a/errors/v2.yml b/errors/v2.yml index cf4bf28bea2..e22c42e88cf 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. %s" + +250005: + name: StackLocked + http_code: 422 + message: "Cannot stage new app, stack '%s' is locked. %s" + 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..45cd9823896 100644 --- a/lib/cloud_controller/diego/buildpack/staging_action_builder.rb +++ b/lib/cloud_controller/diego/buildpack/staging_action_builder.rb @@ -17,16 +17,20 @@ def task_environment_variables private + def lifecycle + staging_details.lifecycle + end + def stage_action staging_details_env = BbsEnvironmentBuilder.build(staging_details.environment_variables) - ::Diego::Bbs::Models::RunAction.new( + main_staging_action = ::Diego::Bbs::Models::RunAction.new( path: '/tmp/lifecycle/builder', user: 'vcap', args: [ - "-buildpackOrder=#{lifecycle_data[:buildpacks].pluck(:key).join(',')}", + "-buildpackOrder=#{lifecycle.buildpack_infos.map(&:key).join(',')}", "-skipCertVerify=#{config.get(:skip_cert_verify)}", - "-skipDetect=#{skip_detect?}", + "-skipDetect=#{lifecycle.skip_detect?}", '-buildDir=/tmp/app', '-outputDroplet=/tmp/droplet', '-outputMetadata=/tmp/result.json', @@ -37,6 +41,34 @@ def stage_action resource_limits: ::Diego::Bbs::Models::ResourceLimits.new(nofile: config.get(:staging, :minimum_staging_file_descriptor_limit)), env: staging_details_env + platform_options_env ) + + # Check if stack has warnings and add them if needed + stack = Stack.find(name: lifecycle.staging_stack) + warning_actions = [] + + if stack&.state == 'DEPRECATED' + warning_message = "\033[1;33mWARNING: Stack '#{stack.name}' is deprecated. #{stack.description}\033[0m" + warning_actions << ::Diego::Bbs::Models::RunAction.new( + path: '/bin/echo', + user: 'vcap', + args: ['-e', warning_message], + env: staging_details_env + platform_options_env + ) + elsif stack&.state == 'LOCKED' + warning_message = "\033[1;33mNOTICE: Stack '#{stack.name}' is locked and can only be used to update existing applications. #{stack.description}\033[0m" + warning_actions << ::Diego::Bbs::Models::RunAction.new( + path: '/bin/echo', + user: 'vcap', + args: ['-e', warning_message], + env: staging_details_env + platform_options_env + ) + end + + if warning_actions.any? + serial(warning_actions + [main_staging_action]) + else + main_staging_action + end end def platform_options_env diff --git a/lib/cloud_controller/diego/lifecycles/buildpack_info.rb b/lib/cloud_controller/diego/lifecycles/buildpack_info.rb index 0cae5333d22..dd3c5690556 100644 --- a/lib/cloud_controller/diego/lifecycles/buildpack_info.rb +++ b/lib/cloud_controller/diego/lifecycles/buildpack_info.rb @@ -25,5 +25,9 @@ def to_s buildpack_record.nil? ? nil : buildpack_record.name end end + + def key + buildpack_record.try(:key) || buildpack_url + end end end diff --git a/lib/cloud_controller/diego/lifecycles/buildpack_lifecycle.rb b/lib/cloud_controller/diego/lifecycles/buildpack_lifecycle.rb index 25efa5cda17..ac97669d12c 100644 --- a/lib/cloud_controller/diego/lifecycles/buildpack_lifecycle.rb +++ b/lib/cloud_controller/diego/lifecycles/buildpack_lifecycle.rb @@ -23,6 +23,10 @@ def staging_environment_variables } end + def skip_detect? + !buildpack_infos.empty? + end + private def app_stack diff --git a/lib/tasks/db.rake b/lib/tasks/db.rake index 8cbdb229c9b..3bb4435d4cf 100644 --- a/lib/tasks/db.rake +++ b/lib/tasks/db.rake @@ -318,7 +318,7 @@ namespace :db do host = "-h #{uri.host}" port = "-P #{uri.port}" if uri.port user = "-u #{uri.user}" if uri.user - pass = "--password=#{uri.password}" if uri.password + pass = "--password=#{uri.password || ''}" end end [host, port, user, pass, passenv] diff --git a/spec/request/stacks_spec.rb b/spec/request/stacks_spec.rb index fd64bcd017e..bc6d9d3d0da 100644 --- a/spec/request/stacks_spec.rb +++ b/spec/request/stacks_spec.rb @@ -27,6 +27,7 @@ 'build_rootfs_image' => stack1.build_rootfs_image, 'guid' => stack1.guid, 'default' => false, + 'state' => 'ACTIVE', 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, 'updated_at' => iso8601, @@ -43,6 +44,7 @@ 'build_rootfs_image' => stack2.build_rootfs_image, 'guid' => stack2.guid, 'default' => true, + 'state' => 'ACTIVE', 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, 'updated_at' => iso8601, @@ -123,6 +125,7 @@ 'build_rootfs_image' => stack1.build_rootfs_image, 'guid' => stack1.guid, 'default' => false, + 'state' => 'ACTIVE', 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, 'updated_at' => iso8601, @@ -139,6 +142,7 @@ 'build_rootfs_image' => stack2.build_rootfs_image, 'guid' => stack2.guid, 'default' => true, + 'state' => 'ACTIVE', 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, 'updated_at' => iso8601, @@ -178,6 +182,7 @@ 'build_rootfs_image' => stack1.build_rootfs_image, 'guid' => stack1.guid, 'default' => false, + 'state' => 'ACTIVE', 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, 'updated_at' => iso8601, @@ -194,6 +199,7 @@ 'build_rootfs_image' => stack3.build_rootfs_image, 'guid' => stack3.guid, 'default' => false, + 'state' => 'ACTIVE', 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, 'updated_at' => iso8601, @@ -233,6 +239,7 @@ 'build_rootfs_image' => stack2.build_rootfs_image, 'guid' => stack2.guid, 'default' => true, + 'state' => 'ACTIVE', 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, 'updated_at' => iso8601, @@ -288,6 +295,7 @@ 'build_rootfs_image' => stack1.build_rootfs_image, 'guid' => stack1.guid, 'default' => false, + 'state' => 'ACTIVE', 'metadata' => { 'labels' => { 'release' => 'stable' @@ -307,6 +315,17 @@ ) end end + + context 'when stacks have different states' do + let!(:deprecated_stack) { VCAP::CloudController::Stack.make(state: 'DEPRECATED') } + + it 'returns stacks with their states in the list' do + get '/v3/stacks', nil, user_header + + stack_response = parsed_response['resources'].find { |s| s['guid'] == deprecated_stack.guid } + expect(stack_response['state']).to eq('DEPRECATED') + end + end end end end @@ -324,6 +343,7 @@ 'build_rootfs_image' => stack.build_rootfs_image, 'guid' => stack.guid, 'default' => false, + 'state' => 'ACTIVE', 'metadata' => { 'labels' => {}, 'annotations' => {} }, 'created_at' => iso8601, 'updated_at' => iso8601, @@ -339,6 +359,17 @@ end it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when stack has a non-default state' do + let!(:deprecated_stack) { VCAP::CloudController::Stack.make(state: 'DEPRECATED') } + + it 'returns the stack with its state' do + get "/v3/stacks/#{deprecated_stack.guid}", nil, user_header + + expect(last_response.status).to eq(200) + expect(parsed_response['state']).to eq('DEPRECATED') + end + end end describe 'GET /v3/stacks/:guid/apps' do @@ -633,6 +664,7 @@ { name: 'the-name', description: 'the-description', + state: 'ACTIVE', metadata: { labels: { potato: 'yam' @@ -661,6 +693,7 @@ 'run_rootfs_image' => created_stack.run_rootfs_image, 'build_rootfs_image' => created_stack.build_rootfs_image, 'default' => false, + 'state' => 'ACTIVE', 'metadata' => { 'labels' => { 'potato' => 'yam' @@ -681,6 +714,60 @@ ) end + context 'when creating a stack with a specific state' do + let(:request_body_with_state) do + { + name: 'deprecated-stack', + description: 'this stack is deprecated', + state: 'DEPRECATED' + }.to_json + end + + it 'creates a stack with the specified state' do + expect do + post '/v3/stacks', request_body_with_state, 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' => 'deprecated-stack', + 'description' => 'this stack is deprecated', + 'run_rootfs_image' => created_stack.run_rootfs_image, + 'build_rootfs_image' => created_stack.build_rootfs_image, + 'default' => false, + 'state' => 'DEPRECATED', + '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 an invalid state' do + let(:request_body_invalid_state) do + { + name: 'invalid-state-stack', + state: 'INVALID_STATE' + }.to_json + end + + it 'responds with 422 for invalid state' do + post '/v3/stacks', request_body_invalid_state, headers + expect(last_response.status).to eq(422) + expect(last_response).to have_error_message('State must be one of [ACTIVE, DEPRECATED, LOCKED, DISABLED]') + end + end + context 'when there is a model validation failure' do let(:name) { 'the-name' } @@ -725,6 +812,7 @@ 'run_rootfs_image' => stack.run_rootfs_image, 'build_rootfs_image' => stack.build_rootfs_image, 'default' => false, + 'state' => 'ACTIVE', 'metadata' => { 'labels' => { 'potato' => 'yam' @@ -744,6 +832,134 @@ } ) end + + context 'when updating stack state' do + let(:request_body_with_state) do + { + state: 'LOCKED' + }.to_json + end + + it 'updates the stack with the specified state' do + patch "/v3/stacks/#{stack.guid}", request_body_with_state, 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, + 'state' => 'LOCKED', + '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 stack description' do + let(:request_body_with_description) do + { + description: 'Updated stack description' + }.to_json + end + + it 'updates the stack with the specified description' do + patch "/v3/stacks/#{stack.guid}", request_body_with_description, headers + + expect(last_response.status).to eq(200) + expect(parsed_response).to be_a_response_like( + { + 'name' => stack.name, + 'description' => 'Updated stack description', + 'run_rootfs_image' => stack.run_rootfs_image, + 'build_rootfs_image' => stack.build_rootfs_image, + 'default' => false, + 'state' => 'ACTIVE', + '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 both state and description' do + let(:request_body_with_both) do + { + state: 'DEPRECATED', + description: 'This stack is deprecated. Please migrate to a newer stack.' + }.to_json + end + + it 'updates both the state and description' do + patch "/v3/stacks/#{stack.guid}", request_body_with_both, headers + + expect(last_response.status).to eq(200) + expect(parsed_response).to be_a_response_like( + { + 'name' => stack.name, + 'description' => 'This stack is deprecated. Please migrate to a newer stack.', + 'run_rootfs_image' => stack.run_rootfs_image, + 'build_rootfs_image' => stack.build_rootfs_image, + 'default' => false, + 'state' => 'DEPRECATED', + '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 with an invalid state' do + let(:request_body_invalid_state) do + { + state: 'INVALID_STATE' + }.to_json + end + + it 'responds with 422 for invalid state' do + patch "/v3/stacks/#{stack.guid}", request_body_invalid_state, headers + expect(last_response.status).to eq(422) + expect(last_response).to have_error_message('State must be one of [ACTIVE, DEPRECATED, LOCKED, DISABLED]') + end + end + + context 'when updating with description that exceeds maximum length' do + let(:request_body_long_description) do + { + description: 'a' * 251 + }.to_json + end + + it 'responds with 422 for description too long' do + patch "/v3/stacks/#{stack.guid}", request_body_long_description, headers + expect(last_response.status).to eq(422) + expect(last_response).to have_error_message('Description is too long (maximum is 250 characters)') + 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..b3959bcd5c1 100644 --- a/spec/request/v2/apps_spec.rb +++ b/spec/request/v2/apps_spec.rb @@ -192,167 +192,67 @@ 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']['state']).to eq('ACTIVE') + 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..280f81073f2 100644 --- a/spec/unit/actions/build_create_spec.rb +++ b/spec/unit/actions/build_create_spec.rb @@ -171,6 +171,65 @@ 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', state: 'DEPRECATED', description: 'use the new stack') } + + it 'logs a warning' do + spy_logger = spy('logger') + allow(Steno).to receive(:logger).and_return(spy_logger) + + action.create_and_stage(package:, lifecycle:) + expect(spy_logger).to have_received(:warn).with("Stack 'cflinuxfs3' is deprecated. use the new stack") + end + end + + context 'when the stack is locked' do + let!(:stack) { Stack.make(name: 'cflinuxfs3', state: 'LOCKED', description: 'this stack is locked') } + + 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. this stack 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', state: 'DISABLED', description: 'this stack is disabled') } + + 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. this stack is disabled/) + end + end + end + context 'creating a build for type cnb' do let(:request_lifecycle) do { diff --git a/spec/unit/actions/stack_create_spec.rb b/spec/unit/actions/stack_create_spec.rb index 19a45e0748b..4c7ac2e97e3 100644 --- a/spec/unit/actions/stack_create_spec.rb +++ b/spec/unit/actions/stack_create_spec.rb @@ -9,6 +9,7 @@ module VCAP::CloudController message = VCAP::CloudController::StackCreateMessage.new( name: 'the-name', description: 'the-description', + state: 'ACTIVE', metadata: { labels: { release: 'stable', @@ -68,7 +69,7 @@ module VCAP::CloudController let(:name) { 'Gaby' } it 'ensures one creation is successful and the other fails due to name conflict' do - message = VCAP::CloudController::StackCreateMessage.new(name:) + message = VCAP::CloudController::StackCreateMessage.new(name: name, state: 'ACTIVE') # First request, should succeed expect do StackCreate.new.create(message) diff --git a/spec/unit/controllers/v3/stacks_controller_spec.rb b/spec/unit/controllers/v3/stacks_controller_spec.rb index 196ea25112f..ab0d237a5cf 100644 --- a/spec/unit/controllers/v3/stacks_controller_spec.rb +++ b/spec/unit/controllers/v3/stacks_controller_spec.rb @@ -126,7 +126,7 @@ describe '#create' do let(:user) { VCAP::CloudController::User.make } let(:req_body) do - { name: 'the-name' } + { name: 'the-name', state: 'ACTIVE' } end before do diff --git a/spec/unit/lib/cloud_controller/diego/buildpack/staging_action_builder_spec.rb b/spec/unit/lib/cloud_controller/diego/buildpack/staging_action_builder_spec.rb index 78e60c95f6d..7ad565dd050 100644 --- a/spec/unit/lib/cloud_controller/diego/buildpack/staging_action_builder_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/buildpack/staging_action_builder_spec.rb @@ -31,8 +31,18 @@ module Buildpack StagingDetails.new.tap do |details| details.staging_guid = droplet.guid details.environment_variables = env + details.lifecycle = lifecycle end end + let(:lifecycle) do + instance_double(BuildpackLifecycle, + buildpack_infos: lifecycle_data[:buildpacks].map do |b| + buildpack_record = instance_double(VCAP::CloudController::Buildpack, key: b[:key]) + VCAP::CloudController::BuildpackInfo.new(b[:name], buildpack_record) + end, + skip_detect?: lifecycle_data[:buildpacks].any? { |b| b[:skip_detect] }, + staging_stack: stack) + end let(:env) { double(:env) } let(:stack) { 'buildpack-stack' } let(:lifecycle_data) 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/models/runtime/stack_spec.rb b/spec/unit/models/runtime/stack_spec.rb index 48987111025..608fd3660cb 100644 --- a/spec/unit/models/runtime/stack_spec.rb +++ b/spec/unit/models/runtime/stack_spec.rb @@ -26,11 +26,22 @@ module VCAP::CloudController it { is_expected.to validate_presence :name } it { is_expected.to validate_uniqueness :name } it { is_expected.to strip_whitespace :name } + + it 'validates that the state is one of the allowed values' do + stack = Stack.make + expect(stack).to be_valid + %w[ACTIVE DEPRECATED LOCKED DISABLED].each do |state| + stack.state = state + expect(stack).to be_valid + end + stack.state = 'INVALID' + expect(stack).not_to be_valid + end 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, :state } + it { is_expected.to import_attributes :name, :description, :build_rootfs_image, :run_rootfs_image, :state } end describe '.configure' do