cloudfoundry/cloud_controller_ng

View on GitHub
spec/unit/lib/cloud_controller/config_spec.rb

Summary

Maintainability
C
1 day
Test Coverage
require 'spec_helper'

module VCAP::CloudController
  RSpec.describe Config do
    let(:test_config_hash) do
      {
        packages: {
          fog_connection: {},
          fog_aws_storage_options: {
            encryption: 'AES256'
          },
          fog_gcp_storage_options: {},
          app_package_directory_key: 'app_key'
        },
        droplets: {
          fog_connection: {},
          droplet_directory_key: 'droplet_key'
        },
        buildpacks: {
          fog_connection: {},
          buildpack_directory_key: 'bp_key'
        },
        resource_pool: {
          minimum_size: 9001,
          maximum_size: 0,
          fog_connection: {},
          resource_directory_key: 'resource_key'
        },
        external_domain: 'host',
        tls_port: 1234,
        staging: {
          auth: {
            user: 'user',
            password: 'password'
          }
        },
        reserved_private_domains: File.join(Paths::FIXTURES, 'config/reserved_private_domains.dat'),
        diego: {},
        stacks_file: 'path/to/stacks/file',
        db_encryption_key: '123-456',
        install_buildpacks: [
          {
            name: 'some-buildpack'
          }
        ]
      }
    end

    subject(:config_instance) { Config.new(test_config_hash) }

    describe '.read_file' do
      it 'raises error if the file does not exist' do
        expect do
          Config.read_file('nonexistent.yml')
        end.to raise_error(Errno::ENOENT, /No such file or directory @ rb_sysopen - nonexistent.yml/)
      end

      context 'read a file' do
        context 'when the file has entries' do
          let(:config_contents) do
            {
              'db' => {
                'max_connections' => 2
              }
            }
          end

          let(:cc_local_worker_config_file) do
            file = Tempfile.new('cc_local_config_file.yml')
            file.write(YAML.dump(config_contents))
            file.close
            file
          end

          it 'returns a valid hash' do
            config_hash = Config.read_file(cc_local_worker_config_file)
            expect(config_hash[:db][:max_connections]).to eq(2)
          end
        end

        context 'when empty YAML file is provided' do
          let(:cc_local_worker_config_file) { 'config/cloud_controller_local_worker_override.yml' }

          it 'returns an empty hash' do
            config_hash = Config.read_file(cc_local_worker_config_file)
            expect(config_hash).to eq({})
          end
        end
      end
    end

    describe '.load_from_hash' do
      it 'raises error if an empty hash is provided' do
        expect do
          Config.load_from_hash({}, context: :api)
        end.to raise_error(Membrane::SchemaValidationError)
      end

      context 'when no config values are provided' do
        let(:cc_config_hash) do
          YAMLConfig.safe_load_file('config/cloud_controller.yml')
        end

        let(:config_hash) do
          Config.load_from_hash(cc_config_hash, context: :api).config_hash
        end

        it 'has the default values' do
          expect(config_hash[:db][:max_connections]).to eq(42)
          expect(config_hash[:name]).to eq('api')
          expect(config_hash[:local_route]).to eq('127.0.0.1')
        end
      end
    end

    describe '.load_from_file' do
      it 'raises if the file does not exist' do
        expect do
          Config.load_from_file('nonexistent.yml', context: :worker)
        end.to raise_error(Errno::ENOENT, /No such file or directory @ rb_sysopen - nonexistent.yml/)
      end

      context 'merges default values' do
        after do
          cc_config_file.unlink
        end

        context 'when no config values are provided' do
          let(:cc_config_file) do
            config = YAMLConfig.safe_load_file('config/cloud_controller.yml')
            config['db'].delete('database')

            file = Tempfile.new('cc_config.yml')
            file.write(YAML.dump(config))
            file.close
            file
          end

          let(:config) do
            Config.load_from_file(cc_config_file, context: :worker).config_hash
          end

          it 'sets a default value for database' do
            expect(config[:db][:db_connection_string]).to eq(ENV.fetch('DB_CONNECTION_STRING', nil))
            expect(config[:db][:database]).to eq(DatabasePartsParser.database_parts_from_connection(ENV.fetch('DB_CONNECTION_STRING', nil)))
          end

          context 'special passwords characters' do
            let(:uri) { "http://user:#{password}@example.com/databasename" }
            let(:raw_password) { 'pass@word' }

            context 'unescaped' do
              let(:password) { raw_password }

              it "can't handle an unescaped @" do
                expect do
                  DatabasePartsParser.database_parts_from_connection(uri)
                end.to raise_error(URI::InvalidURIError, "bad URI(is not URI?): \"#{uri}\"")
              end
            end

            context 'escaped' do
              let(:password) { CGI.escape(raw_password) }

              it "can't handle an unescaped @" do
                parts = DatabasePartsParser.database_parts_from_connection(uri)
                expect(parts[:password]).to eq(raw_password)
              end
            end
          end
        end

        context 'when config values are provided' do
          context 'and the values are valid' do
            let(:cc_config_file) do
              config = YAMLConfig.safe_load_file('config/cloud_controller.yml')
              config['stacks_file'] = '/tmp/foo'
              config['database_encryption'] = {
                'keys' => {
                  'foo' => 'bar',
                  'head' => 'banging'
                },
                'current_key_label' => 'foo'
              }

              file = Tempfile.new('cc_config.yml')
              file.write(YAML.dump(config))
              file.close
              file
            end

            let(:config) do
              Config.load_from_file(cc_config_file.path, context: :worker).config_hash
            end

            it 'preserves the current_encryption_key_label value from the file' do
              expect(config[:database_encryption][:current_key_label]).to eq('foo')
            end

            it 'preserves the stacks_file value from the file' do
              expect(config[:stacks_file]).to eq('/tmp/foo')
            end

            it 'preserves the external_protocol value from the file' do
              expect(config[:external_protocol]).to eq('http')
            end

            it 'preserves the request_timeout_in_seconds value from the file' do
              expect(config[:request_timeout_in_seconds]).to eq(600)
            end

            it 'preserves the threadpool_size value from the file' do
              expect(config[:threadpool_size]).to eq(20)
            end

            it 'preserves the value of the staging auth user/password' do
              expect(config[:staging][:auth][:user]).to eq('bob')
              expect(config[:staging][:auth][:password]).to eq('laura')
            end

            it 'preserves the values of the minimum staging limits' do
              expect(config[:staging][:minimum_staging_disk_mb]).to eq(42)
            end

            it 'preserves the enable allow ssh configuration from the file' do
              expect(config[:allow_app_ssh_access]).to be(true)
            end

            it 'preserves the internal_service_hostname value from the file' do
              expect(config[:internal_service_hostname]).to eq('api.internal.cf')
            end

            context 'when the staging auth is already url encoded' do
              let(:cc_config_file) do
                config = YAMLConfig.safe_load_file('config/cloud_controller.yml')
                config['staging']['auth']['user'] = 'f%40t%3A%25a'
                config['staging']['auth']['password'] = 'm%40%2Fn!'

                file = Tempfile.new('cc_config.yml')
                file.write(YAML.dump(config))
                file.close
                file
              end

              let(:config_load_from_file) do
                Config.load_from_file(cc_config_file.path, context: :worker).config_hash
              end

              it 'preserves the url-encoded values' do
                config_load_from_file[:staging][:auth][:user] = 'f%40t%3A%25a'
                config_load_from_file[:staging][:auth][:password] = 'm%40%2Fn!'
              end
            end
          end

          context 'and the password contains double quotes' do
            let(:cc_config_file) do
              config = YAMLConfig.safe_load_file('config/cloud_controller.yml')
              config['staging']['auth']['password'] = 'pass"wor"d'

              file = Tempfile.new('cc_config.yml')
              file.write(YAML.dump(config))
              file.close
              file
            end

            let(:config_load_from_file) do
              Config.load_from_file(cc_config_file.path, context: :worker).config_hash
            end

            it 'URL-encodes staging password as neccesary' do
              expect(config_load_from_file[:staging][:auth][:password]).to eq('pass%22wor%22d')
            end
          end

          context 'and the values are invalid' do
            let(:cc_config_file) do
              config = YAMLConfig.safe_load_file('config/cloud_controller.yml')
              config['staging']['auth']['user'] = 'f@t:%a'
              config['staging']['auth']['password'] = 'm@/n!'

              file = Tempfile.new('cc_config.yml')
              file.write(YAML.dump(config))
              file.close
              file
            end

            let(:config_load_from_file) do
              Config.load_from_file(cc_config_file.path, context: :worker).config_hash
            end

            it 'URL-encodes staging auth as necessary' do
              expect(config_load_from_file[:staging][:auth][:user]).to eq('f%40t%3A%25a')
              expect(config_load_from_file[:staging][:auth][:password]).to eq('m%40%2Fn%21')
            end
          end

          context 'unit test' do
            let(:config_contents) do
              {
                'some_key' => 'some-value',
                'database_encryption' => {
                  'keys' => {
                    'foo' => 'bar',
                    'head' => 'banging',
                    'array' => %w[a b c]
                  },
                  'current_key_label' => 'foo'
                }
              }
            end
            let(:cc_config_file) do
              file = Tempfile.new('cc_config.yml')
              file.write(YAML.dump(config_contents))
              file.close
              file
            end

            it 'validates a symbolized version of the config file contents with the correct schema file' do
              allow(VCAP::CloudController::ConfigSchemas::Vms::WorkerSchema).to receive(:validate)

              Config.load_from_file(cc_config_file.path, context: :worker).config_hash

              expect(VCAP::CloudController::ConfigSchemas::Vms::WorkerSchema).to have_received(:validate).with(
                some_key: 'some-value',
                database_encryption: {
                  keys: {
                    foo: 'bar',
                    head: 'banging',
                    array: %w[a b c]
                  },
                  current_key_label: 'foo'
                }
              )
            end

            context 'when the secrets hash is provided' do
              let(:secrets_hash) do
                {
                  'some_secret' => 'shhhhh!',
                  'database_encryption' => {
                    'keys' => {
                      'password' => 'totes-s3cre7'
                    }
                  }
                }
              end

              it 'merges the secrets hash into the file contents and validates with the correct schema' do
                allow(VCAP::CloudController::ConfigSchemas::Vms::WorkerSchema).to receive(:validate)

                Config.load_from_file(cc_config_file.path, context: :worker, secrets_hash: secrets_hash).config_hash

                expect(VCAP::CloudController::ConfigSchemas::Vms::WorkerSchema).to have_received(:validate).with(
                  some_key: 'some-value',
                  some_secret: 'shhhhh!',
                  database_encryption: {
                    keys: {
                      foo: 'bar',
                      head: 'banging',
                      array: %w[a b c],
                      password: 'totes-s3cre7'
                    },
                    current_key_label: 'foo'
                  }
                )
              end
            end

            context 'when the config has no "kubernetes" key' do
              let(:config_contents) do
                { 'some_non_kubernetes_key' => true }
              end

              it 'uses the Vms schema to validate the config' do
                allow(VCAP::CloudController::ConfigSchemas::Vms::ApiSchema).to receive(:validate)
                Config.load_from_file(cc_config_file.path, context: :api).config_hash

                expect(VCAP::CloudController::ConfigSchemas::Vms::ApiSchema).to have_received(:validate)
              end
            end
          end
        end
      end
    end

    describe '#configure_components' do
      let(:dependency_locator) { CloudController::DependencyLocator.instance }
      let(:test_config_hash) do
        {
          packages: {
            fog_connection: {},
            fog_aws_storage_options: {
              encryption: 'AES256'
            },
            fog_gcp_storage_options: {},
            app_package_directory_key: 'app_key'
          },
          droplets: {
            fog_connection: {},
            droplet_directory_key: 'droplet_key'
          },
          buildpacks: {
            fog_connection: {},
            buildpack_directory_key: 'bp_key'
          },
          resource_pool: {
            minimum_size: 9001,
            maximum_size: 0,
            fog_connection: {},
            resource_directory_key: 'resource_key'
          },
          external_host: 'host',
          tls_port: 1234,
          staging: {
            auth: {
              user: 'user',
              password: 'password'
            }
          },

          reserved_private_domains: File.join(Paths::FIXTURES, 'config/reserved_private_domains.dat'),
          diego: {},
          stacks_file: 'path/to/stacks/file',
          db_encryption_key: '123-456'
        }
      end

      before do
        allow(Stack).to receive(:configure)
      end

      after do
        AccountCapacity.admin[:memory] = AccountCapacity::ADMIN_MEM
      end

      it 'sets up the db encryption key' do
        expect(Encryptor).to receive(:db_encryption_key=).with('123-456')
        config_instance.configure_components
      end

      it 'sets up the resource pool instance' do
        config_instance.configure_components
        expect(ResourcePool.instance.minimum_size).to eq(9001)
      end

      it 'sets up the app manager' do
        expect(ProcessObserver).to receive(:configure).with(instance_of(Stagers), instance_of(Runners))

        config_instance.configure_components
      end

      it 'sets up the quota definition' do
        expect(QuotaDefinition).to receive(:configure).with(config_instance)
        config_instance.configure_components
      end

      it 'sets up the stack' do
        expect(Stack).to receive(:configure).with('path/to/stacks/file')
        config_instance.configure_components
      end

      it 'sets up app with whether custom buildpacks are enabled' do
        config = Config.new(test_config_hash.merge(disable_custom_buildpacks: true))
        expect(config.get(:disable_custom_buildpacks)).to be true

        config = Config.new(test_config_hash.merge(disable_custom_buildpacks: false))
        expect(config.get(:disable_custom_buildpacks)).to be false
      end

      context 'when newrelic is disabled' do
        let(:config_instance) do
          Config.new(test_config_hash.merge(newrelic_enabled: false))
        end

        before do
          GC::Profiler.disable
        end

        it 'does not enable GC profiling' do
          config_instance.configure_components
          expect(GC::Profiler.enabled?).to be(false)
        end
      end

      context 'when database encryption keys are used' do
        let(:keys) do
          {
            keys: {
              current: 'abc-123',
              previous: 'def-456',
              old: 'ghi-789'
            },
            current_key_label: 'current',
            pbkdf2_hmac_iterations: 100_020
          }
        end

        let(:config_instance) do
          Config.new(test_config_hash.merge(database_encryption: keys))
        end

        before do
          allow(Encryptor).to receive(:current_encryption_key_label=)
        end

        it 'sets up the current encryption key label' do
          expect(Encryptor).to receive(:current_encryption_key_label=).with(keys[:current_key_label])
          config_instance.configure_components
        end

        it 'sets up the database encryption keys' do
          expect(Encryptor).to receive(:database_encryption_keys=).with(keys[:keys])
          config_instance.configure_components
        end

        it 'sets up pbkdf2_hmac_iterations' do
          expect(Encryptor).to receive(:pbkdf2_hmac_iterations=).with(keys[:pbkdf2_hmac_iterations])
          config_instance.configure_components
        end
      end

      context 'when newrelic is enabled' do
        let(:config_instance) do
          Config.new(test_config_hash.merge(newrelic_enabled: true))
        end

        before do
          GC::Profiler.disable
        end

        it 'enables GC profiling' do
          config_instance.configure_components
          expect(GC::Profiler.enabled?).to be(true)
        end
      end

      it 'sets up the reserved private domain' do
        expect(PrivateDomain).to receive(:configure).with(test_config_hash[:reserved_private_domains])
        config_instance.configure_components
      end
    end

    describe '#get' do
      it 'returns the value at the given key' do
        expect(config_instance.get(:external_domain)).to eq 'host'
      end

      it 'returns an array at the given key' do
        expect(config_instance.get(:install_buildpacks)).to eq([{ name: 'some-buildpack' }])
      end

      it 'returns a hash for nested properties' do
        expect(config_instance.get(:packages)).to eq({
                                                       fog_connection: {},
                                                       fog_aws_storage_options: {
                                                         encryption: 'AES256'
                                                       },
                                                       fog_gcp_storage_options: {},
                                                       app_package_directory_key: 'app_key'
                                                     })
        expect(config_instance.get(:packages, :fog_aws_storage_options)).to eq(encryption: 'AES256')
      end

      it 'raises an exception when given an invalid key' do
        expect do
          config_instance.get(:blub_blub)
        end.to raise_error Config::InvalidConfigPath, /"blub_blub" is not a valid config key/
      end

      it 'raises when you dig into a leaf property' do
        expect do
          config_instance.get(:external_domain, :pantaloons)
        end.to raise_error Config::InvalidConfigPath, /"external_domain.pantaloons" is not a valid config key/
      end

      it 'raises when you dig into hashes' do
        expect do
          config_instance.get(:packages, :fog_aws_storage_options, :encryption)
        end.to raise_error Config::InvalidConfigPath, /"packages.fog_aws_storage_options.encryption" is not a valid config key/
      end

      it 'raises when given a path with an invalid key' do
        expect do
          config_instance.get(:packages, :ham_sandwich)
        end.to raise_error Config::InvalidConfigPath, /"packages.ham_sandwich" is not a valid config key/
      end

      it 'raises when you dig into arrays' do
        expect do
          config_instance.get(:install_buildpacks, :name)
        end.to raise_error Config::InvalidConfigPath, /"install_buildpacks.name" is not a valid config key/
      end
    end

    describe '#set' do
      it 'saves the value at the key in the config' do
        config_instance.set(:external_host, 'foobar.example.com')
        expect(config_instance.get(:external_host)).to eq 'foobar.example.com'
      end
    end

    describe 'broker_client_async_poll_exponential_backoff_rate' do
      let(:cc_config_file) do
        config = YAMLConfig.safe_load_file('config/cloud_controller.yml')
        config['broker_client_async_poll_exponential_backoff_rate'] = backoff_rate

        file = Tempfile.new('cc_config.yml')
        file.write(YAML.dump(config))
        file.close
        file
      end

      let(:config) do
        Config.load_from_file(cc_config_file, context: schema_context)
      end

      context 'worker schema' do
        let(:schema_context) { :worker }

        context 'when given an Integer' do
          let(:backoff_rate) { 1 }

          it 'succeeds' do
            expect(config.get(:broker_client_async_poll_exponential_backoff_rate)).to eq 1
          end
        end

        context 'when given a Float' do
          let(:backoff_rate) { 1.0 }

          it 'succeeds' do
            expect(config.get(:broker_client_async_poll_exponential_backoff_rate)).to eq 1
          end
        end
      end

      context 'api schema' do
        let(:schema_context) { :api }

        context 'when given an Integer' do
          let(:backoff_rate) { 1 }

          it 'succeeds' do
            expect(config.get(:broker_client_async_poll_exponential_backoff_rate)).to eq 1
          end
        end

        context 'when given a Float' do
          let(:backoff_rate) { 1.0 }

          it 'succeeds' do
            expect(config.get(:broker_client_async_poll_exponential_backoff_rate)).to eq 1
          end
        end
      end
    end
  end
end