diff --git a/app/actions/app_feature_update.rb b/app/actions/app_feature_update.rb index 7e6efdc44f8..1fc865f78b0 100644 --- a/app/actions/app_feature_update.rb +++ b/app/actions/app_feature_update.rb @@ -6,6 +6,8 @@ def self.update(feature_name, app, message) app.update(enable_ssh: message.enabled) when 'revisions' app.update(revisions_enabled: message.enabled) + when 'file-based-service-bindings' + app.update(file_based_service_bindings_enabled: message.enabled) end end end diff --git a/app/controllers/runtime/apps_controller.rb b/app/controllers/runtime/apps_controller.rb index d4c6e47e6ba..b70f5eada73 100644 --- a/app/controllers/runtime/apps_controller.rb +++ b/app/controllers/runtime/apps_controller.rb @@ -72,7 +72,7 @@ def read_env(guid) staging_env_json: EnvironmentVariableGroup.staging.environment_json, running_env_json: EnvironmentVariableGroup.running.environment_json, environment_json: process.app.environment_variables, - system_env_json: SystemEnvPresenter.new(process.service_bindings).system_env, + system_env_json: SystemEnvPresenter.new(process).system_env, application_env_json: { 'VCAP_APPLICATION' => vcap_application } }, mode: :compat) ] diff --git a/app/controllers/v3/app_features_controller.rb b/app/controllers/v3/app_features_controller.rb index d8c361d0c6f..7359e524f76 100644 --- a/app/controllers/v3/app_features_controller.rb +++ b/app/controllers/v3/app_features_controller.rb @@ -2,6 +2,7 @@ require 'controllers/v3/mixins/app_sub_resource' require 'presenters/v3/app_ssh_feature_presenter' require 'presenters/v3/app_revisions_feature_presenter' +require 'presenters/v3/app_file_based_service_bindings_feature_presenter' require 'presenters/v3/app_ssh_status_presenter' require 'actions/app_feature_update' @@ -10,8 +11,9 @@ class AppFeaturesController < ApplicationController SSH_FEATURE = 'ssh'.freeze REVISIONS_FEATURE = 'revisions'.freeze + FILE_BASED_SERVICE_BINDINGS_FEATURE = 'file-based-service-bindings'.freeze - TRUSTED_APP_FEATURES = [SSH_FEATURE].freeze + TRUSTED_APP_FEATURES = [SSH_FEATURE, FILE_BASED_SERVICE_BINDINGS_FEATURE].freeze UNTRUSTED_APP_FEATURES = [REVISIONS_FEATURE].freeze APP_FEATURES = (TRUSTED_APP_FEATURES + UNTRUSTED_APP_FEATURES).freeze @@ -80,7 +82,8 @@ def present_unpagination_hash(result, path) def feature_presenter_for(feature_name, app) presenters = { SSH_FEATURE => Presenters::V3::AppSshFeaturePresenter, - REVISIONS_FEATURE => Presenters::V3::AppRevisionsFeaturePresenter + REVISIONS_FEATURE => Presenters::V3::AppRevisionsFeaturePresenter, + FILE_BASED_SERVICE_BINDINGS_FEATURE => Presenters::V3::AppFileBasedServiceBindingsFeaturePresenter } presenters[feature_name].new(app) end @@ -88,7 +91,8 @@ def feature_presenter_for(feature_name, app) def presented_app_features(app) [ Presenters::V3::AppSshFeaturePresenter.new(app), - Presenters::V3::AppRevisionsFeaturePresenter.new(app) + Presenters::V3::AppRevisionsFeaturePresenter.new(app), + Presenters::V3::AppFileBasedServiceBindingsFeaturePresenter.new(app) ] end end diff --git a/app/models/runtime/process_model.rb b/app/models/runtime/process_model.rb index a931f493322..984a8f3c723 100644 --- a/app/models/runtime/process_model.rb +++ b/app/models/runtime/process_model.rb @@ -177,6 +177,8 @@ def revisions_enabled? app.revisions_enabled end + delegate :file_based_service_bindings_enabled, to: :app + def package_hash # this caches latest_package for performance reasons package = latest_package diff --git a/app/presenters/system_environment/system_env_presenter.rb b/app/presenters/system_environment/system_env_presenter.rb index 7fbab00f106..1207d6ae102 100644 --- a/app/presenters/system_environment/system_env_presenter.rb +++ b/app/presenters/system_environment/system_env_presenter.rb @@ -2,11 +2,14 @@ require 'presenters/system_environment/service_binding_presenter' class SystemEnvPresenter - def initialize(service_bindings) - @service_bindings = service_bindings + def initialize(app_or_process) + @file_based_service_bindings_enabled = app_or_process.file_based_service_bindings_enabled + @service_bindings = app_or_process.service_bindings end def system_env + return { SERVICE_BINDING_ROOT: '/etc/cf-service-bindings' } if @file_based_service_bindings_enabled + { VCAP_SERVICES: service_binding_env_variables } end diff --git a/app/presenters/v3/app_env_presenter.rb b/app/presenters/v3/app_env_presenter.rb index aa253842436..80d8a043a09 100644 --- a/app/presenters/v3/app_env_presenter.rb +++ b/app/presenters/v3/app_env_presenter.rb @@ -25,7 +25,7 @@ def to_hash environment_variables: app.environment_variables, staging_env_json: EnvironmentVariableGroup.staging.environment_json, running_env_json: EnvironmentVariableGroup.running.environment_json, - system_env_json: redact_hash(SystemEnvPresenter.new(app.service_bindings).system_env), + system_env_json: redact_hash(SystemEnvPresenter.new(app).system_env), application_env_json: vcap_application } end diff --git a/app/presenters/v3/app_file_based_service_bindings_feature_presenter.rb b/app/presenters/v3/app_file_based_service_bindings_feature_presenter.rb new file mode 100644 index 00000000000..fbd5928c7a3 --- /dev/null +++ b/app/presenters/v3/app_file_based_service_bindings_feature_presenter.rb @@ -0,0 +1,19 @@ +require 'presenters/v3/base_presenter' + +module VCAP::CloudController::Presenters::V3 + class AppFileBasedServiceBindingsFeaturePresenter < BasePresenter + def to_hash + { + name: 'file-based-service-bindings', + description: 'Enable file-based service bindings for the app', + enabled: app.file_based_service_bindings_enabled + } + end + + private + + def app + @resource + end + end +end diff --git a/db/migrations/20240927091800_add_apps_file_based_service_bindings_enabled_column.rb b/db/migrations/20240927091800_add_apps_file_based_service_bindings_enabled_column.rb new file mode 100644 index 00000000000..cf0ee939311 --- /dev/null +++ b/db/migrations/20240927091800_add_apps_file_based_service_bindings_enabled_column.rb @@ -0,0 +1,5 @@ +Sequel.migration do + change do + add_column :apps, :file_based_service_bindings_enabled, :boolean, default: false, null: false + end +end diff --git a/docs/v3/source/includes/api_resources/_app_features.erb b/docs/v3/source/includes/api_resources/_app_features.erb index 51fe2b7d44b..f947f9c0a6c 100644 --- a/docs/v3/source/includes/api_resources/_app_features.erb +++ b/docs/v3/source/includes/api_resources/_app_features.erb @@ -18,10 +18,15 @@ "name": "revisions", "description": "Enable versioning of an application", "enabled": false + }, + { + "name": "file-based-service-bindings", + "description": "Enable file-based service bindings for the app", + "enabled": false } ], "pagination": { - "total_results": 1, + "total_results": 3, "total_pages": 1, "first": { "href": "/v3/apps/05d39de4-2c9e-4c76-8fd6-10417da07e42/features" }, "last": { "href": "/v3/apps/05d39de4-2c9e-4c76-8fd6-10417da07e42/features" }, diff --git a/docs/v3/source/includes/resources/app_features/_supported_features.md.erb b/docs/v3/source/includes/resources/app_features/_supported_features.md.erb index 558e3b11dda..7f8bfede853 100644 --- a/docs/v3/source/includes/resources/app_features/_supported_features.md.erb +++ b/docs/v3/source/includes/resources/app_features/_supported_features.md.erb @@ -6,3 +6,4 @@ Name | Description ---- | ----------- **ssh** | Enable SSHing into the app **revisions** | Enable [versioning](#revisions) of an application +**file-based-service-bindings** | Enable file-based service bindings for the app (experimental) diff --git a/lib/cloud_controller/backends/staging_environment_builder.rb b/lib/cloud_controller/backends/staging_environment_builder.rb index 775339492e7..9395c5d3c5c 100644 --- a/lib/cloud_controller/backends/staging_environment_builder.rb +++ b/lib/cloud_controller/backends/staging_environment_builder.rb @@ -27,7 +27,7 @@ def build(app, space, lifecycle, memory_limit, staging_disk_in_mb, vars_from_mes 'MEMORY_LIMIT' => "#{memory_limit}m" } ). - merge(SystemEnvPresenter.new(app.service_bindings).system_env.stringify_keys) + merge(SystemEnvPresenter.new(app).system_env.stringify_keys) end end end diff --git a/lib/cloud_controller/diego/app_recipe_builder.rb b/lib/cloud_controller/diego/app_recipe_builder.rb index 46ce619baa6..97161260c3b 100644 --- a/lib/cloud_controller/diego/app_recipe_builder.rb +++ b/lib/cloud_controller/diego/app_recipe_builder.rb @@ -6,6 +6,7 @@ require 'cloud_controller/diego/cnb/desired_lrp_builder' require 'cloud_controller/diego/process_guid' require 'cloud_controller/diego/ssh_key' +require 'cloud_controller/diego/service_binding_files_builder' require 'credhub/config_helpers' require 'models/helpers/health_check_types' require 'cloud_controller/diego/main_lrp_action_builder' @@ -100,7 +101,8 @@ def app_lrp_arguments organizational_unit: ["organization:#{process.organization.guid}", "space:#{process.space.guid}", "app:#{process.app_guid}"] ), image_username: process.desired_droplet.docker_receipt_username, - image_password: process.desired_droplet.docker_receipt_password + image_password: process.desired_droplet.docker_receipt_password, + volume_mounted_files: ServiceBindingFilesBuilder.build(process) }.compact end diff --git a/lib/cloud_controller/diego/environment.rb b/lib/cloud_controller/diego/environment.rb index 0a728918647..d5789782432 100644 --- a/lib/cloud_controller/diego/environment.rb +++ b/lib/cloud_controller/diego/environment.rb @@ -41,7 +41,7 @@ def common_json_and_merge(&blk) @initial_env. merge(process.environment_json || {}). merge(blk.call). - merge(SystemEnvPresenter.new(process.service_bindings).system_env) + merge(SystemEnvPresenter.new(process).system_env) diego_env = diego_env.merge(DATABASE_URL: process.database_uri) if process.database_uri diff --git a/lib/cloud_controller/diego/service_binding_files_builder.rb b/lib/cloud_controller/diego/service_binding_files_builder.rb new file mode 100644 index 00000000000..75e7006d0d4 --- /dev/null +++ b/lib/cloud_controller/diego/service_binding_files_builder.rb @@ -0,0 +1,86 @@ +module VCAP::CloudController + module Diego + class ServiceBindingFilesBuilder + class IncompatibleBindings < StandardError; end + + MAX_ALLOWED_BYTESIZE = 1_000_000 + + def self.build(app_or_process) + new(app_or_process).build + end + + def initialize(app_or_process) + @file_based_service_bindings_enabled = app_or_process.file_based_service_bindings_enabled + @service_bindings = app_or_process.service_bindings + end + + def build + return nil unless @file_based_service_bindings_enabled + + service_binding_files = {} + names = Set.new # to check for duplicate binding names + total_bytesize = 0 # to check the total bytesize + + @service_bindings.select(&:create_succeeded?).each do |service_binding| + sb_hash = ServiceBindingPresenter.new(service_binding, include_instance: true).to_hash + name = sb_hash[:name] + raise IncompatibleBindings.new("Invalid binding name: #{name}") unless valid_name?(name) + raise IncompatibleBindings.new("Duplicate binding name: #{name}") if names.add?(name).nil? + + # add the credentials first + sb_hash.delete(:credentials)&.each { |k, v| total_bytesize += add_file(service_binding_files, name, k.to_s, v) } + + # add the rest of the hash; already existing credential keys are overwritten + # VCAP_SERVICES attribute names are transformed (e.g. binding_guid -> binding-guid) + sb_hash.each { |k, v| total_bytesize += add_file(service_binding_files, name, transform_vcap_services_attribute(k.to_s), v) } + + # add the type and provider + label = sb_hash[:label] + total_bytesize += add_file(service_binding_files, name, 'type', label) + total_bytesize += add_file(service_binding_files, name, 'provider', label) + end + + raise IncompatibleBindings.new("Bindings exceed the maximum allowed bytesize of #{MAX_ALLOWED_BYTESIZE}: #{total_bytesize}") if total_bytesize > MAX_ALLOWED_BYTESIZE + + service_binding_files.values + end + + private + + # - adds a Diego::Bbs::Models::File object to the service_binding_files hash + # - binding name is used as the directory name, key is used as the file name + # - returns the bytesize of the path and content + # - skips (and returns 0) if the value is nil or an empty array or hash + # - serializes the value to JSON if it is a non-string object + def add_file(service_binding_files, name, key, value) + raise IncompatibleBindings.new("Invalid file name: #{key}") unless valid_name?(key) + + path = "#{name}/#{key}" + content = if value.nil? + return 0 + elsif value.is_a?(String) + value + else + return 0 if (value.is_a?(Array) || value.is_a?(Hash)) && value.empty? + + Oj.dump(value, mode: :compat) + end + + service_binding_files[path] = ::Diego::Bbs::Models::File.new(path:, content:) + path.bytesize + content.bytesize + end + + def valid_name?(name) + name.match?(/^[a-z0-9\-.]{1,253}$/) + end + + def transform_vcap_services_attribute(name) + if %w[binding_guid binding_name instance_guid instance_name syslog_drain_url volume_mounts].include?(name) + name.tr('_', '-') + else + name + end + end + end + end +end diff --git a/lib/cloud_controller/diego/task_environment.rb b/lib/cloud_controller/diego/task_environment.rb index e4da6bff6fb..de28a44af05 100644 --- a/lib/cloud_controller/diego/task_environment.rb +++ b/lib/cloud_controller/diego/task_environment.rb @@ -19,7 +19,7 @@ def build initial_envs. merge(app_env). merge('VCAP_APPLICATION' => vcap_application, 'MEMORY_LIMIT' => "#{task.memory_in_mb}m"). - merge(SystemEnvPresenter.new(app.service_bindings).system_env.stringify_keys) + merge(SystemEnvPresenter.new(app).system_env.stringify_keys) task_env = task_env.merge('VCAP_PLATFORM_OPTIONS' => credhub_url) if credhub_url.present? && cred_interpolation_enabled? diff --git a/lib/cloud_controller/diego/task_recipe_builder.rb b/lib/cloud_controller/diego/task_recipe_builder.rb index 3b6a5527430..4395d6d0b96 100644 --- a/lib/cloud_controller/diego/task_recipe_builder.rb +++ b/lib/cloud_controller/diego/task_recipe_builder.rb @@ -5,6 +5,7 @@ require 'cloud_controller/diego/bbs_environment_builder' require 'cloud_controller/diego/task_completion_callback_generator' require 'cloud_controller/diego/task_cpu_weight_calculator' +require 'cloud_controller/diego/service_binding_files_builder' module VCAP::CloudController module Diego @@ -52,7 +53,8 @@ def build_app_task(config, task) ] ), image_username: task.droplet.docker_receipt_username, - image_password: task.droplet.docker_receipt_password + image_password: task.droplet.docker_receipt_password, + volume_mounted_files: ServiceBindingFilesBuilder.build(task.app) }.compact) end @@ -90,7 +92,8 @@ def build_staging_task(config, staging_details) ] ), image_username: staging_details.package.docker_username, - image_password: staging_details.package.docker_password + image_password: staging_details.package.docker_password, + volume_mounted_files: ServiceBindingFilesBuilder.build(staging_details.package.app) }.compact) end diff --git a/lib/diego/bbs/models/actions_pb.rb b/lib/diego/bbs/models/actions_pb.rb index 518aa62ede2..80ab0d6eb48 100644 --- a/lib/diego/bbs/models/actions_pb.rb +++ b/lib/diego/bbs/models/actions_pb.rb @@ -4,6 +4,7 @@ require 'google/protobuf' require 'environment_variables_pb' +require 'file_pb' Google::Protobuf::DescriptorPool.generated_pool.build do add_message "diego.bbs.models.Action" do optional :download_action, :message, 1, "diego.bbs.models.DownloadAction" @@ -42,6 +43,7 @@ optional :user, :string, 6 optional :log_source, :string, 7 optional :suppress_log_output, :bool, 8 + repeated :volume_mounted_files, :message, 9, "diego.bbs.models.File" end add_message "diego.bbs.models.TimeoutAction" do optional :action, :message, 1, "diego.bbs.models.Action" diff --git a/lib/diego/bbs/models/desired_lrp_pb.rb b/lib/diego/bbs/models/desired_lrp_pb.rb index 6b5a4c972e0..91b60107456 100644 --- a/lib/diego/bbs/models/desired_lrp_pb.rb +++ b/lib/diego/bbs/models/desired_lrp_pb.rb @@ -16,6 +16,7 @@ require 'metric_tags_pb' require 'sidecar_pb' require 'log_rate_limit_pb' +require 'file_pb' Google::Protobuf::DescriptorPool.generated_pool.build do add_message "diego.bbs.models.DesiredLRPSchedulingInfo" do optional :desired_lrp_key, :message, 1, "diego.bbs.models.DesiredLRPKey" @@ -55,6 +56,7 @@ map :metric_tags, :string, :message, 25, "diego.bbs.models.MetricTagValue" repeated :sidecars, :message, 26, "diego.bbs.models.Sidecar" optional :log_rate_limit, :message, 27, "diego.bbs.models.LogRateLimit" + repeated :volume_mounted_files, :message, 28, "diego.bbs.models.File" end add_message "diego.bbs.models.ProtoRoutes" do map :routes, :string, :bytes, 1 @@ -118,6 +120,7 @@ map :metric_tags, :string, :message, 35, "diego.bbs.models.MetricTagValue" repeated :sidecars, :message, 36, "diego.bbs.models.Sidecar" optional :log_rate_limit, :message, 37, "diego.bbs.models.LogRateLimit" + repeated :volume_mounted_files, :message, 38, "diego.bbs.models.File" end end diff --git a/lib/diego/bbs/models/desired_lrp_requests_pb.rb b/lib/diego/bbs/models/desired_lrp_requests_pb.rb index 668ba53ba2a..1d9aefb8bae 100644 --- a/lib/diego/bbs/models/desired_lrp_requests_pb.rb +++ b/lib/diego/bbs/models/desired_lrp_requests_pb.rb @@ -25,6 +25,10 @@ optional :error, :message, 1, "diego.bbs.models.Error" repeated :desired_lrp_scheduling_infos, :message, 2, "diego.bbs.models.DesiredLRPSchedulingInfo" end + add_message "diego.bbs.models.DesiredLRPSchedulingInfoByProcessGuidResponse" do + optional :error, :message, 1, "diego.bbs.models.Error" + optional :desired_lrp_scheduling_info, :message, 2, "diego.bbs.models.DesiredLRPSchedulingInfo" + end add_message "diego.bbs.models.DesiredLRPByProcessGuidRequest" do optional :process_guid, :string, 1 end @@ -48,6 +52,7 @@ module Models DesiredLRPsRequest = Google::Protobuf::DescriptorPool.generated_pool.lookup("diego.bbs.models.DesiredLRPsRequest").msgclass DesiredLRPResponse = Google::Protobuf::DescriptorPool.generated_pool.lookup("diego.bbs.models.DesiredLRPResponse").msgclass DesiredLRPSchedulingInfosResponse = Google::Protobuf::DescriptorPool.generated_pool.lookup("diego.bbs.models.DesiredLRPSchedulingInfosResponse").msgclass + DesiredLRPSchedulingInfoByProcessGuidResponse = Google::Protobuf::DescriptorPool.generated_pool.lookup("diego.bbs.models.DesiredLRPSchedulingInfoByProcessGuidResponse").msgclass DesiredLRPByProcessGuidRequest = Google::Protobuf::DescriptorPool.generated_pool.lookup("diego.bbs.models.DesiredLRPByProcessGuidRequest").msgclass DesireLRPRequest = Google::Protobuf::DescriptorPool.generated_pool.lookup("diego.bbs.models.DesireLRPRequest").msgclass UpdateDesiredLRPRequest = Google::Protobuf::DescriptorPool.generated_pool.lookup("diego.bbs.models.UpdateDesiredLRPRequest").msgclass diff --git a/lib/diego/bbs/models/file_pb.rb b/lib/diego/bbs/models/file_pb.rb new file mode 100644 index 00000000000..54c5064fd7e --- /dev/null +++ b/lib/diego/bbs/models/file_pb.rb @@ -0,0 +1,19 @@ +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: file.proto + +require 'google/protobuf' + +Google::Protobuf::DescriptorPool.generated_pool.build do + add_message "diego.bbs.models.File" do + optional :path, :string, 1 + optional :content, :string, 2 + end +end + +module Diego + module Bbs + module Models + File = Google::Protobuf::DescriptorPool.generated_pool.lookup("diego.bbs.models.File").msgclass + end + end +end diff --git a/lib/diego/bbs/models/task_pb.rb b/lib/diego/bbs/models/task_pb.rb index 0ef8c0a36e4..717f3b3a375 100644 --- a/lib/diego/bbs/models/task_pb.rb +++ b/lib/diego/bbs/models/task_pb.rb @@ -13,6 +13,7 @@ require 'image_layer_pb' require 'log_rate_limit_pb' require 'metric_tags_pb' +require 'file_pb' Google::Protobuf::DescriptorPool.generated_pool.build do add_message "diego.bbs.models.TaskDefinition" do optional :root_fs, :string, 1 @@ -42,6 +43,7 @@ repeated :image_layers, :message, 25, "diego.bbs.models.ImageLayer" optional :log_rate_limit, :message, 26, "diego.bbs.models.LogRateLimit" map :metric_tags, :string, :message, 27, "diego.bbs.models.MetricTagValue" + repeated :volume_mounted_files, :message, 28, "diego.bbs.models.File" end add_message "diego.bbs.models.Task" do optional :task_definition, :message, 1, "diego.bbs.models.TaskDefinition" diff --git a/spec/migrations/20240927091800_add_apps_file_based_service_bindings_enabled_column_spec.rb b/spec/migrations/20240927091800_add_apps_file_based_service_bindings_enabled_column_spec.rb new file mode 100644 index 00000000000..8038d565473 --- /dev/null +++ b/spec/migrations/20240927091800_add_apps_file_based_service_bindings_enabled_column_spec.rb @@ -0,0 +1,35 @@ +require 'spec_helper' +require 'migrations/helpers/migration_shared_context' + +RSpec.describe 'migration to add file_based_service_bindings_enabled column to apps table', isolation: :truncation, type: :migration do + include_context 'migration' do + let(:migration_filename) { '20240927091800_add_apps_file_based_service_bindings_enabled_column.rb' } + end + + describe 'apps table' do + subject(:run_migration) { Sequel::Migrator.run(db, migrations_path, target: current_migration_index, allow_missing_migration_files: true) } + + it 'adds a column `file_based_service_bindings_enabled`' do + expect(db[:apps].columns).not_to include(:file_based_service_bindings_enabled) + run_migration + expect(db[:apps].columns).to include(:file_based_service_bindings_enabled) + end + + it 'sets the default value of existing entries to false' do + db[:apps].insert(guid: 'existing_app_guid') + run_migration + expect(db[:apps].first(guid: 'existing_app_guid')[:file_based_service_bindings_enabled]).to be(false) + end + + it 'sets the default value of new entries to false' do + run_migration + db[:apps].insert(guid: 'new_app_guid') + expect(db[:apps].first(guid: 'new_app_guid')[:file_based_service_bindings_enabled]).to be(false) + end + + it 'forbids null values' do + run_migration + expect { db[:apps].insert(guid: 'app_guid__nil', file_based_service_bindings_enabled: nil) }.to raise_error(Sequel::NotNullConstraintViolation) + end + end +end diff --git a/spec/request/app_features_spec.rb b/spec/request/app_features_spec.rb index 9f6d098d608..1efcc9eea6d 100644 --- a/spec/request/app_features_spec.rb +++ b/spec/request/app_features_spec.rb @@ -7,7 +7,7 @@ let(:admin_header) { admin_headers_for(user) } let(:org) { VCAP::CloudController::Organization.make(created_at: 3.days.ago) } let(:space) { VCAP::CloudController::Space.make(organization: org) } - let(:app_model) { VCAP::CloudController::AppModel.make(space: space, enable_ssh: true) } + let(:app_model) { VCAP::CloudController::AppModel.make(space: space, enable_ssh: true, file_based_service_bindings_enabled: true) } describe 'GET /v3/apps/:guid/features' do context 'getting a list of available features for the app' do @@ -24,11 +24,16 @@ 'name' => 'revisions', 'description' => 'Enable versioning of an application', 'enabled' => true + }, + { + 'name' => 'file-based-service-bindings', + 'description' => 'Enable file-based service bindings for the app', + 'enabled' => true } ], 'pagination' => { - 'total_results' => 2, + 'total_results' => 3, 'total_pages' => 1, 'first' => { 'href' => "/v3/apps/#{app_model.guid}/features" }, 'last' => { 'href' => "/v3/apps/#{app_model.guid}/features" }, @@ -94,6 +99,19 @@ it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS end + + context 'file-based-service-bindings app feature' do + let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}/features/file-based-service-bindings", nil, user_headers } } + let(:feature_response_object) do + { + 'name' => 'file-based-service-bindings', + 'description' => 'Enable file-based service bindings for the app', + 'enabled' => true + } + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end end describe 'PATCH /v3/apps/:guid/features/:name' do @@ -172,5 +190,39 @@ it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS end end + + context 'file-based-service-bindings app feature' do + let(:api_call) { ->(user_headers) { patch "/v3/apps/#{app_model.guid}/features/file-based-service-bindings", request_body.to_json, user_headers } } + let(:feature_response_object) do + { + 'name' => 'file-based-service-bindings', + 'description' => 'Enable file-based service bindings for the app', + 'enabled' => false + } + end + + let(:expected_codes_and_responses) do + h = Hash.new(code: 403, errors: CF_NOT_AUTHORIZED) + %w[no_role org_auditor org_billing_manager].each { |r| h[r] = { code: 404 } } + %w[admin space_developer].each { |r| h[r] = { code: 200, response_object: feature_response_object } } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + h['space_developer'] = { code: 403, errors: CF_ORG_SUSPENDED } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end end end diff --git a/spec/request/apps_spec.rb b/spec/request/apps_spec.rb index 55bd493a2eb..1e63c8e3dc1 100644 --- a/spec/request/apps_spec.rb +++ b/spec/request/apps_spec.rb @@ -1601,6 +1601,20 @@ end it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when file-based service bindings are enabled' do + let(:app_model_response_object) do + r = super() + r[:system_env_json] = { SERVICE_BINDING_ROOT: '/etc/cf-service-bindings' } + r + end + + before do + app_model.update(file_based_service_bindings_enabled: true) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end end context 'when VCAP_SERVICES contains potentially sensitive information' do diff --git a/spec/unit/actions/app_feature_update_spec.rb b/spec/unit/actions/app_feature_update_spec.rb index f52edad7930..071c00287a9 100644 --- a/spec/unit/actions/app_feature_update_spec.rb +++ b/spec/unit/actions/app_feature_update_spec.rb @@ -5,7 +5,7 @@ module VCAP::CloudController RSpec.describe AppFeatureUpdate do subject(:app_feature_update) { AppFeatureUpdate } - let(:app) { AppModel.make(enable_ssh: false, revisions_enabled: false) } + let(:app) { AppModel.make(enable_ssh: false, revisions_enabled: false, file_based_service_bindings_enabled: false) } let(:message) { AppFeatureUpdateMessage.new(enabled: true) } describe '.update' do @@ -24,6 +24,14 @@ module VCAP::CloudController end.to change { app.reload.revisions_enabled }.to(true) end end + + context 'when the feature name is file-based-service-bindings' do + it 'updates the file_based_service_bindings_enabled column on the app' do + expect do + AppFeatureUpdate.update('file-based-service-bindings', app, message) + end.to change { app.reload.file_based_service_bindings_enabled }.to(true) + end + end end end end diff --git a/spec/unit/controllers/v3/app_features_controller_spec.rb b/spec/unit/controllers/v3/app_features_controller_spec.rb index 9cfa39ecb5e..f1317edcb39 100644 --- a/spec/unit/controllers/v3/app_features_controller_spec.rb +++ b/spec/unit/controllers/v3/app_features_controller_spec.rb @@ -4,12 +4,15 @@ ## NOTICE: Prefer request specs over controller specs as per ADR #0003 ## RSpec.describe AppFeaturesController, type: :controller do - let(:app_model) { VCAP::CloudController::AppModel.make(enable_ssh: true) } + let(:app_model) { VCAP::CloudController::AppModel.make(enable_ssh: true, file_based_service_bindings_enabled: true) } let(:space) { app_model.space } let(:org) { space.organization } let(:user) { VCAP::CloudController::User.make } let(:app_feature_ssh_response) { { 'name' => 'ssh', 'description' => 'Enable SSHing into the app.', 'enabled' => true } } let(:app_feature_revisions_response) { { 'name' => 'revisions', 'description' => 'Enable versioning of an application', 'enabled' => true } } + let(:app_feature_file_based_service_bindings_response) do + { 'name' => 'file-based-service-bindings', 'description' => 'Enable file-based service bindings for the app', 'enabled' => true } + end before do space.update(allow_ssh: true) @@ -20,7 +23,7 @@ describe '#index' do let(:pagination_hash) do { - 'total_results' => 2, + 'total_results' => 3, 'total_pages' => 1, 'first' => { 'href' => "/v3/apps/#{app_model.guid}/features" }, 'last' => { 'href' => "/v3/apps/#{app_model.guid}/features" }, @@ -39,7 +42,7 @@ it 'returns app features' do get :index, params: { app_guid: app_model.guid } expect(parsed_body).to eq( - 'resources' => [app_feature_ssh_response, app_feature_revisions_response], + 'resources' => [app_feature_ssh_response, app_feature_revisions_response, app_feature_file_based_service_bindings_response], 'pagination' => pagination_hash ) end @@ -67,6 +70,11 @@ expect(parsed_body).to eq(app_feature_revisions_response) end + it 'returns the file-based-service-bindings app feature' do + get :show, params: { app_guid: app_model.guid, name: 'file-based-service-bindings' } + expect(parsed_body).to eq(app_feature_file_based_service_bindings_response) + end + it 'throws 404 for a non-existent feature' do set_current_user_as_role(role: 'admin', org: org, space: space, user: user) diff --git a/spec/unit/lib/cloud_controller/diego/app_recipe_builder_spec.rb b/spec/unit/lib/cloud_controller/diego/app_recipe_builder_spec.rb index bb7366609f8..59ed02432cb 100644 --- a/spec/unit/lib/cloud_controller/diego/app_recipe_builder_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/app_recipe_builder_spec.rb @@ -52,6 +52,8 @@ module Diego expect(lrp.trusted_system_certificates_path).to eq(RUNNING_TRUSTED_SYSTEM_CERT_PATH) expect(lrp.PlacementTags).to eq(['placement-tag']) expect(lrp.certificate_properties).to eq(expected_certificate_properties) + + expect(lrp.volume_mounted_files).to be_empty end end @@ -914,6 +916,19 @@ module Diego expect(lrp2.action).to eq(expected_action) end end + + context 'when file-based service bindings are enabled' do + before do + app = process.app + app.update(file_based_service_bindings_enabled: true) + VCAP::CloudController::ServiceBinding.make(service_instance: ManagedServiceInstance.make(space: app.space), app: app) + end + + it 'includes volume mounted files' do + lrp = builder.build_app_lrp + expect(lrp.volume_mounted_files).not_to be_empty + end + end end context 'when the lifecycle_type is "cnb"' do diff --git a/spec/unit/lib/cloud_controller/diego/environment_spec.rb b/spec/unit/lib/cloud_controller/diego/environment_spec.rb index 25ccee2d9ca..28942bd544a 100644 --- a/spec/unit/lib/cloud_controller/diego/environment_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/environment_spec.rb @@ -23,7 +23,7 @@ module VCAP::CloudController::Diego encoded_vcap_application_json = vcap_app.to_json vcap_services_key = :VCAP_SERVICES - system_env = SystemEnvPresenter.new(process.service_bindings).system_env + system_env = SystemEnvPresenter.new(process).system_env expect(system_env).to have_key(vcap_services_key) encoded_vcap_services_json = system_env[vcap_services_key].to_json diff --git a/spec/unit/lib/cloud_controller/diego/service_binding_files_builder_spec.rb b/spec/unit/lib/cloud_controller/diego/service_binding_files_builder_spec.rb new file mode 100644 index 00000000000..2d6c7b0c511 --- /dev/null +++ b/spec/unit/lib/cloud_controller/diego/service_binding_files_builder_spec.rb @@ -0,0 +1,277 @@ +require 'spec_helper' +require 'cloud_controller/diego/service_binding_files_builder' + +module VCAP::CloudController::Diego + RSpec.shared_examples 'mapping of type and provider' do |label| + it 'sets type and provider to the service label' do + expect(service_binding_files.find { |f| f.path == "#{directory}/type" }).to have_attributes(content: label || 'service-name') + expect(service_binding_files.find { |f| f.path == "#{directory}/provider" }).to have_attributes(content: label || 'service-name') + expect(service_binding_files.find { |f| f.path == "#{directory}/label" }).to have_attributes(content: label || 'service-name') + end + end + + RSpec.shared_examples 'mapping of binding metadata' do |name| + it 'maps service binding metadata attributes to files' do + expect(service_binding_files.find { |f| f.path == "#{directory}/binding-guid" }).to have_attributes(content: binding.guid) + expect(service_binding_files.find { |f| f.path == "#{directory}/name" }).to have_attributes(content: name || 'binding-name') + expect(service_binding_files.find { |f| f.path == "#{directory}/binding-name" }).to have_attributes(content: 'binding-name') if name.nil? + end + end + + RSpec.shared_examples 'mapping of instance metadata' do |instance_name| + it 'maps service instance metadata attributes to files' do + expect(service_binding_files.find { |f| f.path == "#{directory}/instance-guid" }).to have_attributes(content: instance.guid) + expect(service_binding_files.find { |f| f.path == "#{directory}/instance-name" }).to have_attributes(content: instance_name || 'instance-name') + end + end + + RSpec.shared_examples 'mapping of plan metadata' do + it 'maps service plan metadata attributes to files' do + expect(service_binding_files.find { |f| f.path == "#{directory}/plan" }).to have_attributes(content: 'plan-name') + end + end + + RSpec.shared_examples 'mapping of tags' do |tags| + it 'maps (service tags merged with) instance tags to a file' do + expect(service_binding_files.find do |f| + f.path == "#{directory}/tags" + end).to have_attributes(content: tags || '["a-service-tag","another-service-tag","an-instance-tag","another-instance-tag"]') + end + end + + RSpec.shared_examples 'mapping of credentials' do |credential_files| + it 'maps service binding credentials to individual files' do + expected_credential_files = credential_files || { + string: 'a string', + number: '42', + boolean: 'true', + array: '["one","two","three"]', + hash: '{"key":"value"}' + } + expected_credential_files.each do |name, content| + expect(service_binding_files.find { |f| f.path == "#{directory}/#{name}" }).to have_attributes(content:) + end + end + end + + RSpec.shared_examples 'expected files' do |files| + it 'does not include other files' do + other_files = service_binding_files.reject do |file| + match = file.path.match(%r{^#{directory}/(.+)$}) + !match.nil? && !files.delete(match[1]).nil? + end + + expect(files).to be_empty + expect(other_files).to be_empty + end + end + + RSpec.describe ServiceBindingFilesBuilder do + let(:service) { VCAP::CloudController::Service.make(label: 'service-name', tags: %w[a-service-tag another-service-tag]) } + let(:plan) { VCAP::CloudController::ServicePlan.make(name: 'plan-name', service: service) } + let(:instance) { VCAP::CloudController::ManagedServiceInstance.make(name: 'instance-name', tags: %w[an-instance-tag another-instance-tag], service_plan: plan) } + let(:binding_name) { 'binding-name' } + let(:credentials) do + { + string: 'a string', + number: 42, + boolean: true, + array: %w[one two three], + hash: { + key: 'value' + } + } + end + let(:syslog_drain_url) { nil } + let(:volume_mounts) { nil } + let(:binding) do + VCAP::CloudController::ServiceBinding.make( + name: binding_name, + credentials: credentials, + service_instance: instance, + syslog_drain_url: syslog_drain_url, + volume_mounts: volume_mounts + ) + end + let(:app) { binding.app } + let(:directory) { 'binding-name' } + + before do + app.update(file_based_service_bindings_enabled: true) + end + + describe '#build' do + subject(:build) { ServiceBindingFilesBuilder.build(app) } + + it 'returns an array of Diego::Bbs::Models::File objects' do + expect(build).to be_an(Array) + expect(build).not_to be_empty + expect(build).to all(be_a(Diego::Bbs::Models::File)) + end + + describe 'mapping rules for service binding files' do + subject(:service_binding_files) { build } + + it 'puts all files into a directory named after the service binding' do + expect(service_binding_files).not_to be_empty + expect(service_binding_files).to all(have_attributes(path: match(%r{^binding-name/.+$}))) + end + + include_examples 'mapping of type and provider' + include_examples 'mapping of binding metadata' + include_examples 'mapping of instance metadata' + include_examples 'mapping of plan metadata' + include_examples 'mapping of tags' + include_examples 'mapping of credentials' + + it 'omits null or empty array attributes' do + expect(service_binding_files).not_to include(have_attributes(path: 'binding-name/syslog_drain_url')) + expect(service_binding_files).not_to include(have_attributes(path: 'binding-name/volume_mounts')) + end + + include_examples 'expected files', %w[type provider label binding-guid name binding-name instance-guid instance-name plan tags string number boolean array hash] + + context 'when binding_name is nil' do + let(:binding_name) { nil } + let(:directory) { 'instance-name' } + + include_examples 'mapping of type and provider' + include_examples 'mapping of binding metadata', 'instance-name' + include_examples 'mapping of instance metadata' + include_examples 'mapping of plan metadata' + include_examples 'mapping of tags' + include_examples 'mapping of credentials' + + include_examples 'expected files', %w[type provider label binding-guid name instance-guid instance-name plan tags string number boolean array hash] + end + + context 'when syslog_drain_url is set' do + let(:syslog_drain_url) { 'https://syslog.drain' } + + it 'maps the attribute to a file' do + expect(service_binding_files.find { |f| f.path == 'binding-name/syslog-drain-url' }).to have_attributes(content: 'https://syslog.drain') + end + + include_examples 'mapping of type and provider' + include_examples 'mapping of binding metadata' + include_examples 'mapping of instance metadata' + include_examples 'mapping of plan metadata' + include_examples 'mapping of tags' + include_examples 'mapping of credentials' + + include_examples 'expected files', + %w[type provider label binding-guid name binding-name instance-guid instance-name plan tags string number boolean array hash syslog-drain-url] + end + + context 'when volume_mounts is set' do + let(:volume_mounts) do + [{ + container_dir: 'dir1', + device_type: 'type1', + mode: 'mode1', + foo: 'bar' + }, { + container_dir: 'dir2', + device_type: 'type2', + mode: 'mode2', + foo: 'baz' + }] + end + + it 'maps the attribute to a file' do + expect(service_binding_files.find do |f| + f.path == 'binding-name/volume-mounts' + end).to have_attributes(content: '[{"container_dir":"dir1","device_type":"type1","mode":"mode1"},{"container_dir":"dir2","device_type":"type2","mode":"mode2"}]') + end + + include_examples 'mapping of type and provider' + include_examples 'mapping of binding metadata' + include_examples 'mapping of instance metadata' + include_examples 'mapping of plan metadata' + include_examples 'mapping of tags' + include_examples 'mapping of credentials' + + include_examples 'expected files', + %w[type provider label binding-guid name binding-name instance-guid instance-name plan tags string number boolean array hash volume-mounts] + end + + context 'when the instance is user-provided' do + let(:instance) { VCAP::CloudController::UserProvidedServiceInstance.make(name: 'upsi', tags: %w[an-upsi-tag another-upsi-tag]) } + + include_examples 'mapping of type and provider', 'user-provided' + include_examples 'mapping of binding metadata' + include_examples 'mapping of instance metadata', 'upsi' + include_examples 'mapping of tags', '["an-upsi-tag","another-upsi-tag"]' + include_examples 'mapping of credentials' + + include_examples 'expected files', %w[type provider label binding-guid name binding-name instance-guid instance-name tags string number boolean array hash] + end + + context 'when there are duplicate keys at different levels' do + let(:credentials) { { type: 'duplicate-type', name: 'duplicate-name', credentials: { password: 'secret' } } } + + include_examples 'mapping of type and provider' # no 'duplicate-type' + include_examples 'mapping of binding metadata' # no 'duplicate-name' + include_examples 'mapping of instance metadata' + include_examples 'mapping of plan metadata' + include_examples 'mapping of tags' + include_examples 'mapping of credentials', { credentials: '{"password":"secret"}' } + + include_examples 'expected files', %w[type provider label binding-guid name binding-name instance-guid instance-name plan tags credentials] + end + + context 'when there are duplicate binding names' do + let(:binding_name) { 'duplicate-name' } + + before do + VCAP::CloudController::ServiceBinding.make(app: app, + service_instance: VCAP::CloudController::UserProvidedServiceInstance.make( + space: app.space, name: 'duplicate-name' + )) + end + + it 'raises an exception' do + expect { service_binding_files }.to raise_error(ServiceBindingFilesBuilder::IncompatibleBindings, 'Duplicate binding name: duplicate-name') + end + end + + context 'when binding names violate the Service Binding Specification for Kubernetes' do + let(:binding_name) { 'binding_name' } + + it 'raises an exception' do + expect { service_binding_files }.to raise_error(ServiceBindingFilesBuilder::IncompatibleBindings, 'Invalid binding name: binding_name') + end + end + + context 'when the bindings exceed the maximum allowed bytesize' do + let(:xxl_credentials) do + c = {} + value = 'v' * 1000 + 1000.times do |i| + c["key#{i}"] = value + end + c + end + + before do + allow_any_instance_of(ServiceBindingPresenter).to receive(:to_hash).and_wrap_original do |original| + original.call.merge(credentials: xxl_credentials) + end + end + + it 'raises an exception' do + expect { service_binding_files }.to raise_error(ServiceBindingFilesBuilder::IncompatibleBindings, /^Bindings exceed the maximum allowed bytesize of 1000000: \d+/) + end + end + + context 'when credential keys violate the Service Binding Specification for Kubernetes for binding entry file names' do + let(:credentials) { { '../secret': 'hidden' } } + + it 'raises an exception' do + expect { service_binding_files }.to raise_error(ServiceBindingFilesBuilder::IncompatibleBindings, 'Invalid file name: ../secret') + end + end + end + end + end +end diff --git a/spec/unit/lib/cloud_controller/diego/task_recipe_builder_spec.rb b/spec/unit/lib/cloud_controller/diego/task_recipe_builder_spec.rb index b547a76cad0..854864171ce 100644 --- a/spec/unit/lib/cloud_controller/diego/task_recipe_builder_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/task_recipe_builder_spec.rb @@ -177,6 +177,8 @@ module Diego expect(result.placement_tags).to eq(['potato-segment']) expect(result.max_pids).to eq(100) expect(result.certificate_properties).to eq(certificate_properties) + + expect(result.volume_mounted_files).to be_empty end it 'gives the task a TrustedSystemCertificatesPath' do @@ -198,6 +200,19 @@ module Diego expect(result.placement_tags).to eq([]) end end + + context 'when file-based service bindings are enabled' do + before do + app = staging_details.package.app + app.update(file_based_service_bindings_enabled: true) + VCAP::CloudController::ServiceBinding.make(service_instance: ManagedServiceInstance.make(space: app.space), app: app) + end + + it 'includes volume mounted files' do + result = task_recipe_builder.build_staging_task(config, staging_details) + expect(result.volume_mounted_files).not_to be_empty + end + end end context 'with a docker backend' do @@ -505,6 +520,8 @@ module Diego expect(result.metric_tags['organization_name'].static).to eq('MyOrg') expect(result.metric_tags['space_name'].static).to eq('MySpace') expect(result.metric_tags['app_name'].static).to eq('MyApp') + + expect(result.volume_mounted_files).to be_empty end context 'when a volume mount is provided' do @@ -583,6 +600,19 @@ module Diego expect(result.placement_tags).to eq([]) end end + + context 'when file-based service bindings are enabled' do + before do + app = task.app + app.update(file_based_service_bindings_enabled: true) + VCAP::CloudController::ServiceBinding.make(service_instance: ManagedServiceInstance.make(space: app.space), app: app) + end + + it 'includes volume mounted files' do + result = task_recipe_builder.build_app_task(config, task) + expect(result.volume_mounted_files).not_to be_empty + end + end end context 'with a docker backend' do @@ -658,6 +688,8 @@ module Diego expect(result.image_username).to eq('dockerusername') expect(result.image_password).to eq('dockerpassword') + + expect(result.volume_mounted_files).to be_empty end context 'when a volume mount is provided' do diff --git a/spec/unit/presenters/runtime_environment/system_env_presenter_spec.rb b/spec/unit/presenters/runtime_environment/system_env_presenter_spec.rb deleted file mode 100644 index 188281ca4fd..00000000000 --- a/spec/unit/presenters/runtime_environment/system_env_presenter_spec.rb +++ /dev/null @@ -1,104 +0,0 @@ -require 'spec_helper' - -module VCAP::CloudController - RSpec.describe SystemEnvPresenter do - subject(:system_env_presenter) { SystemEnvPresenter.new(app.service_bindings) } - - describe '#system_env' do - context 'when there are no services' do - let(:app) { AppModel.make(environment_variables: { 'jesse' => 'awesome' }) } - - it 'contains an empty vcap_services' do - expect(system_env_presenter.system_env[:VCAP_SERVICES]).to eq({}) - end - end - - context 'when there are services' do - let(:space) { Space.make } - let(:app) { AppModel.make(environment_variables: { 'jesse' => 'awesome' }, space: space) } - let(:service) { Service.make(label: 'elephantsql-n/a') } - let(:service_alt) { Service.make(label: 'giraffesql-n/a') } - let(:service_plan) { ServicePlan.make(service:) } - let(:service_plan_alt) { ServicePlan.make(service: service_alt) } - let(:service_instance) { ManagedServiceInstance.make(space: space, service_plan: service_plan, name: 'elephantsql-vip-uat', tags: ['excellent']) } - let(:service_instance_same_label) { ManagedServiceInstance.make(space: space, service_plan: service_plan, name: 'elephantsql-2') } - let(:service_instance_diff_label) { ManagedServiceInstance.make(space: space, service_plan: service_plan_alt, name: 'giraffesql-vip-uat') } - let!(:service_binding) { ServiceBinding.make(app: app, service_instance: service_instance, syslog_drain_url: 'logs.go-here.com') } - - it 'contains a populated vcap_services' do - expect(system_env_presenter.system_env[:VCAP_SERVICES]).not_to eq({}) - expect(system_env_presenter.system_env[:VCAP_SERVICES]).to have_key(service.label.to_sym) - expect(system_env_presenter.system_env[:VCAP_SERVICES][service.label.to_sym]).to have(1).services - end - - it 'includes service binding and instance information' do - expect(system_env_presenter.system_env[:VCAP_SERVICES][service.label.to_sym]).to have(1).items - binding = system_env_presenter.system_env[:VCAP_SERVICES][service.label.to_sym].first.to_hash - - expect(binding[:credentials]).to eq(service_binding.credentials) - expect(binding[:name]).to eq('elephantsql-vip-uat') - end - - describe 'volume mounts' do - context 'when the service binding has volume mounts' do - let!(:service_binding) do - ServiceBinding.make( - app: app, - service_instance: service_instance, - syslog_drain_url: 'logs.go-here.com', - volume_mounts: [{ - container_dir: '/data/images', - mode: 'r', - device_type: 'shared', - device: { - driver: 'cephfs', - volume_id: 'abc', - mount_config: { - key: 'value' - } - } - }] - ) - end - - it 'includes only the public volume information' do - expect(system_env_presenter.system_env[:VCAP_SERVICES][service.label.to_sym][0].to_hash[:volume_mounts]).to eq([{ 'container_dir' => '/data/images', - 'mode' => 'r', - 'device_type' => 'shared' }]) - end - end - - context 'when the service binding has no volume mounts' do - it 'is an empty array' do - expect(system_env_presenter.system_env[:VCAP_SERVICES][service.label.to_sym][0].to_hash[:volume_mounts]).to eq([]) - end - end - end - - context 'when the service is user-provided' do - let(:service_instance) { UserProvidedServiceInstance.make(space: space, name: 'elephantsql-vip-uat') } - - it 'includes service binding and instance information' do - expect(system_env_presenter.system_env[:VCAP_SERVICES][:'user-provided']).to have(1).items - binding = system_env_presenter.system_env[:VCAP_SERVICES][:'user-provided'].first.to_hash - expect(binding[:credentials]).to eq(service_binding.credentials) - expect(binding[:name]).to eq('elephantsql-vip-uat') - end - end - - describe 'grouping' do - before do - ServiceBinding.make(app: app, service_instance: service_instance_same_label) - ServiceBinding.make(app: app, service_instance: service_instance_diff_label) - end - - it 'groups services by label' do - expect(system_env_presenter.system_env[:VCAP_SERVICES]).to have(2).groups - expect(system_env_presenter.system_env[:VCAP_SERVICES][service.label.to_sym]).to have(2).services - expect(system_env_presenter.system_env[:VCAP_SERVICES][service_alt.label.to_sym]).to have(1).service - end - end - end - end - end -end diff --git a/spec/unit/presenters/system_environment/system_env_presenter_spec.rb b/spec/unit/presenters/system_environment/system_env_presenter_spec.rb index 87efc9c2bc2..090e5fa5cf6 100644 --- a/spec/unit/presenters/system_environment/system_env_presenter_spec.rb +++ b/spec/unit/presenters/system_environment/system_env_presenter_spec.rb @@ -2,7 +2,23 @@ module VCAP::CloudController RSpec.describe SystemEnvPresenter do - subject(:system_env_presenter) { SystemEnvPresenter.new(app.service_bindings) } + subject(:system_env_presenter) { SystemEnvPresenter.new(app) } + + shared_examples 'file-based service bindings' do + context 'when file-based service bindings are enabled' do + before do + app.update(file_based_service_bindings_enabled: true) + end + + it 'does not contain vcap_services' do + expect(system_env_presenter.system_env).not_to have_key(:VCAP_SERVICES) + end + + it 'contains service_binding_root' do + expect(system_env_presenter.system_env[:SERVICE_BINDING_ROOT]).to eq('/etc/cf-service-bindings') + end + end + end describe '#system_env' do context 'when there are no services' do @@ -11,6 +27,8 @@ module VCAP::CloudController it 'contains an empty vcap_services' do expect(system_env_presenter.system_env[:VCAP_SERVICES]).to eq({}) end + + include_examples 'file-based service bindings' end context 'when there are services' do @@ -154,6 +172,8 @@ module VCAP::CloudController expect(system_env_presenter.system_env[:VCAP_SERVICES][service_alt.label.to_sym]).to have(1).service end end + + include_examples 'file-based service bindings' end end end diff --git a/spec/unit/presenters/v3/app_feature_presenter_spec.rb b/spec/unit/presenters/v3/app_feature_presenter_spec.rb index bb6195ea630..c720a02f9de 100644 --- a/spec/unit/presenters/v3/app_feature_presenter_spec.rb +++ b/spec/unit/presenters/v3/app_feature_presenter_spec.rb @@ -1,5 +1,6 @@ require 'spec_helper' require 'presenters/v3/app_ssh_feature_presenter' +require 'presenters/v3/app_file_based_service_bindings_feature_presenter' module VCAP::CloudController::Presenters::V3 RSpec.describe AppSshFeaturePresenter do @@ -14,4 +15,17 @@ module VCAP::CloudController::Presenters::V3 end end end + + RSpec.describe AppFileBasedServiceBindingsFeaturePresenter do + let(:app) { VCAP::CloudController::AppModel.make } + + describe '#to_hash' do + it 'presents the app feature as json' do + result = AppFileBasedServiceBindingsFeaturePresenter.new(app).to_hash + expect(result[:name]).to eq('file-based-service-bindings') + expect(result[:description]).to eq('Enable file-based service bindings for the app') + expect(result[:enabled]).to eq(app.file_based_service_bindings_enabled) + end + end + end end