diff --git a/.travis.yml b/.travis.yml index 1e49e3ed329..bb0a7a1bf24 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,13 +19,13 @@ before_script: script: - bundle exec rake $TASKS +after_success: + - "[ $TRAVIS_BRANCH == 'master' -a $UPLOAD_DOCUMENTATION == 'true' ] && travis-artifacts upload --path doc/api --target-path $TRAVIS_BUILD_ID && echo https://s3-us-east-1.amazonaws.com/cc-api-docs/$TRAVIS_BUILD_ID/index.html" + services: - mysql - postgresql -after_script: # and this only on success, fail the whole build if it fails - - "[ $TRAVIS_TEST_RESULT == 0 -a $TRAVIS_BRANCH == 'master' ] && travis-artifacts upload --path doc/api --target-path $TRAVIS_BUILD_ID && echo https://s3-us-east-1.amazonaws.com/cc-api-docs/$TRAVIS_BUILD_ID/index.html" - env: global: # API Docs @@ -39,5 +39,5 @@ env: - COVERAGE=true DB=postgres TASKS=spec:all - DB=mysql TASKS=spec:all - - DB=postgres TASKS=spec:api + - DB=postgres TASKS=spec:api UPLOAD_DOCUMENTATION=true - TASKS=rubocop diff --git a/app/access/v3/package_access.rb b/app/access/v3/package_access.rb new file mode 100644 index 00000000000..ee4df66e87e --- /dev/null +++ b/app/access/v3/package_access.rb @@ -0,0 +1,34 @@ +module VCAP::CloudController + class PackageModelAccess + include Allowy::AccessControl + + def read?(package) + return true if context.roles.admin? + + has_read_scope = SecurityContext.scopes.include?('cloud_controller.read') + user_visible = Space.user_visible(context.user).where(guid: package.space_guid).count > 0 + + has_read_scope && user_visible + end + + def create?(_, space) + return true if context.roles.admin? + + has_write_scope = SecurityContext.scopes.include?('cloud_controller.write') + is_space_developer = space && space.developers.include?(context.user) + org_active = space && space.organization.active? + + has_write_scope && is_space_developer && org_active + end + + def delete?(package, space) + create?(package, space) + end + + def upload?(package, space) + return true if context.roles.admin? + return false unless FeatureFlag.enabled?('app_bits_upload') + create?(package, space) + end + end +end diff --git a/app/collection_transformers/username_populator.rb b/app/collection_transformers/username_populator.rb new file mode 100644 index 00000000000..7a9e50b44be --- /dev/null +++ b/app/collection_transformers/username_populator.rb @@ -0,0 +1,15 @@ +module VCAP::CloudController + class UsernamePopulator + attr_reader :uaa_client + + def initialize(uaa_client) + @uaa_client = uaa_client + end + + def transform(users) + user_ids = users.collect(&:guid) + username_mapping = uaa_client.usernames_for_ids(user_ids) + users.each { |user| user.username = username_mapping[user.guid] } + end + end +end diff --git a/app/controllers/base/model_controller.rb b/app/controllers/base/model_controller.rb index 6f8722bd273..bd6652619c9 100644 --- a/app/controllers/base/model_controller.rb +++ b/app/controllers/base/model_controller.rb @@ -5,6 +5,8 @@ module VCAP::CloudController::RestController class ModelController < BaseController include Routes + attr_reader :object_renderer, :collection_renderer + def inject_dependencies(dependencies) super @object_renderer = dependencies.fetch(:object_renderer) @@ -74,11 +76,15 @@ def update(guid) def do_delete(obj) raise_if_has_associations!(obj) if v2_api? && !recursive? model_deletion_job = Jobs::Runtime::ModelDeletion.new(obj.class, obj.guid) + enqueue_deletion_job(model_deletion_job) + end + + def enqueue_deletion_job(deletion_job) if async? - job = Jobs::Enqueuer.new(model_deletion_job, queue: 'cc-generic').enqueue + job = Jobs::Enqueuer.new(deletion_job, queue: 'cc-generic').enqueue [HTTP::ACCEPTED, JobPresenter.new(job).to_json] else - model_deletion_job.perform + deletion_job.perform [HTTP::NO_CONTENT, nil] end end @@ -130,7 +136,9 @@ def enumerate_related(guid, name) @opts ) - collection_renderer.render_json( + associated_controller_instance = CloudController::ControllerFactory.new(@config, @logger, @env, @params, @body, @sinatra).create_controller(associated_controller) + + associated_controller_instance.collection_renderer.render_json( associated_controller, filtered_dataset, associated_path, @@ -228,10 +236,6 @@ def model self.class.model end - protected - - attr_reader :object_renderer, :collection_renderer - private def enumerate_dataset diff --git a/app/controllers/internal/bulk_apps_controller.rb b/app/controllers/internal/bulk_apps_controller.rb index fbb9015893b..9c9fb66583d 100644 --- a/app/controllers/internal/bulk_apps_controller.rb +++ b/app/controllers/internal/bulk_apps_controller.rb @@ -2,6 +2,7 @@ require 'controllers/base/base_controller' require 'cloud_controller/diego/client' require 'cloud_controller/internal_api' +require 'cloud_controller/diego/process_guid' module VCAP::CloudController class BulkAppsController < RestController::BaseController @@ -21,9 +22,34 @@ def bulk_apps bulk_token = MultiJson.load(params.fetch('token')) last_id = Integer(bulk_token['id'] || 0) - dependency_locator = ::CloudController::DependencyLocator.instance - runners = dependency_locator.runners + if params['format'] == 'fingerprint' + bulk_fingerprint_format(batch_size, last_id) + else + bulk_desire_app_format(batch_size, last_id) + end + rescue IndexError => e + raise ApiError.new_from_details('BadQueryParameter', e.message) + end + + get '/internal/bulk/apps', :bulk_apps + + def filtered_bulk_apps + raise ApiError.new_from_details('MessageParseError', 'Missing request body') if body.length == 0 + payload = MultiJson.load(body) + + apps = runners.diego_apps_from_process_guids(payload) + messages = apps.map { |app| runners.runner_for_app(app).desire_app_message } + + MultiJson.dump(messages) + rescue MultiJson::ParseError => e + raise ApiError.new_from_details('MessageParseError', e.message) + end + + post '/internal/bulk/apps', :filtered_bulk_apps + private + + def bulk_desire_app_format(batch_size, last_id) apps = runners.diego_apps(batch_size, last_id) messages = apps.map { |app| runners.runner_for_app(app).desire_app_message } id_for_next_token = apps.empty? ? nil : apps.last.id @@ -32,10 +58,24 @@ def bulk_apps apps: messages, token: { 'id' => id_for_next_token } ) - rescue IndexError => e - raise ApiError.new_from_details('BadQueryParameter', e.message) end - get '/internal/bulk/apps', :bulk_apps + def bulk_fingerprint_format(batch_size, last_id) + id_for_next_token = nil + messages = runners.diego_apps_cache_data(batch_size, last_id).map do |id, guid, version, updated| + id_for_next_token = id + { 'process_guid' => Diego::ProcessGuid.from(guid, version), 'etag' => updated.to_f.to_s } + end + + MultiJson.dump( + fingerprints: messages, + token: { 'id' => id_for_next_token } + ) + end + + def runners + dependency_locator = ::CloudController::DependencyLocator.instance + @runners ||= dependency_locator.runners + end end end diff --git a/app/controllers/runtime/users_controller.rb b/app/controllers/runtime/users_controller.rb index 93f2772181c..c0c11a4e2b5 100644 --- a/app/controllers/runtime/users_controller.rb +++ b/app/controllers/runtime/users_controller.rb @@ -1,5 +1,9 @@ module VCAP::CloudController class UsersController < RestController::ModelController + def self.dependencies + [:username_populating_collection_renderer] + end + define_attributes do attribute :guid, String, exclude_in: :update attribute :admin, Message::Boolean, default: false @@ -33,6 +37,11 @@ def delete(guid) do_delete(find_guid_and_validate_access(:delete, guid)) end + def inject_dependencies(dependencies) + super + @collection_renderer = dependencies[:username_populating_collection_renderer] + end + define_messages define_routes end diff --git a/app/controllers/services/service_bindings_controller.rb b/app/controllers/services/service_bindings_controller.rb index b02bc0ae218..55fa32da47e 100644 --- a/app/controllers/services/service_bindings_controller.rb +++ b/app/controllers/services/service_bindings_controller.rb @@ -24,19 +24,14 @@ def inject_dependencies(dependencies) post path, :create def create - json_msg = self.class::CreateMessage.decode(body) - - @request_attrs = json_msg.extract(stringify_keys: true) + @request_attrs = self.class::CreateMessage.decode(body).extract(stringify_keys: true) logger.debug 'cc.create', model: self.class.model_class_name, attributes: request_attrs raise InvalidRequest unless request_attrs - instance_guid = request_attrs['service_instance_guid'] - app_guid = request_attrs['app_guid'] - - validate_service_instance(instance_guid) - validate_app(app_guid) + validate_service_instance(request_attrs['service_instance_guid']) + validate_app(request_attrs['app_guid']) service_binding = ServiceBinding.new(@request_attrs) validate_access(:create, service_binding) @@ -63,13 +58,7 @@ def delete(guid) deletion_job = Jobs::Runtime::ModelDeletion.new(ServiceBinding, guid) delete_and_audit_job = Jobs::AuditEventJob.new(deletion_job, @services_event_repository, :record_service_binding_event, :delete, service_binding) - if async? - job = Jobs::Enqueuer.new(delete_and_audit_job, queue: 'cc-generic').enqueue - [HTTP::ACCEPTED, JobPresenter.new(job).to_json] - else - delete_and_audit_job.perform - [HTTP::NO_CONTENT, nil] - end + enqueue_deletion_job(delete_and_audit_job) end private diff --git a/app/controllers/services/service_instances_controller.rb b/app/controllers/services/service_instances_controller.rb index 9bfaad6f836..79320049445 100644 --- a/app/controllers/services/service_instances_controller.rb +++ b/app/controllers/services/service_instances_controller.rb @@ -200,13 +200,7 @@ def delete(guid) event_method = service_instance.type == 'managed_service_instance' ? :record_service_instance_event : :record_user_provided_service_instance_event delete_and_audit_job = Jobs::AuditEventJob.new(deletion_job, @services_event_repository, event_method, :delete, service_instance, {}) - if async? - job = Jobs::Enqueuer.new(delete_and_audit_job, queue: 'cc-generic').enqueue - [HTTP::ACCEPTED, JobPresenter.new(job).to_json] - else - delete_and_audit_job.perform - [HTTP::NO_CONTENT, nil] - end + enqueue_deletion_job(delete_and_audit_job) end def get_filtered_dataset_for_enumeration(model, ds, qp, opts) diff --git a/app/controllers/services/service_plan_visibilities_controller.rb b/app/controllers/services/service_plan_visibilities_controller.rb index 8392fcd302e..6a5b9b1661a 100644 --- a/app/controllers/services/service_plan_visibilities_controller.rb +++ b/app/controllers/services/service_plan_visibilities_controller.rb @@ -81,13 +81,7 @@ def delete(guid) {} ) - if async? - job = Jobs::Enqueuer.new(delete_and_audit_job, queue: 'cc-generic').enqueue - [HTTP::ACCEPTED, JobPresenter.new(job).to_json] - else - delete_and_audit_job.perform - [HTTP::NO_CONTENT, nil] - end + enqueue_deletion_job(delete_and_audit_job) end define_messages diff --git a/app/controllers/services/user_provided_service_instances_controller.rb b/app/controllers/services/user_provided_service_instances_controller.rb index 1cb6a9e1bf1..25552119caf 100644 --- a/app/controllers/services/user_provided_service_instances_controller.rb +++ b/app/controllers/services/user_provided_service_instances_controller.rb @@ -99,13 +99,7 @@ def delete(guid) {} ) - if async? - job = Jobs::Enqueuer.new(delete_and_audit_job, queue: 'cc-generic').enqueue - [HTTP::ACCEPTED, JobPresenter.new(job).to_json] - else - delete_and_audit_job.perform - [HTTP::NO_CONTENT, nil] - end + enqueue_deletion_job(delete_and_audit_job) end define_messages diff --git a/app/controllers/v3/apps_controller.rb b/app/controllers/v3/apps_controller.rb index 45474b93dee..264f001fe13 100644 --- a/app/controllers/v3/apps_controller.rb +++ b/app/controllers/v3/apps_controller.rb @@ -1,6 +1,7 @@ require 'presenters/v3/app_presenter' require 'handlers/processes_handler' require 'handlers/apps_handler' +require 'cloud_controller/paging/pagination_options' module VCAP::CloudController class AppsV3Controller < RestController::BaseController @@ -15,6 +16,14 @@ def inject_dependencies(dependencies) @process_presenter = dependencies[:process_presenter] end + get '/v3/apps', :list + def list + pagination_options = PaginationOptions.from_params(params) + paginated_result = @app_handler.list(pagination_options, @access_context) + + [HTTP::OK, @app_presenter.present_json_list(paginated_result)] + end + get '/v3/apps/:guid', :show def show(guid) app = @app_handler.show(guid, @access_context) @@ -73,7 +82,10 @@ def list_processes(guid) app = @app_handler.show(guid, @access_context) app_not_found! if app.nil? - [HTTP::OK, @process_presenter.present_json_list(app.processes)] + pagination_options = PaginationOptions.from_params(params) + paginated_result = @process_handler.list(pagination_options, @access_context, app_guid: app.guid) + + [HTTP::OK, @process_presenter.present_json_list(paginated_result, "/v3/apps/#{guid}/processes")] end put '/v3/apps/:guid/processes', :add_process diff --git a/app/controllers/v3/packages_controller.rb b/app/controllers/v3/packages_controller.rb new file mode 100644 index 00000000000..e95068c7943 --- /dev/null +++ b/app/controllers/v3/packages_controller.rb @@ -0,0 +1,101 @@ +require 'presenters/v3/package_presenter' +require 'handlers/packages_handler' + +module VCAP::CloudController + class PackagesController < RestController::BaseController + def self.dependencies + [:packages_handler, :package_presenter, :apps_handler] + end + + def inject_dependencies(dependencies) + @packages_handler = dependencies[:packages_handler] + @package_presenter = dependencies[:package_presenter] + @apps_handler = dependencies[:apps_handler] + end + + post '/v3/apps/:guid/packages', :create + def create(app_guid) + app = @apps_handler.show(app_guid, @access_context) + app_not_found! if app.nil? + + message = PackageCreateMessage.create_from_http_request(app.space_guid, body) + valid, errors = message.validate + unprocessable!(errors.join(', ')) if !valid + + package = @packages_handler.create(message, @access_context) + package_json = @package_presenter.present_json(package) + + [HTTP::CREATED, package_json] + rescue PackagesHandler::Unauthorized + unauthorized! + end + + post '/v3/packages/:guid/upload', :upload + def upload(package_guid) + message = PackageUploadMessage.new(package_guid, params) + valid, error = message.validate + unprocessable!(error) if !valid + + package = @packages_handler.upload(message, @access_context) + package_json = @package_presenter.present_json(package) + + [HTTP::CREATED, package_json] + rescue PackagesHandler::InvalidPackageType => e + invalid_request!(e.message) + rescue PackagesHandler::SpaceNotFound + app_not_found! + rescue PackagesHandler::PackageNotFound + package_not_found! + rescue PackagesHandler::Unauthorized + unauthorized! + rescue PackagesHandler::BitsAlreadyUploaded + bits_already_uploaded! + end + + get '/v3/packages/:guid', :show + def show(package_guid) + package = @packages_handler.show(package_guid, @access_context) + package_not_found! if package.nil? + + package_json = @package_presenter.present_json(package) + [HTTP::OK, package_json] + rescue PackagesHandler::Unauthorized + unauthorized! + end + + delete '/v3/packages/:guid', :delete + def delete(package_guid) + package = @packages_handler.delete(package_guid, @access_context) + package_not_found! unless package + [HTTP::NO_CONTENT] + rescue PackagesHandler::Unauthorized + unauthorized! + end + + private + + def package_not_found! + raise VCAP::Errors::ApiError.new_from_details('ResourceNotFound', 'Package not found') + end + + def app_not_found! + raise VCAP::Errors::ApiError.new_from_details('ResourceNotFound', 'App not found') + end + + def unauthorized! + raise VCAP::Errors::ApiError.new_from_details('NotAuthorized') + end + + def bits_already_uploaded! + raise VCAP::Errors::ApiError.new_from_details('PackageBitsAlreadyUploaded') + end + + def unprocessable!(message) + raise VCAP::Errors::ApiError.new_from_details('UnprocessableEntity', message) + end + + def invalid_request!(message) + raise VCAP::Errors::ApiError.new_from_details('InvalidRequest', message) + end + end +end diff --git a/app/controllers/v3/processes_controller.rb b/app/controllers/v3/processes_controller.rb index 7f728ce837d..3b9579ba9c9 100644 --- a/app/controllers/v3/processes_controller.rb +++ b/app/controllers/v3/processes_controller.rb @@ -1,5 +1,6 @@ require 'presenters/v3/process_presenter' require 'handlers/processes_handler' +require 'cloud_controller/paging/pagination_options' module VCAP::CloudController # TODO: would be nice not needing to use this BaseController @@ -12,6 +13,14 @@ def inject_dependencies(dependencies) @process_presenter = dependencies[:process_presenter] end + get '/v3/processes', :list + def list + pagination_options = PaginationOptions.from_params(params) + paginated_result = @processes_handler.list(pagination_options, @access_context) + + [HTTP::OK, @process_presenter.present_json_list(paginated_result, '/v3/processes')] + end + get '/v3/processes/:guid', :show def show(guid) process = @processes_handler.show(guid, @access_context) diff --git a/app/handlers/apps_handler.rb b/app/handlers/apps_handler.rb index 2bb7368a8f3..72a246d236b 100644 --- a/app/handlers/apps_handler.rb +++ b/app/handlers/apps_handler.rb @@ -1,3 +1,6 @@ +require 'cloud_controller/paging/sequel_paginator' +require 'cloud_controller/paging/paginated_result' + module VCAP::CloudController class AppCreateMessage attr_reader :name, :space_guid @@ -46,8 +49,9 @@ class DuplicateProcessType < StandardError; end class InvalidApp < StandardError; end class IncorrectProcessSpace < StandardError; end - def initialize(process_handler) + def initialize(process_handler, paginator=SequelPaginator.new) @process_handler = process_handler + @paginator = paginator end def show(guid, access_context) @@ -56,6 +60,17 @@ def show(guid, access_context) app end + def list(pagination_options, access_context) + dataset = nil + if access_context.roles.admin? + dataset = AppModel.dataset + else + dataset = AppModel.user_visible(access_context.user) + end + + @paginator.get_page(dataset, pagination_options) + end + def create(message, access_context) app = AppModel.new app.name = message.name diff --git a/app/handlers/packages_handler.rb b/app/handlers/packages_handler.rb new file mode 100644 index 00000000000..4388c786aa2 --- /dev/null +++ b/app/handlers/packages_handler.rb @@ -0,0 +1,139 @@ +module VCAP::CloudController + class PackageUploadMessage + attr_reader :package_path, :package_guid + + def initialize(package_guid, opts) + @package_guid = package_guid + @package_path = opts['bits_path'] + end + + def validate + return false, 'An application zip file must be uploaded.' unless @package_path + true + end + end + + class PackageCreateMessage + attr_reader :space_guid, :type, :url + attr_accessor :error + + def self.create_from_http_request(space_guid, body) + opts = body && MultiJson.load(body) + raise MultiJson::ParseError.new('invalid request body') unless opts.is_a?(Hash) + PackageCreateMessage.new(space_guid, opts) + rescue MultiJson::ParseError => e + message = PackageCreateMessage.new(space_guid, {}) + message.error = e.message + message + end + + def initialize(space_guid, opts) + @space_guid = space_guid + @type = opts['type'] + @url = opts['url'] + end + + def validate + return false, [error] if error + errors = [] + errors << validate_type_field + errors << validate_url + errs = errors.compact + [errs.length == 0, errs] + end + + private + + def validate_type_field + return 'The type field is required' if @type.nil? + valid_type_fields = %w(bits docker) + + if !valid_type_fields.include?(@type) + return "The type field needs to be one of '#{valid_type_fields.join(', ')}'" + end + nil + end + + def validate_url + return 'The url field cannot be provided when type is bits.' if @type == 'bits' && !@url.nil? + return 'The url field must be provided for type docker.' if @type == 'docker' && @url.nil? + nil + end + end + + class PackagesHandler + class Unauthorized < StandardError; end + class InvalidPackageType < StandardError; end + class InvalidPackage < StandardError; end + class SpaceNotFound < StandardError; end + class PackageNotFound < StandardError; end + class BitsAlreadyUploaded < StandardError; end + + def initialize(config) + @config = config + end + + def create(message, access_context) + package = PackageModel.new + package.space_guid = message.space_guid + package.type = message.type + package.url = message.url + package.state = message.type == 'bits' ? PackageModel::CREATED_STATE : PackageModel::READY_STATE + + space = Space.find(guid: package.space_guid) + raise SpaceNotFound if space.nil? + + raise Unauthorized if access_context.cannot?(:create, package, space) + package.save + + package + rescue Sequel::ValidationFailed => e + raise InvalidPackage.new(e.message) + end + + def upload(message, access_context) + package = PackageModel.find(guid: message.package_guid) + + raise PackageNotFound if package.nil? + raise InvalidPackageType.new('Package type must be bits.') if package.type != 'bits' + raise BitsAlreadyUploaded.new('Bits may be uploaded only once. Create a new package to upload different bits.') if package.state != PackageModel::CREATED_STATE + + space = Space.find(guid: package.space_guid) + raise SpaceNotFound if space.nil? + + raise Unauthorized if access_context.cannot?(:create, package, space) + + package.update(state: PackageModel::PENDING_STATE) + + bits_upload_job = Jobs::Runtime::PackageBits.new(package.guid, message.package_path) + Jobs::Enqueuer.new(bits_upload_job, queue: Jobs::LocalQueue.new(@config)).enqueue + + package + end + + def delete(guid, access_context) + package = PackageModel.find(guid: guid) + return nil if package.nil? + + space = Space.find(guid: package.space_guid) + + package.db.transaction do + package.lock! + raise Unauthorized if access_context.cannot?(:delete, package, space) + package.destroy + end + + blobstore_delete = Jobs::Runtime::BlobstoreDelete.new(package.guid, :package_blobstore, nil) + Jobs::Enqueuer.new(blobstore_delete, queue: 'cc-generic').enqueue + + package + end + + def show(guid, access_context) + package = PackageModel.find(guid: guid) + return nil if package.nil? + raise Unauthorized if access_context.cannot?(:read, package) + package + end + end +end diff --git a/app/handlers/processes_handler.rb b/app/handlers/processes_handler.rb index 13c483da66c..1c671436053 100644 --- a/app/handlers/processes_handler.rb +++ b/app/handlers/processes_handler.rb @@ -41,7 +41,7 @@ def validate errors = [] errors << validate_name_field errors << validate_has_opts - errors.compact! + errors.compact end private @@ -66,9 +66,23 @@ class ProcessesHandler class InvalidProcess < StandardError; end class Unauthorized < StandardError; end - def initialize(process_repository, process_event_repository) - @process_repository = process_repository + def initialize(process_repository, process_event_repository, paginator=SequelPaginator.new) + @process_repository = process_repository @process_event_repository = process_event_repository + @paginator = paginator + end + + def list(pagination_options, access_context, filter_options={}) + dataset = nil + if access_context.roles.admin? + dataset = App.dataset + else + dataset = App.user_visible(access_context.user) + end + + dataset = dataset.where(app_guid: filter_options[:app_guid]) if filter_options[:app_guid] + + @paginator.get_page(dataset, pagination_options) end def show(guid, access_context) diff --git a/app/jobs/audit_event_job.rb b/app/jobs/audit_event_job.rb index 77facfa8d01..0a8308b09b6 100644 --- a/app/jobs/audit_event_job.rb +++ b/app/jobs/audit_event_job.rb @@ -1,6 +1,17 @@ module VCAP::CloudController module Jobs - class AuditEventJob < Struct.new(:job, :event_repository, :event_creation_method, :event_type, :model, :params) + class AuditEventJob < VCAP::CloudController::Jobs::CCJob + attr_accessor :job, :event_repository, :event_creation_method, :event_type, :model, :params + + def initialize(job, event_repository, event_creation_method, event_type, model, params={}) + @job = job + @event_repository = event_repository + @event_creation_method = event_creation_method + @event_type = event_type + @model = model + @params = params + end + def perform job.perform event_repository.send(event_creation_method, event_type, model, params) @@ -13,6 +24,10 @@ def job_name_in_configuration def max_attempts 1 end + + def reschedule_at(time, attempts) + job.reschedule_at(time, attempts) + end end end end diff --git a/app/jobs/cc_job.rb b/app/jobs/cc_job.rb new file mode 100644 index 00000000000..cade751539f --- /dev/null +++ b/app/jobs/cc_job.rb @@ -0,0 +1,9 @@ +module VCAP::CloudController + module Jobs + class CCJob + def reschedule_at(time, attempts) + time + (attempts**4) + 5 + end + end + end +end diff --git a/app/jobs/exception_catching_job.rb b/app/jobs/exception_catching_job.rb index 8a4c7f908ed..155844855bf 100644 --- a/app/jobs/exception_catching_job.rb +++ b/app/jobs/exception_catching_job.rb @@ -2,7 +2,13 @@ module VCAP::CloudController module Jobs - class ExceptionCatchingJob < Struct.new(:handler) + class ExceptionCatchingJob < VCAP::CloudController::Jobs::CCJob + attr_accessor :handler + + def initialize(handler) + @handler = handler + end + def perform handler.perform end @@ -17,6 +23,10 @@ def max_attempts handler.max_attempts end + def reschedule_at(time, attempts) + handler.reschedule_at(time, attempts) + end + private def save_error(error_presenter, job) diff --git a/app/jobs/request_job.rb b/app/jobs/request_job.rb index fc864c44b42..eae5b054026 100644 --- a/app/jobs/request_job.rb +++ b/app/jobs/request_job.rb @@ -1,6 +1,13 @@ module VCAP::CloudController module Jobs - class RequestJob < Struct.new(:job, :request_id) + class RequestJob < VCAP::CloudController::Jobs::CCJob + attr_accessor :job, :request_id + + def initialize(job, request_id) + @job = job + @request_id = request_id + end + def perform current_request_id = ::VCAP::Request.current_id begin @@ -14,6 +21,10 @@ def perform def max_attempts job.max_attempts end + + def reschedule_at(time, attempts) + job.reschedule_at(time, attempts) + end end end end diff --git a/app/jobs/runtime/app_bits_copier.rb b/app/jobs/runtime/app_bits_copier.rb index aa119c9f96f..2c4628bc516 100644 --- a/app/jobs/runtime/app_bits_copier.rb +++ b/app/jobs/runtime/app_bits_copier.rb @@ -1,7 +1,7 @@ module VCAP::CloudController module Jobs module Runtime - class AppBitsCopier + class AppBitsCopier < VCAP::CloudController::Jobs::CCJob def initialize(src_app, dest_app, app_event_repo, user, email) @user = user @email = email diff --git a/app/jobs/runtime/app_bits_packer.rb b/app/jobs/runtime/app_bits_packer.rb index 448a81fd4d9..d884b0a9b4c 100644 --- a/app/jobs/runtime/app_bits_packer.rb +++ b/app/jobs/runtime/app_bits_packer.rb @@ -4,7 +4,15 @@ module VCAP::CloudController module Jobs module Runtime - class AppBitsPacker < Struct.new(:app_guid, :uploaded_compressed_path, :fingerprints) + class AppBitsPacker < VCAP::CloudController::Jobs::CCJob + attr_accessor :app_guid, :uploaded_compressed_path, :fingerprints + + def initialize(app_guid, uploaded_compressed_path, fingerprints) + @app_guid = app_guid + @uploaded_compressed_path = uploaded_compressed_path + @fingerprints = fingerprints + end + def perform logger = Steno.logger('cc.background') logger.info("Packing the app bits for app '#{app_guid}'") diff --git a/app/jobs/runtime/app_events_cleanup.rb b/app/jobs/runtime/app_events_cleanup.rb index 211f7606a42..1dfbaa24cbd 100644 --- a/app/jobs/runtime/app_events_cleanup.rb +++ b/app/jobs/runtime/app_events_cleanup.rb @@ -1,7 +1,13 @@ module VCAP::CloudController module Jobs module Runtime - class AppEventsCleanup < Struct.new(:cutoff_age_in_days) + class AppEventsCleanup < VCAP::CloudController::Jobs::CCJob + attr_accessor :cutoff_age_in_days + + def initialize(cutoff_age_in_days) + @cutoff_age_in_days = cutoff_age_in_days + end + def perform old_app_events = AppEvent.where("created_at < CURRENT_TIMESTAMP - INTERVAL '?' DAY", cutoff_age_in_days.to_i) logger = Steno.logger('cc.background') diff --git a/app/jobs/runtime/app_usage_events_cleanup.rb b/app/jobs/runtime/app_usage_events_cleanup.rb index 6ba15a9ab11..138d5fa9e2e 100644 --- a/app/jobs/runtime/app_usage_events_cleanup.rb +++ b/app/jobs/runtime/app_usage_events_cleanup.rb @@ -3,7 +3,13 @@ module VCAP::CloudController module Jobs module Runtime - class AppUsageEventsCleanup < Struct.new(:cutoff_age_in_days) + class AppUsageEventsCleanup < VCAP::CloudController::Jobs::CCJob + attr_accessor :cutoff_age_in_days + + def initialize(cutoff_age_in_days) + @cutoff_age_in_days = cutoff_age_in_days + end + def perform logger = Steno.logger('cc.background') logger.info('Cleaning up old AppUsageEvent rows') diff --git a/app/jobs/runtime/blobstore_delete.rb b/app/jobs/runtime/blobstore_delete.rb index 0eae93da2eb..0a0ad60125f 100644 --- a/app/jobs/runtime/blobstore_delete.rb +++ b/app/jobs/runtime/blobstore_delete.rb @@ -1,14 +1,23 @@ module VCAP::CloudController module Jobs module Runtime - class BlobstoreDelete < Struct.new(:key, :blobstore_name, :attributes) + class BlobstoreDelete < VCAP::CloudController::Jobs::CCJob + attr_accessor :key, :blobstore_name, :attributes + + def initialize(key, blobstore_name, attributes=nil) + @key = key + @blobstore_name = blobstore_name + @attributes = attributes + end + def perform logger = Steno.logger('cc.background') - logger.info("Deleting '#{key}' from blobstore '#{blobstore_name}'") + logger.info("Attempting delete of '#{key}' from blobstore '#{blobstore_name}'") blobstore = CloudController::DependencyLocator.instance.public_send(blobstore_name) blob = blobstore.blob(key) if blob && same_blob(blob) + logger.info("Deleting '#{key}' from blobstore '#{blobstore_name}'") blobstore.delete_blob(blob) end end diff --git a/app/jobs/runtime/blobstore_upload.rb b/app/jobs/runtime/blobstore_upload.rb index 6b5c59aa43e..d2c1327495e 100644 --- a/app/jobs/runtime/blobstore_upload.rb +++ b/app/jobs/runtime/blobstore_upload.rb @@ -1,7 +1,7 @@ module VCAP::CloudController module Jobs module Runtime - class BlobstoreUpload + class BlobstoreUpload < VCAP::CloudController::Jobs::CCJob attr_reader :local_path, :blobstore_key, :blobstore_name attr_reader :max_attempts diff --git a/app/jobs/runtime/buildpack_installer.rb b/app/jobs/runtime/buildpack_installer.rb index ea275a53e9e..cfac8f1c698 100644 --- a/app/jobs/runtime/buildpack_installer.rb +++ b/app/jobs/runtime/buildpack_installer.rb @@ -1,7 +1,15 @@ module VCAP::CloudController module Jobs module Runtime - class BuildpackInstaller < Struct.new(:name, :file, :opts, :config) + class BuildpackInstaller < VCAP::CloudController::Jobs::CCJob + attr_accessor :name, :file, :opts + + def initialize(name, file, opts) + @name = name + @file = file + @opts = opts + end + def perform logger = Steno.logger('cc.background') logger.info "Installing buildpack #{name}" diff --git a/app/jobs/runtime/droplet_deletion.rb b/app/jobs/runtime/droplet_deletion.rb index c1d7d6a39ad..1586584ea47 100644 --- a/app/jobs/runtime/droplet_deletion.rb +++ b/app/jobs/runtime/droplet_deletion.rb @@ -1,7 +1,14 @@ module VCAP::CloudController module Jobs module Runtime - class DropletDeletion < Struct.new(:new_droplet_key, :old_droplet_key) + class DropletDeletion < VCAP::CloudController::Jobs::CCJob + attr_accessor :new_droplet_key, :old_droplet_key + + def initialize(new_droplet_key, old_droplet_key) + @new_droplet_key = new_droplet_key + @old_droplet_key = old_droplet_key + end + def perform logger = Steno.logger('cc.background') logger.info("Deleting droplet '#{new_droplet_key}' (and '#{old_droplet_key}') from droplet blobstore") diff --git a/app/jobs/runtime/droplet_upload.rb b/app/jobs/runtime/droplet_upload.rb index a2b0779d04a..83a4d4e3bde 100644 --- a/app/jobs/runtime/droplet_upload.rb +++ b/app/jobs/runtime/droplet_upload.rb @@ -1,7 +1,7 @@ module VCAP::CloudController module Jobs module Runtime - class DropletUpload + class DropletUpload < VCAP::CloudController::Jobs::CCJob attr_reader :local_path, :app_id attr_reader :max_attempts diff --git a/app/jobs/runtime/events_cleanup.rb b/app/jobs/runtime/events_cleanup.rb index d825636ce44..efe86ca4cef 100644 --- a/app/jobs/runtime/events_cleanup.rb +++ b/app/jobs/runtime/events_cleanup.rb @@ -1,7 +1,13 @@ module VCAP::CloudController module Jobs module Runtime - class EventsCleanup < Struct.new(:cutoff_age_in_days) + class EventsCleanup < VCAP::CloudController::Jobs::CCJob + attr_accessor :cutoff_age_in_days + + def initialize(cutoff_age_in_days) + @cutoff_age_in_days = cutoff_age_in_days + end + def perform old_events = Event.where("created_at < CURRENT_TIMESTAMP - INTERVAL '?' DAY", cutoff_age_in_days.to_i) logger = Steno.logger('cc.background') diff --git a/app/jobs/runtime/failed_jobs_cleanup.rb b/app/jobs/runtime/failed_jobs_cleanup.rb index fd41b968eaa..a85677b6e86 100644 --- a/app/jobs/runtime/failed_jobs_cleanup.rb +++ b/app/jobs/runtime/failed_jobs_cleanup.rb @@ -1,7 +1,13 @@ module VCAP::CloudController module Jobs module Runtime - class FailedJobsCleanup < Struct.new(:cutoff_age_in_days) + class FailedJobsCleanup < VCAP::CloudController::Jobs::CCJob + attr_accessor :cutoff_age_in_days + + def initialize(cutoff_age_in_days) + @cutoff_age_in_days = cutoff_age_in_days + end + def perform old_delayed_jobs = Delayed::Job. where('failed_at is not null'). diff --git a/app/jobs/runtime/model_deletion.rb b/app/jobs/runtime/model_deletion.rb index 396a8e67f52..fadfae73fbb 100644 --- a/app/jobs/runtime/model_deletion.rb +++ b/app/jobs/runtime/model_deletion.rb @@ -1,7 +1,14 @@ module VCAP::CloudController module Jobs module Runtime - class ModelDeletion < Struct.new(:model_class, :guid) + class ModelDeletion < VCAP::CloudController::Jobs::CCJob + attr_accessor :model_class, :guid + + def initialize(model_class, guid) + @model_class = model_class + @guid = guid + end + def perform logger = Steno.logger('cc.background') logger.info("Deleting model class '#{model_class}' with guid '#{guid}'") diff --git a/app/jobs/runtime/pending_packages_cleanup.rb b/app/jobs/runtime/pending_packages_cleanup.rb index e5c1acde79d..fe475885005 100644 --- a/app/jobs/runtime/pending_packages_cleanup.rb +++ b/app/jobs/runtime/pending_packages_cleanup.rb @@ -1,7 +1,13 @@ module VCAP::CloudController module Jobs module Runtime - class PendingPackagesCleanup < Struct.new(:expiration_in_seconds) + class PendingPackagesCleanup < VCAP::CloudController::Jobs::CCJob + attr_accessor :expiration_in_seconds + + def initialize(expiration_in_seconds) + @expiration_in_seconds = expiration_in_seconds + end + def perform cutoff_time = Time.now - expiration_in_seconds App.where('package_pending_since < ?', cutoff_time).update( diff --git a/app/jobs/services/service_instance_deprovision.rb b/app/jobs/services/service_instance_deprovision.rb index bacf5b1343c..be090b138ba 100644 --- a/app/jobs/services/service_instance_deprovision.rb +++ b/app/jobs/services/service_instance_deprovision.rb @@ -1,8 +1,20 @@ module VCAP::CloudController module Jobs module Services - class ServiceInstanceDeprovision < Struct.new(:name, :client_attrs, :service_instance_guid, :service_plan_guid) + class ServiceInstanceDeprovision < VCAP::CloudController::Jobs::CCJob + attr_accessor :name, :client_attrs, :service_instance_guid, :service_plan_guid + + def initialize(name, client_attrs, service_instance_guid, service_plan_guid) + @name = name + @client_attrs = client_attrs + @service_instance_guid = service_instance_guid + @service_plan_guid = service_plan_guid + end + def perform + logger = Steno.logger('cc-background') + logger.info('There was an error during service instance provisioning. Attempting to delete potentially orphaned instance.') + client = VCAP::Services::ServiceBrokers::V2::Client.new(client_attrs) service_plan = ServicePlan.first(guid: service_plan_guid) service_instance = ManagedServiceInstance.new(guid: service_instance_guid, service_plan: service_plan) @@ -14,7 +26,11 @@ def job_name_in_configuration end def max_attempts - 3 + 10 + end + + def reschedule_at(time, attempts) + time + (2**attempts).minutes end end end diff --git a/app/jobs/services/service_instance_state_fetch.rb b/app/jobs/services/service_instance_state_fetch.rb new file mode 100644 index 00000000000..8d8ecc96222 --- /dev/null +++ b/app/jobs/services/service_instance_state_fetch.rb @@ -0,0 +1,47 @@ +module VCAP::CloudController + module Jobs + module Services + class ServiceInstanceStateFetch < VCAP::CloudController::Jobs::CCJob + attr_accessor :name, :client_attrs, :service_instance_guid, :service_plan_guid + + def initialize(name, client_attrs, service_instance_guid, service_plan_guid) + @name = name + @client_attrs = client_attrs + @service_instance_guid = service_instance_guid + @service_plan_guid = service_plan_guid + end + + def perform + client = VCAP::Services::ServiceBrokers::V2::Client.new(client_attrs) + service_plan = ServicePlan.first(guid: service_plan_guid) + service_instance = ManagedServiceInstance.first(guid: service_instance_guid, service_plan: service_plan) + client.fetch_service_instance_state(service_instance) + + service_instance.save + + if service_instance.state != 'available' + retry_job + end + rescue HttpRequestError, Sequel::Error + retry_job + end + + def job_name_in_configuration + :service_instance_state_fetch + end + + def max_attempts + 1 + end + + private + + def retry_job + poll_interval = 1.minute + opts = { queue: 'cc-generic', run_at: Delayed::Job.db_time_now + poll_interval } + VCAP::CloudController::Jobs::Enqueuer.new(self, opts).enqueue + end + end + end + end +end diff --git a/app/jobs/services/service_instance_unbind.rb b/app/jobs/services/service_instance_unbind.rb index 55f20c87410..a1acb3ee5c5 100644 --- a/app/jobs/services/service_instance_unbind.rb +++ b/app/jobs/services/service_instance_unbind.rb @@ -1,15 +1,27 @@ module VCAP::CloudController module Jobs module Services - class ServiceInstanceUnbind < Struct.new(:name, :client_attrs, :binding_guid, :service_instance_guid, :app_guid) + class ServiceInstanceUnbind < VCAP::CloudController::Jobs::CCJob + attr_accessor :name, :client_attrs, :binding_guid, :service_instance_guid, :app_guid + + def initialize(name, client_attrs, binding_guid, service_instance_guid, app_guid) + @name = name + @client_attrs = client_attrs + @binding_guid = binding_guid + @service_instance_guid = service_instance_guid + @app_guid = app_guid + end + def perform + logger = Steno.logger('cc-background') + logger.info('There was an error during service binding creation. Attempting to delete potentially orphaned binding.') + client = VCAP::Services::ServiceBrokers::V2::Client.new(client_attrs) app = VCAP::CloudController::App.first(guid: app_guid) service_instance = VCAP::CloudController::ServiceInstance.first(guid: service_instance_guid) - binding = VCAP::CloudController::ServiceBinding.new(guid: binding_guid, app: app, service_instance: service_instance) - - client.unbind(binding) + service_binding = VCAP::CloudController::ServiceBinding.new(guid: binding_guid, app: app, service_instance: service_instance) + client.unbind(service_binding) end def job_name_in_configuration @@ -17,7 +29,11 @@ def job_name_in_configuration end def max_attempts - 3 + 10 + end + + def reschedule_at(time, attempts) + time + (2**attempts).minutes end end end diff --git a/app/jobs/timeout_job.rb b/app/jobs/timeout_job.rb index 3c77a5d3782..3f0233c598b 100644 --- a/app/jobs/timeout_job.rb +++ b/app/jobs/timeout_job.rb @@ -1,6 +1,12 @@ module VCAP::CloudController module Jobs - class TimeoutJob < Struct.new(:job) + class TimeoutJob < VCAP::CloudController::Jobs::CCJob + attr_accessor :job + + def initialize(job) + @job = job + end + def perform Timeout.timeout max_run_time(job.job_name_in_configuration) do job.perform @@ -18,6 +24,10 @@ def max_run_time(job_name_in_configuration) job_config = jobs_config[job_name_in_configuration] || jobs_config[:global] job_config[:timeout_in_seconds] end + + def reschedule_at(time, attempts) + job.reschedule_at(time, attempts) + end end end end diff --git a/app/jobs/v3/package_bits.rb b/app/jobs/v3/package_bits.rb new file mode 100644 index 00000000000..4cbf494ec1b --- /dev/null +++ b/app/jobs/v3/package_bits.rb @@ -0,0 +1,34 @@ +require 'cloud_controller/blobstore/cdn' +require 'cloud_controller/dependency_locator' + +module VCAP::CloudController + module Jobs + module Runtime + class PackageBits + def initialize(package_guid, uploaded_compressed_path) + @package_guid = package_guid + @uploaded_compressed_path = uploaded_compressed_path + end + + def perform + logger = Steno.logger('cc.background') + logger.info("Packing the app bits for package '#{@package_guid}'") + + package_blobstore = CloudController::DependencyLocator.instance.package_blobstore + max_package_size = VCAP::CloudController::Config.config[:packages][:max_package_size] || 512 * 1024 * 1024 + + app_bits_packer = AppBitsPackage.new(package_blobstore, nil, max_package_size, VCAP::CloudController::Config.config[:directories][:tmpdir]) + app_bits_packer.create_package_in_blobstore(@package_guid, @uploaded_compressed_path) + end + + def job_name_in_configuration + :package_bits + end + + def max_attempts + 1 + end + end + end + end +end diff --git a/app/models.rb b/app/models.rb index ceeb6bd614e..81e1b85b855 100644 --- a/app/models.rb +++ b/app/models.rb @@ -62,3 +62,4 @@ require 'models/v3/domain/app_process' require 'models/v3/persistence/app_model' +require 'models/v3/persistence/package_model' diff --git a/app/models/runtime/app.rb b/app/models/runtime/app.rb index 4ffca6ce136..177defc442f 100644 --- a/app/models/runtime/app.rb +++ b/app/models/runtime/app.rb @@ -138,6 +138,10 @@ def before_save self.memory ||= Config.config[:default_app_memory] self.disk_quota ||= Config.config[:default_app_disk_in_mb] + if Config.config[:container_file_descriptor_limit] + self.file_descriptors ||= Config.config[:container_file_descriptor_limit] + end + set_new_version if version_needs_to_be_updated? AppStopEvent.create_from_app(self) if generate_stop_event? @@ -227,10 +231,6 @@ def desired_instances started? ? instances : 0 end - def versioned_guid - "#{guid}-#{version}" - end - def organization space && space.organization end diff --git a/app/models/runtime/app_bits_package.rb b/app/models/runtime/app_bits_package.rb index 6adf7853718..313736f5dd9 100644 --- a/app/models/runtime/app_bits_package.rb +++ b/app/models/runtime/app_bits_package.rb @@ -2,6 +2,9 @@ require 'cloud_controller/blobstore/fingerprints_collection' class AppBitsPackage + class PackageNotFound < StandardError; end + class ZipSizeExceeded < StandardError; end + attr_reader :package_blobstore, :global_app_bits_cache, :max_package_size, :tmp_dir def initialize(package_blobstore, global_app_bits_cache, max_package_size, tmp_dir) @@ -30,8 +33,43 @@ def create(app, uploaded_tmp_compressed_path, fingerprints_in_app_cache) FileUtils.rm_f(uploaded_tmp_compressed_path) if uploaded_tmp_compressed_path end + def create_package_in_blobstore(package_guid, package_path) + return unless package_path + + package = VCAP::CloudController::PackageModel.find(guid: package_guid) + raise PackageNotFound if package.nil? + + begin + package_file = File.new(package_path) + raise ZipSizeExceeded if @max_package_size && package_size(package_path) > @max_package_size + + package_blobstore.cp_to_blobstore(package_path, package_guid) + + package.db.transaction do + package.lock! + package.package_hash = package_file.hexdigest + package.state = VCAP::CloudController::PackageModel::READY_STATE + package.save + end + rescue => e + package.db.transaction do + package.state = VCAP::CloudController::PackageModel::FAILED_STATE + package.error = e.message + package.save + end + raise e + end + ensure + FileUtils.rm_f(package_path) if package_path + end + private + def package_size(package_path) + zip_info = `unzip -l #{package_path}` + zip_info.split("\n").last.match(/^\s*(\d+)/)[1].to_i + end + def validate_size!(fingerprints_in_app_cache, local_app_bits) return unless max_package_size diff --git a/app/models/runtime/constraints/health_check_policy.rb b/app/models/runtime/constraints/health_check_policy.rb index 7d4ee8e2a2f..758416d21c8 100644 --- a/app/models/runtime/constraints/health_check_policy.rb +++ b/app/models/runtime/constraints/health_check_policy.rb @@ -8,9 +8,9 @@ def initialize(app, health_check_timeout) def validate return unless @health_check_timeout @errors.add(:health_check_timeout, :less_than_zero) unless @health_check_timeout >= 0 - - if @health_check_timeout > VCAP::CloudController::Config.config[:maximum_health_check_timeout] - @errors.add(:health_check_timeout, :maximum_exceeded) + max_timeout = VCAP::CloudController::Config.config[:maximum_health_check_timeout] + if @health_check_timeout > max_timeout + @errors.add(:health_check_timeout, "Maximum exceeded: max #{max_timeout}s") end end end diff --git a/app/models/runtime/user.rb b/app/models/runtime/user.rb index 82af78f7560..24201aebec9 100644 --- a/app/models/runtime/user.rb +++ b/app/models/runtime/user.rb @@ -1,6 +1,7 @@ module VCAP::CloudController class User < Sequel::Model class InvalidOrganizationRelation < VCAP::Errors::InvalidRelation; end + attr_accessor :username no_auto_guid @@ -81,6 +82,15 @@ def validate_organization_roles(org) end end + def export_attrs + attrs = super + if username + attrs + [:username] + else + attrs + end + end + def admin? admin end diff --git a/app/models/services/managed_service_instance.rb b/app/models/services/managed_service_instance.rb index 51db1023e56..f4cb1256938 100644 --- a/app/models/services/managed_service_instance.rb +++ b/app/models/services/managed_service_instance.rb @@ -54,7 +54,8 @@ def do_request(method, payload=nil) many_to_one :service_plan export_attributes :name, :credentials, :service_plan_guid, - :space_guid, :gateway_data, :dashboard_url, :type + :space_guid, :gateway_data, :dashboard_url, :type, :state, + :state_description import_attributes :name, :service_plan_guid, :space_guid, :gateway_data diff --git a/app/models/v3/persistence/package_model.rb b/app/models/v3/persistence/package_model.rb new file mode 100644 index 00000000000..b8d41e841a8 --- /dev/null +++ b/app/models/v3/persistence/package_model.rb @@ -0,0 +1,13 @@ +module VCAP::CloudController + class PackageModel < Sequel::Model(:packages) + PENDING_STATE = 'PROCESSING_UPLOAD' + READY_STATE = 'READY' + FAILED_STATE = 'FAILED' + CREATED_STATE = 'AWAITING_UPLOAD' + PACKAGE_STATES = [CREATED_STATE, PENDING_STATE, READY_STATE, FAILED_STATE].map(&:freeze).freeze + + def validate + validates_includes PACKAGE_STATES, :state, allow_missing: true + end + end +end diff --git a/app/presenters/v3/app_presenter.rb b/app/presenters/v3/app_presenter.rb index 97c54f46d4b..e0f061e4c76 100644 --- a/app/presenters/v3/app_presenter.rb +++ b/app/presenters/v3/app_presenter.rb @@ -1,9 +1,33 @@ +require 'presenters/v3/pagination_presenter' + module VCAP::CloudController class AppPresenter + def initialize(pagination_presenter=PaginationPresenter.new) + @pagination_presenter = pagination_presenter + end + def present_json(app) - app_hash = { - guid: app.guid, - name: app.name, + MultiJson.dump(app_hash(app), pretty: true) + end + + def present_json_list(paginated_result) + apps = paginated_result.records + app_hashes = apps.collect { |app| app_hash(app) } + + paginated_response = { + pagination: @pagination_presenter.present_pagination_hash(paginated_result, '/v3/apps'), + resources: app_hashes + } + + MultiJson.dump(paginated_response, pretty: true) + end + + private + + def app_hash(app) + { + guid: app.guid, + name: app.name, _links: { self: { href: "/v3/apps/#{app.guid}" }, @@ -11,8 +35,6 @@ def present_json(app) space: { href: "/v2/spaces/#{app.space_guid}" }, } } - - MultiJson.dump(app_hash, pretty: true) end end end diff --git a/app/presenters/v3/package_presenter.rb b/app/presenters/v3/package_presenter.rb new file mode 100644 index 00000000000..a1d88bd47ca --- /dev/null +++ b/app/presenters/v3/package_presenter.rb @@ -0,0 +1,28 @@ +module VCAP::CloudController + class PackagePresenter + def present_json(package) + package_hash = { + guid: package.guid, + type: package.type, + hash: package.package_hash, + url: package.url, + state: package.state, + error: package.error, + created_at: package.created_at, + _links: { + self: { + href: "/v3/packages/#{package.guid}" + }, + upload: { + href: "/v3/packages/#{package.guid}/upload", + }, + space: { + href: "/v2/spaces/#{package.space_guid}", + }, + }, + } + + MultiJson.dump(package_hash, pretty: true) + end + end +end diff --git a/app/presenters/v3/pagination_presenter.rb b/app/presenters/v3/pagination_presenter.rb new file mode 100644 index 00000000000..f169babb790 --- /dev/null +++ b/app/presenters/v3/pagination_presenter.rb @@ -0,0 +1,22 @@ +module VCAP::CloudController + class PaginationPresenter + def present_pagination_hash(paginated_result, base_url) + page = paginated_result.pagination_options.page + per_page = paginated_result.pagination_options.per_page + total_results = paginated_result.total + + last_page = (total_results.to_f / per_page.to_f).ceil + last_page = 1 if last_page < 1 + previous_page = page - 1 + next_page = page + 1 + + { + total_results: total_results, + first: { href: "#{base_url}?page=1&per_page=#{per_page}" }, + last: { href: "#{base_url}?page=#{last_page}&per_page=#{per_page}" }, + next: next_page <= last_page ? { href: "#{base_url}?page=#{next_page}&per_page=#{per_page}" } : nil, + previous: previous_page > 0 ? { href: "#{base_url}?page=#{previous_page}&per_page=#{per_page}" } : nil, + } + end + end +end diff --git a/app/presenters/v3/process_presenter.rb b/app/presenters/v3/process_presenter.rb index 57921e36507..5a4e96c8f69 100644 --- a/app/presenters/v3/process_presenter.rb +++ b/app/presenters/v3/process_presenter.rb @@ -1,12 +1,25 @@ +require 'presenters/v3/pagination_presenter' + module VCAP::CloudController class ProcessPresenter + def initialize(pagination_presenter=PaginationPresenter.new) + @pagination_presenter = pagination_presenter + end + def present_json(process) MultiJson.dump(process_hash(process), pretty: true) end - def present_json_list(processes) - process_hashes = processes.collect { |process| process_hash(process) } - MultiJson.dump(process_hashes, pretty: true) + def present_json_list(paginated_result, base_url) + processes = paginated_result.records + process_hashes = processes.collect { |app| process_hash(app) } + + paginated_response = { + pagination: @pagination_presenter.present_pagination_hash(paginated_result, base_url), + resources: process_hashes + } + + MultiJson.dump(paginated_response, pretty: true) end private diff --git a/bosh-templates/cloud_controller_api.yml.erb b/bosh-templates/cloud_controller_api.yml.erb index 8a8d566c6f1..dc5322f7aab 100644 --- a/bosh-templates/cloud_controller_api.yml.erb +++ b/bosh-templates/cloud_controller_api.yml.erb @@ -74,6 +74,8 @@ default_app_memory: <%= p("cc.default_app_memory") %> default_app_disk_in_mb: <%= p("cc.default_app_disk_in_mb") %> maximum_app_disk_in_mb: <%= p("cc.maximum_app_disk_in_mb") %> +container_file_descriptor_limit: <%= p("cc.container_file_descriptor_limit") %> + request_timeout_in_seconds: <%= p("request_timeout_in_seconds") %> cc_partition: <%= p("cc.cc_partition") %> @@ -238,6 +240,9 @@ uaa_client_secret: <%= p("uaa.clients.cc-service-dashboards.secret") %> uaa_client_scope: <%= p("uaa.clients.cc-service-dashboards.scope") %> <% end %> +cloud_controller_username_lookup_client_name: "cloud_controller_username_lookup" +cloud_controller_username_lookup_client_secret: <%= p("uaa.clients.cloud_controller_username_lookup.secret") %> + diego: staging: <%= p("cc.diego.staging") %> running: <%= p("cc.diego.running") %> diff --git a/bosh-templates/cloud_controller_clock.yml.erb b/bosh-templates/cloud_controller_clock.yml.erb index dcf3105e372..bb1ca892399 100644 --- a/bosh-templates/cloud_controller_clock.yml.erb +++ b/bosh-templates/cloud_controller_clock.yml.erb @@ -78,6 +78,8 @@ default_app_memory: <%= p("cc.default_app_memory") %> default_app_disk_in_mb: <%= p("cc.default_app_disk_in_mb") %> maximum_app_disk_in_mb: <%= p("cc.maximum_app_disk_in_mb") %> +container_file_descriptor_limit: <%= p("cc.container_file_descriptor_limit") %> + request_timeout_in_seconds: <%= p("request_timeout_in_seconds") %> cc_partition: <%= p("cc.cc_partition") %> diff --git a/bosh-templates/cloud_controller_worker.yml.erb b/bosh-templates/cloud_controller_worker.yml.erb index 103bf882ae0..6561f94af46 100644 --- a/bosh-templates/cloud_controller_worker.yml.erb +++ b/bosh-templates/cloud_controller_worker.yml.erb @@ -74,6 +74,8 @@ default_app_memory: <%= p("cc.default_app_memory") %> default_app_disk_in_mb: <%= p("cc.default_app_disk_in_mb") %> maximum_app_disk_in_mb: <%= p("cc.maximum_app_disk_in_mb") %> +container_file_descriptor_limit: <%= p("cc.container_file_descriptor_limit") %> + request_timeout_in_seconds: <%= p("request_timeout_in_seconds") %> cc_partition: <%= p("cc.cc_partition") %> diff --git a/bosh-templates/nginx.conf.erb b/bosh-templates/nginx.conf.erb index 13d14d9d96f..21085c0417b 100644 --- a/bosh-templates/nginx.conf.erb +++ b/bosh-templates/nginx.conf.erb @@ -91,7 +91,7 @@ http { } <% end %> - location ~ (/apps/.*/application|/v2/apps/.*/bits|/services/v\d+/configurations/.*/serialized/data|/v2/buildpacks/.*/bits) { + location ~ (/apps/.*/application|/v2/apps/.*/bits|/services/v\d+/configurations/.*/serialized/data|/v2/buildpacks/.*/bits|/v3/packages/.*/upload) { # Pass altered request body to this location upload_pass @cc_uploads; upload_pass_args on; diff --git a/config/cloud_controller.yml b/config/cloud_controller.yml index a5f16458c22..0b262a333cf 100644 --- a/config/cloud_controller.yml +++ b/config/cloud_controller.yml @@ -142,6 +142,9 @@ uaa_client_name: 'cc-service-dashboards' uaa_client_secret: 'some-sekret' uaa_client_scope: openid,cloud_controller_service_permissions.read +cloud_controller_username_lookup_client_name: 'username_lookup_client_name' +cloud_controller_username_lookup_client_secret: 'username_lookup_secret' + diego: staging: optional # disabled|optional|required running: optional # disabled|optional|required diff --git a/db/migrations/20141226222846_create_packages.rb b/db/migrations/20141226222846_create_packages.rb new file mode 100644 index 00000000000..a7f2c374435 --- /dev/null +++ b/db/migrations/20141226222846_create_packages.rb @@ -0,0 +1,15 @@ +Sequel.migration do + change do + create_table :packages do + VCAP::Migration.common(self) + String :space_guid + index :space_guid + String :type + index :type + String :package_hash + String :state, null: false + String :error, text: true + String :url + end + end +end diff --git a/db/migrations/20150113000312_add_state_and_state_description_to_service_instances.rb b/db/migrations/20150113000312_add_state_and_state_description_to_service_instances.rb new file mode 100644 index 00000000000..d01ac2b6c71 --- /dev/null +++ b/db/migrations/20150113000312_add_state_and_state_description_to_service_instances.rb @@ -0,0 +1,6 @@ +Sequel.migration do + change do + add_column :service_instances, :state, String + add_column :service_instances, :state_description, String, text: true + end +end diff --git a/db/migrations/20150113201824_fix_created_at_column.rb b/db/migrations/20150113201824_fix_created_at_column.rb new file mode 100644 index 00000000000..8b40d4a2295 --- /dev/null +++ b/db/migrations/20150113201824_fix_created_at_column.rb @@ -0,0 +1,53 @@ +Sequel.migration do + TABLE_NAMES = %w( + app_events + app_usage_events + apps + apps_v3 + billing_events + buildpacks + delayed_jobs + domains + droplets + env_groups + events + feature_flags + organizations + quota_definitions + routes + security_groups + service_auth_tokens + service_bindings + service_brokers + service_dashboard_clients + service_instances + service_plan_visibilities + service_plans + service_usage_events + services + space_quota_definitions + spaces + stacks + users + ) + + up do + if self.class.name.match /mysql/i + TABLE_NAMES.each do |table| + run <<-SQL + ALTER TABLE #{table} MODIFY created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; + SQL + end + end + end + + down do + if self.class.name.match /mysql/i + TABLE_NAMES.each do |table| + run <<-SQL + ALTER TABLE #{table} MODIFY created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP; + SQL + end + end + end +end diff --git a/lib/cloud_controller.rb b/lib/cloud_controller.rb index 563f4de1bfe..8c69955412d 100644 --- a/lib/cloud_controller.rb +++ b/lib/cloud_controller.rb @@ -43,6 +43,7 @@ module VCAP::CloudController; end require 'cloud_controller/runner' require 'cloud_controller/app_observer' require 'cloud_controller/dea/app_stager_task' +require 'cloud_controller/collection_transformers' require 'cloud_controller/controllers' require 'cloud_controller/roles' require 'cloud_controller/encryptor' @@ -88,4 +89,7 @@ module VCAP::CloudController; end require 'cloud_controller/errors/instances_unavailable' +require 'cloud_controller/uaa/errors' +require 'cloud_controller/uaa/uaa_client' + require 'services' diff --git a/lib/cloud_controller/backends/runners.rb b/lib/cloud_controller/backends/runners.rb index 882fa0f39e4..fd8b221e7b2 100644 --- a/lib/cloud_controller/backends/runners.rb +++ b/lib/cloud_controller/backends/runners.rb @@ -1,5 +1,6 @@ require 'cloud_controller/dea/runner' require 'cloud_controller/diego/runner' +require 'cloud_controller/diego/process_guid' require 'cloud_controller/diego/traditional/protocol' require 'cloud_controller/diego/docker/protocol' require 'cloud_controller/diego/common/protocol' @@ -38,6 +39,36 @@ def diego_apps(batch_size, last_id) all end + def diego_apps_from_process_guids(process_guids) + return [] if diego_running_disabled? + + process_guids = Array(process_guids).to_set + App. + eager(:current_saved_droplet, :space, :stack, :service_bindings, { routes: :domain }). + where(guid: process_guids.map { |pg| Diego::ProcessGuid.app_guid(pg) }). + where('deleted_at IS NULL'). + where(state: 'STARTED'). + where(package_state: 'STAGED'). + where(diego: true). + order(:id). + all. + select { |app| process_guids.include?(Diego::ProcessGuid.from_app(app)) } + end + + def diego_apps_cache_data(batch_size, last_id) + return [] if diego_running_disabled? + + App.select(:id, :guid, :version, :updated_at). + where('id > ?', last_id). + where(state: 'STARTED'). + where(package_state: 'STAGED'). + where('deleted_at IS NULL'). + where(diego: true). + order(:id). + limit(batch_size). + select_map([:id, :guid, :version, :updated_at]) + end + def dea_apps(batch_size, last_id) query = App. where('id > ?', last_id). diff --git a/lib/cloud_controller/collection_transformers.rb b/lib/cloud_controller/collection_transformers.rb new file mode 100644 index 00000000000..ee0dfee0372 --- /dev/null +++ b/lib/cloud_controller/collection_transformers.rb @@ -0,0 +1,3 @@ +Dir[File.expand_path('../../../app/collection_transformers/**/*.rb', __FILE__)].each do |file| + require file +end diff --git a/lib/cloud_controller/config.rb b/lib/cloud_controller/config.rb index b027c74b210..d4742a3b1cb 100644 --- a/lib/cloud_controller/config.rb +++ b/lib/cloud_controller/config.rb @@ -44,6 +44,8 @@ class Config < VCAP::Config optional(:maximum_app_disk_in_mb) => Fixnum, :maximum_health_check_timeout => Fixnum, + optional(:container_file_descriptor_limit) => Fixnum, + optional(:allow_debug) => bool, optional(:login) => { @@ -180,6 +182,9 @@ class Config < VCAP::Config optional(:uaa_client_secret) => String, optional(:uaa_client_scope) => String, + optional(:cloud_controller_username_lookup_client_name) => String, + optional(:cloud_controller_username_lookup_client_secret) => String, + :renderer => { max_results_per_page: Integer, default_results_per_page: Integer, diff --git a/lib/cloud_controller/constants.rb b/lib/cloud_controller/constants.rb index bbea378bbc2..b70fd554b57 100644 --- a/lib/cloud_controller/constants.rb +++ b/lib/cloud_controller/constants.rb @@ -1,5 +1,5 @@ module VCAP::CloudController class Constants - API_VERSION = '2.20.0'.freeze + API_VERSION = '2.21.0'.freeze end end diff --git a/lib/cloud_controller/dependency_locator.rb b/lib/cloud_controller/dependency_locator.rb index d1b2192be1a..1438379f0e1 100644 --- a/lib/cloud_controller/dependency_locator.rb +++ b/lib/cloud_controller/dependency_locator.rb @@ -177,6 +177,14 @@ def app_presenter AppPresenter.new end + def packages_handler + PackagesHandler.new(@config) + end + + def package_presenter + PackagePresenter.new + end + def object_renderer eager_loader = VCAP::CloudController::RestController::SecureEagerLoader.new serializer = VCAP::CloudController::RestController::PreloadedObjectSerializer.new @@ -198,6 +206,18 @@ def entity_only_paginated_collection_renderer create_paginated_collection_renderer(serializer: VCAP::CloudController::RestController::EntityOnlyPreloadedObjectSerializer.new) end + def username_populating_collection_renderer + create_paginated_collection_renderer(collection_transformer: UsernamePopulator.new(username_lookup_uaa_client)) + end + + def username_lookup_uaa_client + client_id = @config[:cloud_controller_username_lookup_client_name] + secret = @config[:cloud_controller_username_lookup_client_secret] + target = @config[:uaa][:url] + skip_cert_verify = @config[:skip_cert_verify] + UaaClient.new(target, client_id, secret, { skip_ssl_validation: skip_cert_verify }) + end + def missing_blob_handler CloudController::BlobSender::MissingBlobHandler.new end @@ -218,11 +238,13 @@ def create_paginated_collection_renderer(opts={}) max_results_per_page = opts[:max_results_per_page] || @config[:renderer][:max_results_per_page] default_results_per_page = opts[:default_results_per_page] || @config[:renderer][:default_results_per_page] max_inline_relations_depth = opts[:max_inline_relations_depth] || @config[:renderer][:max_inline_relations_depth] + collection_transformer = opts[:collection_transformer] VCAP::CloudController::RestController::PaginatedCollectionRenderer.new(eager_loader, serializer, { max_results_per_page: max_results_per_page, default_results_per_page: default_results_per_page, max_inline_relations_depth: max_inline_relations_depth, + collection_transformer: collection_transformer }) end end diff --git a/lib/cloud_controller/diego/client.rb b/lib/cloud_controller/diego/client.rb index 6e4bda1306a..90cd1e95ca7 100644 --- a/lib/cloud_controller/diego/client.rb +++ b/lib/cloud_controller/diego/client.rb @@ -1,4 +1,5 @@ require 'cloud_controller/diego/unavailable' +require 'cloud_controller/diego/process_guid' module VCAP::CloudController module Diego @@ -17,7 +18,7 @@ def lrp_instances(app) end address = @service_registry.tps_addrs.first - guid = app.versioned_guid + guid = ProcessGuid.from_app(app) uri = URI("#{address}/lrps/#{guid}") logger.info "Requesting lrp information for #{guid} from #{address}" diff --git a/lib/cloud_controller/diego/common/protocol.rb b/lib/cloud_controller/diego/common/protocol.rb index e8f903c4bcd..70b4ae4c58b 100644 --- a/lib/cloud_controller/diego/common/protocol.rb +++ b/lib/cloud_controller/diego/common/protocol.rb @@ -6,14 +6,47 @@ def stop_index_request(app, index) ['diego.stop.index', stop_index_message(app, index).to_json] end + def staging_egress_rules + staging_security_groups = SecurityGroup.where(staging_default: true).all + EgressNetworkRulesPresenter.new(staging_security_groups).to_array.collect { |sg| transform_rule(sg) }.flatten + end + + def running_egress_rules(app) + EgressNetworkRulesPresenter.new(app.space.security_groups).to_array.collect { |sg| transform_rule(sg) }.flatten + end + private def stop_index_message(app, index) { - 'process_guid' => app.versioned_guid, + 'process_guid' => ProcessGuid.from_app(app), 'index' => index, } end + + def transform_rule(rule) + protocol = rule['protocol'] + template = { + 'protocol' => protocol, + 'destination' => rule['destination'], + } + + case protocol + when 'icmp' + template['icmp_info'] = { 'type' => rule['type'], 'code' => rule['code'] } + when 'tcp', 'udp' + range = rule['ports'].split('-') + if range.size == 1 + template['ports'] = range[0].split(',').collect(&:to_i) + else + template['port_range'] = { 'start' => range[0].to_i, 'end' => range[1].to_i } + end + end + + template['log'] = rule['log'] if rule['log'] + + template + end end end end diff --git a/lib/cloud_controller/diego/docker/protocol.rb b/lib/cloud_controller/diego/docker/protocol.rb index 7c38ac2aa8d..3a975d89428 100644 --- a/lib/cloud_controller/diego/docker/protocol.rb +++ b/lib/cloud_controller/diego/docker/protocol.rb @@ -1,4 +1,5 @@ require 'cloud_controller/diego/environment' +require 'cloud_controller/diego/process_guid' module VCAP::CloudController module Diego @@ -21,6 +22,7 @@ def stage_app_message(app, staging_timeout) 'file_descriptors' => app.file_descriptors, 'stack' => app.stack.name, 'docker_image' => app.docker_image, + 'egress_rules' => @common_protocol.staging_egress_rules, 'timeout' => staging_timeout, } end @@ -35,7 +37,7 @@ def stop_staging_app_request(app, task_id) def desire_app_message(app) message = { - 'process_guid' => app.versioned_guid, + 'process_guid' => ProcessGuid.from_app(app), 'memory_mb' => app.memory, 'disk_mb' => app.disk_quota, 'file_descriptors' => app.file_descriptors, @@ -48,6 +50,8 @@ def desire_app_message(app) 'log_guid' => app.guid, 'docker_image' => app.docker_image, 'health_check_type' => app.health_check_type, + 'egress_rules' => @common_protocol.running_egress_rules(app), + 'etag' => app.updated_at.to_f.to_s } message['health_check_timeout_in_seconds'] = app.health_check_timeout if app.health_check_timeout diff --git a/lib/cloud_controller/diego/process_guid.rb b/lib/cloud_controller/diego/process_guid.rb new file mode 100644 index 00000000000..765ded312de --- /dev/null +++ b/lib/cloud_controller/diego/process_guid.rb @@ -0,0 +1,21 @@ +module VCAP::CloudController + module Diego + class ProcessGuid + def self.from(app_guid, app_version) + "#{app_guid}-#{app_version}" + end + + def self.from_app(app) + from(app.guid, app.version) + end + + def self.app_guid(versioned_guid) + versioned_guid[0..35] + end + + def self.app_version(versioned_guid) + versioned_guid[37..-1] + end + end + end +end diff --git a/lib/cloud_controller/diego/traditional/protocol.rb b/lib/cloud_controller/diego/traditional/protocol.rb index 823d6b30002..d0511da0b66 100644 --- a/lib/cloud_controller/diego/traditional/protocol.rb +++ b/lib/cloud_controller/diego/traditional/protocol.rb @@ -1,5 +1,6 @@ require 'cloud_controller/diego/traditional/buildpack_entry_generator' require 'cloud_controller/diego/environment' +require 'cloud_controller/diego/process_guid' module VCAP::CloudController module Diego @@ -37,13 +38,14 @@ def stage_app_message(app, staging_timeout) 'droplet_upload_uri' => @blobstore_url_generator.droplet_upload_url(app), 'build_artifacts_cache_download_uri' => @blobstore_url_generator.buildpack_cache_download_url(app), 'build_artifacts_cache_upload_uri' => @blobstore_url_generator.buildpack_cache_upload_url(app), + 'egress_rules' => @common_protocol.staging_egress_rules, 'timeout' => staging_timeout, } end def desire_app_message(app) message = { - 'process_guid' => app.versioned_guid, + 'process_guid' => ProcessGuid.from_app(app), 'memory_mb' => app.memory, 'disk_mb' => app.disk_quota, 'file_descriptors' => app.file_descriptors, @@ -56,6 +58,8 @@ def desire_app_message(app) 'routes' => app.uris, 'log_guid' => app.guid, 'health_check_type' => app.health_check_type, + 'egress_rules' => @common_protocol.running_egress_rules(app), + 'etag' => app.updated_at.to_f.to_s } message['health_check_timeout_in_seconds'] = app.health_check_timeout if app.health_check_timeout diff --git a/lib/cloud_controller/jobs.rb b/lib/cloud_controller/jobs.rb index 77ca951b7a1..3b5c0d93aa8 100644 --- a/lib/cloud_controller/jobs.rb +++ b/lib/cloud_controller/jobs.rb @@ -1,3 +1,4 @@ +require 'jobs/cc_job' require 'jobs/runtime/app_bits_packer' require 'jobs/runtime/app_bits_copier' require 'jobs/runtime/app_events_cleanup' @@ -12,6 +13,7 @@ require 'jobs/runtime/legacy_jobs' require 'jobs/runtime/failed_jobs_cleanup' require 'jobs/runtime/pending_packages_cleanup' +require 'jobs/v3/package_bits' require 'jobs/enqueuer' require 'jobs/exception_catching_job' require 'jobs/request_job' diff --git a/lib/cloud_controller/paging/paginated_result.rb b/lib/cloud_controller/paging/paginated_result.rb new file mode 100644 index 00000000000..dfc042d992f --- /dev/null +++ b/lib/cloud_controller/paging/paginated_result.rb @@ -0,0 +1,11 @@ +module VCAP::CloudController + class PaginatedResult + attr_reader :records, :total, :pagination_options + + def initialize(records, total, pagination_options) + @records = records + @total = total + @pagination_options = pagination_options + end + end +end diff --git a/lib/cloud_controller/paging/pagination_options.rb b/lib/cloud_controller/paging/pagination_options.rb new file mode 100644 index 00000000000..81cb0afe44d --- /dev/null +++ b/lib/cloud_controller/paging/pagination_options.rb @@ -0,0 +1,16 @@ +module VCAP::CloudController + class PaginationOptions + attr_reader :page, :per_page + + def initialize(page, per_page) + @page = page + @per_page = per_page + end + + def self.from_params(params) + page = params['page'].to_i + per_page = params['per_page'].to_i + PaginationOptions.new(page, per_page) + end + end +end diff --git a/lib/cloud_controller/paging/sequel_paginator.rb b/lib/cloud_controller/paging/sequel_paginator.rb new file mode 100644 index 00000000000..980835818b5 --- /dev/null +++ b/lib/cloud_controller/paging/sequel_paginator.rb @@ -0,0 +1,20 @@ +module VCAP::CloudController + class SequelPaginator + PAGE_DEFAULT = 1 + PER_PAGE_DEFAULT = 50 + PER_PAGE_MAX = 5000 + + def get_page(sequel_dataset, pagination_options) + page = pagination_options.page.nil? ? PAGE_DEFAULT : pagination_options.page + page = PAGE_DEFAULT if page < 1 + + per_page = pagination_options.per_page.nil? ? PER_PAGE_DEFAULT : pagination_options.per_page + per_page = PER_PAGE_DEFAULT if per_page < 1 + per_page = PER_PAGE_MAX if per_page > PER_PAGE_MAX + + query = sequel_dataset.extension(:pagination).paginate(page, per_page).order(:id) + + PaginatedResult.new(query.all, query.pagination_record_count, PaginationOptions.new(page, per_page)) + end + end +end diff --git a/lib/cloud_controller/rest_controller/paginated_collection_renderer.rb b/lib/cloud_controller/rest_controller/paginated_collection_renderer.rb index b29e58e3f7c..34f3c2166d4 100644 --- a/lib/cloud_controller/rest_controller/paginated_collection_renderer.rb +++ b/lib/cloud_controller/rest_controller/paginated_collection_renderer.rb @@ -3,6 +3,8 @@ module VCAP::CloudController::RestController class PaginatedCollectionRenderer + attr_reader :collection_transformer + def initialize(eager_loader, serializer, opts) @eager_loader = eager_loader @serializer = serializer @@ -12,6 +14,8 @@ def initialize(eager_loader, serializer, opts) @max_inline_relations_depth = opts.fetch(:max_inline_relations_depth) @default_inline_relations_depth = 0 + + @collection_transformer = opts[:collection_transformer] end # @param [RestController] controller Controller for the @@ -51,13 +55,6 @@ def render_json(controller, ds, path, opts, request_params) ordered_dataset = order_applicator.apply(ds) paginated_dataset = ordered_dataset.extension(:pagination).paginate(page, page_size) - dataset = @eager_loader.eager_load_dataset( - paginated_dataset, - controller, - default_visibility_filter, - opts[:additional_visibility_filters] || {}, - inline_relations_depth, - ) if paginated_dataset.prev_page prev_url = url(controller, path, paginated_dataset.prev_page, page_size, order_direction, opts, request_params) @@ -69,7 +66,8 @@ def render_json(controller, ds, path, opts, request_params) opts[:max_inline] ||= PreloadedObjectSerializer::MAX_INLINE_DEFAULT orphans = opts[:orphan_relations] == 1 ? {} : nil - resources = dataset.all.map { |obj| @serializer.serialize(controller, obj, opts, orphans) } + + resources = fetch_and_process_records(paginated_dataset, controller, inline_relations_depth, orphans, opts) result = { total_results: paginated_dataset.pagination_record_count, @@ -88,6 +86,22 @@ def render_json(controller, ds, path, opts, request_params) private + def fetch_and_process_records(paginated_dataset, controller, inline_relations_depth, orphans, opts) + dataset = @eager_loader.eager_load_dataset( + paginated_dataset, + controller, + default_visibility_filter, + opts[:additional_visibility_filters] || {}, + inline_relations_depth, + ) + + dataset_records = dataset.all + + collection_transformer.transform(dataset_records) if collection_transformer + + dataset_records.map { |obj| @serializer.serialize(controller, obj, opts, orphans) } + end + def default_visibility_filter user = VCAP::CloudController::SecurityContext.current_user admin = VCAP::CloudController::SecurityContext.admin? diff --git a/lib/cloud_controller/runner.rb b/lib/cloud_controller/runner.rb index 6f709d9f02c..bd28a8e1354 100644 --- a/lib/cloud_controller/runner.rb +++ b/lib/cloud_controller/runner.rb @@ -140,14 +140,6 @@ def stop! end end - def merge_vcap_config - services = JSON.parse(ENV['VCAP_SERVICES']) - pg_key = services.keys.select { |svc| svc =~ /postgres/i }.first - c = services[pg_key].first['credentials'] - @config[:db][:database] = "postgres://#{c['user']}:#{c['password']}@#{c['hostname']}:#{c['port']}/#{c['name']}" - @config[:external_port] = ENV['VCAP_APP_PORT'].to_i - end - private def stop_router_registrar(&blk) diff --git a/lib/services/sso/uaa/errors.rb b/lib/cloud_controller/uaa/errors.rb similarity index 95% rename from lib/services/sso/uaa/errors.rb rename to lib/cloud_controller/uaa/errors.rb index b5575468382..b30e0b4152a 100644 --- a/lib/services/sso/uaa/errors.rb +++ b/lib/cloud_controller/uaa/errors.rb @@ -1,4 +1,4 @@ -module VCAP::Services::SSO::UAA +module VCAP::CloudController class UaaError < StandardError; end class UaaResourceNotFound < UaaError diff --git a/lib/cloud_controller/uaa/uaa_client.rb b/lib/cloud_controller/uaa/uaa_client.rb new file mode 100644 index 00000000000..2523a302a38 --- /dev/null +++ b/lib/cloud_controller/uaa/uaa_client.rb @@ -0,0 +1,61 @@ +module VCAP::CloudController + class UaaClient + attr_reader :uaa_target, :client_id, :secret, :options + def initialize(uaa_target, client_id, secret, options={}) + @uaa_target = uaa_target + @client_id = client_id + @secret = secret + @options = options + end + + def scim + @scim ||= CF::UAA::Scim.new(uaa_target, token_info.auth_header, uaa_connection_opts) + end + + def get_clients(client_ids) + client_ids.map do |id| + begin + scim.get(:client, id) + rescue CF::UAA::NotFound + nil + end + end.compact + end + + def token_info + token_issuer.client_credentials_grant + rescue CF::UAA::NotFound => e + logger.error("UAA request for token failed: #{e.inspect}") + raise UaaUnavailable.new + end + + def usernames_for_ids(user_ids) + return {} unless user_ids.present? + filter_string = user_ids.map { |user_id| %(id eq "#{user_id}") }.join(' or ') + results = scim.query(:user_id, filter: filter_string) + + results['resources'].each_with_object({}) do |resource, results_hash| + results_hash[resource['id']] = resource['username'] + results_hash + end + rescue UaaUnavailable, CF::UAA::TargetError + {} + end + + private + + def token_issuer + CF::UAA::TokenIssuer.new(uaa_target, client_id, secret, uaa_connection_opts) + end + + def uaa_connection_opts + { + skip_ssl_validation: !!options[:skip_ssl_validation] + } + end + + def logger + @logger ||= Steno.logger('cc.uaa_client') + end + end +end diff --git a/lib/sequel_plugins/vcap_serialization.rb b/lib/sequel_plugins/vcap_serialization.rb index d4cc0c387cf..32284a567ef 100644 --- a/lib/sequel_plugins/vcap_serialization.rb +++ b/lib/sequel_plugins/vcap_serialization.rb @@ -17,7 +17,7 @@ module InstanceMethods def to_hash(opts={}) hash = {} redact_vals = opts[:redact] - attrs = opts[:attrs] || self.class.export_attrs || [] + attrs = opts[:attrs] || self.export_attrs attrs.each do |k| if opts[:only].nil? || opts[:only].include?(k) @@ -36,6 +36,11 @@ def to_hash(opts={}) hash end + # Returns an array of attribute names that will be used when calling to_hash + def export_attrs + self.class.export_attrs || [] + end + # Update the model instance from the supplied json string. Only update # attributes specified by import_attributes. # @@ -100,7 +105,7 @@ def create_from_hash(hash, opts={}) # @param [Array] List of attributes to include when serializing to # json or a hash. def export_attributes(*attributes) - self.export_attrs = attributes + self.export_attrs = attributes.freeze end # @param [Array] List of attributes to include when importing diff --git a/lib/services/service_brokers/v2.rb b/lib/services/service_brokers/v2.rb index b756d515b9f..4aa8059e2b6 100644 --- a/lib/services/service_brokers/v2.rb +++ b/lib/services/service_brokers/v2.rb @@ -6,5 +6,7 @@ module VCAP::Services::ServiceBrokers::V2 end require 'services/service_brokers/v2/catalog_plan' require 'services/service_brokers/v2/http_client' require 'services/service_brokers/v2/client' -require 'services/service_brokers/v2/service_instance_deprovisioner' -require 'services/service_brokers/v2/service_instance_unbinder' +require 'services/service_brokers/v2/orphan_mitigator' +require 'services/service_brokers/v2/service_instance_state_poller' +require 'services/service_brokers/v2/response_parser' +require 'services/service_brokers/v2/errors' diff --git a/lib/services/service_brokers/v2/client.rb b/lib/services/service_brokers/v2/client.rb index 9f6cc2da4ee..45677349e25 100644 --- a/lib/services/service_brokers/v2/client.rb +++ b/lib/services/service_brokers/v2/client.rb @@ -1,98 +1,25 @@ module VCAP::Services::ServiceBrokers::V2 - class ServiceBrokerBadResponse < HttpResponseError - def initialize(uri, method, response) - begin - hash = MultiJson.load(response.body) - rescue MultiJson::ParseError - end - - if hash.is_a?(Hash) && hash.key?('description') - message = "Service broker error: #{hash['description']}" - else - message = "The service broker API returned an error from #{uri}: #{response.code} #{response.message}" - end - - super(message, uri, method, response) - end - - def response_code - 502 - end - end - - class ServiceBrokerResponseMalformed < HttpResponseError - def initialize(uri, method, response) - super( - 'The service broker response was not understood', - uri, - method, - response - ) - end - end - - class ServiceBrokerApiAuthenticationFailed < HttpResponseError - def initialize(uri, method, response) - super( - "Authentication failed for the service broker API. Double-check that the username and password are correct: #{uri}", - uri, - method, - response - ) - end - - def response_code - 502 - end - end - - class ServiceBrokerConflict < HttpResponseError - def initialize(uri, method, response) - error_message = nil - if parsed_json(response.body).key?('message') - error_message = parsed_json(response.body)['message'] - else - error_message = parsed_json(response.body)['description'] - end - - super( - error_message || "Resource conflict: #{uri}", - uri, - method, - response - ) - end - - def response_code - 409 - end - - private - - def parsed_json(str) - MultiJson.load(str) - rescue MultiJson::ParseError - {} - end - end - class Client CATALOG_PATH = '/v2/catalog'.freeze def initialize(attrs) - @http_client = VCAP::Services::ServiceBrokers::V2::HttpClient.new(attrs) + http_client_attrs = attrs.select { |key, _| [:url, :auth_username, :auth_password].include?(key) } + @http_client = VCAP::Services::ServiceBrokers::V2::HttpClient.new(http_client_attrs) + @response_parser = VCAP::Services::ServiceBrokers::V2::ResponseParser.new(@http_client.url) @attrs = attrs + @orphan_mitigator = VCAP::Services::ServiceBrokers::V2::OrphanMitigator.new + @state_poller = VCAP::Services::ServiceBrokers::V2::ServiceInstanceStatePoller.new end def catalog response = @http_client.get(CATALOG_PATH) - parse_response(:get, CATALOG_PATH, response) + @response_parser.parse(:get, CATALOG_PATH, response) end # The broker is expected to guarantee uniqueness of instance_id. # raises ServiceBrokerConflict if the id is already in use def provision(instance) - path = "/v2/service_instances/#{instance.guid}" + path = "/v2/service_instances/#{instance.guid}?accepts_incomplete=true" response = @http_client.put(path, { service_id: instance.service.broker_provided_id, @@ -101,17 +28,36 @@ def provision(instance) space_guid: instance.space.guid, }) - parsed_response = parse_response(:put, path, response) + parsed_response = @response_parser.parse(:put, path, response) instance.dashboard_url = parsed_response['dashboard_url'] + instance.state_description = parsed_response['state_description'] || '' + + if parsed_response['state'] + instance.state = parsed_response['state'] + @state_poller.poll_service_instance_state(@attrs, instance) if instance.state == 'creating' + else + instance.state = 'available' + end # DEPRECATED, but needed because of not null constraint instance.credentials = {} - - rescue ServiceBrokerApiTimeout, ServiceBrokerBadResponse => e - VCAP::CloudController::ServiceBrokers::V2::ServiceInstanceDeprovisioner.deprovision(@attrs, instance) + rescue Errors::ServiceBrokerApiTimeout, Errors::ServiceBrokerBadResponse => e + @orphan_mitigator.cleanup_failed_provision(@attrs, instance) raise e end + def fetch_service_instance_state(instance) + path = "/v2/service_instances/#{instance.guid}" + + response = @http_client.get(path) + parsed_response = @response_parser.parse(:get, path, response) + + instance.dashboard_url = parsed_response['dashboard_url'] + instance.state = parsed_response['state'] + instance.state_description = parsed_response['state_description'] + instance + end + def bind(binding) path = "/v2/service_instances/#{binding.service_instance.guid}/service_bindings/#{binding.guid}" response = @http_client.put(path, { @@ -119,15 +65,15 @@ def bind(binding) plan_id: binding.service_plan.broker_provided_id, app_guid: binding.app_guid }) - parsed_response = parse_response(:put, path, response) + parsed_response = @response_parser.parse(:put, path, response) binding.credentials = parsed_response['credentials'] if parsed_response.key?('syslog_drain_url') binding.syslog_drain_url = parsed_response['syslog_drain_url'] end - rescue ServiceBrokerApiTimeout, ServiceBrokerBadResponse => e - VCAP::CloudController::ServiceBrokers::V2::ServiceInstanceUnbinder.unbind(@attrs, binding) + rescue Errors::ServiceBrokerApiTimeout, Errors::ServiceBrokerBadResponse => e + @orphan_mitigator.cleanup_failed_bind(@attrs, binding) raise e end @@ -139,7 +85,7 @@ def unbind(binding) plan_id: binding.service_plan.broker_provided_id, }) - parse_response(:delete, path, response) + @response_parser.parse(:delete, path, response) end def deprovision(instance) @@ -150,9 +96,9 @@ def deprovision(instance) plan_id: instance.service_plan.broker_provided_id, }) - parse_response(:delete, path, response) + @response_parser.parse(:delete, path, response) - rescue VCAP::Services::ServiceBrokers::V2::ServiceBrokerConflict => e + rescue VCAP::Services::ServiceBrokers::V2::Errors::ServiceBrokerConflict => e raise VCAP::Errors::ApiError.new_from_details('ServiceInstanceDeprovisionFailed', e.message) end @@ -169,56 +115,11 @@ def update_service_plan(instance, plan) } }) - parse_response(:put, path, response) + @response_parser.parse(:put, path, response) end private - def uri_for(path) - URI(@http_client.url + path) - end - - def parse_response(method, path, response) - uri = uri_for(path) - code = response.code.to_i - - case code - when 204 - return nil # no body - - when 200..299 - - begin - response_hash = MultiJson.load(response.body) - rescue MultiJson::ParseError - logger.warn("MultiJson parse error `#{response.try(:body).inspect}'") - end - - unless response_hash.is_a?(Hash) - raise VCAP::Services::ServiceBrokers::V2::ServiceBrokerResponseMalformed.new(uri.to_s, method, response) - end - - return response_hash - - when HTTP::Status::UNAUTHORIZED - raise VCAP::Services::ServiceBrokers::V2::ServiceBrokerApiAuthenticationFailed.new(uri.to_s, method, response) - - when 408 - raise VCAP::Services::ServiceBrokers::V2::ServiceBrokerApiTimeout.new(uri.to_s, method, response) - - when 409 - raise VCAP::Services::ServiceBrokers::V2::ServiceBrokerConflict.new(uri.to_s, method, response) - - when 410 - if method == :delete - logger.warn("Already deleted: #{uri}") - return nil - end - end - - raise VCAP::Services::ServiceBrokers::V2::ServiceBrokerBadResponse.new(uri.to_s, method, response) - end - def logger @logger ||= Steno.logger('cc.service_broker.v2.client') end diff --git a/lib/services/service_brokers/v2/errors.rb b/lib/services/service_brokers/v2/errors.rb new file mode 100644 index 00000000000..208d7197502 --- /dev/null +++ b/lib/services/service_brokers/v2/errors.rb @@ -0,0 +1,7 @@ +require 'services/service_brokers/v2/errors/service_broker_api_unreachable' +require 'services/service_brokers/v2/errors/service_broker_api_timeout' +require 'services/service_brokers/v2/errors/service_broker_response_malformed' +require 'services/service_brokers/v2/errors/service_broker_api_authentication_failed' +require 'services/service_brokers/v2/errors/service_broker_bad_response' +require 'services/service_brokers/v2/errors/service_broker_conflict' +require 'services/service_brokers/v2/errors/service_broker_request_rejected' diff --git a/lib/services/service_brokers/v2/errors/service_broker_api_authentication_failed.rb b/lib/services/service_brokers/v2/errors/service_broker_api_authentication_failed.rb new file mode 100644 index 00000000000..b9d7d5ea1c0 --- /dev/null +++ b/lib/services/service_brokers/v2/errors/service_broker_api_authentication_failed.rb @@ -0,0 +1,22 @@ +module VCAP::Services + module ServiceBrokers + module V2 + module Errors + class ServiceBrokerApiAuthenticationFailed < HttpResponseError + def initialize(uri, method, response) + super( + "Authentication failed for the service broker API. Double-check that the username and password are correct: #{uri}", + uri, + method, + response + ) + end + + def response_code + 502 + end + end + end + end + end +end diff --git a/lib/services/service_brokers/v2/errors/service_broker_api_timeout.rb b/lib/services/service_brokers/v2/errors/service_broker_api_timeout.rb new file mode 100644 index 00000000000..ddad3e8936d --- /dev/null +++ b/lib/services/service_brokers/v2/errors/service_broker_api_timeout.rb @@ -0,0 +1,24 @@ +require 'net/http' + +module VCAP::Services + module ServiceBrokers + module V2 + module Errors + class ServiceBrokerApiTimeout < HttpRequestError + def initialize(uri, method, source) + super( + "The service broker API timed out: #{uri}", + uri, + method, + source + ) + end + + def response_code + 504 + end + end + end + end + end +end diff --git a/lib/services/service_brokers/v2/errors/service_broker_api_unreachable.rb b/lib/services/service_brokers/v2/errors/service_broker_api_unreachable.rb new file mode 100644 index 00000000000..e0dd4838a91 --- /dev/null +++ b/lib/services/service_brokers/v2/errors/service_broker_api_unreachable.rb @@ -0,0 +1,24 @@ +require 'net/http' + +module VCAP::Services + module ServiceBrokers + module V2 + module Errors + class ServiceBrokerApiUnreachable < HttpRequestError + def initialize(uri, method, source) + super( + "The service broker API could not be reached: #{uri}", + uri, + method, + source + ) + end + + def response_code + 502 + end + end + end + end + end +end diff --git a/lib/services/service_brokers/v2/errors/service_broker_bad_response.rb b/lib/services/service_brokers/v2/errors/service_broker_bad_response.rb new file mode 100644 index 00000000000..1b7c7b42abe --- /dev/null +++ b/lib/services/service_brokers/v2/errors/service_broker_bad_response.rb @@ -0,0 +1,28 @@ +module VCAP::Services + module ServiceBrokers + module V2 + module Errors + class ServiceBrokerBadResponse < HttpResponseError + def initialize(uri, method, response) + begin + hash = MultiJson.load(response.body) + rescue MultiJson::ParseError + end + + if hash.is_a?(Hash) && hash.key?('description') + message = "Service broker error: #{hash['description']}" + else + message = "The service broker API returned an error from #{uri}: #{response.code} #{response.message}" + end + + super(message, uri, method, response) + end + + def response_code + 502 + end + end + end + end + end +end diff --git a/lib/services/service_brokers/v2/errors/service_broker_conflict.rb b/lib/services/service_brokers/v2/errors/service_broker_conflict.rb new file mode 100644 index 00000000000..8272a397c78 --- /dev/null +++ b/lib/services/service_brokers/v2/errors/service_broker_conflict.rb @@ -0,0 +1,35 @@ +module VCAP::Services + module ServiceBrokers + module V2 + module Errors + class ServiceBrokerConflict < HttpResponseError + def initialize(uri, method, response) + error_message = nil + if parsed_json(response.body).key?('description') + error_message = parsed_json(response.body)['description'] + end + + super( + error_message || "Resource conflict: #{uri}", + uri, + method, + response + ) + end + + def response_code + 409 + end + + private + + def parsed_json(str) + MultiJson.load(str) + rescue MultiJson::ParseError + {} + end + end + end + end + end +end diff --git a/lib/services/service_brokers/v2/errors/service_broker_request_rejected.rb b/lib/services/service_brokers/v2/errors/service_broker_request_rejected.rb new file mode 100644 index 00000000000..7110d6df70a --- /dev/null +++ b/lib/services/service_brokers/v2/errors/service_broker_request_rejected.rb @@ -0,0 +1,28 @@ +module VCAP::Services + module ServiceBrokers + module V2 + module Errors + class ServiceBrokerRequestRejected < HttpResponseError + def initialize(uri, method, response) + begin + hash = MultiJson.load(response.body) + rescue MultiJson::ParseError + end + + if hash.is_a?(Hash) && hash.key?('description') + message = "Service broker error: #{hash['description']}" + else + message = "The service broker API returned an error from #{uri}: #{response.code} #{response.message}" + end + + super(message, uri, method, response) + end + + def response_code + 502 + end + end + end + end + end +end diff --git a/lib/services/service_brokers/v2/errors/service_broker_response_malformed.rb b/lib/services/service_brokers/v2/errors/service_broker_response_malformed.rb new file mode 100644 index 00000000000..815e696c762 --- /dev/null +++ b/lib/services/service_brokers/v2/errors/service_broker_response_malformed.rb @@ -0,0 +1,22 @@ +module VCAP::Services + module ServiceBrokers + module V2 + module Errors + class ServiceBrokerResponseMalformed < HttpResponseError + def initialize(uri, method, response) + super( + 'The service broker response was not understood', + uri, + method, + response + ) + end + + def response_code + 502 + end + end + end + end + end +end diff --git a/lib/services/service_brokers/v2/http_client.rb b/lib/services/service_brokers/v2/http_client.rb index 26301712f88..83bf1d12d77 100644 --- a/lib/services/service_brokers/v2/http_client.rb +++ b/lib/services/service_brokers/v2/http_client.rb @@ -1,30 +1,22 @@ -require 'net/http' +require 'httpclient' module VCAP::Services module ServiceBrokers::V2 - class ServiceBrokerApiUnreachable < HttpRequestError - def initialize(uri, method, source) - super( - "The service broker API could not be reached: #{uri}", - uri, - method, - source - ) + class HttpResponse + def initialize(http_client_response) + @http_client_response = http_client_response + end + + def code + @http_client_response.code end - end - class ServiceBrokerApiTimeout < HttpRequestError - def initialize(uri, method, source) - super( - "The service broker API timed out: #{uri}", - uri, - method, - source - ) + def message + @http_client_response.reason end - def response_code - 504 + def body + @http_client_response.body end end @@ -66,61 +58,35 @@ def uri_for(path) end def make_request(method, uri, body, content_type) - req = build_request(method, uri, body, content_type) - opts = build_options(uri) - - response = Net::HTTP.start(uri.hostname, uri.port, opts) do |http| - http.open_timeout = broker_client_timeout - http.read_timeout = broker_client_timeout - - http.request(req) - end - - log_response(uri, response) - return response - rescue SocketError, Errno::ECONNREFUSED => error - raise ServiceBrokerApiUnreachable.new(uri.to_s, method, error) - rescue Timeout::Error => error - raise ServiceBrokerApiTimeout.new(uri.to_s, method, error) - rescue => error - raise HttpRequestError.new(error.message, uri.to_s, method, error) - end + client = HTTPClient.new(force_basic_auth: true) + client.set_auth(uri, auth_username, auth_password) - def log_request(uri, req) - logger.debug "Sending #{req.method} to #{uri}, BODY: #{req.body.inspect}, HEADERS: #{req.to_hash.inspect}" - end - - def log_response(uri, response) - logger.debug "Response from request to #{uri}: STATUS #{response.code}, BODY: #{response.body.inspect}, HEADERS: #{response.to_hash.inspect}" - end - - def build_request(method, uri, body, content_type) - req_class = method.to_s.capitalize - req = Net::HTTP.const_get(req_class).new(uri.request_uri) - - req.basic_auth(auth_username, auth_password) + client.default_header[VCAP::Request::HEADER_BROKER_API_VERSION] = '2.4' + client.default_header[VCAP::Request::HEADER_NAME] = VCAP::Request.current_id + client.default_header['Accept'] = 'application/json' - req[VCAP::Request::HEADER_NAME] = VCAP::Request.current_id - req[VCAP::Request::HEADER_BROKER_API_VERSION] = '2.4' - req['Accept'] = 'application/json' + client.ssl_config.verify_mode = verify_certs? ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE + client.connect_timeout = broker_client_timeout + client.receive_timeout = broker_client_timeout + client.send_timeout = broker_client_timeout - req.body = body - req.content_type = content_type if content_type + opts = { body: body } + opts[:header] = { 'Content-Type' => content_type } if content_type - log_request(uri, req) + headers = client.default_header.merge(opts[:header]) if opts[:header] + logger.debug "Sending #{method} to #{uri}, BODY: #{body.inspect}, HEADERS: #{headers}" - req - end - - def build_options(uri) - opts = {} + response = client.request(method, uri, opts) - use_ssl = uri.scheme.to_s.downcase == 'https' - opts.merge!(use_ssl: use_ssl) + logger.debug "Response from request to #{uri}: STATUS #{response.code}, BODY: #{response.body.inspect}, HEADERS: #{response.headers.inspect}" - verify_mode = verify_certs? ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE - opts.merge!(verify_mode: verify_mode) if use_ssl - opts + HttpResponse.new(response) + rescue SocketError, Errno::ECONNREFUSED => error + raise Errors::ServiceBrokerApiUnreachable.new(uri.to_s, method, error) + rescue HTTPClient::TimeoutError => error + raise Errors::ServiceBrokerApiTimeout.new(uri.to_s, method, error) + rescue => error + raise HttpRequestError.new(error.message, uri.to_s, method, error) end def verify_certs? diff --git a/lib/services/service_brokers/v2/orphan_mitigator.rb b/lib/services/service_brokers/v2/orphan_mitigator.rb new file mode 100644 index 00000000000..7d3882a663b --- /dev/null +++ b/lib/services/service_brokers/v2/orphan_mitigator.rb @@ -0,0 +1,35 @@ +require 'jobs/services/service_instance_deprovision' +require 'jobs/services/service_instance_unbind' + +module VCAP::Services + module ServiceBrokers + module V2 + class OrphanMitigator + def cleanup_failed_provision(client_attrs, service_instance) + deprovision_job = VCAP::CloudController::Jobs::Services::ServiceInstanceDeprovision.new( + 'service-instance-deprovision', + client_attrs, + service_instance.guid, + service_instance.service_plan.guid + ) + + opts = { queue: 'cc-generic', run_at: Delayed::Job.db_time_now } + VCAP::CloudController::Jobs::Enqueuer.new(deprovision_job, opts).enqueue + end + + def cleanup_failed_bind(client_attrs, binding) + unbind_job = VCAP::CloudController::Jobs::Services::ServiceInstanceUnbind.new( + 'service-instance-unbind', + client_attrs, + binding.guid, + binding.service_instance.guid, + binding.app.guid + ) + + opts = { queue: 'cc-generic', run_at: Delayed::Job.db_time_now } + VCAP::CloudController::Jobs::Enqueuer.new(unbind_job, opts).enqueue + end + end + end + end +end diff --git a/lib/services/service_brokers/v2/response_parser.rb b/lib/services/service_brokers/v2/response_parser.rb new file mode 100644 index 00000000000..eba850d0131 --- /dev/null +++ b/lib/services/service_brokers/v2/response_parser.rb @@ -0,0 +1,65 @@ +module VCAP::Services + module ServiceBrokers + module V2 + class ResponseParser + def initialize(url) + @url = url + end + + def parse(method, path, response) + uri = uri_for(path) + code = response.code.to_i + + case code + when 204 + return nil # no body + + when 200..299 + + begin + response_hash = MultiJson.load(response.body) + rescue MultiJson::ParseError + logger.warn("MultiJson parse error `#{response.try(:body).inspect}'") + end + + unless response_hash.is_a?(Hash) + raise Errors::ServiceBrokerResponseMalformed.new(uri.to_s, method, response) + end + + return response_hash + + when HTTP::Status::UNAUTHORIZED + raise Errors::ServiceBrokerApiAuthenticationFailed.new(uri.to_s, method, response) + + when 408 + raise Errors::ServiceBrokerApiTimeout.new(uri.to_s, method, response) + + when 409 + raise Errors::ServiceBrokerConflict.new(uri.to_s, method, response) + + when 410 + if method == :delete + logger.warn("Already deleted: #{uri}") + return nil + end + + when 400..499 + raise Errors::ServiceBrokerRequestRejected.new(uri.to_s, method, response) + end + + raise Errors::ServiceBrokerBadResponse.new(uri.to_s, method, response) + end + + private + + def uri_for(path) + URI(@url + path) + end + + def logger + @logger ||= Steno.logger('cc.service_broker.v2.client') + end + end + end + end +end diff --git a/lib/services/service_brokers/v2/service_instance_deprovisioner.rb b/lib/services/service_brokers/v2/service_instance_deprovisioner.rb deleted file mode 100644 index 4782ecb000d..00000000000 --- a/lib/services/service_brokers/v2/service_instance_deprovisioner.rb +++ /dev/null @@ -1,20 +0,0 @@ -require 'jobs/services/service_instance_deprovision' - -module VCAP::CloudController - module ServiceBrokers - module V2 - class ServiceInstanceDeprovisioner - def self.deprovision(client_attrs, service_instance) - deprovision_job = VCAP::CloudController::Jobs::Services::ServiceInstanceDeprovision.new( - 'service-instance-deprovision', - client_attrs, - service_instance.guid, - service_instance.service_plan.guid - ) - Delayed::Job.enqueue(deprovision_job, queue: 'cc-generic', run_at: Delayed::Job.db_time_now) - deprovision_job - end - end - end - end -end diff --git a/lib/services/service_brokers/v2/service_instance_state_poller.rb b/lib/services/service_brokers/v2/service_instance_state_poller.rb new file mode 100644 index 00000000000..5415c4a0a04 --- /dev/null +++ b/lib/services/service_brokers/v2/service_instance_state_poller.rb @@ -0,0 +1,21 @@ +require 'jobs/services/service_instance_state_fetch' + +module VCAP::Services + module ServiceBrokers + module V2 + class ServiceInstanceStatePoller + def poll_service_instance_state(client_attrs, service_instance) + job = VCAP::CloudController::Jobs::Services::ServiceInstanceStateFetch.new( + 'service-instance-state-fetch', + client_attrs, + service_instance.guid, + service_instance.service_plan.guid + ) + + opts = { queue: 'cc-generic', run_at: Delayed::Job.db_time_now } + VCAP::CloudController::Jobs::Enqueuer.new(job, opts).enqueue + end + end + end + end +end diff --git a/lib/services/service_brokers/v2/service_instance_unbinder.rb b/lib/services/service_brokers/v2/service_instance_unbinder.rb deleted file mode 100644 index 611666fa512..00000000000 --- a/lib/services/service_brokers/v2/service_instance_unbinder.rb +++ /dev/null @@ -1,21 +0,0 @@ -require 'jobs/services/service_instance_unbind' - -module VCAP::CloudController - module ServiceBrokers - module V2 - class ServiceInstanceUnbinder - def self.unbind(client_attrs, binding) - unbind_job = VCAP::CloudController::Jobs::Services::ServiceInstanceUnbind.new( - 'service-instance-unbind', - client_attrs, - binding.guid, - binding.service_instance.guid, - binding.app.guid - ) - Delayed::Job.enqueue(unbind_job, queue: 'cc-generic', run_at: Delayed::Job.db_time_now) - unbind_job - end - end - end - end -end diff --git a/lib/services/sso/dashboard_client_manager.rb b/lib/services/sso/dashboard_client_manager.rb index 0e3257d1495..aaa1cf73076 100644 --- a/lib/services/sso/dashboard_client_manager.rb +++ b/lib/services/sso/dashboard_client_manager.rb @@ -90,7 +90,7 @@ def all_clients_can_be_claimed_in_uaa?(existing_uaa_client_ids, catalog) def fetch_clients_from_uaa(requested_client_ids) client_manager.get_clients(requested_client_ids) - rescue VCAP::Services::SSO::UAA::UaaError => e + rescue VCAP::CloudController::UaaError => e raise VCAP::Errors::ApiError.new_from_details('ServiceBrokerDashboardClientFailure', e.message) end @@ -109,7 +109,7 @@ def claim_clients_and_update_uaa(requested_clients, existing_db_clients, existin db_changeset.each(&:db_command) client_manager.modify_transaction(uaa_changeset) end - rescue VCAP::Services::SSO::UAA::UaaError => e + rescue VCAP::CloudController::UaaError => e raise VCAP::Errors::ApiError.new_from_details('ServiceBrokerDashboardClientFailure', e.message) end diff --git a/lib/services/sso/uaa.rb b/lib/services/sso/uaa.rb index ac2155dee2c..25b96d574b3 100644 --- a/lib/services/sso/uaa.rb +++ b/lib/services/sso/uaa.rb @@ -1,4 +1,3 @@ module VCAP::Services::SSO::UAA end -require 'services/sso/uaa/errors' require 'services/sso/uaa/uaa_client_manager' diff --git a/lib/services/sso/uaa/uaa_client_manager.rb b/lib/services/sso/uaa/uaa_client_manager.rb index 107bc3a1bda..7e8ddcda75d 100644 --- a/lib/services/sso/uaa/uaa_client_manager.rb +++ b/lib/services/sso/uaa/uaa_client_manager.rb @@ -7,16 +7,11 @@ class UaaClientManager def initialize(opts={}) @opts = opts + @uaa_client = create_uaa_client end def get_clients(client_ids) - client_ids.map do |id| - begin - scim.get(:client, id) - rescue CF::UAA::NotFound - nil - end - end.compact + @uaa_client.get_clients(client_ids) end def modify_transaction(changeset) @@ -29,7 +24,7 @@ def modify_transaction(changeset) request = Net::HTTP::Post.new(uri.path) request.body = request_body.to_json request.content_type = 'application/json' - request['Authorization'] = token_info.auth_header + request['Authorization'] = uaa_client.token_info.auth_header http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = use_ssl @@ -45,25 +40,27 @@ def modify_transaction(changeset) return when 400 log_bad_uaa_response(response) - raise UaaResourceInvalid.new + raise VCAP::CloudController::UaaResourceInvalid.new when 404 log_bad_uaa_response(response) if response[ROUTER_404_KEY] == ROUTER_404_VALUE - raise UaaUnavailable.new + raise VCAP::CloudController::UaaUnavailable.new else - raise UaaResourceNotFound.new + raise VCAP::CloudController::UaaResourceNotFound.new end when 409 log_bad_uaa_response(response) - raise UaaResourceAlreadyExists.new + raise VCAP::CloudController::UaaResourceAlreadyExists.new else log_bad_uaa_response(response) - raise UaaUnexpectedResponse.new + raise VCAP::CloudController::UaaUnexpectedResponse.new end end private + attr_reader :uaa_client + def verify_certs? !VCAP::CloudController::Config.config[:skip_cert_verify] end @@ -86,34 +83,6 @@ def batch_request(changeset) end end - def scim - @opts.fetch(:scim) do - CF::UAA::Scim.new(uaa_target, token_info.auth_header, uaa_connection_opts) - end - end - - def uaa_target - VCAP::CloudController::Config.config[:uaa][:url] - end - - def uaa_connection_opts - { - skip_ssl_validation: !verify_certs? - } - end - - def issuer - uaa_client, uaa_client_secret = issuer_client_config - CF::UAA::TokenIssuer.new(uaa_target, uaa_client, uaa_client_secret, uaa_connection_opts) - end - - def token_info - issuer.client_credentials_grant - rescue CF::UAA::NotFound => e - logger.error("UAA request for token failed: #{e.inspect}") - raise UaaUnavailable.new - end - def sso_client_info(client_attrs) { client_id: client_attrs['id'], @@ -124,13 +93,6 @@ def sso_client_info(client_attrs) } end - def issuer_client_config - uaa_client = VCAP::CloudController::Config.config[:uaa_client_name] - uaa_client_secret = VCAP::CloudController::Config.config[:uaa_client_secret] - - [uaa_client, uaa_client_secret] if uaa_client && uaa_client_secret - end - def logger @logger ||= Steno.logger('cc.uaa_client_manager') end @@ -143,5 +105,27 @@ def filter_uaa_client_scope filtered_scope end + + def create_uaa_client + VCAP::CloudController::UaaClient.new(uaa_target, uaa_client_name, uaa_client_secret, uaa_connection_opts) + end + + def uaa_target + VCAP::CloudController::Config.config[:uaa][:url] + end + + def uaa_client_name + VCAP::CloudController::Config.config[:uaa_client_name] + end + + def uaa_client_secret + VCAP::CloudController::Config.config[:uaa_client_secret] + end + + def uaa_connection_opts + { + skip_ssl_validation: !verify_certs? + } + end end end diff --git a/lib/tasks/db.rake b/lib/tasks/db.rake index 333ffbc1314..9d550c6a6a6 100644 --- a/lib/tasks/db.rake +++ b/lib/tasks/db.rake @@ -31,7 +31,7 @@ end task :rollback do Steno.init(Steno::Config.new(sinks: [Steno::Sink::IO.new(STDOUT)])) db_logger = Steno.logger("cc.db.migrations") - DBMigrator.from_config(config, db_logger).rollback(number_to_rollback=1) + DBMigrator.from_config(config, db_logger).rollback(1) end namespace :migrate do @@ -39,6 +39,22 @@ end task :redo => [:rollback, :migrate] end + namespace :dev do + task :migrate do + require_relative "../../spec/support/bootstrap/db_config" + + config[:db][:database] = DbConfig.connection_string + Rake::Task["db:migrate"].invoke + end + + task :rollback do + require_relative "../../spec/support/bootstrap/db_config" + + config[:db][:database] = DbConfig.connection_string + Rake::Task["db:rollback"].invoke + end + end + task :pick do unless ENV["DB_CONNECTION_STRING"] || ENV["DB_CONNECTION"] ENV["DB"] ||= %w[mysql postgres].sample diff --git a/lib/vcap/rest_api/query.rb b/lib/vcap/rest_api/query.rb index 7e918ba2190..64e808e9817 100644 --- a/lib/vcap/rest_api/query.rb +++ b/lib/vcap/rest_api/query.rb @@ -153,19 +153,10 @@ def clean_up_foreign_key(query_key, query_values, foreign_key_column_name) { foreign_key_column_name => foreign_key_value } end - TINYINT_TYPE = 'tinyint(1)'.freeze - TINYINT_FROM_TRUE_FALSE = { 't' => 1, 'f' => 0 }.freeze - # Sequel uses tinyint(1) to store booleans in Mysql. # Mysql does not support using 't'/'f' for querying. - def clean_up_boolean(q_key, q_val) - column = model.db_schema[q_key.to_sym] - - if column[:db_type] == TINYINT_TYPE - TINYINT_FROM_TRUE_FALSE.fetch(q_val, q_val) - else - q_val == 't' - end + def clean_up_boolean(_, q_val) + q_val == 't' || q_val == 'true' end def clean_up_datetime(q_val) diff --git a/spec/acceptance/orphan_mitigation_spec.rb b/spec/acceptance/orphan_mitigation_spec.rb index 8a85f38aea5..7ffbd6a5179 100644 --- a/spec/acceptance/orphan_mitigation_spec.rb +++ b/spec/acceptance/orphan_mitigation_spec.rb @@ -19,7 +19,7 @@ before do stub_request(:put, %r{#{broker_url}/v2/service_instances/#{guid_pattern}}).to_return { |request| - raise Timeout::Error.new('fake-timeout') + raise HTTPClient::TimeoutError.new('fake-timeout') } stub_request(:delete, %r{#{broker_url}/v2/service_instances/#{guid_pattern}}). @@ -34,19 +34,17 @@ json_headers(admin_headers)) end - context 'when the broker times out' do - it 'makes the request to the broker and deprovisions' do - expect(a_request(:put, %r{http://username:password@broker-url/v2/service_instances/#{guid_pattern}})).to have_been_made + it 'makes the request to the broker and deprovisions' do + expect(a_request(:put, %r{http://username:password@broker-url/v2/service_instances/#{guid_pattern}})).to have_been_made - successes, failures = Delayed::Worker.new.work_off - expect([successes, failures]).to eq [1, 0] + successes, failures = Delayed::Worker.new.work_off + expect([successes, failures]).to eq [1, 0] - expect(a_request(:delete, %r{http://username:password@broker-url/v2/service_instances/#{guid_pattern}})).to have_been_made - end + expect(a_request(:delete, %r{http://username:password@broker-url/v2/service_instances/#{guid_pattern}})).to have_been_made + end - it 'responds to user with 504' do - expect(last_response.status).to eq(504) - end + it 'responds to user with 504' do + expect(last_response.status).to eq(504) end end @@ -59,7 +57,7 @@ create_app stub_request(:put, %r{/v2/service_instances/#{service_instance_guid}/service_bindings/#{guid_pattern}}).to_return { |request| - raise Timeout::Error.new('fake-timeout') + raise HTTPClient::TimeoutError.new('fake-timeout') } stub_request(:delete, %r{/v2/service_instances/#{service_instance_guid}/service_bindings/#{guid_pattern}}). @@ -70,21 +68,19 @@ json_headers(admin_headers)) end - context 'when the broker times out' do - it 'makes the request to the broker and deprovisions' do - expect(a_request(:put, %r{http://username:password@broker-url/v2/service_instances/#{service_instance_guid}/service_bindings/#{guid_pattern}})). - to have_been_made + it 'makes the request to the broker and deprovisions' do + expect(a_request(:put, %r{http://username:password@broker-url/v2/service_instances/#{service_instance_guid}/service_bindings/#{guid_pattern}})). + to have_been_made - successes, failures = Delayed::Worker.new.work_off - expect([successes, failures]).to eq [1, 0] + successes, failures = Delayed::Worker.new.work_off + expect([successes, failures]).to eq [1, 0] - expect(a_request(:delete, %r{http://username:password@broker-url/v2/service_instances/#{service_instance_guid}/service_bindings/#{guid_pattern}})). - to have_been_made - end + expect(a_request(:delete, %r{http://username:password@broker-url/v2/service_instances/#{service_instance_guid}/service_bindings/#{guid_pattern}})). + to have_been_made + end - it 'responds to user with 504' do - expect(last_response.status).to eq(504) - end + it 'responds to user with 504' do + expect(last_response.status).to eq(504) end end end diff --git a/spec/api/api_version_spec.rb b/spec/api/api_version_spec.rb index 34e1ca37081..f7cd638742a 100644 --- a/spec/api/api_version_spec.rb +++ b/spec/api/api_version_spec.rb @@ -2,10 +2,10 @@ require 'digest/sha1' describe 'Stable API warning system', api_version_check: true do - API_FOLDER_CHECKSUM = '313d3c0d4d4186b577e08247694d34081d8bae3d' + API_FOLDER_CHECKSUM = 'f0c5604325a95cd780d3eab15b415a1651ba494f' it 'double-checks the version' do - expect(VCAP::CloudController::Constants::API_VERSION).to eq('2.20.0') + expect(VCAP::CloudController::Constants::API_VERSION).to eq('2.21.0') end it 'tells the developer if the API specs change' do diff --git a/spec/api/documentation/job_api_spec.rb b/spec/api/documentation/job_api_spec.rb index 3ef4ffdb842..ef849c811c4 100644 --- a/spec/api/documentation/job_api_spec.rb +++ b/spec/api/documentation/job_api_spec.rb @@ -19,6 +19,10 @@ def job_name_in_configuration def max_attempts 2 end + + def reschedule_at(time, attempts) + Time.now + 5 + end end before { Delayed::Job.delete_all } diff --git a/spec/api/documentation/organizations_api_spec.rb b/spec/api/documentation/organizations_api_spec.rb index f53d33da6c3..58fe3f9c668 100644 --- a/spec/api/documentation/organizations_api_spec.rb +++ b/spec/api/documentation/organizations_api_spec.rb @@ -83,6 +83,7 @@ describe 'Users' do before do organization.add_user(associated_user) + allow_any_instance_of(VCAP::CloudController::UaaClient).to receive(:usernames_for_ids).and_return({ associated_user.guid => 'user@example.com' }) end let!(:associated_user) { VCAP::CloudController::User.make } @@ -98,6 +99,7 @@ describe 'Managers' do before do organization.add_manager(associated_manager) + allow_any_instance_of(VCAP::CloudController::UaaClient).to receive(:usernames_for_ids).and_return({ associated_manager.guid => 'manager@example.com' }) make_manager_for_org(organization) end @@ -114,6 +116,7 @@ describe 'Billing Managers' do before do organization.add_billing_manager(associated_billing_manager) + allow_any_instance_of(VCAP::CloudController::UaaClient).to receive(:usernames_for_ids).and_return({ associated_billing_manager.guid => 'billing_manager@example.com' }) end let!(:associated_billing_manager) { VCAP::CloudController::User.make } @@ -129,6 +132,7 @@ describe 'Auditors' do before do organization.add_auditor(associated_auditor) + allow_any_instance_of(VCAP::CloudController::UaaClient).to receive(:usernames_for_ids).and_return({ associated_auditor.guid => 'auditor@example.com' }) end let!(:associated_auditor) { VCAP::CloudController::User.make } diff --git a/spec/api/documentation/spaces_api_spec.rb b/spec/api/documentation/spaces_api_spec.rb index ae567b794b0..509f9087c13 100644 --- a/spec/api/documentation/spaces_api_spec.rb +++ b/spec/api/documentation/spaces_api_spec.rb @@ -93,6 +93,7 @@ def after_standard_model_delete(guid) space.organization.add_user(associated_developer) space.organization.add_user(developer) space.add_developer(associated_developer) + allow_any_instance_of(VCAP::CloudController::UaaClient).to receive(:usernames_for_ids).and_return({ associated_developer.guid => 'developer@example.com' }) end let!(:associated_developer) { VCAP::CloudController::User.make } @@ -110,6 +111,7 @@ def after_standard_model_delete(guid) space.organization.add_user(associated_manager) space.organization.add_user(manager) space.add_manager(associated_manager) + allow_any_instance_of(VCAP::CloudController::UaaClient).to receive(:usernames_for_ids).and_return({ associated_manager.guid => 'manager@example.com' }) end let!(:associated_manager) { VCAP::CloudController::User.make } @@ -127,6 +129,7 @@ def after_standard_model_delete(guid) space.organization.add_user(associated_auditor) space.organization.add_user(auditor) space.add_auditor(associated_auditor) + allow_any_instance_of(VCAP::CloudController::UaaClient).to receive(:usernames_for_ids).and_return({ associated_auditor.guid => 'auditor@example.com' }) end let!(:associated_auditor) { VCAP::CloudController::User.make } diff --git a/spec/api/documentation/users_api_spec.rb b/spec/api/documentation/users_api_spec.rb index b219cdc1580..ce1563aa22d 100644 --- a/spec/api/documentation/users_api_spec.rb +++ b/spec/api/documentation/users_api_spec.rb @@ -19,6 +19,10 @@ end describe 'Standard endpoints' do + before do + allow_any_instance_of(VCAP::CloudController::UaaClient).to receive(:usernames_for_ids).and_return({ guid => 'user@example.com' }) + end + standard_model_list(:user, VCAP::CloudController::UsersController) standard_model_get(:user, nested_associations: [:default_space]) standard_model_delete(:user) diff --git a/spec/api/documentation/v3/apps_api_spec.rb b/spec/api/documentation/v3/apps_api_spec.rb index acd879ae1ec..d96c9f89f6b 100644 --- a/spec/api/documentation/v3/apps_api_spec.rb +++ b/spec/api/documentation/v3/apps_api_spec.rb @@ -17,6 +17,65 @@ def do_request_with_error_handling end context 'standard endpoints' do + get '/v3/apps' do + parameter :page, 'Page to display', valid_values: '>= 1' + parameter :per_page, 'Number of results per page', valid_values: '1-5000' + + let(:name1) { 'my_app1' } + let(:name2) { 'my_app2' } + let(:name3) { 'my_app3' } + let!(:app_model1) { VCAP::CloudController::AppModel.make(name: name1, space_guid: space.guid) } + let!(:app_model2) { VCAP::CloudController::AppModel.make(name: name2, space_guid: space.guid) } + let!(:app_model3) { VCAP::CloudController::AppModel.make(name: name3, space_guid: space.guid) } + let!(:app_model4) { VCAP::CloudController::AppModel.make(space_guid: VCAP::CloudController::Space.make.guid) } + let(:space) { VCAP::CloudController::Space.make } + let(:page) { 1 } + let(:per_page) { 2 } + + before do + space.organization.add_user user + space.add_developer user + end + + example 'List all Apps' do + expected_response = { + 'pagination' => { + 'total_results' => 3, + 'first' => { 'href' => '/v3/apps?page=1&per_page=2' }, + 'last' => { 'href' => '/v3/apps?page=2&per_page=2' }, + 'next' => { 'href' => '/v3/apps?page=2&per_page=2' }, + 'previous' => nil, + }, + 'resources' => [ + { + 'name' => name1, + 'guid' => app_model1.guid, + '_links' => { + 'self' => { 'href' => "/v3/apps/#{app_model1.guid}" }, + 'processes' => { 'href' => "/v3/apps/#{app_model1.guid}/processes" }, + 'space' => { 'href' => "/v2/spaces/#{space.guid}" }, + } + }, + { + 'name' => name2, + 'guid' => app_model2.guid, + '_links' => { + 'self' => { 'href' => "/v3/apps/#{app_model2.guid}" }, + 'processes' => { 'href' => "/v3/apps/#{app_model2.guid}/processes" }, + 'space' => { 'href' => "/v2/spaces/#{space.guid}" }, + } + } + ] + } + + do_request_with_error_handling + + parsed_response = MultiJson.load(response_body) + expect(response_status).to eq(200) + expect(parsed_response).to match(expected_response) + end + end + get '/v3/apps/:guid' do let(:app_model) { VCAP::CloudController::AppModel.make(name: name) } let(:guid) { app_model.guid } @@ -68,7 +127,7 @@ def do_request_with_error_handling do_request_with_error_handling }.to change { VCAP::CloudController::AppModel.count }.by(1) - expected_guid = VCAP::CloudController::AppModel.last.guid + expected_guid = VCAP::CloudController::AppModel.last.guid expected_response = { 'name' => name, 'guid' => expected_guid, @@ -191,12 +250,22 @@ def do_request_with_error_handling end example 'List associated processes' do - expected_response = [ - { - 'guid' => process_guid, - 'type' => process_type, - } - ] + expected_response = { + 'pagination' => { + 'total_results' => 1, + 'first' => { 'href' => "/v3/apps/#{guid}/processes?page=1&per_page=50" }, + 'last' => { 'href' => "/v3/apps/#{guid}/processes?page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil, + }, + 'resources' => [ + { + 'guid' => process_guid, + 'type' => process_type, + } + ] + } + do_request_with_error_handling parsed_response = MultiJson.load(response_body) diff --git a/spec/api/documentation/v3/packages_api_spec.rb b/spec/api/documentation/v3/packages_api_spec.rb new file mode 100644 index 00000000000..da4410527dc --- /dev/null +++ b/spec/api/documentation/v3/packages_api_spec.rb @@ -0,0 +1,208 @@ +require 'spec_helper' +require 'awesome_print' +require 'rspec_api_documentation/dsl' + +resource 'Packages (Experimental)', type: :api do + let(:tmpdir) { Dir.mktmpdir } + let(:valid_zip) { + zip_name = File.join(tmpdir, 'file.zip') + TestZip.create(zip_name, 1, 1024) + File.new(zip_name) + } + + let(:user) { VCAP::CloudController::User.make } + let(:user_header) { headers_for(user)['HTTP_AUTHORIZATION'] } + header 'AUTHORIZATION', :user_header + + def do_request_with_error_handling + do_request + if response_status == 500 + error = MultiJson.load(response_body) + ap error + raise error['description'] + end + end + + context 'standard endpoints' do + get '/v3/packages/:guid' do + let(:space) { VCAP::CloudController::Space.make } + let(:package_model) do + VCAP::CloudController::PackageModel.make(space_guid: space_guid) + end + + let(:guid) { package_model.guid } + let(:space_guid) { space.guid } + + before do + space.organization.add_user user + space.add_developer user + end + + example 'Get a Package' do + expected_response = { + 'type' => package_model.type, + 'guid' => guid, + 'hash' => nil, + 'state' => VCAP::CloudController::PackageModel::CREATED_STATE, + 'url' => nil, + 'error' => nil, + 'created_at' => package_model.created_at.as_json, + '_links' => { + 'self' => { 'href' => "/v3/packages/#{guid}" }, + 'upload' => { 'href' => "/v3/packages/#{guid}/upload" }, + 'space' => { 'href' => "/v2/spaces/#{space_guid}" }, + } + } + + do_request_with_error_handling + + parsed_response = MultiJson.load(response_body) + expect(response_status).to eq(200) + expect(parsed_response).to match(expected_response) + end + end + + post '/v3/packages/:guid/upload' do + let(:type) { 'bits' } + let!(:package_model) do + VCAP::CloudController::PackageModel.make(space_guid: space_guid, type: type) + end + let(:space) { VCAP::CloudController::Space.make } + let(:space_guid) { space.guid } + let(:guid) { package_model.guid } + + before do + space.organization.add_user(user) + space.add_developer(user) + end + + parameter :bits, 'A binary zip file containing the package bits', required: true + + let(:packages_params) do + { + bits_name: 'application.zip', + bits_path: "#{tmpdir}/application.zip", + } + end + + let(:request_body_example) do + <<-eos.gsub(/^ */, '') + Content-type: multipart/form-data, boundary=AaB03x + --AaB03x + Content-Disposition: form-data; name="type" + + #{type} + --AaB03x + Content-Disposition: form-data; name="bits"; filename="application.zip" + Content-Type: application/zip + Content-Length: 123 + Content-Transfer-Encoding: binary + + <<binary artifact bytes>> + --AaB03x + eos + end + + example 'Upload Bits for a Package of type bits' do + expect { do_request packages_params }.to change { Delayed::Job.count }.by(1) + + job = Delayed::Job.last + expect(job.handler).to include(package_model.guid) + expect(job.guid).not_to be_nil + + expected_response = { + 'guid' => guid, + 'type' => type, + 'hash' => nil, + 'state' => VCAP::CloudController::PackageModel::PENDING_STATE, + 'url' => nil, + 'error' => nil, + 'created_at' => package_model.created_at.as_json, + '_links' => { + 'self' => { 'href' => "/v3/packages/#{package_model.guid}" }, + 'upload' => { 'href' => "/v3/packages/#{package_model.guid}/upload" }, + 'space' => { 'href' => "/v2/spaces/#{space_guid}" }, + } + } + + parsed_response = MultiJson.load(response_body) + expect(response_status).to eq(201) + expect(parsed_response).to match(expected_response) + end + end + + post '/v3/apps/:guid/packages' do + let(:space) { VCAP::CloudController::Space.make } + let(:space_guid) { space.guid } + let(:app_model) { VCAP::CloudController::AppModel.make(space_guid: space_guid) } + let(:guid) { app_model.guid } + let(:type) { 'docker' } + let(:url) { 'docker://cloudfoundry/runtime-ci' } + let(:packages_params) do + { + type: type, + url: url + } + end + + before do + space.organization.add_user(user) + space.add_developer(user) + end + + let(:raw_post) { MultiJson.dump(packages_params, pretty: true) } + + parameter :type, 'Package type', required: true, valid_values: ['bits', 'docker'] + parameter :url, 'Url of docker image', required: false + + example 'Create a Package' do + expect { + do_request packages_params + }.to change { VCAP::CloudController::PackageModel.count }.by(1) + + package = VCAP::CloudController::PackageModel.last + + expected_response = { + 'guid' => package.guid, + 'type' => type, + 'hash' => nil, + 'state' => 'READY', + 'error' => nil, + 'url' => url, + 'created_at' => package.created_at.as_json, + '_links' => { + 'self' => { 'href' => "/v3/packages/#{package.guid}" }, + 'upload' => { 'href' => "/v3/packages/#{package.guid}/upload" }, + 'space' => { 'href' => "/v2/spaces/#{space_guid}" }, + } + } + + parsed_response = MultiJson.load(response_body) + expect(response_status).to eq(201) + expect(parsed_response).to match(expected_response) + end + end + + delete '/v3/packages/:guid' do + let(:space) { VCAP::CloudController::Space.make } + let(:space_guid) { space.guid } + let!(:package_model) do + VCAP::CloudController::PackageModel.make(space_guid: space_guid) + end + + let(:guid) { package_model.guid } + + before do + space.organization.add_user user + space.add_developer user + end + + example 'Delete a Package' do + expect { + do_request_with_error_handling + }.to change { VCAP::CloudController::PackageModel.count }.by(-1) + expect(response_status).to eq(204) + end + end + end +end diff --git a/spec/api/documentation/v3/processes_api_spec.rb b/spec/api/documentation/v3/processes_api_spec.rb index 2c3b499d4d2..9e37eb02a57 100644 --- a/spec/api/documentation/v3/processes_api_spec.rb +++ b/spec/api/documentation/v3/processes_api_spec.rb @@ -16,6 +16,55 @@ def do_request_with_error_handling end end + get '/v3/processes' do + parameter :page, 'Page to display', valid_values: '>= 1' + parameter :per_page, 'Number of results per page', valid_values: '1-5000' + + let(:name1) { 'my_process1' } + let(:name2) { 'my_process2' } + let(:name3) { 'my_process3' } + let!(:process1) { VCAP::CloudController::App.make(name: name1, space: space) } + let!(:process2) { VCAP::CloudController::App.make(name: name2, space: space) } + let!(:process3) { VCAP::CloudController::App.make(name: name3, space: space) } + let!(:process4) { VCAP::CloudController::App.make(space: VCAP::CloudController::Space.make) } + let(:space) { VCAP::CloudController::Space.make } + let(:page) { 1 } + let(:per_page) { 2 } + + before do + space.organization.add_user user + space.add_developer user + end + + example 'List all Processes' do + expected_response = { + 'pagination' => { + 'total_results' => 3, + 'first' => { 'href' => '/v3/processes?page=1&per_page=2' }, + 'last' => { 'href' => '/v3/processes?page=2&per_page=2' }, + 'next' => { 'href' => '/v3/processes?page=2&per_page=2' }, + 'previous' => nil, + }, + 'resources' => [ + { + 'guid' => process1.guid, + 'type' => process1.type, + }, + { + 'guid' => process2.guid, + 'type' => process2.type, + } + ] + } + + do_request_with_error_handling + + parsed_response = MultiJson.load(response_body) + expect(response_status).to eq(200) + expect(parsed_response).to match(expected_response) + end + end + get '/v3/processes/:guid' do let(:process) { VCAP::CloudController::AppFactory.make } let(:guid) { process.guid } diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 47b1e6cf683..ef0c3425d0f 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -86,6 +86,10 @@ TmpdirCleaner.clean end + rspec_config.after :each do + Timecop.return + end + rspec_config.after(:each, type: :legacy_api) { add_deprecation_warning } RspecApiDocumentation.configure do |c| diff --git a/spec/support/fakes/blueprints.rb b/spec/support/fakes/blueprints.rb index 175da5e1307..a7db1d0c16c 100644 --- a/spec/support/fakes/blueprints.rb +++ b/spec/support/fakes/blueprints.rb @@ -35,6 +35,11 @@ module VCAP::CloudController space_guid { Space.make.guid } end + PackageModel.blueprint do + guid { Sham.guid } + state { VCAP::CloudController::PackageModel::CREATED_STATE } + end + User.blueprint do guid { Sham.uaa_id } end diff --git a/spec/support/matchers/sequel_validations.rb b/spec/support/matchers/sequel_validations.rb index 3c46b882b59..823dca24ccd 100644 --- a/spec/support/matchers/sequel_validations.rb +++ b/spec/support/matchers/sequel_validations.rb @@ -54,3 +54,14 @@ end end end + +RSpec::Matchers.define :validates_includes do |values, attribute, options={}| + description do + "validate includes of #{attribute} with #{values}" + end + match do |instance| + allow(instance).to receive(:validates_includes) + instance.valid? + expect(instance).to have_received(:validates_includes).with(values, attribute, options) + end +end diff --git a/spec/unit/access/v3/package_access_spec.rb b/spec/unit/access/v3/package_access_spec.rb new file mode 100644 index 00000000000..c801680cfee --- /dev/null +++ b/spec/unit/access/v3/package_access_spec.rb @@ -0,0 +1,169 @@ +require 'spec_helper' + +module VCAP::CloudController + describe PackageModelAccess, type: :access do + let(:token) { {} } + let(:admin) { false } + let(:user) { User.make } + let(:roles) { double(:roles, admin?: admin) } + let(:package) { PackageModel.make } + let(:access_context) { double(:access_context, roles: roles, user: user) } + + before do + SecurityContext.set(nil, token) + end + + after do + SecurityContext.clear + end + + describe '#read?' do + let(:space) { Space.make } + let(:package) { PackageModel.new(space_guid: space.guid) } + + context 'admin user' do + let(:admin) { true } + + it 'allows the user to read' do + access_control = PackageModelAccess.new(access_context) + expect(access_control.read?(nil)).to be_truthy + end + end + + context 'non admin users' do + context 'when the user has sufficient scope and permission' do + let(:token) { { 'scope' => ['cloud_controller.read'] } } + + it 'allows the user to read' do + allow(Space).to receive(:user_visible).and_return(Space.where(guid: space.guid)) + access_control = PackageModelAccess.new(access_context) + expect(access_control.read?(package)).to be_truthy + end + end + + context 'when the user has insufficient scope' do + it 'disallows the user from reading' do + allow(Space).to receive(:user_visible).and_return(Space.where(guid: space.guid)) + access_control = PackageModelAccess.new(access_context) + expect(access_control.read?(package)).to be_falsey + end + end + + context 'when the package is not visible to the user' do + let(:token) { { 'scope' => ['cloud_controller.read'] } } + + it 'disallows the user from reading' do + allow(Space).to receive(:user_visible).and_return(Space.where(guid: nil)) + access_control = PackageModelAccess.new(access_context) + expect(access_control.read?(package)).to be_falsey + end + end + end + end + + describe '#create?, #delete?, #upload?' do + let(:space) { Space.make } + let(:package) { PackageModel.new(space_guid: space.guid) } + + context 'admin user' do + let(:admin) { true } + + it 'allows the user to perform the action' do + access_control = PackageModelAccess.new(access_context) + expect(access_control.create?(package, space)).to be_truthy + expect(access_control.delete?(package, space)).to be_truthy + expect(access_control.upload?(package, space)).to be_truthy + end + end + + context 'non admin users' do + context 'when the user has sufficient scope and permissions' do + let(:token) { { 'scope' => ['cloud_controller.write'] } } + + before do + space.organization.add_user(user) + space.add_developer(user) + end + + it 'allows the user to create' do + access_control = PackageModelAccess.new(access_context) + expect(access_control.create?(package, space)).to be_truthy + expect(access_control.delete?(package, space)).to be_truthy + expect(access_control.upload?(package, space)).to be_truthy + end + end + + context 'when the user has insufficient scope' do + let(:token) { { 'scope' => ['cloud_controller.read'] } } + + before do + space.organization.add_user(user) + space.add_developer(user) + end + + it 'disallows the user from creating' do + access_control = PackageModelAccess.new(access_context) + expect(access_control.create?(package, space)).to be_falsey + expect(access_control.delete?(package, space)).to be_falsey + expect(access_control.upload?(package, space)).to be_falsey + end + end + + context 'when the user has insufficient permissions' do + let(:token) { { 'scope' => ['cloud_controller.write'] } } + + it 'disallows the user from creating' do + access_control = PackageModelAccess.new(access_context) + expect(access_control.create?(package, space)).to be_falsey + expect(access_control.delete?(package, space)).to be_falsey + expect(access_control.upload?(package, space)).to be_falsey + end + end + + context 'when the organization is not active' do + let(:token) { { 'scope' => ['cloud_controller.write'] } } + + before do + space.organization.add_user(user) + space.add_developer(user) + space.organization.update(status: 'suspended') + end + + it 'disallows the user from creating' do + access_control = PackageModelAccess.new(access_context) + expect(access_control.create?(package, space)).to be_falsey + expect(access_control.delete?(package, space)).to be_falsey + expect(access_control.upload?(package, space)).to be_falsey + end + end + end + end + + describe '#upload? when the app_bits_upload feature flag is disabled' do + let(:space) { Space.make } + let(:package) { PackageModel.new(space_guid: space.guid) } + + before do + FeatureFlag.make(name: 'app_bits_upload', enabled: false) + end + + context 'as an admin user' do + let(:admin) { true } + + it 'allows the user to upload' do + access_control = PackageModelAccess.new(access_context) + expect(access_control.upload?(package, space)).to be_truthy + end + end + + context 'as a non-admin user' do + let(:token) { { 'scope' => ['cloud_controller.write'] } } + + it 'disallows the user from uploading' do + access_control = PackageModelAccess.new(access_context) + expect(access_control.upload?(package, space)).to be_falsey + end + end + end + end +end diff --git a/spec/unit/collection_transformers/username_populator_spec.rb b/spec/unit/collection_transformers/username_populator_spec.rb new file mode 100644 index 00000000000..cf952c214c3 --- /dev/null +++ b/spec/unit/collection_transformers/username_populator_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +module VCAP::CloudController + describe UsernamePopulator do + let(:uaa_client) { double(UaaClient) } + let(:username_populator) { UsernamePopulator.new(uaa_client) } + let(:user1) { User.new(guid: '1') } + let(:user2) { User.new(guid: '2') } + let(:users) { [user1, user2] } + + before do + allow(uaa_client).to receive(:usernames_for_ids).with(['1', '2']).and_return({ + '1' => 'Username1', + '2' => 'Username2' + }) + end + + describe 'transform' do + it 'populates users with usernames from UAA' do + username_populator.transform(users) + expect(user1.username).to eq('Username1') + expect(user2.username).to eq('Username2') + end + end + end +end diff --git a/spec/unit/controllers/base/model_controller_spec.rb b/spec/unit/controllers/base/model_controller_spec.rb index e12215868a7..50e782aca2d 100644 --- a/spec/unit/controllers/base/model_controller_spec.rb +++ b/spec/unit/controllers/base/model_controller_spec.rb @@ -687,6 +687,15 @@ def run_delayed_job expect(found_guids).to match_array([associated_model1.guid, associated_model2.guid]) end + it 'uses the collection_renderer for the associated class' do + collection_renderer = double('Collection Renderer', render_json: 'JSON!') + allow_any_instance_of(TestModelManyToManiesController).to receive(:collection_renderer).and_return(collection_renderer) + + get "/v2/test_models/#{model.guid}/test_model_many_to_manies", '', admin_headers + + expect(last_response.body).to eq('JSON!') + end + it 'fails when you do not have access to the associated model' do allow_any_instance_of(TestModelManyToOneAccess).to receive(:index?). with(TestModelManyToOne, { related_obj: instance_of(TestModel), related_model: TestModel }).and_return(false) diff --git a/spec/unit/controllers/internal/bulk_apps_controller_spec.rb b/spec/unit/controllers/internal/bulk_apps_controller_spec.rb index cf4edb5e688..14e5dd21ad5 100644 --- a/spec/unit/controllers/internal/bulk_apps_controller_spec.rb +++ b/spec/unit/controllers/internal/bulk_apps_controller_spec.rb @@ -1,47 +1,55 @@ require 'spec_helper' -require 'membrane' +require 'cloud_controller/diego/process_guid' module VCAP::CloudController describe BulkAppsController do + def make_diego_app(options={}) + AppFactory.make(options).tap do |app| + app.environment_json = (app.environment_json || {}).merge('DIEGO_RUN_BETA' => 'true') + app.package_state = 'STAGED' + app.save + end + end + + def app_table_entry(index) + App.order_by(:id).all[index - 1] + end + + let(:runners) do + ::CloudController::DependencyLocator.instance.runners + end + before do allow_any_instance_of(::CloudController::Blobstore::UrlGenerator). to receive(:perma_droplet_download_url). and_return('http://blobsto.re/droplet') - end - before do @internal_user = 'internal_user' @internal_password = 'internal_password' + + 5.times { |i| make_diego_app(state: 'STARTED') } end describe 'GET', '/internal/bulk/apps' do - def make_diego_app(options={}) - AppFactory.make(options).tap do |app| - app.environment_json = (app.environment_json || {}).merge('DIEGO_RUN_BETA' => 'true') - app.package_state = 'STAGED' - app.save + context 'without credentials' do + it 'rejects the request as unauthorized' do + get '/internal/bulk/apps' + expect(last_response.status).to eq(401) end end - before do - 5.times do |i| - make_diego_app( - id: i + 1, - state: 'STARTED', - ) + context 'with invalid credentials' do + before do + authorize 'bar', 'foo' end - end - - it 'requires authentication' do - get '/internal/bulk/apps' - expect(last_response.status).to eq(401) - authorize 'bar', 'foo' - get '/internal/bulk/apps' - expect(last_response.status).to eq(401) + it 'rejects the request as unauthorized' do + get '/internal/bulk/apps' + expect(last_response.status).to eq(401) + end end - describe 'with authentication' do + context 'with valid credentials' do before do authorize @internal_user, @internal_password end @@ -56,11 +64,7 @@ def make_diego_app(options={}) context 'when diego_running is set to disabled' do before do - allow(Config.config).to receive(:[]).with(anything).and_call_original - allow(Config.config).to receive(:[]).with(:diego).and_return( - staging: 'optional', - running: 'disabled', - ) + TestConfig.override(diego: { staging: 'optional', running: 'disabled' }) end it 'returns an empty result' do @@ -76,11 +80,7 @@ def make_diego_app(options={}) context 'when diego_running is set to optional' do before do - allow(Config.config).to receive(:[]).with(anything).and_call_original - allow(Config.config).to receive(:[]).with(:diego).and_return( - staging: 'optional', - running: 'optional', - ) + TestConfig.override(diego: { staging: 'optional', running: 'optional' }) end it 'returns a populated token for the initial request (which has an empty bulk token)' do @@ -90,160 +90,152 @@ def make_diego_app(options={}) } expect(last_response.status).to eq(200) - expect(decoded_response['token']).to eq({ 'id' => 3 }) + expect(decoded_response['token']).to eq({ 'id' => app_table_entry(3).id }) end it 'returns apps in the response body' do get '/internal/bulk/apps', { 'batch_size' => 20, - 'token' => { id: 2 }.to_json, + 'token' => { id: app_table_entry(2).id }.to_json, } expect(last_response.status).to eq(200) expect(decoded_response['apps'].size).to eq(3) end - it 'returns apps that have the desired data' do - last_app = make_diego_app( - id: 6, - state: 'STARTED', - package_state: 'STAGED', - package_hash: 'package-hash', - disk_quota: 1_024, - environment_json: { + context 'when a format parameter is not specified' do + before do + app = make_diego_app( + state: 'STARTED', + package_state: 'STAGED', + package_hash: 'package-hash', + disk_quota: 1_024, + environment_json: { 'env-key-3' => 'env-value-3', 'env-key-4' => 'env-value-4', 'DIEGO_RUN_BETA' => 'true', - }, - file_descriptors: 16_384, - instances: 4, - memory: 1_024, - guid: 'app-guid-6', - command: 'start-command-6', - stack: Stack.make(name: 'stack-6'), - ) - - route1 = Route.make( - space: last_app.space, + }, + file_descriptors: 16_384, + instances: 4, + memory: 1_024, + guid: 'app-guid-6', + command: 'start-command-6', + stack: Stack.make(name: 'stack-6'), + ) + + route1 = Route.make( + space: app.space, host: 'arsenio', domain: SharedDomain.make(name: 'lo-mein.com'), - ) - last_app.add_route(route1) + ) + app.add_route(route1) - route2 = Route.make( - space: last_app.space, + route2 = Route.make( + space: app.space, host: 'conan', domain: SharedDomain.make(name: 'doe-mane.com'), - ) - last_app.add_route(route2) + ) + app.add_route(route2) - last_app.version = 'app-version-6' - last_app.save + app.version = 'app-version-6' + app.save + end - get '/internal/bulk/apps', { - 'batch_size' => 100, - 'token' => "{\"id\": 0 }", - } + it 'uses the desire app message format' do + get '/internal/bulk/apps', { + 'batch_size' => 100, + 'token' => { id: 0 }.to_json, + } - expect(last_response.status).to eq(200) + expect(last_response.status).to eq(200) + expect(decoded_response['apps'].size).to eq(6) + + last_response_app = decoded_response['apps'].last + last_app = app_table_entry(6) - expect(decoded_response['apps'].size).to eq(6) - - last_response_app = decoded_response['apps'][5] - expect(last_response_app.except('environment')).to match_object({ - 'disk_mb' => 1_024, - 'file_descriptors' => 16_384, - 'num_instances' => 4, - 'log_guid' => 'app-guid-6', - 'memory_mb' => 1_024, - 'process_guid' => 'app-guid-6-app-version-6', - 'routes' => ['arsenio.lo-mein.com', 'conan.doe-mane.com'], - 'droplet_uri' => 'http://blobsto.re/droplet', - 'stack' => 'stack-6', - 'start_command' => 'start-command-6', - 'execution_metadata' => '', - 'health_check_type' => 'port', - }) - - last_response_app_env = last_response_app['environment'] - expect(last_response_app_env).to(be_any) { |e| e['name'] == 'VCAP_APPLICATION' } - expect(last_response_app_env).to(be_any) { |e| e['name'] == 'MEMORY_LIMIT' } - expect(last_response_app_env).to(be_any) { |e| e['name'] == 'VCAP_SERVICES' } - expect(last_response_app_env.find { |e| e['name'] == 'env-key-3' }['value']).to eq('env-value-3') - expect(last_response_app_env.find { |e| e['name'] == 'env-key-4' }['value']).to eq('env-value-4') - expect(last_response_app_env.find { |e| e['name'] == 'DIEGO_RUN_BETA' }['value']).to eq('true') + expect(last_response_app).to eq(runners.runner_for_app(last_app).desire_app_message) + end end - it 'respects the batch_size parameter' do - [3, 5].each { |size| + context 'when a format=cache parameter is set' do + it 'uses the cache data format' do get '/internal/bulk/apps', { - 'batch_size' => size, - 'token' => "{\"id\":0}", - } + 'batch_size' => 1, + 'format' => 'fingerprint', + 'token' => { id: 0 }.to_json, + } expect(last_response.status).to eq(200) - expect(decoded_response['apps'].size).to eq(size) - } - end - - it 'returns non-intersecting apps when token is supplied' do - get '/internal/bulk/apps', { - 'batch_size' => 2, - 'token' => "{\"id\":0}", - } + expect(decoded_response['fingerprints'].size).to eq(1) - expect(last_response.status).to eq(200) + app = App.order(:id).first - saved_apps = decoded_response['apps'].dup - expect(saved_apps.size).to eq(2) + message = decoded_response['fingerprints'][0] + expect(message).to match_object({ + 'process_guid' => Diego::ProcessGuid.from_app(app), + 'etag' => app.updated_at.to_f.to_s + }) + end + end - get '/internal/bulk/apps', { - 'batch_size' => 2, - 'token' => MultiJson.dump(decoded_response['token']), - } + context 'when there are unstaged apps' do + before do + app = make_diego_app(state: 'STARTED') + app.package_state = 'PENDING' + app.save + end - expect(last_response.status).to eq(200) + it 'only returns staged apps' do + get '/internal/bulk/apps', { + 'batch_size' => App.count, + 'token' => '{}', + } - new_apps = decoded_response['apps'].dup - expect(new_apps.size).to eq(2) - saved_apps.each do |saved_result| - expect(new_apps).not_to include(saved_result) + expect(last_response.status).to eq(200) + expect(decoded_response['apps'].size).to eq(App.count - 1) end end - it 'should eventually return entire collection, batch after batch' do - apps = [] - total_size = App.count + context 'when apps are not in the STARTED state' do + before do + make_diego_app(state: 'STOPPED') + end - token = '{}' - while apps.size < total_size + it 'does not return apps in the STOPPED state' do get '/internal/bulk/apps', { - 'batch_size' => 2, - 'token' => MultiJson.dump(token), - } + 'batch_size' => App.count, + 'token' => '{}', + } expect(last_response.status).to eq(200) - token = decoded_response['token'] - apps += decoded_response['apps'] + expect(decoded_response['apps'].size).to eq(App.count - 1) end + end - expect(apps.size).to eq(total_size) - get '/internal/bulk/apps', { - 'batch_size' => 2, - 'token' => MultiJson.dump(token), - } + context 'when there is a mixture of diego and traditional apps' do + before do + app = AppFactory.make + expect(app.diego).to be_falsey + end - expect(last_response.status).to eq(200) - expect(decoded_response['apps'].size).to eq(0) + it 'only returns diego apps' do + get '/internal/bulk/apps', { + 'batch_size' => App.count, + 'token' => '{}', + } + + expect(last_response.status).to eq(200) + expect(decoded_response['apps'].size).to eq(App.count - 1) + end end context 'when docker is enabled' do before do - allow(Config.config).to receive(:[]).with(:diego_docker).and_return true + TestConfig.override(diego_docker: true, diego: { staging: 'optional', running: 'optional' }) end it 'does return docker apps' do - app = make_diego_app(id: 6, state: 'STARTED', docker_image: 'fake-docker-image') + app = make_diego_app(state: 'STARTED', docker_image: 'fake-docker-image') app.save get '/internal/bulk/apps', { @@ -256,58 +248,159 @@ def make_diego_app(options={}) end end - it 'does not return unstaged apps' do - app = make_diego_app(id: 6, state: 'STARTED') - app.package_state = 'PENDING' - app.save + describe 'pagination' do + it 'respects the batch_size parameter' do + [3, 5].each { |size| + get '/internal/bulk/apps', { + 'batch_size' => size, + 'token' => { id: 0 }.to_json, + } - get '/internal/bulk/apps', { - 'batch_size' => App.count, - 'token' => '{}', - } + expect(last_response.status).to eq(200) + expect(decoded_response['apps'].size).to eq(size) + } + end - expect(last_response.status).to eq(200) - expect(decoded_response['apps'].size).to eq(App.count - 1) + it 'returns non-intersecting apps when token is supplied' do + get '/internal/bulk/apps', { + 'batch_size' => 2, + 'token' => { id: 0 }.to_json, + } + + expect(last_response.status).to eq(200) + + saved_apps = decoded_response['apps'].dup + expect(saved_apps.size).to eq(2) + + get '/internal/bulk/apps', { + 'batch_size' => 2, + 'token' => MultiJson.dump(decoded_response['token']), + } + + expect(last_response.status).to eq(200) + + new_apps = decoded_response['apps'].dup + expect(new_apps.size).to eq(2) + saved_apps.each do |saved_result| + expect(new_apps).not_to include(saved_result) + end + end + + it 'should eventually return entire collection, batch after batch' do + apps = [] + total_size = App.count + + token = '{}' + while apps.size < total_size + get '/internal/bulk/apps', { + 'batch_size' => 2, + 'token' => MultiJson.dump(token), + } + + expect(last_response.status).to eq(200) + token = decoded_response['token'] + apps += decoded_response['apps'] + end + + expect(apps.size).to eq(total_size) + get '/internal/bulk/apps', { + 'batch_size' => 2, + 'token' => MultiJson.dump(token), + } + + expect(last_response.status).to eq(200) + expect(decoded_response['apps'].size).to eq(0) + end end + end + end + end - it "does not return apps which aren't expected to be started" do - make_diego_app(id: 6, state: 'STOPPED') + describe 'POST' '/internal/bulk/apps' do + context 'without credentials' do + it 'rejects the request as unauthorized' do + post '/internal/bulk/apps', {} - get '/internal/bulk/apps', { - 'batch_size' => App.count, - 'token' => '{}', - } + expect(last_response.status).to eq(401) + end + end - expect(last_response.status).to eq(200) - expect(decoded_response['apps'].size).to eq(App.count - 1) + context 'with invalid credentials' do + before do + authorize 'bar', 'foo' + end + + it 'rejects the request as unauthorized' do + post '/internal/bulk/apps' + + expect(last_response.status).to eq(401) + end + end + + context 'with valid credentials' do + before do + authorize @internal_user, @internal_password + end + + context 'without a body' do + it 'is an invalid request' do + post '/internal/bulk/apps' + + expect(last_response.status).to eq(400) end + end - it 'does not return deleted apps' do - make_diego_app(id: 6, state: 'STARTED', deleted_at: DateTime.now) + context 'with a body' do + context 'with invalid json' do + it 'is an invalid request' do + post '/internal/bulk/apps', 'foo' - get '/internal/bulk/apps', { - 'batch_size' => App.count, - 'token' => '{}', - } + expect(last_response.status).to eq(400) + end + end - expect(last_response.status).to eq(200) - expect(decoded_response['apps'].size).to eq(App.count - 1) + context 'with an empty list' do + it 'returns an empty list' do + post '/internal/bulk/apps', [].to_json + + expect(last_response.status).to eq(200) + expect(decoded_response).to eq([]) + end end - it 'only returns apps with DIEGO_RUN_BETA' do - make_diego_app(id: 6, state: 'STARTED').tap do |a| - a.environment_json = {} # remove DIEGO_RUN_BETA - a.save + context 'with a list of process guids' do + it 'returns a list of desire app messages that match the process guids' do + diego_apps = runners.diego_apps(100, 0) + + guids = diego_apps.map { |app| Diego::ProcessGuid.from_app(app) } + post '/internal/bulk/apps', guids.to_json + + expect(last_response.status).to eq(200) + expect(decoded_response.length).to eq(5) + + diego_apps.each do |app| + expect(decoded_response).to include(runners.runner_for_app(app).desire_app_message) + end end - get '/internal/bulk/apps', { - 'batch_size' => App.count, - 'token' => '{}', - } + context 'when there is a mixture of diego and traditional apps' do + before do + 5.times { AppFactory.make } + end - expect(last_response.status).to eq(200) - expect(decoded_response['apps'].size).to eq(App.count - 1) + it 'only returns the diego apps' do + diego_apps = runners.diego_apps(100, 0) + + guids = App.all.map { |app| Diego::ProcessGuid.from_app(app) } + post '/internal/bulk/apps', guids.to_json + + expect(last_response.status).to eq(200) + expect(decoded_response.length).to eq(diego_apps.length) + end + end end + + # validate max batch size; reject requests that are too large end end end diff --git a/spec/unit/controllers/runtime/organizations_controller_spec.rb b/spec/unit/controllers/runtime/organizations_controller_spec.rb index f3f013a67b0..4b424fd888f 100644 --- a/spec/unit/controllers/runtime/organizations_controller_spec.rb +++ b/spec/unit/controllers/runtime/organizations_controller_spec.rb @@ -445,6 +445,9 @@ def decoded_guids let(:mgr) { User.make } let(:user) { User.make } let(:org) { Organization.make(manager_guids: [mgr.guid], user_guids: [mgr.guid, user.guid]) } + before do + allow_any_instance_of(UaaClient).to receive(:usernames_for_ids).and_return({}) + end it 'allows org managers' do get "/v2/organizations/#{org.guid}/users", '', headers_for(mgr) diff --git a/spec/unit/controllers/runtime/spaces_controller_spec.rb b/spec/unit/controllers/runtime/spaces_controller_spec.rb index 744f082424d..f44fe67b16b 100644 --- a/spec/unit/controllers/runtime/spaces_controller_spec.rb +++ b/spec/unit/controllers/runtime/spaces_controller_spec.rb @@ -591,6 +591,9 @@ def decoded_guids let(:user) { User.make } let(:org) { Organization.make(manager_guids: [mgr.guid], user_guids: [mgr.guid, user.guid]) } let(:space) { Space.make(organization: org, manager_guids: [mgr.guid], developer_guids: [user.guid]) } + before do + allow_any_instance_of(UaaClient).to receive(:usernames_for_ids).and_return({}) + end it 'allows space managers' do get "/v2/spaces/#{space.guid}/developers", '', headers_for(mgr) diff --git a/spec/unit/controllers/runtime/users_controller_spec.rb b/spec/unit/controllers/runtime/users_controller_spec.rb index 33386d59fbf..809a0bb4ad7 100644 --- a/spec/unit/controllers/runtime/users_controller_spec.rb +++ b/spec/unit/controllers/runtime/users_controller_spec.rb @@ -63,6 +63,7 @@ module VCAP::CloudController include_context 'permissions' before do @obj_a = member_a + allow_any_instance_of(UaaClient).to receive(:usernames_for_ids).and_return({}) end context 'normal user' do diff --git a/spec/unit/controllers/services/service_bindings_controller_spec.rb b/spec/unit/controllers/services/service_bindings_controller_spec.rb index 7c76807976d..42cf9019f2c 100644 --- a/spec/unit/controllers/services/service_bindings_controller_spec.rb +++ b/spec/unit/controllers/services/service_bindings_controller_spec.rb @@ -350,7 +350,7 @@ def fake_app_staging(app) uri = 'http://broker.url.com' method = 'PUT' response = double(:response, code: 409, body: '{}') - raise VCAP::Services::ServiceBrokers::V2::ServiceBrokerConflict.new(uri, method, response) + raise VCAP::Services::ServiceBrokers::V2::Errors::ServiceBrokerConflict.new(uri, method, response) end end @@ -372,7 +372,7 @@ def fake_app_staging(app) uri = 'http://broker.url.com' method = 'PUT' response = double(:response, code: 500, body: '{"description": "ERROR MESSAGE HERE"}') - raise VCAP::Services::ServiceBrokers::V2::ServiceBrokerBadResponse.new(uri, method, response) + raise VCAP::Services::ServiceBrokers::V2::Errors::ServiceBrokerBadResponse.new(uri, method, response) end end diff --git a/spec/unit/controllers/services/service_instances_controller_spec.rb b/spec/unit/controllers/services/service_instances_controller_spec.rb index a4b59b99a4f..354f10fa58f 100644 --- a/spec/unit/controllers/services/service_instances_controller_spec.rb +++ b/spec/unit/controllers/services/service_instances_controller_spec.rb @@ -178,6 +178,8 @@ def self.user_sees_empty_enumerate(user_role, member_a_ivar, member_b_ivar) allow(client).to receive(:provision) do |instance| instance.credentials = '{}' instance.dashboard_url = 'the dashboard_url' + instance.state = 'creating' + instance.state_description = '' end allow(client).to receive(:deprovision) allow_any_instance_of(Service).to receive(:client).and_return(client) @@ -190,6 +192,8 @@ def self.user_sees_empty_enumerate(user_role, member_a_ivar, member_b_ivar) expect(instance.credentials).to eq('{}') expect(instance.dashboard_url).to eq('the dashboard_url') + expect(decoded_response['entity']['state']).to eq 'creating' + expect(decoded_response['entity']['state_description']).to eq '' end it 'creates a service audit event for creating the service instance' do @@ -558,10 +562,10 @@ def self.user_sees_empty_enumerate(user_role, member_a_ivar, member_b_ivar) let(:uri) { 'http://uri.example.com' } let(:method) { 'GET' } let(:response_body) { '{"description": "error message"}' } - let(:response) { double(code: 422, reason: 'Broker rejected the upate', body: response_body) } + let(:response) { double(code: 500, reason: 'Internal Server Error', body: response_body) } before do - allow(client).to receive(:update_service_plan).and_raise(VCAP::Services::ServiceBrokers::V2::ServiceBrokerBadResponse.new(uri, method, response)) + allow(client).to receive(:update_service_plan).and_raise(VCAP::Services::ServiceBrokers::V2::Errors::ServiceBrokerBadResponse.new(uri, method, response)) end it 'returns a CF-ServiceBrokerBadResponse' do @@ -571,6 +575,23 @@ def self.user_sees_empty_enumerate(user_role, member_a_ivar, member_b_ivar) end end + context 'when the broker client raises a ServiceBrokerRequestRejected' do + let(:uri) { 'http://uri.example.com' } + let(:method) { 'GET' } + let(:response_body) { '{"description": "error message"}' } + let(:response) { double(code: 422, reason: 'Broker rejected the upate', body: response_body) } + + before do + allow(client).to receive(:update_service_plan).and_raise(VCAP::Services::ServiceBrokers::V2::Errors::ServiceBrokerRequestRejected.new(uri, method, response)) + end + + it 'returns a CF-ServiceBrokerBadResponse' do + put "/v2/service_instances/#{service_instance.guid}", body, admin_headers + expect(last_response.status).to eq 502 + expect(decoded_response['error_code']).to eq 'CF-ServiceBrokerRequestRejected' + end + end + describe 'the space_guid parameter' do let(:org) { Organization.make } let(:space) { Space.make(organization: org) } diff --git a/spec/unit/controllers/v3/apps_controller_spec.rb b/spec/unit/controllers/v3/apps_controller_spec.rb index 3b102f5162d..c622687108e 100644 --- a/spec/unit/controllers/v3/apps_controller_spec.rb +++ b/spec/unit/controllers/v3/apps_controller_spec.rb @@ -5,6 +5,7 @@ module VCAP::CloudController let(:logger) { instance_double(Steno::Logger) } let(:user) { User.make } let(:req_body) { '' } + let(:params) { {} } let(:process_handler) { double(:process_handler) } let(:process_presenter) { double(:process_presenter) } let(:apps_handler) { double(:apps_handler) } @@ -12,19 +13,19 @@ module VCAP::CloudController let(:app_presenter) { double(:app_presenter) } let(:apps_controller) do AppsV3Controller.new( - {}, - logger, - {}, - {}, - req_body, - nil, - { - apps_handler: apps_handler, - app_presenter: app_presenter, - processes_handler: process_handler, - process_presenter: process_presenter - }, - ) + {}, + logger, + {}, + params, + req_body, + nil, + { + apps_handler: apps_handler, + app_presenter: app_presenter, + processes_handler: process_handler, + process_presenter: process_presenter + }, + ) end let(:app_response) { 'app_response_body' } let(:process_response) { 'process_response_body' } @@ -36,6 +37,27 @@ module VCAP::CloudController allow(process_presenter).to receive(:present_json_list).and_return(process_response) end + describe '#list' do + let(:page) { 1 } + let(:per_page) { 2 } + let(:params) { { 'page' => page, 'per_page' => per_page } } + let(:list_response) { 'list_response' } + + before do + allow(app_presenter).to receive(:present_json_list).and_return(app_response) + allow(apps_handler).to receive(:list).and_return(list_response) + end + + it 'returns 200 and lists the apps' do + response_code, response_body = apps_controller.list + + expect(apps_handler).to have_received(:list) + expect(app_presenter).to have_received(:present_json_list).with(list_response) + expect(response_code).to eq(200) + expect(response_body).to eq(app_response) + end + end + describe '#show' do context 'when the app does not exist' do let(:guid) { 'ABC123' } @@ -489,6 +511,12 @@ module VCAP::CloudController context 'when the app does exist' do let(:app_model) { AppModel.make } let(:guid) { app_model.guid } + let(:list_response) { 'list_response' } + + before do + allow(process_presenter).to receive(:present_json_list).and_return(process_response) + allow(process_handler).to receive(:list).and_return(list_response) + end it 'returns a 200' do response_code, _ = apps_controller.list_processes(guid) diff --git a/spec/unit/controllers/v3/packages_controller_spec.rb b/spec/unit/controllers/v3/packages_controller_spec.rb new file mode 100644 index 00000000000..f4935efd354 --- /dev/null +++ b/spec/unit/controllers/v3/packages_controller_spec.rb @@ -0,0 +1,328 @@ +require 'spec_helper' + +module VCAP::CloudController + describe PackagesController do + let(:logger) { instance_double(Steno::Logger) } + let(:user) { User.make } + let(:params) { {} } + let(:packages_handler) { double(:packages_handler) } + let(:apps_handler) { double(:apps_handler) } + let(:package_presenter) { double(:package_presenter) } + let(:req_body) { '{}' } + + let(:packages_controller) do + PackagesController.new( + {}, + logger, + {}, + params.stringify_keys, + req_body, + nil, + { + packages_handler: packages_handler, + package_presenter: package_presenter, + apps_handler: apps_handler + }, + ) + end + + before do + allow(logger).to receive(:debug) + end + + describe '#create' do + let(:tmpdir) { Dir.mktmpdir } + let(:app_model) { AppModel.make } + let(:app_guid) { app_model.guid } + let(:space_guid) { app_model.space_guid } + let(:package) { PackageModel.make } + let(:req_body) { '{"type":"bits"}' } + + let(:valid_zip) do + zip_name = File.join(tmpdir, 'file.zip') + TestZip.create(zip_name, 1, 1024) + zip_file = File.new(zip_name) + Rack::Test::UploadedFile.new(zip_file) + end + + let(:package_response) { 'foobar' } + + before do + allow(package_presenter).to receive(:present_json).and_return(MultiJson.dump(package_response, pretty: true)) + allow(packages_handler).to receive(:create).and_return(package) + allow(apps_handler).to receive(:show).and_return(app_model) + end + + after do + FileUtils.rm_rf(tmpdir) + end + + context 'when the app exists' do + context 'when a user can create a package' do + it 'returns a 201 Created response' do + response_code, _ = packages_controller.create(app_guid) + expect(response_code).to eq 201 + end + + it 'returns the package' do + _, response = packages_controller.create(app_guid) + expect(MultiJson.load(response, symbolize_keys: true)).to eq(package_response) + end + end + + context 'as a developer' do + let(:user) { make_developer_for_space(app_model.space) } + + context 'with an invalid package' do + let(:req_body) { 'all sorts of invalid' } + + it 'returns an UnprocessableEntity error' do + expect { + packages_controller.create(app_guid) + }.to raise_error do |error| + expect(error.name).to eq 'UnprocessableEntity' + expect(error.response_code).to eq 422 + end + end + end + + context 'with an invalid type field' do + let(:req_body) { '{ "type": "ninja" }' } + + it 'returns an UnprocessableEntity error' do + expect { + packages_controller.create(app_guid) + }.to raise_error do |error| + expect(error.name).to eq 'UnprocessableEntity' + expect(error.response_code).to eq 422 + end + end + end + end + + context 'when the user cannot create a package' do + before do + allow(packages_handler).to receive(:create).and_raise(PackagesHandler::Unauthorized) + end + + it 'returns a 403 NotAuthorized error' do + expect { + packages_controller.create(app_guid) + }.to raise_error do |error| + expect(error.name).to eq 'NotAuthorized' + expect(error.response_code).to eq 403 + end + end + end + end + + context 'when the app does not exist' do + before do + allow(apps_handler).to receive(:show).and_return(nil) + end + + it 'returns a 404 ResourceNotFound error' do + expect { + packages_controller.create('bogus') + }.to raise_error do |error| + expect(error.name).to eq 'ResourceNotFound' + expect(error.response_code).to eq 404 + end + end + end + end + + describe '#upload' do + let(:package) { PackageModel.make } + let(:params) { { 'bits_path' => 'path/to/bits' } } + + context 'when the package type is not bits' do + before do + allow(packages_handler).to receive(:upload).and_raise(PackagesHandler::InvalidPackageType) + end + + it 'returns a 400 InvalidRequest' do + expect { + packages_controller.upload(package.guid) + }.to raise_error do |error| + expect(error.name).to eq 'InvalidRequest' + expect(error.response_code).to eq 400 + end + end + end + + context 'when the space does not exist' do + before do + allow(packages_handler).to receive(:upload).and_raise(PackagesHandler::SpaceNotFound) + end + + it 'returns a 404 ResourceNotFound error' do + expect { + packages_controller.upload(package.guid) + }.to raise_error do |error| + expect(error.name).to eq 'ResourceNotFound' + expect(error.response_code).to eq 404 + end + end + end + + context 'when the package does not exist' do + before do + allow(packages_handler).to receive(:upload).and_raise(PackagesHandler::PackageNotFound) + end + + it 'returns a 404 ResourceNotFound error' do + expect { + packages_controller.upload(package.guid) + }.to raise_error do |error| + expect(error.name).to eq 'ResourceNotFound' + expect(error.response_code).to eq 404 + end + end + end + + context 'when the message is not valid' do + let(:params) { {} } + + it 'returns a 422 UnprocessableEntity error' do + expect { + packages_controller.upload(package.guid) + }.to raise_error do |error| + expect(error.name).to eq 'UnprocessableEntity' + expect(error.response_code).to eq 422 + end + end + end + + context 'when the user cannot access the package' do + before do + allow(packages_handler).to receive(:upload).and_raise(PackagesHandler::Unauthorized) + end + + it 'returns an Unauthorized error' do + expect { + packages_controller.upload(package.guid) + }.to raise_error do |error| + expect(error.name).to eq 'NotAuthorized' + expect(error.response_code).to eq 403 + end + end + end + + context 'when the bits have already been uploaded' do + before do + allow(packages_handler).to receive(:upload).and_raise(PackagesHandler::BitsAlreadyUploaded) + end + it 'returns a 400 PackageBitsAlreadyUploaded error' do + expect { + packages_controller.upload(package.guid) + }.to raise_error do |error| + expect(error.name).to eq 'PackageBitsAlreadyUploaded' + expect(error.response_code).to eq 400 + end + end + end + end + + describe '#show' do + context 'when the package does not exist' do + before do + allow(packages_handler).to receive(:show).and_return(nil) + end + + it 'returns a 404 Not Found' do + expect { + packages_controller.show('non-existant') + }.to raise_error do |error| + expect(error.name).to eq 'ResourceNotFound' + expect(error.response_code).to eq 404 + end + end + end + + context 'when the package exists' do + let(:package) { PackageModel.make } + let(:package_guid) { package.guid } + + context 'when a user can access a package' do + let(:expected_response) { 'im a response' } + + before do + allow(packages_handler).to receive(:show).and_return(package) + allow(package_presenter).to receive(:present_json).and_return(expected_response) + end + + it 'returns a 200 OK and the package' do + response_code, response = packages_controller.show(package.guid) + expect(response_code).to eq 200 + expect(response).to eq(expected_response) + end + end + + context 'when the user cannot access the package' do + before do + allow(packages_handler).to receive(:show).and_raise(PackagesHandler::Unauthorized) + end + + it 'returns a 403 NotAuthorized error' do + expect { + packages_controller.show(package_guid) + }.to raise_error do |error| + expect(error.name).to eq 'NotAuthorized' + expect(error.response_code).to eq 403 + end + end + end + end + end + + describe '#delete' do + context 'when the package does not exist' do + before do + allow(packages_handler).to receive(:delete).and_return(nil) + end + + it 'returns a 404 Not Found' do + expect { + packages_controller.delete('non-existant') + }.to raise_error do |error| + expect(error.name).to eq 'ResourceNotFound' + expect(error.response_code).to eq 404 + end + end + end + + context 'when the package exists' do + let(:package) { PackageModel.make } + let(:package_guid) { package.guid } + + context 'when a user can access a package' do + before do + allow(packages_handler).to receive(:delete).and_return(package) + end + + it 'returns a 204 NO CONTENT' do + response_code, response = packages_controller.delete(package.guid) + expect(response_code).to eq 204 + expect(response).to be_nil + end + end + + context 'when the user cannot access the package' do + before do + allow(packages_handler).to receive(:delete).and_raise(PackagesHandler::Unauthorized) + end + + it 'returns a 403 NotAuthorized error' do + expect { + packages_controller.delete(package_guid) + }.to raise_error do |error| + expect(error.name).to eq 'NotAuthorized' + expect(error.response_code).to eq 403 + end + end + end + end + end + end +end diff --git a/spec/unit/controllers/v3/processes_controller_spec.rb b/spec/unit/controllers/v3/processes_controller_spec.rb index c270c3ca664..7330b59a17f 100644 --- a/spec/unit/controllers/v3/processes_controller_spec.rb +++ b/spec/unit/controllers/v3/processes_controller_spec.rb @@ -33,6 +33,27 @@ module VCAP::CloudController allow(process_presenter).to receive(:present_json).and_return(expected_response) end + describe '#list' do + let(:page) { 1 } + let(:per_page) { 2 } + let(:params) { { 'page' => page, 'per_page' => per_page } } + let(:list_response) { 'list_response' } + + before do + allow(process_presenter).to receive(:present_json_list).and_return(expected_response) + allow(processes_handler).to receive(:list).and_return(list_response) + end + + it 'returns 200 and lists the apps' do + response_code, response_body = process_controller.list + + expect(processes_handler).to have_received(:list) + expect(process_presenter).to have_received(:present_json_list).with(list_response, '/v3/processes') + expect(response_code).to eq(200) + expect(response_body).to eq(expected_response) + end + end + describe '#show' do before do allow(processes_handler).to receive(:show).and_return(process) @@ -43,6 +64,7 @@ module VCAP::CloudController before do allow(processes_handler).to receive(:show).and_return(nil) end + it 'raises an ApiError with a 404 code' do expect { process_controller.show(guid) diff --git a/spec/unit/handlers/apps_handler_spec.rb b/spec/unit/handlers/apps_handler_spec.rb index 4b98057bc0c..7199ab30898 100644 --- a/spec/unit/handlers/apps_handler_spec.rb +++ b/spec/unit/handlers/apps_handler_spec.rb @@ -11,6 +11,64 @@ module VCAP::CloudController allow(access_context).to receive(:cannot?).and_return(false) end + describe '#list' do + let(:space) { Space.make } + let!(:app_model1) { AppModel.make(space_guid: space.guid) } + let!(:app_model2) { AppModel.make(space_guid: space.guid) } + let(:user) { User.make } + let(:page) { 1 } + let(:per_page) { 1 } + let(:pagination_options) { PaginationOptions.new(page, per_page) } + let(:paginator) { double(:paginator) } + let(:apps_handler) { described_class.new(process_handler, paginator) } + let(:roles) { double(:roles, admin?: admin_role) } + let(:admin_role) { false } + + before do + allow(access_context).to receive(:roles).and_return(roles) + allow(access_context).to receive(:user).and_return(user) + allow(paginator).to receive(:get_page) + end + + context 'when the user is an admin' do + let(:admin_role) { true } + before do + allow(access_context).to receive(:roles).and_return(roles) + AppModel.make + end + + it 'allows viewing all apps' do + apps_handler.list(pagination_options, access_context) + expect(paginator).to have_received(:get_page) do |dataset, _| + expect(dataset.count).to eq(3) + end + end + end + + context 'when the user cannot list any apps' do + it 'applies a user visibility filter properly' do + apps_handler.list(pagination_options, access_context) + expect(paginator).to have_received(:get_page) do |dataset, _| + expect(dataset.count).to eq(0) + end + end + end + + context 'when the user can list apps' do + before do + space.organization.add_user(user) + space.add_developer(user) + end + + it 'applies a user visibility filter properly' do + apps_handler.list(pagination_options, access_context) + expect(paginator).to have_received(:get_page) do |dataset, _| + expect(dataset.count).to eq(2) + end + end + end + end + describe '#show' do let(:app_model) { AppModel.make } @@ -177,7 +235,7 @@ module VCAP::CloudController expect(result.guid).to eq(guid) expect(result.name).to eq(new_name) - updated_app = AppModel.find(guid: guid) + updated_app = AppModel.find(guid: guid) updated_process = App.find(guid: process_guid) expect(updated_app.name).to eq(new_name) @@ -190,7 +248,7 @@ module VCAP::CloudController apps_handler.update(empty_update_message, access_context) }.to raise_error - updated_app = AppModel.find(guid: guid) + updated_app = AppModel.find(guid: guid) updated_process = App.find(guid: process_guid) expect(updated_app.name).to eq(app_model.name) diff --git a/spec/unit/handlers/packages_handler_spec.rb b/spec/unit/handlers/packages_handler_spec.rb new file mode 100644 index 00000000000..1984e51e094 --- /dev/null +++ b/spec/unit/handlers/packages_handler_spec.rb @@ -0,0 +1,417 @@ +require 'spec_helper' +require 'handlers/packages_handler' + +module VCAP::CloudController + describe PackageUploadMessage do + let(:guid) { 'my-guid' } + + context 'when the path is not provided' do + let(:opts) { {} } + it 'is not valid' do + create_message = PackageUploadMessage.new(guid, opts) + valid, error = create_message.validate + expect(valid).to be_falsey + expect(error).to include('An application zip file must be uploaded.') + end + end + + context 'and the path is provided' do + let(:opts) { { 'bits_path' => 'foobar' } } + it 'is valid' do + create_message = PackageUploadMessage.new(guid, opts) + valid, error = create_message.validate + expect(valid).to be_truthy + expect(error).to be_nil + end + end + end + + describe PackageCreateMessage do + let(:guid) { 'my-guid' } + + context 'when a type parameter that is not allowed is provided' do + let(:opts) { { 'type' => 'not-allowed' } } + + it 'is not valid' do + create_message = PackageCreateMessage.new(guid, opts) + valid, errors = create_message.validate + expect(valid).to be_falsey + expect(errors).to include('The type field needs to be one of \'bits, docker\'') + end + end + + context 'when nil type is provided' do + let(:opts) { { 'type' => nil } } + + it 'is not valid' do + create_message = PackageCreateMessage.new(guid, opts) + valid, errors = create_message.validate + expect(valid).to be_falsey + expect(errors).to include('The type field is required') + end + end + + context 'when type is bits' do + let(:opts) { { 'type' => 'bits' } } + + context 'no url is provided' do + it 'is valid' do + create_message = PackageCreateMessage.new(guid, opts) + valid, errors = create_message.validate + expect(valid).to be_truthy + expect(errors).to be_empty + end + end + + context 'and a url is provided' do + let(:opts) { { 'type' => 'bits', 'url' => 'foobar' } } + + it 'is not valid' do + create_message = PackageCreateMessage.new(guid, opts) + valid, errors = create_message.validate + expect(valid).to be_falsey + expect(errors).to include('The url field cannot be provided when type is bits.') + end + end + end + + context 'when type is docker' do + context 'and a url is provided' do + let(:opts) { { 'type' => 'docker', 'url' => 'foobar' } } + it 'is valid' do + create_message = PackageCreateMessage.new(guid, opts) + valid, errors = create_message.validate + expect(valid).to be_truthy + expect(errors).to be_empty + end + end + + context 'and a url is not provided' do + let(:opts) { { 'type' => 'docker' } } + + it 'is not valid' do + create_message = PackageCreateMessage.new(guid, opts) + valid, errors = create_message.validate + expect(valid).to be_falsey + expect(errors).to include('The url field must be provided for type docker.') + end + end + end + + describe 'create_from_http_request' do + context 'when the body is valid json' do + let(:body) { MultiJson.dump({ type: 'bits' }) } + + it 'creates a PackageCreateMessage from the json' do + pcm = PackageCreateMessage.create_from_http_request(guid, body) + valid, errors = pcm.validate + + expect(valid).to be_truthy + expect(errors).to be_empty + end + end + + context 'when the body is not valid json' do + let(:body) { '{{' } + + it 'returns a PackageCreateMessage that is not valid' do + pcm = PackageCreateMessage.create_from_http_request(guid, body) + valid, errors = pcm.validate + + expect(valid).to be_falsey + expect(errors[0]).to include('parse error') + end + end + end + end + + describe PackagesHandler do + let(:tmpdir) { Dir.mktmpdir } + let(:valid_zip) { + zip_name = File.join(tmpdir, 'file.zip') + TestZip.create(zip_name, 1, 1024) + zip_file = File.new(zip_name) + Rack::Test::UploadedFile.new(zip_file) + } + + let(:config) { TestConfig.config } + let(:packages_handler) { described_class.new(config) } + let(:access_context) { double(:access_context) } + let(:space) { Space.make } + + before do + allow(access_context).to receive(:cannot?).and_return(false) + end + + describe '#create' do + let(:url) { 'docker://cloudfoundry/runtime-ci' } + let(:create_opts) do + { + 'type' => 'docker', + 'url' => url + } + end + + context 'when the space exists' do + let(:create_message) { PackageCreateMessage.new(space.guid, create_opts) } + + context 'when a user can create a package' do + it 'creates the package' do + result = packages_handler.create(create_message, access_context) + + created_package = PackageModel.find(guid: result.guid) + expect(created_package).to eq(result) + end + + context 'when the type is bits' do + let(:create_opts) { { 'type' => 'bits' } } + + it 'adds a delayed job to upload the package bits' do + result = packages_handler.create(create_message, access_context) + + expect(result.type).to eq('bits') + expect(result.state).to eq(PackageModel::CREATED_STATE) + expect(result.url).to be_nil + end + end + + context 'when the type is docker' do + it 'adds a delayed job to upload the package bits' do + result = packages_handler.create(create_message, access_context) + + expect(result.type).to eq('docker') + expect(result.state).to eq('READY') + expect(result.url).to eq(url) + end + end + end + + context 'when the user cannot create an package' do + before do + allow(access_context).to receive(:cannot?).and_return(true) + end + + it 'raises Unauthorized error' do + expect { + packages_handler.create(create_message, access_context) + }.to raise_error(PackagesHandler::Unauthorized) + expect(access_context).to have_received(:cannot?).with(:create, kind_of(PackageModel), space) + end + end + + context 'when the package is invalid' do + before do + allow_any_instance_of(PackageModel).to receive(:save).and_raise(Sequel::ValidationFailed.new('the message')) + end + + it 'raises an PackageInvalid error' do + expect { + packages_handler.create(create_message, access_context) + }.to raise_error(PackagesHandler::InvalidPackage, 'the message') + end + end + end + + context 'when the space does not exist' do + let(:create_message) { PackageCreateMessage.new('non-existant', create_opts) } + + it 'raises SpaceNotFound' do + expect { + packages_handler.create(create_message, access_context) + }.to raise_error(PackagesHandler::SpaceNotFound) + end + end + end + + describe '#upload' do + let(:package) { PackageModel.make(space_guid: space_guid, type: 'bits', state: PackageModel::CREATED_STATE) } + let(:upload_message) { PackageUploadMessage.new(package_guid, upload_opts) } + let(:create_opts) { { 'bit_path' => 'path/to/bits' } } + let(:upload_opts) { { 'bits_path' => 'foobar' } } + let(:package_guid) { package.guid } + let(:space_guid) { space.guid } + + before do + allow(access_context).to receive(:cannot?).and_return(false) + end + + context 'when the package exists' do + context 'when the space exists' do + context 'when the user can access the package' do + context 'when the package is of type bits' do + before do + config[:name] = 'local' + config[:index] = '1' + end + + it 'enqueues a upload job' do + expect { + packages_handler.upload(upload_message, access_context) + }.to change { Delayed::Job.count }.by(1) + + job = Delayed::Job.last + expect(job.queue).to eq('cc-local-1') + expect(job.handler).to include(package_guid) + expect(job.handler).to include('PackageBits') + end + + it 'changes the state to pending' do + packages_handler.upload(upload_message, access_context) + expect(PackageModel.find(guid: package_guid).state).to eq(PackageModel::PENDING_STATE) + end + + it 'returns the package' do + resulting_package = packages_handler.upload(upload_message, access_context) + expected_package = PackageModel.find(guid: package_guid) + expect(resulting_package.guid).to eq(expected_package.guid) + end + + context 'when the bits have already been uploaded' do + before do + package.state = PackageModel::PENDING_STATE + package.save + end + + it 'raises BitsAlreadyUploaded error' do + expect { + packages_handler.upload(upload_message, access_context) + }.to raise_error(PackagesHandler::BitsAlreadyUploaded) + end + end + end + + context 'when the package is not of type bits' do + let(:package) { PackageModel.make(space_guid: space_guid, type: 'docker') } + + it 'raises an InvalidPackage exception' do + expect { + packages_handler.upload(upload_message, access_context) + }.to raise_error(PackagesHandler::InvalidPackageType) + end + end + end + + context 'when the user cannot access the package' do + before do + allow(access_context).to receive(:cannot?).and_return(true) + end + + it 'raises an Unathorized exception' do + expect { + packages_handler.upload(upload_message, access_context) + }.to raise_error(PackagesHandler::Unauthorized) + end + end + end + + context 'when the space does not exist' do + let(:space_guid) { 'non-existant' } + + it 'raises an SpaceNotFound exception' do + expect { + packages_handler.upload(upload_message, access_context) + }.to raise_error(PackagesHandler::SpaceNotFound) + end + end + end + + context 'when the package does not exist' do + let(:package_guid) { 'non-existant' } + + it 'raises a PackageNotFound exception' do + expect { + packages_handler.upload(upload_message, access_context) + }.to raise_error(PackagesHandler::PackageNotFound) + end + end + end + + describe '#show' do + let(:package) { PackageModel.make } + let(:package_guid) { package.guid } + + context 'when the package does not exist' do + it 'returns nil' do + expect(access_context).not_to receive(:cannot?) + expect(packages_handler.show('non-existant', access_context)).to eq(nil) + end + end + + context 'when the package does exist' do + context 'when the user can access a package' do + before do + allow(access_context).to receive(:cannot?).and_return(false) + end + + it 'returns the package' do + expect(packages_handler.show(package_guid, access_context)).to eq(package) + end + end + + context 'when the user cannot access a package' do + before do + allow(access_context).to receive(:cannot?).and_return(true) + end + + it 'raises Unauthorized error' do + expect { + packages_handler.show(package_guid, access_context) + }.to raise_error(PackagesHandler::Unauthorized) + expect(access_context).to have_received(:cannot?).with(:read, kind_of(PackageModel)) + end + end + end + end + + describe '#delete' do + let!(:package) { PackageModel.make(space_guid: space.guid) } + let(:package_guid) { package.guid } + + context 'when the user can access a package' do + before do + allow(access_context).to receive(:cannot?).and_return(false) + end + + context 'and the package does not exist' do + it 'returns nil' do + expect(packages_handler.delete('non-existant', access_context)).to eq(nil) + end + end + + context 'and the package exists' do + it 'deletes the package and returns the deleted package' do + expect { + deleted_package = packages_handler.delete(package_guid, access_context) + expect(deleted_package.guid).to eq(package_guid) + }.to change { PackageModel.count }.by(-1) + expect(PackageModel.find(guid: package_guid)).to be_nil + end + + it 'enqueues a job to delete the corresponding blob from the blobstore' do + job_opts = { queue: 'cc-generic' } + expect(Jobs::Enqueuer).to receive(:new).with(kind_of(BlobstoreDelete), job_opts). + and_call_original + + expect { + packages_handler.delete(package_guid, access_context) + }.to change { Delayed::Job.count }.by(1) + end + end + end + + context 'when the user cannot access a package' do + before do + allow(access_context).to receive(:cannot?).and_return(true) + end + + it 'raises Unauthorized error' do + expect { + deleted_package = packages_handler.delete(package_guid, access_context) + expect(deleted_package).to be_nil + }.to raise_error(PackagesHandler::Unauthorized) + expect(access_context).to have_received(:cannot?).with(:delete, kind_of(PackageModel), space) + end + end + end + end +end diff --git a/spec/unit/handlers/processes_handler_spec.rb b/spec/unit/handlers/processes_handler_spec.rb index 48d0eba88b2..5e9a42b3a4d 100644 --- a/spec/unit/handlers/processes_handler_spec.rb +++ b/spec/unit/handlers/processes_handler_spec.rb @@ -31,14 +31,87 @@ module VCAP::CloudController let(:process_repo) { double(:process_repo) } let(:process_event_repo) { double(:process_event_repo) } let(:space) { Space.make } - let!(:handler) { ProcessesHandler.new(process_repo, process_event_repo) } - let(:process_opts) { { space: space } } - let!(:process) do - process_model = AppFactory.make(process_opts) - ProcessMapper.map_model_to_domain(process_model) + let(:handler) { ProcessesHandler.new(process_repo, process_event_repo) } + let(:access_context) { double(:access_context) } + + describe '#list' do + let!(:process1) { AppFactory.make(space: space) } + let!(:process2) { AppFactory.make(space: space) } + let(:user) { User.make } + let(:page) { 1 } + let(:per_page) { 1 } + let(:pagination_options) { PaginationOptions.new(page, per_page) } + let(:paginator) { double(:paginator) } + let(:handler) { described_class.new(process_repo, process_event_repo, paginator) } + let(:roles) { double(:roles, admin?: admin_role) } + let(:admin_role) { false } + + before do + allow(access_context).to receive(:roles).and_return(roles) + allow(access_context).to receive(:user).and_return(user) + allow(paginator).to receive(:get_page) + end + + context 'when the user is an admin' do + let(:admin_role) { true } + before do + allow(access_context).to receive(:roles).and_return(roles) + AppFactory.make + end + + it 'allows viewing all processes' do + handler.list(pagination_options, access_context) + expect(paginator).to have_received(:get_page) do |dataset, _| + expect(dataset.count).to eq(3) + end + end + end + + context 'when the user cannot list any processes' do + it 'applies a user visibility filter properly' do + handler.list(pagination_options, access_context) + expect(paginator).to have_received(:get_page) do |dataset, _| + expect(dataset.count).to eq(0) + end + end + end + + context 'when the user can list processes' do + before do + space.organization.add_user(user) + space.add_developer(user) + end + + it 'applies a user visibility filter properly' do + handler.list(pagination_options, access_context) + expect(paginator).to have_received(:get_page) do |dataset, _| + expect(dataset.count).to eq(2) + end + end + + it 'can filter by app_guid' do + v3app = AppModel.make + process1.app_guid = v3app.guid + process1.save + + filter_options = { app_guid: v3app.guid } + + handler.list(pagination_options, access_context, filter_options) + + expect(paginator).to have_received(:get_page) do |dataset, _| + expect(dataset.count).to eq(1) + end + end + end end context '#update' do + let(:process_opts) { { space: space } } + let(:process) do + process_model = AppFactory.make(process_opts) + ProcessMapper.map_model_to_domain(process_model) + end + context 'changing type to an invalid value' do it 'raises an InvalidProcess exception' do update_opts = { 'type' => 'worker' } @@ -113,6 +186,12 @@ module VCAP::CloudController end context '#delete' do + let(:process_opts) { { space: space } } + let(:process) do + process_model = AppFactory.make(process_opts) + ProcessMapper.map_model_to_domain(process_model) + end + it 'saves an event when deleting a process' do ac = double(:ac, user: User.make, user_email: 'jim@jim.com') diff --git a/spec/unit/jobs/audit_event_job_spec.rb b/spec/unit/jobs/audit_event_job_spec.rb new file mode 100644 index 00000000000..bbe35adfb0d --- /dev/null +++ b/spec/unit/jobs/audit_event_job_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +module VCAP::CloudController + module Jobs + describe AuditEventJob do + let(:event_repository) { double(:event_repository) } + let(:event_creation_method) { :record_service_creation_event } + let(:event_type) { 'audit.service.create' } + let(:model) { Service.make } + let(:params) { {} } + + subject(:audit_event_job) do + AuditEventJob.new(job, event_repository, event_creation_method, event_type, model, params) + end + let(:job) { double(:job, perform: 'fake-perform', max_attempts: 1, reschedule_at: Time.now) } + + describe '#reschedule_at' do + it 'delegates to the handler' do + time = Time.now + attempts = 5 + expect(audit_event_job.reschedule_at(time, attempts)).to eq(job.reschedule_at(time, attempts)) + end + end + end + end +end diff --git a/spec/unit/jobs/cc_job_spec.rb b/spec/unit/jobs/cc_job_spec.rb new file mode 100644 index 00000000000..14852245989 --- /dev/null +++ b/spec/unit/jobs/cc_job_spec.rb @@ -0,0 +1,16 @@ +require 'spec_helper' + +module VCAP::CloudController + module Jobs + describe CCJob do + describe '#reschedule_at' do + it 'uses the default from Delayed::Job' do + time = Time.now + attempts = 5 + job = CCJob.new + expect(job.reschedule_at(time, attempts)).to eq(time + (attempts**4) + 5) + end + end + end + end +end diff --git a/spec/unit/jobs/exception_catching_job_spec.rb b/spec/unit/jobs/exception_catching_job_spec.rb index 81f07c43440..67cf47c402f 100644 --- a/spec/unit/jobs/exception_catching_job_spec.rb +++ b/spec/unit/jobs/exception_catching_job_spec.rb @@ -7,7 +7,7 @@ module Jobs ExceptionCatchingJob.new(handler) end - let(:handler) { double('Handler', perform: 'fake-perform', max_attempts: 1) } + let(:handler) { double('Handler', perform: 'fake-perform', max_attempts: 1, reschedule_at: Time.now) } context '#perform' do it 'delegates to the handler' do @@ -64,6 +64,14 @@ module Jobs exception_catching_job.error(job, 'exception') end end + + describe '#reschedule_at' do + it 'delegates to the handler' do + time = Time.now + attempts = 5 + expect(exception_catching_job.reschedule_at(time, attempts)).to eq(handler.reschedule_at(time, attempts)) + end + end end end end diff --git a/spec/unit/jobs/request_job_spec.rb b/spec/unit/jobs/request_job_spec.rb index 716136c2200..e70b6a091d0 100644 --- a/spec/unit/jobs/request_job_spec.rb +++ b/spec/unit/jobs/request_job_spec.rb @@ -2,7 +2,7 @@ module VCAP::CloudController::Jobs describe RequestJob do - let(:wrapped_job) { double('InnerJob', max_attempts: 2) } + let(:wrapped_job) { double('InnerJob', max_attempts: 2, reschedule_at: Time.now) } let(:request_id) { 'abc123' } subject(:request_job) { RequestJob.new(wrapped_job, request_id) } @@ -52,5 +52,13 @@ module VCAP::CloudController::Jobs expect(subject.max_attempts).to eq(2) end end + + describe '#reschedule_at' do + it 'delegates to the inner job' do + time = Time.now + attempts = 5 + expect(request_job.reschedule_at(time, attempts)).to eq(wrapped_job.reschedule_at(time, attempts)) + end + end end end diff --git a/spec/unit/jobs/runtime/blobstore_delete_spec.rb b/spec/unit/jobs/runtime/blobstore_delete_spec.rb index ebfbba61c6c..a19333ccf60 100644 --- a/spec/unit/jobs/runtime/blobstore_delete_spec.rb +++ b/spec/unit/jobs/runtime/blobstore_delete_spec.rb @@ -4,7 +4,7 @@ module VCAP::CloudController module Jobs::Runtime describe BlobstoreDelete do let(:key) { 'key' } - let(:job) do + subject(:job) do BlobstoreDelete.new(key, :droplet_blobstore) end diff --git a/spec/unit/jobs/runtime/buildpack_installer_spec.rb b/spec/unit/jobs/runtime/buildpack_installer_spec.rb index c79ba2fb099..a9f3b9325c1 100644 --- a/spec/unit/jobs/runtime/buildpack_installer_spec.rb +++ b/spec/unit/jobs/runtime/buildpack_installer_spec.rb @@ -10,9 +10,11 @@ module Jobs::Runtime let(:options) { { enabled: true, locked: false, position: 1 } } - let(:job) { BuildpackInstaller.new(buildpack_name, zipfile, options, TestConfig.config) } + let(:job) { BuildpackInstaller.new(buildpack_name, zipfile, options) } - it { is_expected.to be_a_valid_job } + it 'is a valid job' do + expect(job).to be_a_valid_job + end describe '#perform' do context 'when the buildpack is enabled and unlocked' do @@ -34,7 +36,7 @@ module Jobs::Runtime it 'updates an existing buildpack' do buildpack1 = Buildpack.make(name: buildpack_name, key: 'new_key') - update_job = BuildpackInstaller.new(buildpack_name, zipfile2, { enabled: false }, TestConfig.config) + update_job = BuildpackInstaller.new(buildpack_name, zipfile2, { enabled: false }) update_job.perform buildpack2 = Buildpack.find(name: buildpack_name) @@ -48,7 +50,7 @@ module Jobs::Runtime context 'when the buildpack is locked' do it 'fails to update a locked buildpack' do buildpack = Buildpack.make(name: buildpack_name, locked: true) - update_job = BuildpackInstaller.new(buildpack_name, zipfile2, { enabled: false, locked: false }, TestConfig.config) + update_job = BuildpackInstaller.new(buildpack_name, zipfile2, { enabled: false, locked: false }) update_job.perform buildpack2 = Buildpack.find(name: buildpack_name) diff --git a/spec/unit/jobs/runtime/legacy_jobs_spec.rb b/spec/unit/jobs/runtime/legacy_jobs_spec.rb index 97ca2860eb4..687e2daa249 100644 --- a/spec/unit/jobs/runtime/legacy_jobs_spec.rb +++ b/spec/unit/jobs/runtime/legacy_jobs_spec.rb @@ -2,10 +2,12 @@ describe 'Legacy Jobs' do describe ::AppBitsPackerJob do + subject { ::AppBitsPackerJob.new('app-guid', 'path/to/compressed/file', 'the-fingerprint') } it { is_expected.to be_a(VCAP::CloudController::Jobs::Runtime::AppBitsPacker) } end describe ::BlobstoreDelete do + subject { ::BlobstoreDelete.new('key', 'blobstore-name') } it { is_expected.to be_a(VCAP::CloudController::Jobs::Runtime::BlobstoreDelete) } end @@ -15,6 +17,7 @@ end describe ::DropletDeletionJob do + subject { ::DropletDeletionJob.new('new-key', 'old-key') } it { is_expected.to be_a(VCAP::CloudController::Jobs::Runtime::DropletDeletion) } end @@ -24,6 +27,7 @@ end describe ::ModelDeletionJob do + subject { ::ModelDeletionJob.new(VCAP::CloudController::Space, 'space-guid') } it { is_expected.to be_a(VCAP::CloudController::Jobs::Runtime::ModelDeletion) } end end diff --git a/spec/unit/jobs/runtime/model_deletion_spec.rb b/spec/unit/jobs/runtime/model_deletion_spec.rb index d1fe7bb5977..1a70a53b9cf 100644 --- a/spec/unit/jobs/runtime/model_deletion_spec.rb +++ b/spec/unit/jobs/runtime/model_deletion_spec.rb @@ -6,14 +6,14 @@ module VCAP::CloudController module Jobs::Runtime describe ModelDeletion do + let(:space) { Space.make } + subject(:job) { ModelDeletion.new(Space, space.guid) } + it { is_expected.to be_a_valid_job } describe '#perform' do - let(:space) { Space.make } let!(:app) { App.make(space: space) } - subject(:job) { ModelDeletion.new(Space, space.guid) } - context 'deleting a space' do it 'can delete the space' do expect { job.perform }.to change { Space.count }.by(-1) diff --git a/spec/unit/jobs/services/service_instance_deprovision_spec.rb b/spec/unit/jobs/services/service_instance_deprovision_spec.rb index 7285c580d88..1ca6da718cf 100644 --- a/spec/unit/jobs/services/service_instance_deprovision_spec.rb +++ b/spec/unit/jobs/services/service_instance_deprovision_spec.rb @@ -39,8 +39,18 @@ module Jobs::Services end describe '#max_attempts' do - it 'returns 3' do - expect(job.max_attempts).to eq 3 + it 'returns 10' do + expect(job.max_attempts).to eq 10 + end + end + + describe '#reschedule_at' do + it 'uses exponential backoff' do + now = Time.now + attempts = 5 + + run_at = job.reschedule_at(now, attempts) + expect(run_at).to eq(now + (2**attempts).minutes) end end end diff --git a/spec/unit/jobs/services/service_instance_state_fetch_spec.rb b/spec/unit/jobs/services/service_instance_state_fetch_spec.rb new file mode 100644 index 00000000000..767641ddab3 --- /dev/null +++ b/spec/unit/jobs/services/service_instance_state_fetch_spec.rb @@ -0,0 +1,123 @@ +require 'spec_helper' + +module VCAP::CloudController + module Jobs::Services + describe ServiceInstanceStateFetch do + let(:client) { instance_double('VCAP::Services::ServiceBrokers::V2::Client') } + let(:enqueuer) { instance_double('VCAP::CloudController::Jobs::Enqueuer') } + let(:service_instance) { VCAP::CloudController::ManagedServiceInstance.make } + + let(:name) { 'fake-name' } + + subject(:job) do + VCAP::CloudController::Jobs::Services::ServiceInstanceStateFetch.new(name, {}, + service_instance.guid, service_instance.service_plan.guid) + end + + describe '#perform' do + before do + allow(VCAP::Services::ServiceBrokers::V2::Client).to receive(:new).and_return(client) + allow(VCAP::CloudController::Jobs::Enqueuer).to receive(:new).and_return(enqueuer) + allow(enqueuer).to receive(:enqueue) + end + + context 'when all operations succeed and the state is available' do + before do + allow(client).to receive(:fetch_service_instance_state) do |instance| + instance.state = 'available' + end + end + + it 'fetches the service instance state' do + job.perform + + expect(client).to have_received(:fetch_service_instance_state) do |instance| + expect(instance.guid).to eq service_instance.guid + expect(instance.service_plan.guid).to eq service_instance.service_plan.guid + end + + db_service_instance = ManagedServiceInstance.first(guid: service_instance.guid) + expect(db_service_instance.state).to eq('available') + end + + it 'should not enqueue another fetch job' do + job.perform + + expect(VCAP::CloudController::Jobs::Enqueuer).to_not have_received(:new) + end + end + + context 'when all operations succeed, but the state is not available' do + before do + allow(client).to receive(:fetch_service_instance_state) do |instance| + instance.state = 'creating' + end + end + + it 'fetches the service instance state' do + job.perform + + expect(client).to have_received(:fetch_service_instance_state) do |instance| + expect(instance.guid).to eq service_instance.guid + expect(instance.service_plan.guid).to eq service_instance.service_plan.guid + end + + db_service_instance = ManagedServiceInstance.first(guid: service_instance.guid) + expect(db_service_instance.state).to eq('creating') + end + + it 'should enqueue another fetch job' do + job.perform + + expect(VCAP::CloudController::Jobs::Enqueuer).to have_received(:new).with(job, anything) + expect(enqueuer).to have_received(:enqueue) + end + end + + context 'when saving to the database fails' do + before do + allow(client).to receive(:fetch_service_instance_state) + allow(service_instance).to receive(:save) do |instance| + raise Sequel::ValidationFailed.new(instance) + end + end + + it 'should enqueue another fetch job' do + job.perform + + expect(VCAP::CloudController::Jobs::Enqueuer).to have_received(:new).with(job, anything) + expect(enqueuer).to have_received(:enqueue) + end + end + + context 'when fetching the service instance from the broker fails' do + before do + allow(client).to receive(:fetch_service_instance_state) do |instance| + error = nil + raise HttpRequestError.new('something', '/some/path', :get, error) + end + end + + it 'should enqueue another fetch job' do + job.perform + + expect(VCAP::CloudController::Jobs::Enqueuer).to have_received(:new).with(job, anything) + expect(enqueuer).to have_received(:enqueue) + end + end + end + + describe '#job_name_in_configuration' do + it 'returns the name of the job' do + expect(job.job_name_in_configuration).to eq(:service_instance_state_fetch) + end + end + + describe '#max_attempts' do + it 'returns 1' do + expect(job.max_attempts).to eq(1) + end + end + end + end +end diff --git a/spec/unit/jobs/services/service_instance_unbind_spec.rb b/spec/unit/jobs/services/service_instance_unbind_spec.rb index 5893eefa6e4..534de26860c 100644 --- a/spec/unit/jobs/services/service_instance_unbind_spec.rb +++ b/spec/unit/jobs/services/service_instance_unbind_spec.rb @@ -8,9 +8,9 @@ module Jobs::Services let(:app_guid) { 'fake-app-guid' } let(:binding_guid) { 'fake-binding-guid' } - let(:binding) { instance_double('VCAP::CloudController::ServiceBinding') } + let(:service_binding) { instance_double('VCAP::CloudController::ServiceBinding') } before do - allow(VCAP::CloudController::ServiceBinding).to receive(:new).and_return(binding) + allow(VCAP::CloudController::ServiceBinding).to receive(:new).and_return(service_binding) end let(:name) { 'fake-name' } @@ -18,14 +18,14 @@ module Jobs::Services describe '#perform' do before do - allow(client).to receive(:unbind).with(binding) + allow(client).to receive(:unbind).with(service_binding) allow(VCAP::Services::ServiceBrokers::V2::Client).to receive(:new).and_return(client) end it 'unbinds the binding' do job.perform - expect(client).to have_received(:unbind).with(binding) + expect(client).to have_received(:unbind).with(service_binding) end end @@ -36,8 +36,17 @@ module Jobs::Services end describe '#max_attempts' do - it 'returns 3' do - expect(job.max_attempts).to eq 3 + it 'returns 10' do + expect(job.max_attempts).to eq 10 + end + end + + describe '#reschedule_at' do + it 'uses exponential backoff' do + now = Time.now + + run_at = job.reschedule_at(now, 5) + expect(run_at).to eq(now + (2**5).minutes) end end end diff --git a/spec/unit/jobs/timeout_job_spec.rb b/spec/unit/jobs/timeout_job_spec.rb index 53d8235d717..62adbb7b766 100644 --- a/spec/unit/jobs/timeout_job_spec.rb +++ b/spec/unit/jobs/timeout_job_spec.rb @@ -67,5 +67,19 @@ module VCAP::CloudController::Jobs end end end + + describe '#reschedule_at' do + before do + allow(job).to receive(:reschedule_at) do |time, attempts| + time + attempts + end + end + + it 'defers to the inner job' do + time = Time.now + attempts = 5 + expect(timeout_job.reschedule_at(time, attempts)).to eq(job.reschedule_at(time, attempts)) + end + end end end diff --git a/spec/unit/jobs/v3/package_bits_spec.rb b/spec/unit/jobs/v3/package_bits_spec.rb new file mode 100644 index 00000000000..8bc55b67fdd --- /dev/null +++ b/spec/unit/jobs/v3/package_bits_spec.rb @@ -0,0 +1,46 @@ +require 'spec_helper' + +module VCAP::CloudController + module Jobs::Runtime + describe PackageBits do + let(:uploaded_path) { 'tmp/uploaded.zip' } + let(:package_guid) { SecureRandom.uuid } + + subject(:job) do + PackageBits.new(package_guid, uploaded_path) + end + + it { is_expected.to be_a_valid_job } + + describe '#perform' do + let(:package_blobstore) { double(:package_blobstore) } + let(:tmpdir) { '/tmp/special_temp' } + let(:max_package_size) { 256 } + + before do + TestConfig.override({ directories: { tmpdir: tmpdir }, packages: TestConfig.config[:packages].merge(max_package_size: max_package_size) }) + + allow(AppBitsPackage).to receive(:new) { double(:packer, create_package_in_blobstore: 'done') } + end + + it 'creates blob stores' do + expect(CloudController::DependencyLocator.instance).to receive(:package_blobstore) + job.perform + end + + it 'creates an AppBitsPackage and performs' do + expect(CloudController::DependencyLocator.instance).to receive(:package_blobstore).and_return(package_blobstore) + + packer = double + expect(AppBitsPackage).to receive(:new).with(package_blobstore, nil, max_package_size, tmpdir).and_return(packer) + expect(packer).to receive(:create_package_in_blobstore).with(package_guid, uploaded_path) + job.perform + end + + it 'knows its job name' do + expect(job.job_name_in_configuration).to equal(:package_bits) + end + end + end + end +end diff --git a/spec/unit/lib/cloud_controller/backends/runners_spec.rb b/spec/unit/lib/cloud_controller/backends/runners_spec.rb index 026be6592fc..619de880685 100644 --- a/spec/unit/lib/cloud_controller/backends/runners_spec.rb +++ b/spec/unit/lib/cloud_controller/backends/runners_spec.rb @@ -1,4 +1,5 @@ require 'spec_helper' +require 'cloud_controller/diego/process_guid' module VCAP::CloudController describe Runners do @@ -294,6 +295,198 @@ def make_dea_app(options={}) end end + describe '#diego_apps_from_process_guids' do + before do + 5.times do + app = make_diego_app(state: 'STARTED') + app.add_route(Route.make(space: app.space)) + end + + expect(App.all.length).to eq(5) + end + + it 'does not return unstaged apps' do + unstaged_app = make_diego_app(state: 'STARTED') + unstaged_app.package_state = 'PENDING' + unstaged_app.save + + batch = runners.diego_apps_from_process_guids(Diego::ProcessGuid.from_app(unstaged_app)) + + expect(batch).not_to include(unstaged_app) + end + + it 'does not return apps that are stopped' do + stopped_app = make_diego_app(state: 'STOPPED') + + batch = runners.diego_apps_from_process_guids(Diego::ProcessGuid.from_app(stopped_app)) + + expect(batch).not_to include(stopped_app) + end + + it 'does not return deleted apps' do + deleted_app = make_diego_app(state: 'STARTED', deleted_at: DateTime.now.utc) + + batch = runners.diego_apps_from_process_guids(Diego::ProcessGuid.from_app(deleted_app)) + + expect(batch).not_to include(deleted_app) + end + + it 'only includes diego apps' do + non_diego_app = make_diego_app(state: 'STARTED') + non_diego_app.environment_json = {} + non_diego_app.save + + batch = runners.diego_apps_from_process_guids(Diego::ProcessGuid.from_app(non_diego_app)) + + expect(batch).not_to include(non_diego_app) + end + + it 'accepts a process guid or an array of process guids' do + app = App.where(diego: true).order(:id).first + process_guid = Diego::ProcessGuid.from_app(app) + + expect(runners.diego_apps_from_process_guids(process_guid)).to eq([app]) + expect(runners.diego_apps_from_process_guids([process_guid])).to eq([app]) + end + + it 'returns diego apps for each requested process guid' do + diego_apps = App.where(diego: true).all + diego_guids = diego_apps.map { |app| Diego::ProcessGuid.from_app(app) } + + expect(runners.diego_apps_from_process_guids(diego_guids)).to match_array(diego_apps) + end + + it 'loads all of the associations eagerly' do + diego_apps = App.where(diego: true).all + diego_guids = diego_apps.map { |app| Diego::ProcessGuid.from_app(app) } + + expect { + runners.diego_apps_from_process_guids(diego_guids).each do |app| + app.current_droplet + app.space + app.stack + app.routes + app.service_bindings + app.routes.map(&:domain) + end + }.to have_queried_db_times(/SELECT/, [ + :apps, + :droplets, + :spaces, + :stacks, + :routes, + :service_bindings, + :domain + ].length) + end + + context 'when the process guid is not found' do + it 'does not return an app' do + app = App.where(diego: true).order(:id).first + process_guid = Diego::ProcessGuid.from_app(app) + + expect { + app.set_new_version + app.save + }.to change { + runners.diego_apps_from_process_guids(process_guid) + }.from([app]).to([]) + end + end + + context 'when diego running is disabled' do + before do + allow(runners).to receive(:diego_running_disabled?).and_return(true) + end + + it 'returns no apps' do + all_process_guids = App.all.map { |app| Diego::ProcessGuid.from_app(app) } + + expect(runners.diego_apps_from_process_guids(all_process_guids)).to be_empty + end + end + end + + describe '#diego_apps_cache_data' do + before do + 5.times { make_diego_app(state: 'STARTED') } + expect(App.all.length).to eq(5) + end + + it 'respects the batch_size' do + data_count = [3, 5].map do |batch_size| + runners.diego_apps_cache_data(batch_size, 0).count + end + + expect(data_count).to eq([3, 5]) + end + + it 'returns data for non-intersecting apps across subsequent batches' do + first_batch = runners.diego_apps_cache_data(3, 0) + expect(first_batch.count).to eq(3) + + last_id = first_batch.last[0] + second_batch = runners.diego_apps_cache_data(3, last_id) + expect(second_batch.count).to eq(2) + end + + it 'does not return unstaged apps' do + unstaged_app = make_diego_app(state: 'STARTED') + unstaged_app.package_state = 'PENDING' + unstaged_app.save + + batch = runners.diego_apps_cache_data(100, 0) + app_ids = batch.map { |data| data[0] } + + expect(app_ids).not_to include(unstaged_app.id) + end + + it 'does not return apps that are stopped' do + stopped_app = make_diego_app(state: 'STOPPED') + + batch = runners.diego_apps_cache_data(100, 0) + app_ids = batch.map { |data| data[0] } + + expect(app_ids).not_to include(stopped_app.id) + end + + it 'does not return deleted apps' do + deleted_app = make_diego_app(state: 'STARTED', deleted_at: DateTime.now.utc) + + batch = runners.diego_apps_cache_data(100, 0) + app_ids = batch.map { |data| data[0] } + + expect(app_ids).not_to include(deleted_app.id) + end + + it 'only includes diego apps' do + non_diego_app = make_diego_app(state: 'STARTED') + non_diego_app.environment_json = {} + non_diego_app.save + + batch = runners.diego_apps_cache_data(100, 0) + app_ids = batch.map { |data| data[0] } + + expect(app_ids).not_to include(non_diego_app.id) + end + + context 'when diego running is disabled' do + before do + allow(runners).to receive(:diego_running_disabled?).and_return(true) + end + + it 'returns no apps' do + expect(runners.diego_apps_cache_data(100, 0)).to be_empty + end + end + + it 'acquires the data in one select' do + expect { + runners.diego_apps_cache_data(100, 0) + }.to have_queried_db_times(/SELECT/, 1) + end + end + describe '#dea_apps' do let!(:diego_app) { make_diego_app(id: 99, state: 'STARTED') } diff --git a/spec/unit/lib/cloud_controller/dependency_locator_spec.rb b/spec/unit/lib/cloud_controller/dependency_locator_spec.rb index 0948b1e1cac..afbd2d18181 100644 --- a/spec/unit/lib/cloud_controller/dependency_locator_spec.rb +++ b/spec/unit/lib/cloud_controller/dependency_locator_spec.rb @@ -258,6 +258,7 @@ max_results_per_page: 100_000, default_results_per_page: 100_001, max_inline_relations_depth: 100_002, + collection_transformer: nil } TestConfig.override(renderer: opts) @@ -280,6 +281,7 @@ max_results_per_page: 10, default_results_per_page: 100_001, max_inline_relations_depth: 100_002, + collection_transformer: nil } TestConfig.override(renderer: opts) @@ -302,6 +304,7 @@ max_results_per_page: 100_000, default_results_per_page: 100_001, max_inline_relations_depth: 100_002, + collection_transformer: nil } TestConfig.override(renderer: opts) @@ -316,6 +319,38 @@ end end + describe '#username_populating_collection_renderer' do + it 'returns paginated collection renderer with a UsernamePopulator transformer' do + renderer = locator.username_populating_collection_renderer + expect(renderer.collection_transformer).to be_a(VCAP::CloudController::UsernamePopulator) + end + + it 'uses the username_lookup_uaa_client for the populator' do + uaa_client = double('uaa client') + expect(locator).to receive(:username_lookup_uaa_client).and_return(uaa_client) + renderer = locator.username_populating_collection_renderer + expect(renderer.collection_transformer.uaa_client).to eq(uaa_client) + end + end + + describe '#username_lookup_uaa_client' do + it 'returns a uaa client with credentials for lookuping up usernames' do + uaa_client = locator.username_lookup_uaa_client + expect(uaa_client.client_id).to eq(config[:cloud_controller_username_lookup_client_name]) + expect(uaa_client.secret).to eq(config[:cloud_controller_username_lookup_client_secret]) + expect(uaa_client.uaa_target).to eq(config[:uaa][:url]) + end + + context 'when skip_cert_verify is true in the config' do + before { TestConfig.override(skip_cert_verify: true) } + + it 'skips ssl validation to uaa' do + uaa_client = locator.username_lookup_uaa_client + expect(uaa_client.options[:skip_ssl_validation]).to be true + end + end + end + describe '#missing_blob_handler' do it 'returns the correct handler' do handler = double('a missing blob handler') diff --git a/spec/unit/lib/cloud_controller/diego/common/protocol_spec.rb b/spec/unit/lib/cloud_controller/diego/common/protocol_spec.rb index 5e283a18616..e4d07830c1f 100644 --- a/spec/unit/lib/cloud_controller/diego/common/protocol_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/common/protocol_spec.rb @@ -8,14 +8,54 @@ module Common subject(:protocol) { described_class.new } describe '#stop_index_request' do - let(:app) { double(:app, versioned_guid: 'versioned-guid') } + let(:app) { double(:app, { guid: 'guid', version: 'versioned' }) } it 'includes a subject and message for CfMessageBus::MessageBus#publish' do request = protocol.stop_index_request(app, 33) expect(request.size).to eq(2) expect(request.first).to eq('diego.stop.index') - expect(request.last).to match_json({ 'process_guid' => 'versioned-guid', 'index' => 33 }) + expect(request.last).to match_json({ 'process_guid' => 'guid-versioned', 'index' => 33 }) + end + end + + describe 'staging_egress_rules' do + before do + SecurityGroup.make(rules: [{ 'protocol' => 'udp', 'ports' => '8080-9090', 'destination' => '198.41.191.47/1' }], staging_default: true) + SecurityGroup.make(rules: [{ 'protocol' => 'tcp', 'ports' => '8080,9090', 'destination' => '198.41.191.48/1', 'log' => true }], staging_default: true) + SecurityGroup.make(rules: [{ 'protocol' => 'tcp', 'ports' => '443', 'destination' => '198.41.191.49/1' }], staging_default: true) + SecurityGroup.make(rules: [{ 'protocol' => 'icmp', 'destination' => '0.0.0.0/0', 'type' => 0, 'code' => 1 }], staging_default: true) + SecurityGroup.make(rules: [{ 'protocol' => 'tcp', 'ports' => '80', 'destination' => '0.0.0.0/0' }], staging_default: false) + end + + it 'includes egress security group staging information by aggregating all sg with staging_default=true' do + expect(protocol.staging_egress_rules).to match_array([ + { 'protocol' => 'udp', 'port_range' => { 'start' => 8080, 'end' => 9090 }, 'destination' => '198.41.191.47/1' }, + { 'protocol' => 'tcp', 'ports' => [8080, 9090], 'destination' => '198.41.191.48/1', 'log' => true }, + { 'protocol' => 'tcp', 'ports' => [443], 'destination' => '198.41.191.49/1' }, + { 'protocol' => 'icmp', 'icmp_info' => { 'type' => 0, 'code' => 1 }, 'destination' => '0.0.0.0/0' }, + ]) + end + end + + describe 'running_egress_rules' do + let(:app) { AppFactory.make } + let(:sg_default_rules_1) { [{ 'protocol' => 'udp', 'ports' => '8080', 'destination' => '198.41.191.47/1' }] } + let(:sg_default_rules_2) { [{ 'protocol' => 'tcp', 'ports' => '9090-9095', 'destination' => '198.41.191.48/1', 'log' => true }] } + let(:sg_for_space_rules) { [{ 'protocol' => 'udp', 'ports' => '1010,2020', 'destination' => '198.41.191.49/1' }] } + + before do + SecurityGroup.make(rules: sg_default_rules_1, running_default: true) + SecurityGroup.make(rules: sg_default_rules_2, running_default: true) + app.space.add_security_group(SecurityGroup.make(rules: sg_for_space_rules)) + end + + it 'should provide the egress rules in the start message' do + expect(protocol.running_egress_rules(app)).to match_array([ + { 'protocol' => 'udp', 'ports' => [8080], 'destination' => '198.41.191.47/1' }, + { 'protocol' => 'tcp', 'port_range' => { 'start' => 9090, 'end' => 9095 }, 'destination' => '198.41.191.48/1', 'log' => true }, + { 'protocol' => 'udp', 'ports' => [1010, 2020], 'destination' => '198.41.191.49/1' }, + ]) end end end diff --git a/spec/unit/lib/cloud_controller/diego/docker/protocol_spec.rb b/spec/unit/lib/cloud_controller/diego/docker/protocol_spec.rb index 0a6df69ec99..c9478c701ce 100644 --- a/spec/unit/lib/cloud_controller/diego/docker/protocol_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/docker/protocol_spec.rb @@ -21,6 +21,11 @@ module Docker Protocol.new(common_protocol) end + before do + allow(common_protocol).to receive(:staging_egress_rules).and_return(['staging_egress_rule']) + allow(common_protocol).to receive(:running_egress_rules).with(app).and_return(['running_egress_rule']) + end + describe '#stage_app_request' do subject(:request) do protocol.stage_app_request(app, 900) @@ -47,6 +52,7 @@ module Docker 'file_descriptors' => app.file_descriptors, 'stack' => app.stack.name, 'docker_image' => app.docker_image, + 'egress_rules' => ['staging_egress_rule'], 'timeout' => 900, }) end @@ -71,7 +77,7 @@ module Docker it 'includes the fields needed to desire a Docker app' do expect(message).to eq({ - 'process_guid' => app.versioned_guid, + 'process_guid' => ProcessGuid.from_app(app), 'memory_mb' => app.memory, 'disk_mb' => app.disk_quota, 'file_descriptors' => app.file_descriptors, @@ -84,6 +90,8 @@ module Docker 'log_guid' => app.guid, 'docker_image' => app.docker_image, 'health_check_type' => app.health_check_type, + 'egress_rules' => ['running_egress_rule'], + 'etag' => app.updated_at.to_f.to_s, }) end diff --git a/spec/unit/lib/cloud_controller/diego/messenger_spec.rb b/spec/unit/lib/cloud_controller/diego/messenger_spec.rb index 597d5f293eb..2b0dfbc8dfb 100644 --- a/spec/unit/lib/cloud_controller/diego/messenger_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/messenger_spec.rb @@ -50,6 +50,7 @@ module Diego 'app_bits_download_uri' => 'http://app-package.com', 'buildpacks' => Traditional::BuildpackEntryGenerator.new(blobstore_url_generator).buildpack_entries(app), 'droplet_upload_uri' => 'http://droplet-upload-uri', + 'egress_rules' => [], 'timeout' => 90, } @@ -77,6 +78,8 @@ module Diego 'health_check_type' => app.health_check_type, 'health_check_timeout_in_seconds' => 120, 'log_guid' => app.guid, + 'egress_rules' => [], + 'etag' => app.updated_at.to_f.to_s, } end diff --git a/spec/unit/lib/cloud_controller/diego/process_guid_spec.rb b/spec/unit/lib/cloud_controller/diego/process_guid_spec.rb new file mode 100644 index 00000000000..974c4a0645c --- /dev/null +++ b/spec/unit/lib/cloud_controller/diego/process_guid_spec.rb @@ -0,0 +1,34 @@ +require 'spec_helper' +require 'cloud_controller/diego/process_guid' + +module VCAP::CloudController::Diego + describe ProcessGuid do + let(:app) do + VCAP::CloudController::AppFactory.make + end + + describe 'process_guid' do + it 'returns the appropriate composed guid' do + expect(ProcessGuid.from(app.guid, app.version)).to eq("#{app.guid}-#{app.version}") + end + end + + describe 'from_app' do + it 'returns the appropriate versioned guid for the app' do + expect(ProcessGuid.from_app(app)).to eq("#{app.guid}-#{app.version}") + end + end + + describe 'app_guid' do + it 'it returns the app guid from the versioned guid' do + expect(ProcessGuid.app_guid(ProcessGuid.from_app(app))).to eq(app.guid) + end + end + + describe 'app_version' do + it 'it returns the app version from the versioned guid' do + expect(ProcessGuid.app_version(ProcessGuid.from_app(app))).to eq(app.version) + end + end + end +end diff --git a/spec/unit/lib/cloud_controller/diego/traditional/protocol_spec.rb b/spec/unit/lib/cloud_controller/diego/traditional/protocol_spec.rb index 08c5b3c763b..a5b208d7970 100644 --- a/spec/unit/lib/cloud_controller/diego/traditional/protocol_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/traditional/protocol_spec.rb @@ -16,18 +16,21 @@ module Traditional let(:common_protocol) { double(:common_protocol) } + let(:app) do + AppFactory.make + end + subject(:protocol) do Protocol.new(blobstore_url_generator, common_protocol) end - describe '#stage_app_request' do - let(:app) do - AppFactory.make - end + before do + allow(common_protocol).to receive(:staging_egress_rules).and_return(['staging_egress_rule']) + allow(common_protocol).to receive(:running_egress_rules).with(app).and_return(['running_egress_rule']) + end - subject(:request) do - protocol.stage_app_request(app, 900) - end + describe '#stage_app_request' do + let(:request) { protocol.stage_app_request(app, 900) } it 'returns arguments intended for CfMessageBus::MessageBus#publish' do expect(request.size).to eq(2) @@ -37,37 +40,36 @@ module Traditional end describe '#stage_app_message' do - let(:staging_app) { AppFactory.make } - subject(:message) { protocol.stage_app_message(staging_app, 900) } + let(:message) { protocol.stage_app_message(app, 900) } before do - staging_app.update(staging_task_id: 'fake-staging-task-id') # Mimic Diego::Messenger#send_stage_request + app.update(staging_task_id: 'fake-staging-task-id') # Mimic Diego::Messenger#send_stage_request end it 'is a nats message with the appropriate staging subject and payload' do buildpack_entry_generator = BuildpackEntryGenerator.new(blobstore_url_generator) expect(message).to eq( - 'app_id' => staging_app.guid, + 'app_id' => app.guid, 'task_id' => 'fake-staging-task-id', - 'memory_mb' => staging_app.memory, - 'disk_mb' => staging_app.disk_quota, - 'file_descriptors' => staging_app.file_descriptors, - 'environment' => Environment.new(staging_app).as_json, - 'stack' => staging_app.stack.name, + 'memory_mb' => app.memory, + 'disk_mb' => app.disk_quota, + 'file_descriptors' => app.file_descriptors, + 'environment' => Environment.new(app).as_json, + 'stack' => app.stack.name, 'build_artifacts_cache_download_uri' => 'http://buildpack-artifacts-cache.com', 'build_artifacts_cache_upload_uri' => 'http://buildpack-artifacts-cache.up.com', 'app_bits_download_uri' => 'http://app-package.com', - 'buildpacks' => buildpack_entry_generator.buildpack_entries(staging_app), + 'buildpacks' => buildpack_entry_generator.buildpack_entries(app), 'droplet_upload_uri' => 'http://droplet-upload-uri', + 'egress_rules' => ['staging_egress_rule'], 'timeout' => 900, ) end end describe '#desire_app_request' do - let(:app) { AppFactory.make } - subject(:request) { protocol.desire_app_request(app) } + let(:request) { protocol.desire_app_request(app) } it 'returns arguments intended for CfMessageBus::MessageBus#publish' do expect(request.size).to eq(2) @@ -89,20 +91,19 @@ module Traditional health_check_timeout: 444, memory: 555, stack: instance_double(Stack, name: 'fake-stack'), - versioned_guid: 'fake-versioned_guid', + version: 'version-guid', + updated_at: Time.at(12345.6789), uris: ['fake-uris'], ) end + let(:message) { protocol.desire_app_message(app) } + before do environment = instance_double(Environment, as_json: [{ 'name' => 'fake', 'value' => 'environment' }]) allow(Environment).to receive(:new).with(app).and_return(environment) end - subject(:message) do - protocol.desire_app_message(app) - end - it 'is a messsage with the information nsync needs to desire the app' do expect(message).to eq( 'disk_mb' => 222, @@ -114,11 +115,13 @@ module Traditional 'log_guid' => 'fake-guid', 'memory_mb' => 555, 'num_instances' => 111, - 'process_guid' => 'fake-versioned_guid', + 'process_guid' => 'fake-guid-version-guid', 'stack' => 'fake-stack', 'start_command' => 'the-custom-command', 'execution_metadata' => 'staging-metadata', 'routes' => ['fake-uris'], + 'egress_rules' => ['running_egress_rule'], + 'etag' => '12345.6789' ) end @@ -134,14 +137,8 @@ module Traditional end describe '#stop_staging_app_request' do - let(:app) do - AppFactory.make - end let(:task_id) { 'staging_task_id' } - - subject(:request) do - protocol.stop_staging_app_request(app, task_id) - end + let(:request) { protocol.stop_staging_app_request(app, task_id) } it 'returns an array of arguments including the subject and message' do expect(request.size).to eq(2) @@ -151,20 +148,18 @@ module Traditional end describe '#stop_staging_message' do - let(:staging_app) { AppFactory.make } let(:task_id) { 'staging_task_id' } - subject(:message) { protocol.stop_staging_message(staging_app, task_id) } + let(:message) { protocol.stop_staging_message(app, task_id) } it 'is a nats message with the appropriate staging subject and payload' do expect(message).to eq( - 'app_id' => staging_app.guid, + 'app_id' => app.guid, 'task_id' => task_id, ) end end describe '#stop_index_request' do - let(:app) { AppFactory.make } before { allow(common_protocol).to receive(:stop_index_request) } it 'delegates to the common protocol' do diff --git a/spec/unit/lib/cloud_controller/paging/sequel_paginator_spec.rb b/spec/unit/lib/cloud_controller/paging/sequel_paginator_spec.rb new file mode 100644 index 00000000000..88ea5e1684f --- /dev/null +++ b/spec/unit/lib/cloud_controller/paging/sequel_paginator_spec.rb @@ -0,0 +1,77 @@ +require 'spec_helper' +require 'cloud_controller/paging/sequel_paginator' +require 'cloud_controller/paging/pagination_options' + +module VCAP::CloudController + describe SequelPaginator do + let(:paginator) { SequelPaginator.new } + + describe '#get_page' do + let(:dataset) { AppModel.dataset } + let!(:app_model1) { AppModel.make } + let!(:app_model2) { AppModel.make } + let(:page) { 1 } + let(:per_page) { 1 } + + it 'defaults to the first page if page is nil' do + pagination_options = PaginationOptions.new(nil, per_page) + + paginated_result = paginator.get_page(dataset, pagination_options) + + expect(paginated_result.pagination_options.page).to eq(1) + end + + it 'defaults to the first page if page is 0' do + pagination_options = PaginationOptions.new(0, per_page) + + paginated_result = paginator.get_page(dataset, pagination_options) + + expect(paginated_result.pagination_options.page).to eq(1) + end + + it 'defaults to listing 50 records per page if per_page is nil' do + pagination_options = PaginationOptions.new(page, nil) + + paginated_result = paginator.get_page(dataset, pagination_options) + + expect(paginated_result.pagination_options.per_page).to eq(50) + end + + it 'defaults to listing 50 records per page if per_page is 0' do + pagination_options = PaginationOptions.new(page, 0) + + paginated_result = paginator.get_page(dataset, pagination_options) + + expect(paginated_result.pagination_options.per_page).to eq(50) + end + + it 'limits the listing to 5000 records per page' do + pagination_options = PaginationOptions.new(page, 5001) + + paginated_result = paginator.get_page(dataset, pagination_options) + + expect(paginated_result.pagination_options.per_page).to eq(5000) + end + + it 'finds all records from the page upto the per_page limit' do + per_page = 1 + pagination_options = PaginationOptions.new(page, per_page) + + paginated_result = paginator.get_page(dataset, pagination_options) + + expect(paginated_result.records.length).to eq(1) + end + + it 'pages properly' do + pagination_options = PaginationOptions.new(1, per_page) + first_paginated_result = paginator.get_page(dataset, pagination_options) + + pagination_options = PaginationOptions.new(2, per_page) + second_paginated_result = paginator.get_page(dataset, pagination_options) + + expect(first_paginated_result.records.first.guid).to eq(app_model1.guid) + expect(second_paginated_result.records.first.guid).to eq(app_model2.guid) + end + end + end +end diff --git a/spec/unit/lib/rest_controller/paginated_collection_renderer_spec.rb b/spec/unit/lib/rest_controller/paginated_collection_renderer_spec.rb index 3e9c565a6ec..e304bc3d338 100644 --- a/spec/unit/lib/rest_controller/paginated_collection_renderer_spec.rb +++ b/spec/unit/lib/rest_controller/paginated_collection_renderer_spec.rb @@ -14,11 +14,13 @@ module VCAP::CloudController::RestController default_results_per_page: default_results_per_page, max_results_per_page: max_results_per_page, max_inline_relations_depth: max_inline_relations_depth, + collection_transformer: collection_transformer } end let(:default_results_per_page) { 100_000 } let(:max_results_per_page) { 100_000 } let(:max_inline_relations_depth) { 100_000 } + let(:collection_transformer) { nil } describe '#render_json' do let(:opts) do @@ -290,6 +292,27 @@ module VCAP::CloudController::RestController end end end + + context 'when collection_transformer is given' do + let(:collection_transformer) { double('collection_transformer') } + let!(:test_model) { VCAP::CloudController::TestModel.make } + + it 'passes the populated dataset to the transformer' do + expect(collection_transformer).to receive(:transform) do |collection| + expect(collection).to eq([test_model]) + end + + render_json_call + end + + it 'serializes the transformed collection' do + expect(collection_transformer).to receive(:transform) do |collection| + collection.first.unique_value = 'test_value' + end + + expect(JSON.parse(render_json_call)['resources'][0]['entity']['unique_value']).to eq('test_value') + end + end end end end diff --git a/spec/unit/lib/sequel_plugins/vcap_serialization_spec.rb b/spec/unit/lib/sequel_plugins/vcap_serialization_spec.rb index ed5eab5bd26..561d87dde7e 100644 --- a/spec/unit/lib/sequel_plugins/vcap_serialization_spec.rb +++ b/spec/unit/lib/sequel_plugins/vcap_serialization_spec.rb @@ -54,4 +54,19 @@ class TestModel < Sequel::Model expect(instance.unique_value).to eq('unique') end end + + describe '#to_hash' do + context 'when the instance defines different attributes to export' do + class TestModel < Sequel::Model + def export_attrs + super + [:required_attr] + end + end + + it 'uses the attributes defined by the instance' do + hash = TestModel.new.to_hash + expect(hash.keys).to eq(['required_attr']) + end + end + end end diff --git a/spec/unit/lib/services/service_brokers/service_broker_registration_spec.rb b/spec/unit/lib/services/service_brokers/service_broker_registration_spec.rb index 5c7b5d18897..2d3e5b2da2e 100644 --- a/spec/unit/lib/services/service_brokers/service_broker_registration_spec.rb +++ b/spec/unit/lib/services/service_brokers/service_broker_registration_spec.rb @@ -162,7 +162,7 @@ module VCAP::Services::ServiceBrokers it "raises an error, even though we'd rather it not" do expect { registration.create - }.to raise_error V2::ServiceBrokerBadResponse + }.to raise_error V2::Errors::ServiceBrokerBadResponse end it 'does not create a new service broker' do @@ -174,7 +174,7 @@ module VCAP::Services::ServiceBrokers it 'does not synchronize uaa clients' do begin registration.create - rescue V2::ServiceBrokerBadResponse + rescue V2::Errors::ServiceBrokerBadResponse end expect(client_manager).not_to have_received(:synchronize_clients_with_catalog) @@ -183,7 +183,7 @@ module VCAP::Services::ServiceBrokers it 'does not synchronize the catalog' do begin registration.create - rescue V2::ServiceBrokerBadResponse + rescue V2::Errors::ServiceBrokerBadResponse end expect(service_manager).not_to have_received(:sync_services_and_plans) @@ -433,7 +433,7 @@ module VCAP::Services::ServiceBrokers it "raises an error, even though we'd rather it not" do expect { registration.update - }.to raise_error V2::ServiceBrokerBadResponse + }.to raise_error V2::Errors::ServiceBrokerBadResponse end it 'not update the service broker' do @@ -446,7 +446,7 @@ module VCAP::Services::ServiceBrokers it 'does not synchronize uaa clients' do begin registration.update - rescue V2::ServiceBrokerBadResponse + rescue V2::Errors::ServiceBrokerBadResponse end expect(client_manager).not_to have_received(:synchronize_clients_with_catalog) @@ -455,7 +455,7 @@ module VCAP::Services::ServiceBrokers it 'does not synchronize the catalog' do begin registration.update - rescue V2::ServiceBrokerBadResponse + rescue V2::Errors::ServiceBrokerBadResponse end expect(service_manager).not_to have_received(:sync_services_and_plans) diff --git a/spec/unit/lib/services/service_brokers/v2/client_spec.rb b/spec/unit/lib/services/service_brokers/v2/client_spec.rb index 499d66b70db..6a40bc05410 100644 --- a/spec/unit/lib/services/service_brokers/v2/client_spec.rb +++ b/spec/unit/lib/services/service_brokers/v2/client_spec.rb @@ -1,147 +1,6 @@ require 'spec_helper' module VCAP::Services::ServiceBrokers::V2 - describe ServiceBrokerBadResponse do - let(:uri) { 'http://www.example.com/' } - let(:response) { double(code: 500, message: 'Internal Server Error', body: response_body) } - let(:method) { 'PUT' } - - context 'with a description in the body' do - let(:response_body) do - { - 'description' => 'Some error text' - }.to_json - end - - it 'generates the correct hash' do - exception = described_class.new(uri, method, response) - exception.set_backtrace(['/foo:1', '/bar:2']) - - expect(exception.to_h).to eq({ - 'description' => 'Service broker error: Some error text', - 'backtrace' => ['/foo:1', '/bar:2'], - 'http' => { - 'status' => 500, - 'uri' => uri, - 'method' => 'PUT' - }, - 'source' => { - 'description' => 'Some error text' - } - }) - end - end - - context 'without a description in the body' do - let(:response_body) do - { 'foo' => 'bar' }.to_json - end - it 'generates the correct hash' do - exception = described_class.new(uri, method, response) - exception.set_backtrace(['/foo:1', '/bar:2']) - - expect(exception.to_h).to eq({ - 'description' => 'The service broker API returned an error from http://www.example.com/: 500 Internal Server Error', - 'backtrace' => ['/foo:1', '/bar:2'], - 'http' => { - 'status' => 500, - 'uri' => uri, - 'method' => 'PUT' - }, - 'source' => { 'foo' => 'bar' } - }) - end - end - end - - describe 'the remaining ServiceBrokers::V2 exceptions' do - let(:uri) { 'http://uri.example.com' } - let(:method) { 'POST' } - let(:error) { StandardError.new } - - describe ServiceBrokerResponseMalformed do - let(:response_body) { 'foo' } - let(:response) { double(code: 200, reason: 'OK', body: response_body) } - - it 'initializes the base class correctly' do - exception = ServiceBrokerResponseMalformed.new(uri, method, response) - expect(exception.message).to eq('The service broker response was not understood') - expect(exception.uri).to eq(uri) - expect(exception.method).to eq(method) - expect(exception.source).to be(response.body) - end - end - - describe ServiceBrokerApiAuthenticationFailed do - let(:response_body) { 'foo' } - let(:response) { double(code: 401, reason: 'Auth Error', body: response_body) } - - it 'initializes the base class correctly' do - exception = ServiceBrokerApiAuthenticationFailed.new(uri, method, response) - expect(exception.message).to eq("Authentication failed for the service broker API. Double-check that the username and password are correct: #{uri}") - expect(exception.uri).to eq(uri) - expect(exception.method).to eq(method) - expect(exception.source).to be(response.body) - end - end - - describe ServiceBrokerConflict do - let(:response_body) { '{"message": "error message"}' } - let(:response) { double(code: 409, reason: 'Conflict', body: response_body) } - - it 'initializes the base class correctly' do - exception = ServiceBrokerConflict.new(uri, method, response) - expect(exception.message).to eq('error message') - expect(exception.uri).to eq(uri) - expect(exception.method).to eq(method) - expect(exception.source).to eq(MultiJson.load(response.body)) - end - - it 'has a response_code of 409' do - exception = ServiceBrokerConflict.new(uri, method, response) - expect(exception.response_code).to eq(409) - end - - context 'when the response body has no message' do - let(:response_body) { '{"description": "error description"}' } - - context 'and there is a description field' do - it 'initializes the base class correctly' do - exception = ServiceBrokerConflict.new(uri, method, response) - expect(exception.message).to eq('error description') - expect(exception.uri).to eq(uri) - expect(exception.method).to eq(method) - expect(exception.source).to eq(MultiJson.load(response.body)) - end - end - - context 'and there is no description field' do - let(:response_body) { '{"field": "value"}' } - - it 'initializes the base class correctly' do - exception = ServiceBrokerConflict.new(uri, method, response) - expect(exception.message).to eq("Resource conflict: #{uri}") - expect(exception.uri).to eq(uri) - expect(exception.method).to eq(method) - expect(exception.source).to eq(MultiJson.load(response.body)) - end - end - end - - context 'when the body is not JSON-parsable' do - let(:response_body) { 'foo' } - - it 'initializes the base class correctly' do - exception = ServiceBrokerConflict.new(uri, method, response) - expect(exception.message).to eq("Resource conflict: #{uri}") - expect(exception.uri).to eq(uri) - expect(exception.method).to eq(method) - expect(exception.source).to eq(response.body) - end - end - end - end - describe Client do let(:service_broker) { VCAP::CloudController::ServiceBroker.make } @@ -156,82 +15,28 @@ module VCAP::Services::ServiceBrokers::V2 subject(:client) { Client.new(client_attrs) } let(:http_client) { double('http_client') } + let(:orphan_mitigator) { double('orphan_mitigator', cleanup_failed_provision: nil, cleanup_failed_bind: nil) } + let(:state_poller) { double('state_poller', poll_service_instance_state: nil) } before do allow(HttpClient).to receive(:new). with(url: service_broker.broker_url, auth_username: service_broker.auth_username, auth_password: service_broker.auth_password). and_return(http_client) - allow(http_client).to receive(:url).and_return(service_broker.broker_url) - end - - shared_examples 'handles standard error conditions' do - context 'when the API returns an error code' do - let(:response_data) { { 'foo' => 'bar' } } - let(:code) { '500' } - let(:message) { 'Internal Server Error' } - - it 'should raise a ServiceBrokerBadResponse' do - expect { - operation - }.to raise_error { |e| - expect(e).to be_a(ServiceBrokerBadResponse) - error_hash = e.to_h - expect(error_hash.fetch('description')).to eq("The service broker API returned an error from #{service_broker.broker_url}#{path}: 500 Internal Server Error") - expect(error_hash.fetch('source')).to include({ 'foo' => 'bar' }) - } - end - end - - context 'when the API returns an invalid response' do - context 'because of an unexpected status code' do - let(:code) { '404' } - let(:message) { 'Not Found' } - - it 'should raise an invalid response error' do - expect { - operation - }.to raise_error( - ServiceBrokerBadResponse, - "The service broker API returned an error from #{service_broker.broker_url}#{path}: 404 Not Found" - ) - end - end - - context 'because of a response that does not return a valid hash' do - let(:response_data) { [] } - - it 'should raise an invalid response error' do - expect { - operation - }.to raise_error(ServiceBrokerResponseMalformed) - end - end + allow(VCAP::Services::ServiceBrokers::V2::OrphanMitigator).to receive(:new). + and_return(orphan_mitigator) - context 'because of an invalid JSON body' do - let(:response_data) { 'invalid' } + allow(VCAP::Services::ServiceBrokers::V2::ServiceInstanceStatePoller).to receive(:new). + and_return(state_poller) - it 'should raise an invalid response error' do - expect { - operation - }.to raise_error(ServiceBrokerResponseMalformed) - end - end - end + allow(http_client).to receive(:url).and_return(service_broker.broker_url) + end - context 'when the API cannot authenticate the client' do - let(:code) { '401' } + describe '#initialize' do + it 'creates HttpClient with correct attrs' do + Client.new(client_attrs.merge(extra_arg: 'foo')) - it 'should raise an authentication error' do - expect { - operation - }.to raise_error { |e| - expect(e).to be_a(ServiceBrokerApiAuthenticationFailed) - error_hash = e.to_h - expect(error_hash.fetch('description')). - to eq("Authentication failed for the service broker API. Double-check that the username and password are correct: #{service_broker.broker_url}#{path}") - } - end + expect(HttpClient).to have_received(:new).with(client_attrs) end end @@ -279,12 +84,6 @@ module VCAP::Services::ServiceBrokers::V2 it 'returns a catalog' do expect(client.catalog).to eq(response_data) end - - describe 'handling errors' do - it_behaves_like 'handles standard error conditions' do - let(:operation) { client.catalog } - end - end end describe '#provision' do @@ -303,7 +102,7 @@ module VCAP::Services::ServiceBrokers::V2 } end - let(:path) { "/v2/service_instances/#{instance.guid}" } + let(:path) { "/v2/service_instances/#{instance.guid}?accepts_incomplete=true" } let(:response) { double('response') } let(:response_body) { response_data.to_json } let(:code) { '201' } @@ -322,7 +121,7 @@ module VCAP::Services::ServiceBrokers::V2 client.provision(instance) expect(http_client).to have_received(:put). - with("/v2/service_instances/#{instance.guid}", anything) + with("/v2/service_instances/#{instance.guid}?accepts_incomplete=true", anything) end it 'makes a put request with correct message' do @@ -345,197 +144,195 @@ module VCAP::Services::ServiceBrokers::V2 expect(instance.dashboard_url).to eq('foo') end + it 'defaults the state to "available"' do + client.provision(instance) + + expect(instance.state).to eq('available') + end + + it 'leaves the description blank' do + client.provision(instance) + + expect(instance.state_description).to eq('') + end + it 'DEPRECATED, maintain for database not null contraint: sets the credentials on the instance' do client.provision(instance) expect(instance.credentials).to eq({}) end - describe 'error handling' do - context 'the instance_id is already in use' do - let(:code) { '409' } + context 'when the broker returns no state or the state is created, or available' do + let(:response_data) do + { + } + end - it 'raises ServiceBrokerConflict' do - expect { - client.provision(instance) - }.to raise_error(ServiceBrokerConflict) - end + it 'return immediately with the broker response' do + client = Client.new(client_attrs.merge(accepts_incomplete: true)) + client.provision(instance) + + expect(instance.state).to eq('available') + expect(instance.state_description).to eq('') end - it_behaves_like 'handles standard error conditions' do - let(:operation) { client.provision(instance) } + it 'does not enqueue a polling job' do + client.provision(instance) + expect(state_poller).to_not have_received(:poll_service_instance_state) end end - context 'when provision fails' do - before do - allow(VCAP::CloudController::ServiceBrokers::V2::ServiceInstanceDeprovisioner).to receive(:deprovision) + context 'when the broker returns the state as creating' do + let(:response_data) do + { + state: 'creating', + state_description: '10% done' + } end - context 'and http client response is 408' do - before do - allow(response).to receive(:code).and_return('408', '200') - end + it 'return immediately with the broker response' do + client = Client.new(client_attrs.merge(accepts_incomplete: true)) + client.provision(instance) - it 'raises ServiceBrokerApiTimeout and deprovisions' do - expect { - client.provision(instance) - }.to raise_error(ServiceBrokerApiTimeout) + expect(instance.state).to eq('creating') + expect(instance.state_description).to eq('10% done') + end - expect(VCAP::CloudController::ServiceBrokers::V2::ServiceInstanceDeprovisioner). - to have_received(:deprovision).with(client_attrs, instance) - end + it 'enqueues a polling job' do + client.provision(instance) + expect(state_poller).to have_received(:poll_service_instance_state).with(client_attrs, instance) end + end - context 'and http client response is 5xx' do - context 'and http error code is 500' do - before do - allow(response).to receive(:code).and_return('500', '200') - end + context 'when the broker returns the state as failed' do + let(:response_data) do + { + state: 'failed', + state_description: '100% failed' + } + end - it 'raises ServiceBrokerBadResponse and deprovisions' do - expect { - client.provision(instance) - }.to raise_error(ServiceBrokerBadResponse) + it 'return immediately with the broker response' do + client = Client.new(client_attrs.merge(accepts_incomplete: true)) + client.provision(instance) - expect(VCAP::CloudController::ServiceBrokers::V2::ServiceInstanceDeprovisioner). - to have_received(:deprovision). - with(client_attrs, instance) - end - end + expect(instance.state).to eq('failed') + expect(instance.state_description).to eq('100% failed') + end - context 'and http error code is 501' do - before do - allow(response).to receive(:code).and_return('501', '200') - end + it 'does not enqueue a polling job' do + client.provision(instance) + expect(state_poller).to_not have_received(:poll_service_instance_state) + end + end - it 'raises ServiceBrokerBadResponse and deprovisions' do - expect { - client.provision(instance) - }.to raise_error(ServiceBrokerBadResponse) + context 'when provision fails' do + let(:uri) { 'some-uri.com/v2/service_instances/some-guid' } + let(:response) { double(:response, body: nil, message: nil) } - expect(VCAP::CloudController::ServiceBrokers::V2::ServiceInstanceDeprovisioner). - to have_received(:deprovision). - with(client_attrs, instance) - end + context 'due to an http client error' do + let(:http_client) { double(:http_client) } + + before do + allow(http_client).to receive(:put).and_raise(error) end - context 'and http error code is 502' do - before do - allow(response).to receive(:code).and_return('502', '200') - end + context 'Errors::ServiceBrokerApiTimeout error' do + let(:error) { Errors::ServiceBrokerApiTimeout.new(uri, :put, Timeout::Error.new) } - it 'raises ServiceBrokerBadResponse and deprovisions' do + it 'propagates the error and follows up with a deprovision request' do expect { client.provision(instance) - }.to raise_error(ServiceBrokerBadResponse) + }.to raise_error(Errors::ServiceBrokerApiTimeout) - expect(VCAP::CloudController::ServiceBrokers::V2::ServiceInstanceDeprovisioner). - to have_received(:deprovision). - with(client_attrs, instance) + expect(orphan_mitigator).to have_received(:cleanup_failed_provision).with(client_attrs, instance) end end + end - context 'and http error code is 503' do - before do - allow(response).to receive(:code).and_return('503', '200') - end - - it 'raises ServiceBrokerBadResponse and deprovisions' do - expect { - client.provision(instance) - }.to raise_error(ServiceBrokerBadResponse) + context 'due to a response parser error' do + let(:response_parser) { double(:response_parser) } - expect(VCAP::CloudController::ServiceBrokers::V2::ServiceInstanceDeprovisioner). - to have_received(:deprovision). - with(client_attrs, instance) - end + before do + allow(response_parser).to receive(:parse).and_raise(error) + allow(VCAP::Services::ServiceBrokers::V2::ResponseParser).to receive(:new).and_return(response_parser) end - context 'and http error code is 504' do - before do - allow(response).to receive(:code).and_return('504', '200') - end + context 'Errors::ServiceBrokerApiTimeout error' do + let(:error) { Errors::ServiceBrokerApiTimeout.new(uri, :put, Timeout::Error.new) } - it 'raises ServiceBrokerBadResponse and deprovisions' do + it 'propagates the error and follows up with a deprovision request' do expect { client.provision(instance) - }.to raise_error(ServiceBrokerBadResponse) + }.to raise_error(Errors::ServiceBrokerApiTimeout) - expect(VCAP::CloudController::ServiceBrokers::V2::ServiceInstanceDeprovisioner). - to have_received(:deprovision). + expect(orphan_mitigator).to have_received(:cleanup_failed_provision). with(client_attrs, instance) end end - context 'and http error code is 505' do - before do - allow(response).to receive(:code).and_return('505', '200') - end + context 'ServiceBrokerBadResponse error' do + let(:error) { Errors::ServiceBrokerBadResponse.new(uri, :put, response) } - it 'raises ServiceBrokerBadResponse and deprovisions' do + it 'propagates the error and follows up with a deprovision request' do expect { client.provision(instance) - }.to raise_error(ServiceBrokerBadResponse) + }.to raise_error(Errors::ServiceBrokerBadResponse) - expect(VCAP::CloudController::ServiceBrokers::V2::ServiceInstanceDeprovisioner). - to have_received(:deprovision). - with(client_attrs, instance) + expect(orphan_mitigator).to have_received(:cleanup_failed_provision).with(client_attrs, instance) end end end end + end - context 'when provision takes longer than broker configured timeout' do - before do - allow(VCAP::CloudController::ServiceBrokers::V2::ServiceInstanceDeprovisioner).to receive(:deprovision) - end + describe '#fetch_service_instance_state' do + let(:plan) { VCAP::CloudController::ServicePlan.make } + let(:space) { VCAP::CloudController::Space.make } + let(:instance) do + VCAP::CloudController::ManagedServiceInstance.new( + service_plan: plan, + space: space + ) + end - context 'when http_client make request fails with ServiceBrokerApiTimeout' do - before do - allow(http_client).to receive(:put) do |path, message| - raise ServiceBrokerApiTimeout.new(path, :put, Timeout::Error.new(message)) - end - end + let(:response_data) do + { + 'dashboard_url' => 'bar', + 'state' => 'created', + 'state_description' => '100% created' + } + end - it 'deprovisions the instance' do - expect { - client.provision(instance) - }.to raise_error(ServiceBrokerApiTimeout) + let(:path) { "/v2/service_instances/#{instance.guid}" } + let(:response) { double('response') } + let(:response_body) { response_data.to_json } + let(:code) { '200' } + let(:message) { 'OK' } - expect(VCAP::CloudController::ServiceBrokers::V2::ServiceInstanceDeprovisioner). - to have_received(:deprovision). - with(client_attrs, instance) - end - end + before do + allow(http_client).to receive(:get).and_return(response) - context 'when http_client make request fails with ServiceBrokerApiUnreachable' do - before do - allow(http_client).to receive(:put) do |path, message| - raise ServiceBrokerApiUnreachable.new(path, :put, Errno::ECONNREFUSED) - end - end + allow(response).to receive(:body).and_return(response_body) + allow(response).to receive(:code).and_return(code) + allow(response).to receive(:message).and_return(message) + end - it 'fails' do - expect { - client.provision(instance) - }.to raise_error(ServiceBrokerApiUnreachable) - end - end + it 'makes a put request with correct path' do + client.fetch_service_instance_state(instance) - context 'when http_client make request fails with HttpRequestError' do - before do - allow(http_client).to receive(:put) do |path, message| - raise HttpRequestError.new(message, path, :put, Exception.new(message)) - end - end + expect(http_client).to have_received(:get). + with("/v2/service_instances/#{instance.guid}") + end - it 'fails' do - expect { - client.provision(instance) - }.to raise_error(HttpRequestError) - end - end + it 'returns the instance given with new state values' do + returned_instance = client.fetch_service_instance_state(instance) + + expect(returned_instance).to be(instance) + + expect(returned_instance.dashboard_url).to eq('bar') + expect(returned_instance.state).to eq('created') + expect(returned_instance.state_description).to eq('100% created') end end @@ -554,9 +351,15 @@ module VCAP::Services::ServiceBrokers::V2 let(:service_plan_guid) { new_plan.guid } let(:path) { "/v2/service_instances/#{instance.guid}/" } + let(:code) { 200 } + let(:message) { 'OK' } + let(:response_data) { '{}' } + + before do + allow(http_client).to receive(:patch).and_return(double('response', code: code, body: response_data, message: message)) + end it 'makes a patch request with the new service plan' do - allow(http_client).to receive(:patch).and_return(double('response', code: 200, body: '{}')) client.update_service_plan(instance, new_plan) expect(http_client).to have_received(:patch).with( @@ -574,46 +377,26 @@ module VCAP::Services::ServiceBrokers::V2 end it 'makes a patch request to the correct path' do - allow(http_client).to receive(:patch).and_return(double('response', code: 200, body: '{}')) - client.update_service_plan(instance, new_plan) expect(http_client).to have_received(:patch).with(path, anything) end describe 'error handling' do - before do - fake_response = double('response', code: status_code, body: body) - allow(http_client).to receive(:patch).and_return(fake_response) - end - - context 'when the broker returns a 400' do - let(:status_code) { '400' } - let(:body) { { description: 'the request was malformed' }.to_json } - it 'raises a ServiceBrokerBadResponse error' do - expect { client.update_service_plan(instance, new_plan) }.to raise_error( - ServiceBrokerBadResponse, /the request was malformed/ - ) - end - end - - context 'when the broker returns a 404' do - let(:status_code) { '404' } - let(:body) { { description: 'service instance not found' }.to_json } - it 'raises a ServiceBrokerBadRequest error' do - expect { client.update_service_plan(instance, new_plan) }.to raise_error( - ServiceBrokerBadResponse, /service instance not found/ - ) + describe 'non-standard errors' do + before do + fake_response = double('response', code: status_code, body: body) + allow(http_client).to receive(:patch).and_return(fake_response) end - end - context 'when the broker returns a 422' do - let(:status_code) { '422' } - let(:body) { { description: 'cannot update to this plan' }.to_json } - it 'raises a ServiceBrokerBadResponse error' do - expect { client.update_service_plan(instance, new_plan) }.to raise_error( - ServiceBrokerBadResponse, /cannot update to this plan/ - ) + context 'when the broker returns a 422' do + let(:status_code) { '422' } + let(:body) { { description: 'cannot update to this plan' }.to_json } + it 'raises a ServiceBrokerRequestRejected error' do + expect { client.update_service_plan(instance, new_plan) }.to raise_error( + Errors::ServiceBrokerRequestRejected, /cannot update to this plan/ + ) + end end end end @@ -708,78 +491,68 @@ module VCAP::Services::ServiceBrokers::V2 end end - context 'when bind takes longer than broker configured timeout' do + context 'when binding fails' do let(:binding) do VCAP::CloudController::ServiceBinding.make( binding_options: { 'this' => 'that' } ) end + let(:uri) { 'some-uri.com/v2/service_instances/instance-guid/service_bindings/binding-guid' } + let(:response) { double(:response, body: nil, message: nil) } - before do - allow(VCAP::CloudController::ServiceBrokers::V2::ServiceInstanceUnbinder).to receive(:unbind) - end + context 'due to an http client error' do + let(:http_client) { double(:http_client) } - context 'when http_client make request fails with ServiceBrokerApiTimeout' do before do - allow(http_client).to receive(:put) do |path, message| - raise ServiceBrokerApiTimeout.new(path, :put, Timeout::Error.new(message)) - end + allow(http_client).to receive(:put).and_raise(error) end - it 'unbinds the binding' do - expect { - client.bind(binding) - }.to raise_error(ServiceBrokerApiTimeout) + context 'Errors::ServiceBrokerApiTimeout error' do + let(:error) { Errors::ServiceBrokerApiTimeout.new(uri, :put, Timeout::Error.new) } + + it 'propagates the error and follows up with a deprovision request' do + expect { + client.bind(binding) + }.to raise_error(Errors::ServiceBrokerApiTimeout) - expect(VCAP::CloudController::ServiceBrokers::V2::ServiceInstanceUnbinder). - to have_received(:unbind). - with(client_attrs, binding) + expect(orphan_mitigator).to have_received(:cleanup_failed_bind). + with(client_attrs, binding) + end end end - context 'when http_client make request fails with ServiceBrokerApiUnreachable' do + context 'due to a response parser error' do + let(:response_parser) { double(:response_parser) } + before do - allow(http_client).to receive(:put) do |path, message| - raise ServiceBrokerApiUnreachable.new(path, :put, Errno::ECONNREFUSED) - end + allow(response_parser).to receive(:parse).and_raise(error) + allow(VCAP::Services::ServiceBrokers::V2::ResponseParser).to receive(:new).and_return(response_parser) end - it 'fails' do - expect { - client.bind(binding) - }.to raise_error(ServiceBrokerApiUnreachable) - end - end + context 'Errors::ServiceBrokerApiTimeout error' do + let(:error) { Errors::ServiceBrokerApiTimeout.new(uri, :put, Timeout::Error.new) } - context 'when http_client make request fails with HttpRequestError' do - before do - allow(http_client).to receive(:put) do |path, message| - raise HttpRequestError.new(message, path, :put, Exception.new(message)) + it 'propagates the error and follows up with a deprovision request' do + expect { + client.bind(binding) + }.to raise_error(Errors::ServiceBrokerApiTimeout) + + expect(orphan_mitigator).to have_received(:cleanup_failed_bind).with(client_attrs, binding) end end - it 'fails' do - expect { - client.bind(binding) - }.to raise_error(HttpRequestError) - end - end - end + context 'ServiceBrokerBadResponse error' do + let(:error) { Errors::ServiceBrokerBadResponse.new(uri, :put, response) } - describe 'error handling' do - context 'the binding id is already in use' do - let(:code) { '409' } + it 'propagates the error and follows up with a deprovision request' do + expect { + client.bind(binding) + }.to raise_error(Errors::ServiceBrokerBadResponse) - it 'raises ServiceBrokerConflict' do - expect { - client.bind(binding) - }.to raise_error(ServiceBrokerConflict) + expect(orphan_mitigator).to have_received(:cleanup_failed_bind).with(client_attrs, binding) + end end end - - it_behaves_like 'handles standard error conditions' do - let(:operation) { client.bind(binding) } - end end end @@ -833,20 +606,6 @@ module VCAP::Services::ServiceBrokers::V2 expect { client.unbind(binding) }.to_not raise_error end end - - describe 'handling errors' do - context 'when the API returns 410' do - let(:code) { '410' } - - it 'should swallow the error' do - expect(client.unbind(binding)).to be_nil - end - end - - it_behaves_like 'handles standard error conditions' do - let(:operation) { client.unbind(binding) } - end - end end describe '#deprovision' do @@ -886,20 +645,6 @@ module VCAP::Services::ServiceBrokers::V2 } ) end - - describe 'handling errors' do - context 'when the API returns 410' do - let(:code) { '410' } - - it 'should swallow the error' do - expect(client.deprovision(instance)).to be_nil - end - end - - it_behaves_like 'handles standard error conditions' do - let(:operation) { client.deprovision(instance) } - end - end end end end diff --git a/spec/unit/lib/services/service_brokers/v2/errors/service_broker_api_authentication_failed_spec.rb b/spec/unit/lib/services/service_brokers/v2/errors/service_broker_api_authentication_failed_spec.rb new file mode 100644 index 00000000000..62f97f7cb95 --- /dev/null +++ b/spec/unit/lib/services/service_brokers/v2/errors/service_broker_api_authentication_failed_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' + +module VCAP::Services + module ServiceBrokers + module V2 + module Errors + describe 'ServiceBrokerAuthenticationFailed' do + let(:uri) { 'http://uri.example.com' } + let(:method) { 'POST' } + let(:error) { StandardError.new } + + describe ServiceBrokerApiAuthenticationFailed do + let(:response_body) { 'foo' } + let(:response) { double(code: 401, reason: 'Auth Error', body: response_body) } + + it 'initializes the base class correctly' do + exception = ServiceBrokerApiAuthenticationFailed.new(uri, method, response) + expect(exception.message).to eq("Authentication failed for the service broker API. Double-check that the username and password are correct: #{uri}") + expect(exception.uri).to eq(uri) + expect(exception.method).to eq(method) + expect(exception.source).to be(response.body) + end + + it 'renders the correct status code to the user' do + exception = ServiceBrokerApiAuthenticationFailed.new(uri, method, response) + expect(exception.response_code).to eq 502 + end + end + end + end + end + end +end diff --git a/spec/unit/lib/services/service_brokers/v2/errors/service_broker_api_timeout_spec.rb b/spec/unit/lib/services/service_brokers/v2/errors/service_broker_api_timeout_spec.rb new file mode 100644 index 00000000000..8c1d8cd0d8c --- /dev/null +++ b/spec/unit/lib/services/service_brokers/v2/errors/service_broker_api_timeout_spec.rb @@ -0,0 +1,28 @@ +require 'spec_helper' + +module VCAP::Services + module ServiceBrokers + module V2 + module Errors + describe ServiceBrokerApiTimeout do + let(:uri) { 'http://uri.example.com' } + let(:method) { 'POST' } + let(:error) { StandardError.new } + + it 'initializes the base class correctly' do + exception = ServiceBrokerApiTimeout.new(uri, method, error) + expect(exception.message).to eq("The service broker API timed out: #{uri}") + expect(exception.uri).to eq(uri) + expect(exception.method).to eq(method) + expect(exception.source).to be(error) + end + + it 'renders the correct status code to the user' do + exception = ServiceBrokerApiTimeout.new(uri, method, error) + expect(exception.response_code).to eq 504 + end + end + end + end + end +end diff --git a/spec/unit/lib/services/service_brokers/v2/errors/service_broker_api_unreachable_spec.rb b/spec/unit/lib/services/service_brokers/v2/errors/service_broker_api_unreachable_spec.rb new file mode 100644 index 00000000000..b3e2159a932 --- /dev/null +++ b/spec/unit/lib/services/service_brokers/v2/errors/service_broker_api_unreachable_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +module VCAP::Services + module ServiceBrokers + module V2 + module Errors + describe ServiceBrokerApiUnreachable do + let(:uri) { 'http://www.example.com/' } + let(:error) { SocketError.new('some message') } + + before do + error.set_backtrace(['/socketerror:1', '/backtrace:2']) + end + + it 'generates the correct hash' do + exception = ServiceBrokerApiUnreachable.new(uri, 'PUT', error) + exception.set_backtrace(['/generatedexception:3', '/backtrace:4']) + + expect(exception.to_h).to eq({ + 'description' => 'The service broker API could not be reached: http://www.example.com/', + 'backtrace' => ['/generatedexception:3', '/backtrace:4'], + 'http' => { + 'uri' => uri, + 'method' => 'PUT' + }, + 'source' => { + 'description' => error.message, + 'backtrace' => ['/socketerror:1', '/backtrace:2'] + } + }) + end + + it 'renders the correct status code to the user' do + exception = ServiceBrokerApiUnreachable.new(uri, 'PUT', error) + expect(exception.response_code).to eq 502 + end + end + end + end + end +end diff --git a/spec/unit/lib/services/service_brokers/v2/errors/service_broker_bad_response_spec.rb b/spec/unit/lib/services/service_brokers/v2/errors/service_broker_bad_response_spec.rb new file mode 100644 index 00000000000..2ce744b810f --- /dev/null +++ b/spec/unit/lib/services/service_brokers/v2/errors/service_broker_bad_response_spec.rb @@ -0,0 +1,71 @@ +module VCAP::Services + module ServiceBrokers + module V2 + module Errors + describe ServiceBrokerBadResponse do + let(:uri) { 'http://www.example.com/' } + let(:response) { double(code: 500, message: 'Internal Server Error', body: response_body) } + let(:method) { 'PUT' } + + context 'with a description in the body' do + let(:response_body) do + { + 'description' => 'Some error text' + }.to_json + end + + it 'generates the correct hash' do + exception = described_class.new(uri, method, response) + exception.set_backtrace(['/foo:1', '/bar:2']) + + expect(exception.to_h).to eq({ + 'description' => 'Service broker error: Some error text', + 'backtrace' => ['/foo:1', '/bar:2'], + 'http' => { + 'status' => 500, + 'uri' => uri, + 'method' => 'PUT' + }, + 'source' => { + 'description' => 'Some error text' + } + }) + end + + it 'renders the correct status code to the user' do + exception = described_class.new(uri, method, response) + expect(exception.response_code).to eq 502 + end + end + + context 'without a description in the body' do + let(:response_body) do + { 'foo' => 'bar' }.to_json + end + + it 'generates the correct hash' do + exception = described_class.new(uri, method, response) + exception.set_backtrace(['/foo:1', '/bar:2']) + + expect(exception.to_h).to eq({ + 'description' => 'The service broker API returned an error from http://www.example.com/: 500 Internal Server Error', + 'backtrace' => ['/foo:1', '/bar:2'], + 'http' => { + 'status' => 500, + 'uri' => uri, + 'method' => 'PUT' + }, + 'source' => { 'foo' => 'bar' } + }) + end + + it 'renders the correct status code to the user' do + exception = described_class.new(uri, method, response) + expect(exception.response_code).to eq 502 + end + end + end + end + end + end +end diff --git a/spec/unit/lib/services/service_brokers/v2/errors/service_broker_conflict_spec.rb b/spec/unit/lib/services/service_brokers/v2/errors/service_broker_conflict_spec.rb new file mode 100644 index 00000000000..db20fab7412 --- /dev/null +++ b/spec/unit/lib/services/service_brokers/v2/errors/service_broker_conflict_spec.rb @@ -0,0 +1,55 @@ +require 'spec_helper' + +module VCAP::Services + module ServiceBrokers + module V2 + module Errors + describe 'ServiceBrokerConflict' do + let(:response_body) { '{"description": "error message"}' } + let(:response) { double(code: 409, reason: 'Conflict', body: response_body) } + + let(:uri) { 'http://uri.example.com' } + let(:method) { 'POST' } + let(:error) { StandardError.new } + + it 'initializes the base class correctly' do + exception = ServiceBrokerConflict.new(uri, method, response) + expect(exception.message).to eq('error message') + expect(exception.uri).to eq(uri) + expect(exception.method).to eq(method) + expect(exception.source).to eq(MultiJson.load(response.body)) + end + + it 'has a response_code of 409' do + exception = ServiceBrokerConflict.new(uri, method, response) + expect(exception.response_code).to eq(409) + end + + context 'when the response body has no description field' do + let(:response_body) { '{"field": "value"}' } + + it 'initializes the base class correctly' do + exception = ServiceBrokerConflict.new(uri, method, response) + expect(exception.message).to eq("Resource conflict: #{uri}") + expect(exception.uri).to eq(uri) + expect(exception.method).to eq(method) + expect(exception.source).to eq(MultiJson.load(response.body)) + end + end + + context 'when the body is not JSON-parsable' do + let(:response_body) { 'foo' } + + it 'initializes the base class correctly' do + exception = ServiceBrokerConflict.new(uri, method, response) + expect(exception.message).to eq("Resource conflict: #{uri}") + expect(exception.uri).to eq(uri) + expect(exception.method).to eq(method) + expect(exception.source).to eq(response.body) + end + end + end + end + end + end +end diff --git a/spec/unit/lib/services/service_brokers/v2/errors/service_broker_request_rejected_spec.rb b/spec/unit/lib/services/service_brokers/v2/errors/service_broker_request_rejected_spec.rb new file mode 100644 index 00000000000..448b41a9291 --- /dev/null +++ b/spec/unit/lib/services/service_brokers/v2/errors/service_broker_request_rejected_spec.rb @@ -0,0 +1,73 @@ +require 'spec_helper' + +module VCAP::Services + module ServiceBrokers + module V2 + module Errors + describe ServiceBrokerRequestRejected do + let(:uri) { 'http://www.example.com/' } + let(:response) { double(code: 400, message: 'Generic bad request error', body: response_body) } + let(:method) { 'PUT' } + + context 'with a description in the body' do + let(:response_body) do + { + 'description' => 'Some error text' + }.to_json + end + + it 'generates the correct hash' do + exception = described_class.new(uri, method, response) + exception.set_backtrace(['/foo:1', '/bar:2']) + + expect(exception.to_h).to eq({ + 'description' => 'Service broker error: Some error text', + 'backtrace' => ['/foo:1', '/bar:2'], + 'http' => { + 'status' => 400, + 'uri' => uri, + 'method' => 'PUT' + }, + 'source' => { + 'description' => 'Some error text' + } + }) + end + + it 'renders the correct status code to the user' do + exception = described_class.new(uri, method, response) + expect(exception.response_code).to eq 502 + end + end + + context 'without a description in the body' do + let(:response_body) do + { 'foo' => 'bar' }.to_json + end + + it 'generates the correct hash' do + exception = described_class.new(uri, method, response) + exception.set_backtrace(['/foo:1', '/bar:2']) + + expect(exception.to_h).to eq({ + 'description' => 'The service broker API returned an error from http://www.example.com/: 400 Generic bad request error', + 'backtrace' => ['/foo:1', '/bar:2'], + 'http' => { + 'status' => 400, + 'uri' => uri, + 'method' => 'PUT' + }, + 'source' => { 'foo' => 'bar' } + }) + end + + it 'renders the correct status code to the user' do + exception = described_class.new(uri, method, response) + expect(exception.response_code).to eq 502 + end + end + end + end + end + end +end diff --git a/spec/unit/lib/services/service_brokers/v2/errors/service_broker_response_malformed_spec.rb b/spec/unit/lib/services/service_brokers/v2/errors/service_broker_response_malformed_spec.rb new file mode 100644 index 00000000000..0d51bc53d25 --- /dev/null +++ b/spec/unit/lib/services/service_brokers/v2/errors/service_broker_response_malformed_spec.rb @@ -0,0 +1,29 @@ +require 'spec_helper' + +module VCAP::Services + module ServiceBrokers + module V2 + module Errors + describe ServiceBrokerResponseMalformed do + let(:uri) { 'http://uri.example.com' } + let(:method) { 'POST' } + let(:error) { StandardError.new } + let(:response_body) { 'foo' } + let(:response) { double(code: 200, reason: 'OK', body: response_body) } + + it 'initializes the base class correctly' do + exception = ServiceBrokerResponseMalformed.new(uri, method, response) + expect(exception.message).to eq('The service broker response was not understood') + expect(exception.uri).to eq(uri) + expect(exception.method).to eq(method) + expect(exception.source).to be(response.body) + end + + it 'renders a 502 to the user' do + expect(ServiceBrokerResponseMalformed.new(uri, method, response).response_code).to eq 502 + end + end + end + end + end +end diff --git a/spec/unit/lib/services/service_brokers/v2/http_client_spec.rb b/spec/unit/lib/services/service_brokers/v2/http_client_spec.rb index 20e21ed29b3..72d8c526628 100644 --- a/spec/unit/lib/services/service_brokers/v2/http_client_spec.rb +++ b/spec/unit/lib/services/service_brokers/v2/http_client_spec.rb @@ -1,47 +1,6 @@ require 'spec_helper' module VCAP::Services::ServiceBrokers::V2 - describe ServiceBrokerApiUnreachable do - let(:uri) { 'http://www.example.com/' } - let(:error) { SocketError.new('some message') } - - before do - error.set_backtrace(['/socketerror:1', '/backtrace:2']) - end - - it 'generates the correct hash' do - exception = ServiceBrokerApiUnreachable.new(uri, 'PUT', error) - exception.set_backtrace(['/generatedexception:3', '/backtrace:4']) - - expect(exception.to_h).to eq({ - 'description' => 'The service broker API could not be reached: http://www.example.com/', - 'backtrace' => ['/generatedexception:3', '/backtrace:4'], - 'http' => { - 'uri' => uri, - 'method' => 'PUT' - }, - 'source' => { - 'description' => error.message, - 'backtrace' => ['/socketerror:1', '/backtrace:2'] - } - }) - end - end - - describe ServiceBrokerApiTimeout do - let(:uri) { 'http://uri.example.com' } - let(:method) { 'POST' } - let(:error) { StandardError.new } - - it 'initializes the base class correctly' do - exception = ServiceBrokerApiTimeout.new(uri, method, error) - expect(exception.message).to eq("The service broker API timed out: #{uri}") - expect(exception.uri).to eq(uri) - expect(exception.method).to eq(method) - expect(exception.source).to be(error) - end - end - describe HttpClient do let(:auth_username) { 'me' } let(:auth_password) { 'abc123' } @@ -69,7 +28,7 @@ module VCAP::Services::ServiceBrokers::V2 describe 'returning a correct response object' do subject { make_request } - its(:code) { should eq('200') } + its(:code) { should eq(200) } its(:body) { should_not be_nil } end @@ -109,22 +68,34 @@ module VCAP::Services::ServiceBrokers::V2 end describe 'ssl cert verification' do - let(:response) { double(code: nil, body: nil, to_hash: nil) } + let(:http_client) do + double(:http_client, + :connect_timeout= => nil, + :receive_timeout= => nil, + :send_timeout= => nil, + :set_auth => nil, + :default_header => {}, + :ssl_config => ssl_config, + :request => response) + end + + let(:response) { double(:response, code: nil, body: nil, headers: nil) } + let(:ssl_config) { double(:ssl_config, :verify_mode= => nil) } before do allow(VCAP::CloudController::Config).to receive(:config).and_return(config) + allow(HTTPClient).to receive(:new).and_return(http_client) + allow(http_client).to receive(http_method) end context 'and the skip_cert_verify is set to true' do let(:config) { { skip_cert_verify: true } } it 'accepts self-signed cert from the broker' do - expect(Net::HTTP).to receive(:start) { |host, port, opts, &blk| - expect(host).to eq 'broker.example.com' - expect(port).to eq 443 - expect(opts).to eq({ use_ssl: true, verify_mode: OpenSSL::SSL::VERIFY_NONE }) - }.and_return(response) make_request + + expect(http_client).to have_received(:ssl_config) + expect(ssl_config).to have_received(:verify_mode=).with(OpenSSL::SSL::VERIFY_NONE) end end @@ -132,12 +103,10 @@ module VCAP::Services::ServiceBrokers::V2 let(:config) { { skip_cert_verify: false } } it 'does not accept self-signed cert from the broker' do - expect(Net::HTTP).to receive(:start) { |host, port, opts, &blk| - expect(host).to eq 'broker.example.com' - expect(port).to eq 443 - expect(opts).to eq({ use_ssl: true, verify_mode: OpenSSL::SSL::VERIFY_PEER }) - }.and_return(response) make_request + + expect(http_client).to have_received(:ssl_config) + expect(ssl_config).to have_received(:verify_mode=).with(OpenSSL::SSL::VERIFY_PEER) end end end @@ -153,7 +122,7 @@ module VCAP::Services::ServiceBrokers::V2 it 'raises an unreachable error' do expect { request }. - to raise_error(ServiceBrokerApiUnreachable) + to raise_error(Errors::ServiceBrokerApiUnreachable) end end @@ -164,7 +133,7 @@ module VCAP::Services::ServiceBrokers::V2 it 'raises an unreachable error' do expect { request }. - to raise_error(ServiceBrokerApiUnreachable) + to raise_error(Errors::ServiceBrokerApiUnreachable) end end end @@ -172,12 +141,12 @@ module VCAP::Services::ServiceBrokers::V2 context 'when the API times out' do context 'because the client gave up' do before do - stub_request(http_method, full_url).to_raise(Timeout::Error) + stub_request(http_method, full_url).to_raise(HTTPClient::TimeoutError) end it 'raises a timeout error' do expect { request }. - to raise_error(ServiceBrokerApiTimeout) + to raise_error(Errors::ServiceBrokerApiTimeout) end end end @@ -186,23 +155,36 @@ module VCAP::Services::ServiceBrokers::V2 shared_examples 'timeout behavior' do before do allow(VCAP::CloudController::Config).to receive(:config).and_return(config) - allow(Net::HTTP).to receive(:start).and_yield(http) + allow(HTTPClient).to receive(:new).and_return(http_client) + allow(http_client).to receive(http_method) + end + + let(:http_client) do + double(:http_client, + :connect_timeout= => nil, + :receive_timeout= => nil, + :send_timeout= => nil, + :set_auth => nil, + :default_header => {}, + :ssl_config => ssl_config, + :request => response) end - let(:http) { double('http', request: response) } - let(:response) { double(:response, code: 200, body: {}.to_json, to_hash: {}) } + let(:response) { double(:response, code: 200, body: {}.to_json, headers: {}) } + let(:ssl_config) { double(:ssl_config, :verify_mode= => nil) } def expect_timeout_to_be(timeout) - expect(http).to receive(:open_timeout=).with(timeout) - expect(http).to receive(:read_timeout=).with(timeout) + expect(http_client).to have_received(:connect_timeout=).with(timeout) + expect(http_client).to have_received(:receive_timeout=).with(timeout) + expect(http_client).to have_received(:send_timeout=).with(timeout) end context 'when the broker client timeout is set' do let(:config) { { broker_client_timeout_seconds: 100 } } it 'sets HTTP timeouts on request' do - expect_timeout_to_be 100 request + expect_timeout_to_be 100 end end @@ -210,8 +192,8 @@ def expect_timeout_to_be(timeout) let(:config) { { missing_broker_client_timeout: nil } } it 'defaults to a 60 second timeout' do - expect_timeout_to_be 60 request + expect_timeout_to_be 60 end end end @@ -244,7 +226,7 @@ def expect_timeout_to_be(timeout) it 'does not have a content body' do make_request expect(a_request(:get, full_url). - with { |req| expect(req.body).to be_nil }). + with { |req| expect(req.body).to be_empty }). to have_been_made end @@ -401,7 +383,7 @@ def expect_timeout_to_be(timeout) make_request expect(a_request(:delete, full_url). with(query: message). - with { |req| expect(req.body).to be_nil }). + with { |req| expect(req.body).to be_empty }). to have_been_made end diff --git a/spec/unit/lib/services/service_brokers/v2/orphan_mitigator_spec.rb b/spec/unit/lib/services/service_brokers/v2/orphan_mitigator_spec.rb new file mode 100644 index 00000000000..e3413fbcc3e --- /dev/null +++ b/spec/unit/lib/services/service_brokers/v2/orphan_mitigator_spec.rb @@ -0,0 +1,88 @@ +require 'spec_helper' + +module VCAP::Services + module ServiceBrokers::V2 + describe OrphanMitigator do + let(:client_attrs) { { uri: 'broker.com' } } + + let(:plan) { VCAP::CloudController::ServicePlan.make } + let(:space) { VCAP::CloudController::Space.make } + let(:service_instance) { VCAP::CloudController::ManagedServiceInstance.new(service_plan: plan, space: space) } + let(:service_binding) do + VCAP::CloudController::ServiceBinding.make( + binding_options: { 'this' => 'that' } + ) + end + let(:name) { 'fake-name' } + + describe 'cleanup_failed_provision' do + it 'enqueues a deprovison job' do + mock_enqueuer = double(:enqueuer, enqueue: nil) + allow(VCAP::CloudController::Jobs::Enqueuer).to receive(:new).and_return(mock_enqueuer) + + OrphanMitigator.new.cleanup_failed_provision(client_attrs, service_instance) + + expect(VCAP::CloudController::Jobs::Enqueuer).to have_received(:new) do |job, opts| + expect(opts[:queue]).to eq 'cc-generic' + expect(opts[:run_at]).to be_within(0.01).of(Delayed::Job.db_time_now) + + expect(job).to be_a VCAP::CloudController::Jobs::Services::ServiceInstanceDeprovision + expect(job.name).to eq 'service-instance-deprovision' + expect(job.client_attrs).to eq client_attrs + expect(job.service_instance_guid).to eq service_instance.guid + expect(job.service_plan_guid).to eq service_instance.service_plan.guid + end + + expect(mock_enqueuer).to have_received(:enqueue) + end + + specify 'the enqueued job has a reschedule_at define such that exponential backoff occurs' do + now = Time.now + + OrphanMitigator.new.cleanup_failed_provision(client_attrs, service_instance) + job = Delayed::Job.first.payload_object + expect(job).to respond_to :reschedule_at + + 10.times do |attempt| + expect(job.reschedule_at(now, attempt)).to be_within(0.01).of(now + (2**attempt).minutes) + end + end + end + + describe 'cleanup_failed_bind' do + it 'enqueues an unbind job' do + mock_enqueuer = double(:enqueuer, enqueue: nil) + allow(VCAP::CloudController::Jobs::Enqueuer).to receive(:new).and_return(mock_enqueuer) + + OrphanMitigator.new.cleanup_failed_bind(client_attrs, service_binding) + + expect(VCAP::CloudController::Jobs::Enqueuer).to have_received(:new) do |job, opts| + expect(opts[:queue]).to eq 'cc-generic' + expect(opts[:run_at]).to be_within(0.01).of(Delayed::Job.db_time_now) + + expect(job).to be_a VCAP::CloudController::Jobs::Services::ServiceInstanceUnbind + expect(job.name).to eq 'service-instance-unbind' + expect(job.client_attrs).to eq client_attrs + expect(job.binding_guid).to eq service_binding.guid + expect(job.service_instance_guid).to eq service_binding.service_instance.guid + expect(job.app_guid).to eq service_binding.app.guid + end + + expect(mock_enqueuer).to have_received(:enqueue) + end + + specify 'the enqueued job has a reschedule_at define such that exponential backoff occurs' do + now = Time.now + + OrphanMitigator.new.cleanup_failed_bind(client_attrs, service_binding) + job = Delayed::Job.first.payload_object + expect(job).to respond_to :reschedule_at + + 10.times do |attempt| + expect(job.reschedule_at(now, attempt)).to be_within(0.01).of(now + (2**attempt).minutes) + end + end + end + end + end +end diff --git a/spec/unit/lib/services/service_brokers/v2/response_parser_spec.rb b/spec/unit/lib/services/service_brokers/v2/response_parser_spec.rb new file mode 100644 index 00000000000..894da5e23e1 --- /dev/null +++ b/spec/unit/lib/services/service_brokers/v2/response_parser_spec.rb @@ -0,0 +1,127 @@ +require 'spec_helper' + +module VCAP::Services + module ServiceBrokers + module V2 + describe ResponseParser do + let(:url) { 'my.service-broker.com' } + subject(:parser) { ResponseParser.new(url) } + + let(:logger) { double(:logger, warn: nil) } + before do + allow(Steno).to receive(:logger).and_return(logger) + end + + describe '#parse' do + let(:response) { double(:response, body: body, code: code) } + let(:body) { '{"foo": "bar"}' } + let(:code) { 200 } + + it 'returns the response body hash' do + response_hash = parser.parse(:get, '/v2/catalog', response) + expect(response_hash).to eq({ 'foo' => 'bar' }) + end + + context 'when the status code is 204' do + let(:code) { 204 } + it 'returns a nil response' do + response_hash = parser.parse(:get, '/v2/catalog', response) + expect(response_hash).to be_nil + end + end + + context 'when the body cannot be json parsed' do + let(:body) { '{ "not json" }' } + + it 'raises a MalformedResponse error' do + expect { + parser.parse(:get, '/v2/catalog', response) + }.to raise_error(Errors::ServiceBrokerResponseMalformed) + expect(logger).to have_received(:warn).with(/MultiJson parse error/) + end + end + + context 'when the body is JSON, but not a hash' do + let(:body) { '["just", "a", "list"]' } + + it 'raises a MalformedResponse error' do + expect { + parser.parse(:get, '/v2/catalog', response) + }.to raise_error(Errors::ServiceBrokerResponseMalformed) + expect(logger).not_to have_received(:warn) + end + end + + context 'when the status code is HTTP Unauthorized (401)' do + let(:code) { 401 } + it 'raises a ServiceBrokerApiAuthenticationFailed error' do + expect { + parser.parse(:get, '/v2/catalog', response) + }.to raise_error(Errors::ServiceBrokerApiAuthenticationFailed) + end + end + + context 'when the status code is HTTP Request Timeout (408)' do + let(:code) { 408 } + it 'raises a Errors::ServiceBrokerApiTimeout error' do + expect { + parser.parse(:get, '/v2/catalog', response) + }.to raise_error(Errors::ServiceBrokerApiTimeout) + end + end + + context 'when the status code is HTTP Conflict (409)' do + let(:code) { 409 } + it 'raises a ServiceBrokerConflict error' do + expect { + parser.parse(:get, '/v2/catalog', response) + }.to raise_error(Errors::ServiceBrokerConflict) + end + end + + context 'when the status code is HTTP Gone (410)' do + let(:code) { 410 } + let(:method) { :get } + let(:body) { '{"description": "there was an error"}' } + it 'raises ServiceBrokerBadResponse' do + expect { + parser.parse(method, '/v2/catalog', response) + }.to raise_error(Errors::ServiceBrokerBadResponse, /there was an error/) + end + + context 'and the http method is delete' do + let(:method) { :delete } + it 'does not raise an error and logs a warning' do + response_hash = parser.parse(method, '/v2/catalog', response) + expect(response_hash).to be_nil + expect(logger).to have_received(:warn).with(/Already deleted/) + end + end + end + + context 'when the status code is any other 4xx error' do + let(:code) { 400 } + let(:method) { :get } + let(:body) { '{"description": "there was an error"}' } + it 'raises ServiceBrokerRequestRejected' do + expect { + parser.parse(method, '/v2/catalog', response) + }.to raise_error(Errors::ServiceBrokerRequestRejected, /there was an error/) + end + end + + context 'when the status code is any 5xx error' do + let(:code) { 500 } + let(:method) { :get } + let(:body) { '{"description": "there was an error"}' } + it 'raises ServiceBrokerBadResponse' do + expect { + parser.parse(method, '/v2/catalog', response) + }.to raise_error(Errors::ServiceBrokerBadResponse, /there was an error/) + end + end + end + end + end + end +end diff --git a/spec/unit/lib/services/service_brokers/v2/service_instance_deprovisioner_spec.rb b/spec/unit/lib/services/service_brokers/v2/service_instance_deprovisioner_spec.rb deleted file mode 100644 index b9444e6585b..00000000000 --- a/spec/unit/lib/services/service_brokers/v2/service_instance_deprovisioner_spec.rb +++ /dev/null @@ -1,31 +0,0 @@ -require 'spec_helper' - -module VCAP::CloudController - module ServiceBrokers::V2 - describe ServiceInstanceDeprovisioner do - let(:client_attrs) { {} } - - let(:plan) { VCAP::CloudController::ServicePlan.make } - let(:space) { VCAP::CloudController::Space.make } - let(:service_instance) { VCAP::CloudController::ManagedServiceInstance.new(service_plan: plan, space: space) } - - let(:name) { 'fake-name' } - - describe 'deprovision' do - it 'creates a ServiceInstanceDeprovision Job' do - job = ServiceInstanceDeprovisioner.deprovision(client_attrs, service_instance) - expect(job).to be_instance_of(VCAP::CloudController::Jobs::Services::ServiceInstanceDeprovision) - expect(job.client_attrs).to be(client_attrs) - expect(job.service_instance_guid).to be(service_instance.guid) - expect(job.service_plan_guid).to be(service_instance.service_plan.guid) - end - - it 'enqueues a ServiceInstanceDeprovision Job' do - expect(Delayed::Job).to receive(:enqueue).with(an_instance_of(VCAP::CloudController::Jobs::Services::ServiceInstanceDeprovision), - hash_including(queue: 'cc-generic')) - ServiceInstanceDeprovisioner.deprovision(client_attrs, service_instance) - end - end - end - end -end diff --git a/spec/unit/lib/services/service_brokers/v2/service_instance_state_poller_spec.rb b/spec/unit/lib/services/service_brokers/v2/service_instance_state_poller_spec.rb new file mode 100644 index 00000000000..0e27f5705b0 --- /dev/null +++ b/spec/unit/lib/services/service_brokers/v2/service_instance_state_poller_spec.rb @@ -0,0 +1,35 @@ +require 'spec_helper' + +module VCAP::Services + module ServiceBrokers::V2 + describe ServiceInstanceStatePoller do + let(:client_attrs) { { uri: 'broker.com' } } + + let(:plan) { VCAP::CloudController::ServicePlan.make } + let(:space) { VCAP::CloudController::Space.make } + let(:service_instance) { VCAP::CloudController::ManagedServiceInstance.new(service_plan: plan, space: space) } + + describe 'poll_service_instance_state' do + it 'enqueues a ServiceInstanceStateFetch job' do + mock_enqueuer = double(:enqueuer, enqueue: nil) + allow(VCAP::CloudController::Jobs::Enqueuer).to receive(:new).and_return(mock_enqueuer) + + ServiceInstanceStatePoller.new.poll_service_instance_state(client_attrs, service_instance) + + expect(VCAP::CloudController::Jobs::Enqueuer).to have_received(:new) do |job, opts| + expect(opts[:queue]).to eq 'cc-generic' + expect(opts[:run_at]).to be_within(0.01).of(Delayed::Job.db_time_now) + + expect(job).to be_a VCAP::CloudController::Jobs::Services::ServiceInstanceStateFetch + expect(job.name).to eq 'service-instance-state-fetch' + expect(job.client_attrs).to eq client_attrs + expect(job.service_instance_guid).to eq service_instance.guid + expect(job.service_plan_guid).to eq service_instance.service_plan.guid + end + + expect(mock_enqueuer).to have_received(:enqueue) + end + end + end + end +end diff --git a/spec/unit/lib/services/service_brokers/v2/service_instance_unbinder_spec.rb b/spec/unit/lib/services/service_brokers/v2/service_instance_unbinder_spec.rb deleted file mode 100644 index 228c95fea38..00000000000 --- a/spec/unit/lib/services/service_brokers/v2/service_instance_unbinder_spec.rb +++ /dev/null @@ -1,32 +0,0 @@ -require 'spec_helper' - -module VCAP::CloudController - module ServiceBrokers::V2 - describe ServiceInstanceUnbinder do - let(:client_attrs) { {} } - let(:binding) do - VCAP::CloudController::ServiceBinding.make( - binding_options: { 'this' => 'that' } - ) - end - let(:name) { 'fake-name' } - - describe 'unbind' do - it 'creates a ServiceInstanceUnbind Job' do - job = ServiceInstanceUnbinder.unbind(client_attrs, binding) - expect(job).to be_instance_of(VCAP::CloudController::Jobs::Services::ServiceInstanceUnbind) - expect(job.client_attrs).to be(client_attrs) - expect(job.binding_guid).to be(binding.guid) - expect(job.service_instance_guid).to be(binding.service_instance.guid) - expect(job.app_guid).to be(binding.app.guid) - end - - it 'enqueues a ServiceInstanceUnbind Job' do - expect(Delayed::Job).to receive(:enqueue).with(an_instance_of(VCAP::CloudController::Jobs::Services::ServiceInstanceUnbind), - hash_including(queue: 'cc-generic')) - ServiceInstanceUnbinder.unbind(client_attrs, binding) - end - end - end - end -end diff --git a/spec/unit/lib/services/sso/dashboard_client_manager_spec.rb b/spec/unit/lib/services/sso/dashboard_client_manager_spec.rb index d6c7f697558..f883ad3bcaa 100644 --- a/spec/unit/lib/services/sso/dashboard_client_manager_spec.rb +++ b/spec/unit/lib/services/sso/dashboard_client_manager_spec.rb @@ -307,7 +307,7 @@ module VCAP::Services::SSO describe 'exception handling' do context 'when getting UAA clients raises an error' do before do - error = VCAP::Services::SSO::UAA::UaaError.new('my test error') + error = VCAP::CloudController::UaaError.new('my test error') expect(client_manager).to receive(:get_clients).and_raise(error) end @@ -324,7 +324,7 @@ module VCAP::Services::SSO before do allow(client_manager).to receive(:get_clients).and_return([{ 'client_id' => unused_id }]) - allow(client_manager).to receive(:modify_transaction).and_raise(VCAP::Services::SSO::UAA::UaaError.new('error message')) + allow(client_manager).to receive(:modify_transaction).and_raise(VCAP::CloudController::UaaError.new('error message')) VCAP::CloudController::ServiceDashboardClient.new( uaa_id: unused_id, @@ -491,7 +491,7 @@ module VCAP::Services::SSO context 'when deleting UAA clients fails' do before do - error = VCAP::Services::SSO::UAA::UaaError.new('error message') + error = VCAP::CloudController::UaaError.new('error message') allow(client_manager).to receive(:modify_transaction).and_raise(error) end @@ -518,7 +518,7 @@ module VCAP::Services::SSO context 'when getting UAA clients raises an error' do before do - error = VCAP::Services::SSO::UAA::UaaError.new('my test error') + error = VCAP::CloudController::UaaError.new('my test error') expect(client_manager).to receive(:get_clients).and_raise(error) end diff --git a/spec/unit/lib/services/sso/uaa/uaa_client_manager_spec.rb b/spec/unit/lib/services/sso/uaa/uaa_client_manager_spec.rb index 679db4a4d6c..b202ae8dc0c 100644 --- a/spec/unit/lib/services/sso/uaa/uaa_client_manager_spec.rb +++ b/spec/unit/lib/services/sso/uaa/uaa_client_manager_spec.rb @@ -9,49 +9,6 @@ module VCAP::Services::SSO::UAA 'redirect_uri' => 'http://redirect.com' } end - let(:scim) { double('scim') } - - describe 'building a scim' do - it 'knows how to build a valid scim' do - creator = UaaClientManager.new - token_info = double('info', auth_header: 'bearer BLAH') - token_issuer = double('issuer', client_credentials_grant: token_info) - - opts = { skip_ssl_validation: false } - allow(CF::UAA::TokenIssuer).to receive(:new).with('http://localhost:8080/uaa', 'cc-service-dashboards', 'some-sekret', opts).and_return(token_issuer) - - expect(creator.send(:scim)).to be_a(CF::UAA::Scim) - expect(token_issuer).to have_received(:client_credentials_grant) - end - end - - describe '#get_clients' do - it 'returns the clients that are in uaa' do - allow(scim).to receive(:get).and_return({ 'client_id' => 'existing-id' }) - client_manager = UaaClientManager.new(scim: scim) - - result = client_manager.get_clients(['existing-id']) - - expect(scim).to have_received(:get).with(:client, 'existing-id').once - expect(result).to be_a(Array) - expect(result.length).to eq(1) - expect(result[0]).to include('client_id' => 'existing-id') - end - - it 'does not return clients that are not in uaa' do - allow(scim).to receive(:get).with(:client, 'existing-id').and_return({ 'client_id' => 'existing-id' }) - allow(scim).to receive(:get).with(:client, 'non-existing-id').and_raise(CF::UAA::NotFound.new) - client_manager = UaaClientManager.new(scim: scim) - - result = client_manager.get_clients(['existing-id', 'non-existing-id']) - - expect(scim).to have_received(:get).with(:client, 'existing-id').once - expect(scim).to have_received(:get).with(:client, 'non-existing-id').once - expect(result).to be_a(Array) - expect(result.length).to eq(1) - expect(result[0]).to include('client_id' => 'existing-id') - end - end describe '#modify_transaction' do let(:uaa_uri) { VCAP::CloudController::Config.config[:uaa][:url] } @@ -152,7 +109,7 @@ module VCAP::Services::SSO::UAA expect { client_manager.modify_transaction(changeset) - }.to raise_error(UaaResourceNotFound) + }.to raise_error(VCAP::CloudController::UaaResourceNotFound) end end @@ -171,7 +128,7 @@ module VCAP::Services::SSO::UAA expect { client_manager.modify_transaction(changeset) - }.to raise_error(UaaUnavailable) + }.to raise_error(VCAP::CloudController::UaaUnavailable) end end @@ -189,7 +146,7 @@ module VCAP::Services::SSO::UAA expect { client_manager.modify_transaction(changeset) - }.to raise_error(UaaResourceAlreadyExists) + }.to raise_error(VCAP::CloudController::UaaResourceAlreadyExists) end end @@ -207,7 +164,7 @@ module VCAP::Services::SSO::UAA expect { client_manager.modify_transaction(changeset) - }.to raise_error(UaaResourceInvalid) + }.to raise_error(VCAP::CloudController::UaaResourceInvalid) end end @@ -225,7 +182,7 @@ module VCAP::Services::SSO::UAA expect { client_manager.modify_transaction(changeset) - }.to raise_error(UaaUnexpectedResponse) + }.to raise_error(VCAP::CloudController::UaaUnexpectedResponse) end end @@ -243,7 +200,7 @@ module VCAP::Services::SSO::UAA expect { client_manager.modify_transaction(changeset) - }.to raise_error(UaaUnavailable) + }.to raise_error(VCAP::CloudController::UaaUnavailable) end end diff --git a/spec/unit/lib/uaa/uaa_client_spec.rb b/spec/unit/lib/uaa/uaa_client_spec.rb new file mode 100644 index 00000000000..ea5dfe5f63c --- /dev/null +++ b/spec/unit/lib/uaa/uaa_client_spec.rb @@ -0,0 +1,124 @@ +require 'spec_helper' + +module VCAP::CloudController + describe UaaClient do + let(:url) { 'http://uaa.example.com' } + let(:client_id) { 'client_id' } + let(:secret) { 'secret_key' } + let(:uaa_options) { { skip_ssl_validation: false } } + + let(:uaa_client) { UaaClient.new(url, client_id, secret) } + let(:auth_header) { 'bearer STUFF' } + let(:token_info) { double(CF::UAA::TokenInfo, auth_header: auth_header) } + let(:token_issuer) { double(CF::UAA::TokenIssuer, client_credentials_grant: token_info) } + + before do + allow(CF::UAA::TokenIssuer).to receive(:new).with(url, client_id, secret, uaa_options).and_return(token_issuer) + end + + describe '#scim' do + it 'knows how to build a valid scim' do + scim = uaa_client.scim + expect(scim).to be_a(CF::UAA::Scim) + expect(scim.instance_variable_get(:@target)).to eq(url) + expect(scim.instance_variable_get(:@auth_header)).to eq(auth_header) + end + + it 'caches the scim' do + expect(uaa_client.scim).to be(uaa_client.scim) + end + + context 'when skip_ssl_validation is true' do + let(:uaa_options) { { skip_ssl_validation: true } } + let(:uaa_client) { UaaClient.new(url, client_id, secret, uaa_options) } + + it 'skips ssl validation for TokenIssuer and Scim creation' do + scim = uaa_client.scim + expect(scim.instance_variable_get(:@skip_ssl_validation)).to eq(true) + end + end + end + + describe '#get_clients' do + let(:scim) { double('scim') } + + it 'returns the clients that are in uaa' do + allow(scim).to receive(:get).and_return({ 'client_id' => 'existing-id' }) + allow(uaa_client).to receive(:scim).and_return(scim) + result = uaa_client.get_clients(['existing-id']) + + expect(scim).to have_received(:get).with(:client, 'existing-id').once + expect(result).to be_a(Array) + expect(result.length).to eq(1) + expect(result[0]).to include('client_id' => 'existing-id') + end + + it 'does not return clients that are not in uaa' do + allow(scim).to receive(:get).with(:client, 'existing-id').and_return({ 'client_id' => 'existing-id' }) + allow(scim).to receive(:get).with(:client, 'non-existing-id').and_raise(CF::UAA::NotFound.new) + allow(uaa_client).to receive(:scim).and_return(scim) + result = uaa_client.get_clients(['existing-id', 'non-existing-id']) + + expect(scim).to have_received(:get).with(:client, 'existing-id').once + expect(scim).to have_received(:get).with(:client, 'non-existing-id').once + expect(result).to be_a(Array) + expect(result.length).to eq(1) + expect(result[0]).to include('client_id' => 'existing-id') + end + end + + describe '#usernames_for_ids' do + let(:userid_1) { '111' } + let(:userid_2) { '222' } + + it 'returns a map of the given ids to the corresponding usernames from UAA' do + response_body = { + 'resources' => [ + { 'id' => '111', 'origin' => 'uaa', 'username' => 'user_1' }, + { 'id' => '222', 'origin' => 'uaa', 'username' => 'user_2' } + ], + 'schemas' => ['urn:scim:schemas:core:1.0'], + 'startindex' => 1, + 'itemsperpage' => 100, + 'totalresults' => 2 } + + WebMock::API.stub_request(:get, "#{url}/ids/Users"). + with(query: { 'filter' => 'id eq "111" or id eq "222"' }). + to_return( + status: 200, + headers: { 'content-type' => 'application/json' }, + body: response_body.to_json) + + mapping = uaa_client.usernames_for_ids([userid_1, userid_2]) + expect(mapping[userid_1]).to eq('user_1') + expect(mapping[userid_2]).to eq('user_2') + end + + it 'returns an empty hash when given no ids' do + expect(uaa_client.usernames_for_ids([])).to eq({}) + end + + context 'when UAA is unavailable' do + before do + allow(uaa_client).to receive(:token_info).and_raise(UaaUnavailable) + end + + it 'returns an empty hash' do + expect(uaa_client.usernames_for_ids([userid_1])).to eq({}) + end + end + + context 'when the endpoint returns an error' do + before do + scim = double('scim') + allow(scim).to receive(:query).and_raise(CF::UAA::TargetError) + allow(uaa_client).to receive(:scim).and_return(scim) + end + + it 'returns an empty hash' do + expect(uaa_client.usernames_for_ids([userid_1])).to eq({}) + end + end + end + end +end diff --git a/spec/unit/lib/vcap/rest_api/query_spec.rb b/spec/unit/lib/vcap/rest_api/query_spec.rb index c754c1cd8c5..adb9290cc6d 100644 --- a/spec/unit/lib/vcap/rest_api/query_spec.rb +++ b/spec/unit/lib/vcap/rest_api/query_spec.rb @@ -230,17 +230,35 @@ class Subscriber < Sequel::Model end describe 'boolean values on boolean column' do - it 'returns correctly filtered results for true' do + it 'returns correctly filtered results for t' do ds = Query.filtered_dataset_from_query_params( Author, Author.dataset, @queryable_attributes, q: 'published:t') expect(ds.all).to eq([Author.first]) end - it 'returns correctly filtered results for false' do + it 'returns correctly filtered results for true' do + ds = Query.filtered_dataset_from_query_params( + Author, Author.dataset, @queryable_attributes, q: 'published:true') + expect(ds.all).to eq([Author.first]) + end + + it 'returns correctly filtered results for f' do ds = Query.filtered_dataset_from_query_params( Author, Author.dataset, @queryable_attributes, q: 'published:f') expect(ds.all).to eq(Author.all - [Author.first]) end + + it 'returns correctly filtered results for false' do + ds = Query.filtered_dataset_from_query_params( + Author, Author.dataset, @queryable_attributes, q: 'published:false') + expect(ds.all).to eq(Author.all - [Author.first]) + end + + it 'returns resulted filtered as false for any other value' do + ds = Query.filtered_dataset_from_query_params( + Author, Author.dataset, @queryable_attributes, q: 'published:foobar') + expect(ds.all).to eq(Author.all - [Author.first]) + end end describe 'errors' do diff --git a/spec/unit/models/runtime/app_bits_package_spec.rb b/spec/unit/models/runtime/app_bits_package_spec.rb index eaa4838775b..c345ce5f9c4 100644 --- a/spec/unit/models/runtime/app_bits_package_spec.rb +++ b/spec/unit/models/runtime/app_bits_package_spec.rb @@ -12,6 +12,7 @@ let(:compressed_path) { File.expand_path('../../../fixtures/good.zip', File.dirname(__FILE__)) } let(:app) { VCAP::CloudController::AppFactory.make } + let(:package) { VCAP::CloudController::PackageModel.make } let(:blobstore_dir) { Dir.mktmpdir } let(:local_tmp_dir) { Dir.mktmpdir } @@ -37,6 +38,73 @@ FileUtils.remove_entry_secure blobstore_dir end + describe '#create_package_in_blobstore' do + let(:max_package_size) { nil } + let(:package_guid) { package.guid } + subject(:create) { packer.create_package_in_blobstore(package_guid, compressed_path) } + + it 'uploads the package zip to the package blob store' do + create + expect(package_blobstore.exists?(package_guid)).to be true + end + + it 'sets the package sha to the package' do + expect { create }.to change { package.refresh.package_hash } + end + + it 'sets the state of the package' do + expect { create }.to change { package.refresh.state }.to('READY') + end + + it 'removes the compressed path afterwards' do + expect(FileUtils).to receive(:rm_f).with(compressed_path) + create + end + + context 'when there is no package uploaded' do + let(:compressed_path) { nil } + + it "doesn't try to remove the file" do + expect(FileUtils).not_to receive(:rm_f) + create + end + end + + context 'when the package no longer exists' do + let(:package_guid) { 'abcd' } + + it 'raises an error and removes the compressed path' do + expect(FileUtils).to receive(:rm_f).with(compressed_path) + expect { create }.to raise_error + end + end + + context 'when copying to the blobstore fails' do + it 'logs the exception on the package and reraises the exception' do + allow(package_blobstore).to receive(:cp_to_blobstore).and_raise('BOOM') + expect { create }.to raise_error + expect(package.reload.state).to eq('FAILED') + expect(package.error).to eq('BOOM') + end + + it 'removes the compressed path afterwards' do + expect(FileUtils).to receive(:rm_f).with(compressed_path) + create + end + end + + context 'when the app bits are too large' do + let(:max_package_size) { 10 } + + it 'raises an exception and deletes the bits' do + expect(FileUtils).to receive(:rm_f).with(compressed_path) + expect { + create + }.to raise_error(AppBitsPackage::ZipSizeExceeded) + end + end + end + describe '#create' do subject(:create) { packer.create(app, compressed_path, fingerprints_in_app_cache) } diff --git a/spec/unit/models/runtime/app_spec.rb b/spec/unit/models/runtime/app_spec.rb index d2141a24104..b531abec61a 100644 --- a/spec/unit/models/runtime/app_spec.rb +++ b/spec/unit/models/runtime/app_spec.rb @@ -1577,18 +1577,6 @@ def self.it_does_not_mark_for_re_staging end end - describe 'versioned_guid' do - before do - @app = App.new - @app.guid = 'appguid' - @app.version = 'versionuuid' - end - - it "is the app's guid qualified by its version" do - expect(@app.versioned_guid).to eq('appguid-versionuuid') - end - end - describe 'uris' do it 'should return the uris on the app' do app = AppFactory.make(space: space) @@ -1655,6 +1643,17 @@ def self.it_does_not_mark_for_re_staging expect(app.disk_quota).to eq(512) end end + + describe 'container_file_descriptor_limit' do + before do + TestConfig.override({ container_file_descriptor_limit: 200 }) + end + + it 'uses the container_file_descriptor_limit config variable' do + app = App.create_from_hash(name: 'awesome app', space_guid: space.guid) + expect(app.file_descriptors).to eq(200) + end + end end describe 'saving' do diff --git a/spec/unit/models/runtime/buildpack_spec.rb b/spec/unit/models/runtime/buildpack_spec.rb index 7f2fb9ad900..4c81b1b9876 100644 --- a/spec/unit/models/runtime/buildpack_spec.rb +++ b/spec/unit/models/runtime/buildpack_spec.rb @@ -52,6 +52,10 @@ def ordered_buildpacks Buildpack.dataset.destroy end + after do + Timecop.return + end + subject(:all_buildpacks) { Buildpack.list_admin_buildpacks } context 'with prioritized buildpacks' do diff --git a/spec/unit/models/runtime/constraints/health_check_policy_spec.rb b/spec/unit/models/runtime/constraints/health_check_policy_spec.rb index 333bc3a5b0f..f88ad821a36 100644 --- a/spec/unit/models/runtime/constraints/health_check_policy_spec.rb +++ b/spec/unit/models/runtime/constraints/health_check_policy_spec.rb @@ -4,17 +4,19 @@ let(:app) { VCAP::CloudController::AppFactory.make } subject(:validator) { HealthCheckPolicy.new(app, health_check_timeout) } + let(:max_health_check_timeout) { 512 } describe 'health_check_timeout' do before do - TestConfig.override({ maximum_health_check_timeout: 512 }) + TestConfig.override({ maximum_health_check_timeout: max_health_check_timeout }) end context 'when a health_check_timeout exceeds the maximum' do let(:health_check_timeout) { 1024 } it 'registers error' do - expect(validator).to validate_with_error(app, :health_check_timeout, :maximum_exceeded) + error_msg = "Maximum exceeded: max #{max_health_check_timeout}s" + expect(validator).to validate_with_error(app, :health_check_timeout, error_msg) end end diff --git a/spec/unit/models/runtime/droplet_spec.rb b/spec/unit/models/runtime/droplet_spec.rb index 673529432b7..c17df9fa68e 100644 --- a/spec/unit/models/runtime/droplet_spec.rb +++ b/spec/unit/models/runtime/droplet_spec.rb @@ -60,10 +60,11 @@ module VCAP::CloudController app = AppFactory.make droplet = app.current_droplet enqueuer = double('Enqueuer', enqueue: true) - expect(Jobs::Enqueuer).to receive(:new).with( - Jobs::Runtime::DropletDeletion.new(droplet.new_blobstore_key, droplet.old_blobstore_key), - queue: 'cc-generic' - ).and_return(enqueuer) + expect(Jobs::Enqueuer).to receive(:new) do |job, opts| + expect(job.new_droplet_key).to eq droplet.new_blobstore_key + expect(job.old_droplet_key).to eq droplet.old_blobstore_key + expect(opts[:queue]).to eq 'cc-generic' + end.and_return(enqueuer) droplet.destroy end end diff --git a/spec/unit/models/runtime/user_spec.rb b/spec/unit/models/runtime/user_spec.rb index 17aca037cd9..6e953e9e03c 100644 --- a/spec/unit/models/runtime/user_spec.rb +++ b/spec/unit/models/runtime/user_spec.rb @@ -230,5 +230,18 @@ module VCAP::CloudController end end end + + describe '#export_attrs' do + let(:user) { User.make } + + it 'does not include username when username has not been set' do + expect(user.export_attrs).to_not include(:username) + end + + it 'includes username when username has been set' do + user.username = 'somebody' + expect(user.export_attrs).to include(:username) + end + end end end diff --git a/spec/unit/models/runtime/v3/persistence/package_model_spec.rb b/spec/unit/models/runtime/v3/persistence/package_model_spec.rb new file mode 100644 index 00000000000..620c0c902b3 --- /dev/null +++ b/spec/unit/models/runtime/v3/persistence/package_model_spec.rb @@ -0,0 +1,8 @@ +# encoding: utf-8 +require 'spec_helper' + +module VCAP::CloudController + describe PackageModel do + it { is_expected.to validates_includes PackageModel::PACKAGE_STATES, :state, allow_missing: true } + end +end diff --git a/spec/unit/models/services/managed_service_instance_spec.rb b/spec/unit/models/services/managed_service_instance_spec.rb index 255546f805b..9783d4143b8 100644 --- a/spec/unit/models/services/managed_service_instance_spec.rb +++ b/spec/unit/models/services/managed_service_instance_spec.rb @@ -63,7 +63,7 @@ module VCAP::CloudController end describe 'Serialization' do - it { is_expected.to export_attributes :name, :credentials, :service_plan_guid, :space_guid, :gateway_data, :dashboard_url, :type } + it { is_expected.to export_attributes :name, :credentials, :service_plan_guid, :space_guid, :gateway_data, :dashboard_url, :type, :state, :state_description } it { is_expected.to import_attributes :name, :service_plan_guid, :space_guid, :gateway_data } end diff --git a/spec/unit/models/services/service_binding_spec.rb b/spec/unit/models/services/service_binding_spec.rb index cc2d3a47a07..5c2b83e3d1e 100644 --- a/spec/unit/models/services/service_binding_spec.rb +++ b/spec/unit/models/services/service_binding_spec.rb @@ -74,12 +74,13 @@ module VCAP::CloudController end context 'when unbind fails' do - before { allow(binding.client).to receive(:unbind).and_raise } + let(:error) { RuntimeError.new('Some error') } + before { allow(binding.client).to receive(:unbind).and_raise(error) } - it 'raises an error and rolls back' do + it 'propagates the error and rolls back' do expect { binding.destroy - }.to raise_error + }.to raise_error(error) expect(binding).to be_exists end diff --git a/spec/unit/presenters/v3/app_presenter_spec.rb b/spec/unit/presenters/v3/app_presenter_spec.rb new file mode 100644 index 00000000000..3db1363d7f2 --- /dev/null +++ b/spec/unit/presenters/v3/app_presenter_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' +require 'presenters/v3/app_presenter' + +module VCAP::CloudController + describe AppPresenter do + describe '#present_json' do + it 'presents the app as json' do + app = AppModel.make + + json_result = AppPresenter.new.present_json(app) + result = MultiJson.load(json_result) + + expect(result['guid']).to eq(app.guid) + end + end + + describe '#present_json_list' do + let(:pagination_presenter) { double(:pagination_presenter, present_pagination_hash: 'pagination_stuff') } + let(:app_model1) { AppModel.make } + let(:app_model2) { AppModel.make } + let(:apps) { [app_model1, app_model2] } + let(:presenter) { AppPresenter.new(pagination_presenter) } + let(:page) { 1 } + let(:per_page) { 1 } + let(:total_results) { 2 } + let(:paginated_result) { PaginatedResult.new(apps, total_results, PaginationOptions.new(page, per_page)) } + + it 'presents the apps as a json array under resources' do + json_result = presenter.present_json_list(paginated_result) + result = MultiJson.load(json_result) + + guids = result['resources'].collect { |app_json| app_json['guid'] } + expect(guids).to eq([app_model1.guid, app_model2.guid]) + end + + it 'includes pagination section' do + json_result = presenter.present_json_list(paginated_result) + result = MultiJson.load(json_result) + + expect(result['pagination']).to eq('pagination_stuff') + end + end + end +end diff --git a/spec/unit/presenters/v3/package_presenter_spec.rb b/spec/unit/presenters/v3/package_presenter_spec.rb new file mode 100644 index 00000000000..62ec0c38002 --- /dev/null +++ b/spec/unit/presenters/v3/package_presenter_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' +require 'presenters/v3/package_presenter' + +module VCAP::CloudController + describe PackagePresenter do + describe '#present_json' do + it 'presents the package as json' do + package = PackageModel.make(type: 'package_type', url: 'foobar') + + json_result = PackagePresenter.new.present_json(package) + result = MultiJson.load(json_result) + + expect(result['guid']).to eq(package.guid) + expect(result['type']).to eq(package.type) + expect(result['state']).to eq(package.state) + expect(result['error']).to eq(package.error) + expect(result['hash']).to eq(package.package_hash) + expect(result['url']).to eq(package.url) + expect(result['created_at']).to eq(package.created_at.as_json) + expect(result['_links']).to include('self') + expect(result['_links']).to include('space') + end + + context 'when the package type is bits' do + let(:package) { PackageModel.make(type: 'bits', url: 'foobar') } + + it 'includes a link to upload' do + json_result = PackagePresenter.new.present_json(package) + result = MultiJson.load(json_result) + + expect(result['_links']['upload']['href']).to eq("/v3/packages/#{package.guid}/upload") + end + end + end + end +end diff --git a/spec/unit/presenters/v3/pagination_presenter_spec.rb b/spec/unit/presenters/v3/pagination_presenter_spec.rb new file mode 100644 index 00000000000..4294c058f31 --- /dev/null +++ b/spec/unit/presenters/v3/pagination_presenter_spec.rb @@ -0,0 +1,92 @@ +require 'spec_helper' +require 'presenters/v3/pagination_presenter' + +module VCAP::CloudController + describe PaginationPresenter do + describe '#present_pagination_hash' do + let(:presenter) { PaginationPresenter.new } + let(:page) { 1 } + let(:per_page) { 1 } + let(:total_results) { 2 } + let(:paginated_result) { PaginatedResult.new(double(:results), total_results, PaginationOptions.new(page, per_page)) } + let(:base_url) { '/cloudfoundry/is-great' } + + it 'includes total_results' do + result = presenter.present_pagination_hash(paginated_result, base_url) + + tr = result[:total_results] + expect(tr).to eq(total_results) + end + + it 'includes first_url' do + result = presenter.present_pagination_hash(paginated_result, base_url) + + first_url = result[:first][:href] + expect(first_url).to eq("/cloudfoundry/is-great?page=1&per_page=#{per_page}") + end + + it 'includes last_url' do + result = presenter.present_pagination_hash(paginated_result, base_url) + + last_url = result[:last][:href] + expect(last_url).to eq("/cloudfoundry/is-great?page=2&per_page=#{per_page}") + end + + it 'sets first and last page to 1 if there is 1 page' do + paginated_result = PaginatedResult.new([], 0, PaginationOptions.new(page, per_page)) + result = presenter.present_pagination_hash(paginated_result, base_url) + + last_url = result[:last][:href] + first_url = result[:first][:href] + expect(last_url).to eq("/cloudfoundry/is-great?page=1&per_page=#{per_page}") + expect(first_url).to eq("/cloudfoundry/is-great?page=1&per_page=#{per_page}") + end + + context 'when on the first page' do + let(:page) { 1 } + + it 'sets previous_url to nil' do + result = presenter.present_pagination_hash(paginated_result, base_url) + + previous_url = result[:previous] + expect(previous_url).to be_nil + end + end + + context 'when NOT on the first page' do + let(:page) { 2 } + + it 'includes previous_url' do + result = presenter.present_pagination_hash(paginated_result, base_url) + + previous_url = result[:previous][:href] + expect(previous_url).to eq("/cloudfoundry/is-great?page=1&per_page=#{per_page}") + end + end + + context 'when on the last page' do + let(:page) { total_results / per_page } + let(:per_page) { 1 } + + it 'sets next_url to nil' do + result = presenter.present_pagination_hash(paginated_result, base_url) + + next_url = result[:next] + expect(next_url).to be_nil + end + end + + context 'when NOT on the last page' do + let(:page) { 1 } + let(:per_page) { 1 } + + it 'includes next_url' do + result = presenter.present_pagination_hash(paginated_result, base_url) + + next_url = result[:next][:href] + expect(next_url).to eq("/cloudfoundry/is-great?page=2&per_page=#{per_page}") + end + end + end + end +end diff --git a/spec/unit/presenters/v3/process_presenter_spec.rb b/spec/unit/presenters/v3/process_presenter_spec.rb index 07a566d2599..a1fc294dfa5 100644 --- a/spec/unit/presenters/v3/process_presenter_spec.rb +++ b/spec/unit/presenters/v3/process_presenter_spec.rb @@ -16,15 +16,34 @@ module VCAP::CloudController end describe '#present_json_list' do - it 'presents the processes as a json array' do - process_model1 = AppFactory.make - process_model2 = AppFactory.make + let(:pagination_presenter) { double(:pagination_presenter) } + let(:process1) { AppFactory.make } + let(:process2) { AppFactory.make } + let(:processes) { [process1, process2] } + let(:presenter) { ProcessPresenter.new(pagination_presenter) } + let(:page) { 1 } + let(:per_page) { 1 } + let(:total_results) { 2 } + let(:paginated_result) { PaginatedResult.new(processes, total_results, PaginationOptions.new(page, per_page)) } + before do + allow(pagination_presenter).to receive(:present_pagination_hash) do |_, url| + "pagination-#{url}" + end + end + + it 'presents the processes as a json array under resources' do + json_result = presenter.present_json_list(paginated_result, 'potato') + result = MultiJson.load(json_result) + + guids = result['resources'].collect { |app_json| app_json['guid'] } + expect(guids).to eq([process1.guid, process2.guid]) + end - json_result = ProcessPresenter.new.present_json_list([process_model1, process_model2]) + it 'includes pagination section' do + json_result = presenter.present_json_list(paginated_result, 'bazooka') result = MultiJson.load(json_result) - guids = result.collect { |process_json| process_json['guid'] } - expect(guids).to eq([process_model1.guid, process_model2.guid]) + expect(result['pagination']).to eq('pagination-bazooka') end end end diff --git a/vendor/errors b/vendor/errors index 9a645f7f513..b4aa6f3996e 160000 --- a/vendor/errors +++ b/vendor/errors @@ -1 +1 @@ -Subproject commit 9a645f7f513754c0fe2be5d89830df007b52cdf8 +Subproject commit b4aa6f3996e3d681ce3010c699511e5d73877ed0