From 313b290b6659b79a6478775f01e0ab8b1708e621 Mon Sep 17 00:00:00 2001 From: Jamis Buck Date: Thu, 1 Jun 2023 08:27:04 -0600 Subject: [PATCH] RUBY-3152 Collect FaaS platform info in handshake (#2722) * add environment detection logic * add environment info, and updated truncation logic * prose tests from handshake spec * updated truncation logic, and AWS detection logic * minor cleanups --- lib/mongo/server/app_metadata.rb | 71 ++--- lib/mongo/server/app_metadata/environment.rb | 255 ++++++++++++++++++ lib/mongo/server/app_metadata/truncator.rb | 142 ++++++++++ spec/integration/connection/faas_env_spec.rb | 63 +++++ spec/mongo/client_construction_spec.rb | 6 +- spec/mongo/cluster_spec.rb | 4 +- .../server/app_metadata/environment_spec.rb | 193 +++++++++++++ .../server/app_metadata/truncator_spec.rb | 158 +++++++++++ spec/mongo/server/app_metadata_spec.rb | 80 +++--- spec/mongo/socket/ssl_spec.rb | 10 +- 10 files changed, 880 insertions(+), 102 deletions(-) create mode 100644 lib/mongo/server/app_metadata/environment.rb create mode 100644 lib/mongo/server/app_metadata/truncator.rb create mode 100644 spec/integration/connection/faas_env_spec.rb create mode 100644 spec/mongo/server/app_metadata/environment_spec.rb create mode 100644 spec/mongo/server/app_metadata/truncator_spec.rb diff --git a/lib/mongo/server/app_metadata.rb b/lib/mongo/server/app_metadata.rb index 8d81dd1f2d..7d4bf00961 100644 --- a/lib/mongo/server/app_metadata.rb +++ b/lib/mongo/server/app_metadata.rb @@ -15,6 +15,9 @@ # See the License for the specific language governing permissions and # limitations under the License. +require 'mongo/server/app_metadata/environment' +require 'mongo/server/app_metadata/truncator' + module Mongo class Server # Application metadata that is sent to the server during a handshake, @@ -26,11 +29,6 @@ class Server class AppMetadata extend Forwardable - # The max application metadata document byte size. - # - # @since 2.4.0 - MAX_DOCUMENT_SIZE = 512.freeze - # The max application name byte size. # # @since 2.4.0 @@ -139,6 +137,23 @@ def validated_document document end + # Get BSON::Document to be used as value for `client` key in + # handshake document. + # + # @return [BSON::Document] Document describing client for handshake. + # + # @api private + def client_document + @client_document ||= + BSON::Document.new.tap do |doc| + doc[:application] = { name: @app_name } if @app_name + doc[:driver] = driver_doc + doc[:os] = os_doc + doc[:platform] = platform + env_doc.tap { |env| doc[:env] = env if env } + end + end + private # Check whether it is possible to build a valid app metadata document @@ -152,20 +167,6 @@ def validate! true end - # Get BSON::Document to be used as value for `client` key in - # handshake document. - # - # @return [BSON::Document] Document describing client for handshake. - def full_client_document - BSON::Document.new.tap do |doc| - doc[:application] = { name: @app_name } if @app_name - doc[:driver] = driver_doc - doc[:os] = os_doc - doc[:platform] = platform - end - end - - # Get the metadata as BSON::Document to be sent to # as part of the handshake. The document should # be appended to a suitable handshake command. @@ -173,30 +174,11 @@ def full_client_document # @return [BSON::Document] Document for connection's handshake. def document @document ||= begin - client_document = full_client_document - while client_document.to_bson.to_s.size > MAX_DOCUMENT_SIZE do - if client_document[:os][:name] || client_document[:os][:architecture] - client_document[:os].delete(:name) - client_document[:os].delete(:architecture) - elsif client_document[:platform] - client_document.delete(:platform) - else - client_document = nil - end + client = Truncator.new(client_document).document + BSON::Document.new(compression: @compressors, client: client).tap do |doc| + doc[:saslSupportedMechs] = @request_auth_mech if @request_auth_mech + doc.update(Utils.transform_server_api(@server_api)) if @server_api end - document = BSON::Document.new( - { - compression: @compressors, - client: client_document, - } - ) - document[:saslSupportedMechs] = @request_auth_mech if @request_auth_mech - if @server_api - document.update( - Utils.transform_server_api(@server_api) - ) - end - document end end @@ -223,6 +205,11 @@ def os_doc } end + def env_doc + env = Environment.new + env.faas? ? env.to_h : nil + end + def type (RbConfig::CONFIG && RbConfig::CONFIG['host_os']) ? RbConfig::CONFIG['host_os'].split('_').first[/[a-z]+/i].downcase : 'unknown' diff --git a/lib/mongo/server/app_metadata/environment.rb b/lib/mongo/server/app_metadata/environment.rb new file mode 100644 index 0000000000..5e9c556f7b --- /dev/null +++ b/lib/mongo/server/app_metadata/environment.rb @@ -0,0 +1,255 @@ +# frozen_string_literal: true + +# Copyright (C) 2016-2023 MongoDB Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module Mongo + class Server + class AppMetadata + # Implements the logic from the handshake spec, for deducing and + # reporting the current FaaS environment in which the program is + # executing. + # + # @api private + class Environment + # Error class for reporting that too many discriminators were found + # in the environment. (E.g. if the environment reports that it is + # running under both AWS and Azure.) + class TooManyEnvironments < Mongo::Error; end + + # Error class for reporting that a required environment variable is + # missing. + class MissingVariable < Mongo::Error; end + + # Error class for reporting that the wrong type was given for a + # field. + class TypeMismatch < Mongo::Error; end + + # Error class for reporting that the value for a field is too long. + class ValueTooLong < Mongo::Error; end + + # This value is not explicitly specified in the spec, only implied to be + # less than 512. + MAXIMUM_VALUE_LENGTH = 500 + + # The mapping that determines which FaaS environment is active, based + # on which environment variable(s) are present. + DISCRIMINATORS = { + 'AWS_EXECUTION_ENV' => { pattern: /^AWS_Lambda_/, name: 'aws.lambda' }, + 'AWS_LAMBDA_RUNTIME_API' => { name: 'aws.lambda' }, + 'FUNCTIONS_WORKER_RUNTIME' => { name: 'azure.func' }, + 'K_SERVICE' => { name: 'gcp.func' }, + 'FUNCTION_NAME' => { name: 'gcp.func' }, + 'VERCEL' => { name: 'vercel' }, + }.freeze + + # Describes how to coerce values of the specified type. + COERCIONS = { + string: ->(v) { String(v) }, + integer: ->(v) { Integer(v) } + }.freeze + + # Describes which fields are required for each FaaS environment, + # along with their expected types, and how they should be named in + # the handshake document. + FIELDS = { + 'aws.lambda' => { + 'AWS_REGION' => { field: :region, type: :string }, + 'AWS_LAMBDA_FUNCTION_MEMORY_SIZE' => { field: :memory_mb, type: :integer }, + }, + + 'azure.func' => {}, + + 'gcp.func' => { + 'FUNCTION_MEMORY_MB' => { field: :memory_mb, type: :integer }, + 'FUNCTION_TIMEOUT_SEC' => { field: :timeout_sec, type: :integer }, + 'FUNCTION_REGION' => { field: :region, type: :string }, + }, + + 'vercel' => { + 'VERCEL_URL' => { field: :url, type: :string }, + 'VERCEL_REGION' => { field: :region, type: :string }, + }, + }.freeze + + # @return [ String | nil ] the name of the FaaS environment that was + # detected, or nil if no valid FaaS environment was detected. + attr_reader :name + + # @return [ Hash | nil ] the fields describing the detected FaaS + # environment. + attr_reader :fields + + # @return [ String | nil ] the error message explaining why a valid + # FaaS environment was not detected, or nil if no error occurred. + # + # @note These error messagess are not to be propogated to the + # user; they are intended only for troubleshooting and debugging.) + attr_reader :error + + # Create a new AppMetadata::Environment object, initializing it from + # the current ENV variables. If no FaaS environment is detected, or + # if the environment contains invalid or contradictory state, it will + # be initialized with {{name}} set to {{nil}}. + def initialize + @error = nil + @name = detect_environment + populate_fields + rescue TooManyEnvironments => e + self.error = "too many environments detected: #{e.message}" + rescue MissingVariable => e + self.error = "missing environment variable: #{e.message}" + rescue TypeMismatch => e + self.error = e.message + rescue ValueTooLong => e + self.error = "value for #{e.message} is too long" + end + + # Queries whether the current environment is a valid FaaS environment. + # + # @return [ true | false ] whether the environment is a FaaS + # environment or not. + def faas? + @name != nil + end + + # Queries whether the current environment is a valid AWS Lambda + # environment. + # + # @return [ true | false ] whether the environment is a AWS Lambda + # environment or not. + def aws? + @name == 'aws.lambda' + end + + # Queries whether the current environment is a valid Azure + # environment. + # + # @return [ true | false ] whether the environment is a Azure + # environment or not. + def azure? + @name == 'azure.func' + end + + # Queries whether the current environment is a valid GCP + # environment. + # + # @return [ true | false ] whether the environment is a GCP + # environment or not. + def gcp? + @name == 'gcp.func' + end + + # Queries whether the current environment is a valid Vercel + # environment. + # + # @return [ true | false ] whether the environment is a Vercel + # environment or not. + def vercel? + @name == 'vercel' + end + + # Compiles the detected environment information into a Hash. It will + # always include a {{name}} key, but may include other keys as well, + # depending on the detected FaaS environment. (See the handshake + # spec for details.) + # + # @return [ Hash ] the detected environment information. + def to_h + fields.merge(name: name) + end + + private + + # Searches the DESCRIMINATORS list to see which (if any) apply to + # the current environment. + # + # @return [ String | nil ] the name of the detected FaaS provider. + # + # @raise [ TooManyEnvironments ] if the environment contains + # discriminating variables for more than one FaaS provider. + def detect_environment + matches = DISCRIMINATORS.keys.select { |k| discriminator_matches?(k) } + names = matches.map { |m| DISCRIMINATORS[m][:name] }.uniq + + raise TooManyEnvironments, names.join(', ') if names.length > 1 + + names.first + end + + # Determines whether the named environment variable exists, and (if + # a pattern has been declared for that descriminator) whether the + # pattern matches the value of the variable. + # + # @param [ String ] var the name of the environment variable + # + # @return [ true | false ] if the variable describes the current + # environment or not. + def discriminator_matches?(var) + return false unless ENV[var] + + disc = DISCRIMINATORS[var] + return true unless disc[:pattern] + + disc[:pattern].match?(ENV[var]) + end + + # Extracts environment information from the current environment + # variables, based on the detected FaaS environment. Populates the + # {{@fields}} instance variable. + def populate_fields + return unless name + + @fields = FIELDS[name].each_with_object({}) do |(var, defn), fields| + fields[defn[:field]] = extract_field(var, defn) + end + end + + # Extracts the named variable from the environment and validates it + # against its declared definition. + # + # @param [ String ] var The name of the environment variable to look + # for. + # @param [ Hash ] definition The definition of the field that applies + # to the named variable. + # + # @return [ Integer | String ] the validated and coerced value of the + # given environment variable. + # + # @raise [ MissingVariable ] if the environment does not include a + # variable required by the current FaaS provider. + # @raise [ ValueTooLong ] if a required variable is too long. + # @raise [ TypeMismatch ] if a required variable cannot be coerced to + # the expected type. + def extract_field(var, definition) + raise MissingVariable, var unless ENV[var] + raise ValueTooLong, var if ENV[var].length > MAXIMUM_VALUE_LENGTH + + COERCIONS[definition[:type]].call(ENV[var]) + rescue ArgumentError + raise TypeMismatch, + "#{var} must be #{definition[:type]} (got #{ENV[var].inspect})" + end + + # Sets the error message to the given value and sets the name to nil. + # + # @param [ String ] msg The error message to store. + def error=(msg) + @name = nil + @error = msg + end + end + end + end +end diff --git a/lib/mongo/server/app_metadata/truncator.rb b/lib/mongo/server/app_metadata/truncator.rb new file mode 100644 index 0000000000..125b8f1e2e --- /dev/null +++ b/lib/mongo/server/app_metadata/truncator.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +# Copyright (C) 2016-2023 MongoDB Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module Mongo + class Server + class AppMetadata + # Implements the metadata truncation logic described in the handshake + # spec. + # + # @api private + class Truncator + # @return [ BSON::Document ] the document being truncated. + attr_reader :document + + # The max application metadata document byte size. + MAX_DOCUMENT_SIZE = 512 + + # Creates a new Truncator instance and tries enforcing the maximum + # document size on the given document. + # + # @param [ BSON::Document] document The document to (potentially) + # truncate. + # + # @note The document is modified in-place; if you wish to keep the + # original unchanged, you must deep-clone it prior to sending it to + # a truncator. + def initialize(document) + @document = document + try_truncate! + end + + # The current size of the document, in bytes, as a serialized BSON + # document. + # + # @return [ Integer ] the size of the document + def size + @document.to_bson.to_s.length + end + + # Whether the document fits within the required maximum document size. + # + # @return [ true | false ] if the document is okay or not. + def ok? + size <= MAX_DOCUMENT_SIZE + end + + private + + # How many extra bytes must be trimmed before the document may be + # considered #ok?. + # + # @return [ Integer ] how many bytes larger the document is than the + # maximum document size. + def excess + size - MAX_DOCUMENT_SIZE + end + + # Attempt to truncate the document using the documented metadata + # priorities (see the handshake specification). + def try_truncate! + %i[ env_fields os_fields env platform ].each do |target| + break if ok? + + send(:"try_truncate_#{target}!") + end + end + + # Attempt to truncate or remove the {{:platform}} key from the + # document. + def try_truncate_platform! + @document.delete(:platform) unless try_truncate_string(@document[:platform]) + end + + # Attempt to truncate the keys in the {{:env}} subdocument. + def try_truncate_env_fields! + try_truncate_hash(@document[:env], reserved: %w[ name ]) + end + + # Attempt to truncate the keys in the {{:os}} subdocument. + def try_truncate_os_fields! + try_truncate_hash(@document[:os], reserved: %w[ type ]) + end + + # Remove the {{:env}} key from the document. + def try_truncate_env! + @document.delete(:env) + end + + # A helper method for truncating a string (in-place) by whatever + # {{#excess}} is required. + # + # @param [ String ] string the string value to truncate. + # + # @note the parameter is modified in-place. + def try_truncate_string(string) + length = string&.length || 0 + + return false if excess > length + + string[(length - excess)..-1] = '' + end + + # A helper method for removing the keys of a Hash (in-place) until + # the document is the necessary size. The keys are considered in order + # (using the Hash's native key ordering), and each will be removed from + # the hash in turn, until the document is the necessary size. + # + # Any keys in the {{reserved}} list will be ignored. + # + # @param [ Hash | nil ] hash the Hash instance to consider. + # @param [ Array ] reserved the list of keys to ignore in the hash. + # + # @note the hash parameter is modified in-place. + def try_truncate_hash(hash, reserved: []) + return false unless hash + + keys = hash.keys - reserved + keys.each do |key| + hash.delete(key) + + return true if ok? + end + + false + end + end + end + end +end diff --git a/spec/integration/connection/faas_env_spec.rb b/spec/integration/connection/faas_env_spec.rb new file mode 100644 index 0000000000..08f2b55867 --- /dev/null +++ b/spec/integration/connection/faas_env_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Test Plan scenarios from the handshake spec +SCENARIOS = { + 'Valid AWS' => { + 'AWS_EXECUTION_ENV' => 'AWS_Lambda_ruby2.7', + 'AWS_REGION' => 'us-east-2', + 'AWS_LAMBDA_FUNCTION_MEMORY_SIZE' => '1024', + }, + + 'Valid Azure' => { + 'FUNCTIONS_WORKER_RUNTIME' => 'ruby', + }, + + 'Valid GCP' => { + 'K_SERVICE' => 'servicename', + 'FUNCTION_MEMORY_MB' => '1024', + 'FUNCTION_TIMEOUT_SEC' => '60', + 'FUNCTION_REGION' => 'us-central1', + }, + + 'Valid Vercel' => { + 'VERCEL' => '1', + 'VERCEL_URL' => '*.vercel.app', + 'VERCEL_REGION' => 'cdg1', + }, + + 'Invalid - multiple providers' => { + 'AWS_EXECUTION_ENV' => 'AWS_Lambda_ruby2.7', + 'AWS_REGION' => 'us-east-2', + 'AWS_LAMBDA_FUNCTION_MEMORY_SIZE' => '1024', + 'FUNCTIONS_WORKER_RUNTIME' => 'ruby', + }, + + 'Invalid - long string' => { + 'AWS_EXECUTION_ENV' => 'AWS_Lambda_ruby2.7', + 'AWS_REGION' => 'a' * 512, + 'AWS_LAMBDA_FUNCTION_MEMORY_SIZE' => '1024', + }, + + 'Invalid - wrong types' => { + 'AWS_EXECUTION_ENV' => 'AWS_Lambda_ruby2.7', + 'AWS_REGION' => 'us-east-2', + 'AWS_LAMBDA_FUNCTION_MEMORY_SIZE' => 'big', + }, +}.freeze + +describe 'Connect under FaaS Env' do + clean_slate + + SCENARIOS.each do |name, env| + context "when given #{name}" do + local_env(env) + + it 'connects successfully' do + resp = authorized_client.database.command(ping: 1) + expect(resp).to be_a(Mongo::Operation::Result) + end + end + end +end diff --git a/spec/mongo/client_construction_spec.rb b/spec/mongo/client_construction_spec.rb index 24a2d8615c..b5008da416 100644 --- a/spec/mongo/client_construction_spec.rb +++ b/spec/mongo/client_construction_spec.rb @@ -953,7 +953,7 @@ end it 'includes the platform info in the app metadata' do - expect(app_metadata.send(:full_client_document)[:platform]).to match(/mongoid-6\.0\.2/) + expect(app_metadata.client_document[:platform]).to match(/mongoid-6\.0\.2/) end end @@ -980,7 +980,7 @@ end it 'does not include the platform info in the app metadata' do - expect(app_metadata.send(:full_client_document)[:platform]).to eq(platform_string) + expect(app_metadata.client_document[:platform]).to eq(platform_string) end end @@ -999,7 +999,7 @@ end it 'does not include the platform info in the app metadata' do - expect(app_metadata.send(:full_client_document)[:platform]).to eq(platform_string) + expect(app_metadata.client_document[:platform]).to eq(platform_string) end end end diff --git a/spec/mongo/cluster_spec.rb b/spec/mongo/cluster_spec.rb index c241fe95fa..adca88d52a 100644 --- a/spec/mongo/cluster_spec.rb +++ b/spec/mongo/cluster_spec.rb @@ -480,7 +480,7 @@ end it 'constructs an AppMetadata object with the app_name' do - expect(cluster.app_metadata.send(:full_client_document)[:application]).to eq('name' => 'cluster_test') + expect(cluster.app_metadata.client_document[:application]).to eq('name' => 'cluster_test') end end @@ -491,7 +491,7 @@ end it 'constructs an AppMetadata object with no app_name' do - expect(cluster.app_metadata.send(:full_client_document)[:application]).to be_nil + expect(cluster.app_metadata.client_document[:application]).to be_nil end end end diff --git a/spec/mongo/server/app_metadata/environment_spec.rb b/spec/mongo/server/app_metadata/environment_spec.rb new file mode 100644 index 0000000000..3bf88acca9 --- /dev/null +++ b/spec/mongo/server/app_metadata/environment_spec.rb @@ -0,0 +1,193 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Mongo::Server::AppMetadata::Environment do + let(:env) { described_class.new } + + shared_examples_for 'running in a FaaS environment' do + it 'reports that a FaaS environment is detected' do + expect(env.faas?).to be true + end + end + + shared_examples_for 'running outside a FaaS environment' do + it 'reports that no FaaS environment is detected' do + expect(env.faas?).to be false + end + end + + context 'when run outside of a FaaS environment' do + it_behaves_like 'running outside a FaaS environment' + end + + context 'when run in a FaaS environment' do + context 'when environment is invalid due to type mismatch' do + local_env( + 'AWS_EXECUTION_ENV' => 'AWS_Lambda_ruby2.7', + 'AWS_REGION' => 'us-east-2', + 'AWS_LAMBDA_FUNCTION_MEMORY_SIZE' => 'big' + ) + + it_behaves_like 'running outside a FaaS environment' + + it 'fails due to type mismatch' do + expect(env.error).to match(/AWS_LAMBDA_FUNCTION_MEMORY_SIZE must be integer/) + end + end + + context 'when environment is invalid due to long string' do + local_env( + 'AWS_EXECUTION_ENV' => 'AWS_Lambda_ruby2.7', + 'AWS_REGION' => 'a' * 512, + 'AWS_LAMBDA_FUNCTION_MEMORY_SIZE' => '1024' + ) + + it_behaves_like 'running outside a FaaS environment' + + it 'fails due to long string' do + expect(env.error).to match(/too long/) + end + end + + context 'when environment is invalid due to multiple providers' do + local_env( + 'AWS_EXECUTION_ENV' => 'AWS_Lambda_ruby2.7', + 'AWS_REGION' => 'us-east-2', + 'AWS_LAMBDA_FUNCTION_MEMORY_SIZE' => '1024', + 'FUNCTIONS_WORKER_RUNTIME' => 'ruby' + ) + + it_behaves_like 'running outside a FaaS environment' + + it 'fails due to multiple providers' do + expect(env.error).to match(/too many environments/) + end + end + + context 'when environment is invalid due to missing variable' do + local_env( + 'AWS_EXECUTION_ENV' => 'AWS_Lambda_ruby2.7', + 'AWS_LAMBDA_FUNCTION_MEMORY_SIZE' => '1024' + ) + + it_behaves_like 'running outside a FaaS environment' + + it 'fails due to missing variable' do + expect(env.error).to match(/missing environment variable/) + end + end + + context 'when FaaS environment is AWS' do + shared_examples_for 'running in an AWS environment' do + context 'when environment is valid' do + local_env( + 'AWS_REGION' => 'us-east-2', + 'AWS_LAMBDA_FUNCTION_MEMORY_SIZE' => '1024' + ) + + it_behaves_like 'running in a FaaS environment' + + it 'recognizes AWS' do + expect(env.name).to be == 'aws.lambda' + expect(env.fields[:region]).to be == 'us-east-2' + expect(env.fields[:memory_mb]).to be == 1024 + end + end + end + + # per DRIVERS-2623, AWS_EXECUTION_ENV must be prefixed + # with 'AWS_Lambda_'. + context 'when AWS_EXECUTION_ENV is invalid' do + local_env( + 'AWS_EXECUTION_ENV' => 'EC2', + 'AWS_REGION' => 'us-east-2', + 'AWS_LAMBDA_FUNCTION_MEMORY_SIZE' => '1024' + ) + + it_behaves_like 'running outside a FaaS environment' + end + + context 'when AWS_EXECUTION_ENV is detected' do + local_env('AWS_EXECUTION_ENV' => 'AWS_Lambda_ruby2.7') + it_behaves_like 'running in an AWS environment' + end + + context 'when AWS_LAMBDA_RUNTIME_API is detected' do + local_env('AWS_LAMBDA_RUNTIME_API' => 'lambda.aws.amazon.com/api') + it_behaves_like 'running in an AWS environment' + end + end + + context 'when FaaS environment is Azure' do + local_env('FUNCTIONS_WORKER_RUNTIME' => 'ruby') + + it_behaves_like 'running in a FaaS environment' + + it 'recognizes Azure' do + expect(env.name).to be == 'azure.func' + end + end + + context 'when FaaS environment is GCP' do + local_env( + 'FUNCTION_MEMORY_MB' => '1024', + 'FUNCTION_TIMEOUT_SEC' => '60', + 'FUNCTION_REGION' => 'us-central1' + ) + + shared_examples_for 'running in a GCP environment' do + it_behaves_like 'running in a FaaS environment' + + it 'recognizes GCP' do + expect(env.name).to be == 'gcp.func' + expect(env.fields[:region]).to be == 'us-central1' + expect(env.fields[:memory_mb]).to be == 1024 + expect(env.fields[:timeout_sec]).to be == 60 + end + end + + context 'when K_SERVICE is present' do + local_env('K_SERVICE' => 'servicename') + it_behaves_like 'running in a GCP environment' + end + + context 'when FUNCTION_NAME is present' do + local_env('FUNCTION_NAME' => 'functionName') + it_behaves_like 'running in a GCP environment' + end + end + + context 'when FaaS environment is Vercel' do + local_env( + 'VERCEL' => '1', + 'VERCEL_URL' => '*.vercel.app', + 'VERCEL_REGION' => 'cdg1' + ) + + it_behaves_like 'running in a FaaS environment' + + it 'recognizes Vercel' do + expect(env.name).to be == 'vercel' + expect(env.fields[:url]).to be == '*.vercel.app' + expect(env.fields[:region]).to be == 'cdg1' + end + end + + context 'when converting environment to a hash' do + local_env( + 'K_SERVICE' => 'servicename', + 'FUNCTION_MEMORY_MB' => '1024', + 'FUNCTION_TIMEOUT_SEC' => '60', + 'FUNCTION_REGION' => 'us-central1' + ) + + it 'includes name and all fields' do + expect(env.to_h).to be == { + name: 'gcp.func', memory_mb: 1024, + timeout_sec: 60, region: 'us-central1', + } + end + end + end +end diff --git a/spec/mongo/server/app_metadata/truncator_spec.rb b/spec/mongo/server/app_metadata/truncator_spec.rb new file mode 100644 index 0000000000..52fcad3240 --- /dev/null +++ b/spec/mongo/server/app_metadata/truncator_spec.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Quoted from specifications/source/mongodb-handshake/handshake.rst: +# +# Implementors SHOULD cumulatively update fields in the following order +# until the document is under the size limit: +# +# 1. Omit fields from env except env.name. +# 2. Omit fields from os except os.type. +# 3. Omit the env document entirely. +# 4. Truncate platform. + +describe Mongo::Server::AppMetadata::Truncator do + let(:truncator) { described_class.new(Marshal.load(Marshal.dump(metadata))) } + + let(:app_name) { 'application' } + let(:driver) { { name: 'driver', version: '1.2.3' } } + let(:os) { { type: 'Darwin', name: 'macOS', architecture: 'arm64', version: '13.4' } } + let(:platform) { { platform: 'platform' } } + let(:env) { { name: 'aws.lambda', region: 'region', memory_mb: 1024 } } + + let(:metadata) do + BSON::Document.new.tap do |doc| + doc[:application] = { name: app_name } + doc[:driver] = driver + doc[:os] = os + doc[:platform] = platform + doc[:env] = env + end + end + + let(:untruncated_length) { metadata.to_bson.to_s.length } + let(:truncated_length) { truncator.document.to_bson.to_s.length } + + shared_examples_for 'a truncated document' do + it 'is shorter' do + expect(truncated_length).to be < untruncated_length + end + + it 'is not be longer than the maximum document size' do + expect(truncated_length).to be <= described_class::MAX_DOCUMENT_SIZE + end + end + + describe 'MAX_DOCUMENT_SIZE' do + it 'is 512 bytes' do + # This test is an additional check that MAX_DOCUMENT_SIZE + # has not been accidentially changed. + expect(described_class::MAX_DOCUMENT_SIZE).to be == 512 + end + end + + context 'when document does not need truncating' do + it 'does not truncate anything' do + expect(truncated_length).to be == untruncated_length + end + end + + context 'when modifying env is sufficient' do + context 'when a single value is too long' do + let(:env) { { name: 'name', a: 'a' * 1000, b: 'b' } } + + it 'preserves name' do + expect(truncator.document[:env][:name]).to be == 'name' + end + + it 'removes the too-long entry and keeps name' do + expect(truncator.document[:env].keys).to be == %w[ name b ] + end + + it_behaves_like 'a truncated document' + end + + context 'when multiple values are too long' do + let(:env) { { name: 'name', a: 'a' * 1000, b: 'b', c: 'c' * 1000, d: 'd' } } + + it 'preserves name' do + expect(truncator.document[:env][:name]).to be == 'name' + end + + it 'removes all other entries until size is satisifed' do + expect(truncator.document[:env].keys).to be == %w[ name d ] + end + + it_behaves_like 'a truncated document' + end + end + + context 'when modifying os is sufficient' do + context 'when a single value is too long' do + let(:os) { { type: 'type', a: 'a' * 1000, b: 'b' } } + + it 'truncates env' do + expect(truncator.document[:env].keys).to be == %w[ name ] + end + + it 'preserves type' do + expect(truncator.document[:os][:type]).to be == 'type' + end + + it 'removes the too-long entry and keeps type' do + expect(truncator.document[:os].keys).to be == %w[ type b ] + end + + it_behaves_like 'a truncated document' + end + + context 'when multiple values are too long' do + let(:os) { { type: 'type', a: 'a' * 1000, b: 'b', c: 'c' * 1000, d: 'd' } } + + it 'truncates env' do + expect(truncator.document[:env].keys).to be == %w[ name ] + end + + it 'preserves type' do + expect(truncator.document[:os][:type]).to be == 'type' + end + + it 'removes all other entries until size is satisifed' do + expect(truncator.document[:os].keys).to be == %w[ type d ] + end + + it_behaves_like 'a truncated document' + end + end + + context 'when truncating os is insufficient' do + let(:env) { { name: 'n' * 1000 } } + + it 'truncates os' do + expect(truncator.document[:os].keys).to be == %w[ type ] + end + + it 'removes env' do + expect(truncator.document.key?(:env)).to be false + end + + it_behaves_like 'a truncated document' + end + + context 'when platform is too long' do + let(:platform) { 'n' * 1000 } + + it 'truncates os' do + expect(truncator.document[:os].keys).to be == %w[ type ] + end + + it 'removes env' do + expect(truncator.document.key?(:env)).to be false + end + + it 'truncates platform' do + expect(truncator.document[:platform].length).to be < 1000 + end + end +end diff --git a/spec/mongo/server/app_metadata_spec.rb b/spec/mongo/server/app_metadata_spec.rb index 72ea2a989b..8422a4f472 100644 --- a/spec/mongo/server/app_metadata_spec.rb +++ b/spec/mongo/server/app_metadata_spec.rb @@ -4,6 +4,7 @@ require 'spec_helper' describe Mongo::Server::AppMetadata do + let(:max_size) { described_class::Truncator::MAX_DOCUMENT_SIZE } let(:app_metadata) do described_class.new(cluster.options) @@ -13,14 +14,6 @@ authorized_client.cluster end - describe 'MAX_DOCUMENT_SIZE' do - it 'should be 512 bytes' do - # This test is an additional check that MAX_DOCUMENT_SIZE - # has not been accidentially changed. - expect(described_class::MAX_DOCUMENT_SIZE).to eq(512) - end - end - describe '#initialize' do context 'when the cluster has an app name option set' do @@ -34,7 +27,7 @@ end it 'sets the app name' do - expect(app_metadata.send(:full_client_document)[:application][:name]).to eq('app_metadata_test') + expect(app_metadata.client_document[:application][:name]).to eq('app_metadata_test') end context 'when the app name exceeds the max length of 128' do @@ -58,77 +51,70 @@ context 'when the cluster does not have an app name option set' do it 'does not set the app name' do - expect(app_metadata.send(:full_client_document)[:application]).to be(nil) + expect(app_metadata.client_document[:application]).to be(nil) end end context 'when the client document exceeds the max of 512 bytes' do - # Server api parameters change metadata length - require_no_required_api_version - - context 'when the os.type length is too long' do - - before do - allow(app_metadata).to receive(:type).and_return('x'*500) + shared_examples_for 'a truncated document' do + it 'is too long before validation' do + expect(app_metadata.client_document.to_bson.to_s.size).to be > max_size end - it 'truncates the document' do - expect( - app_metadata.validated_document.to_bson.to_s.size - ).to be < described_class::MAX_DOCUMENT_SIZE + it 'is acceptable after validation' do + app_metadata.validated_document # force validation + expect(app_metadata.client_document.to_bson.to_s.size).to be <= max_size end end context 'when the os.name length is too long' do - before do allow(app_metadata).to receive(:name).and_return('x'*500) end - it 'truncates the document' do - expect( - app_metadata.validated_document.to_bson.to_s.size - ).to be < described_class::MAX_DOCUMENT_SIZE - end + it_behaves_like 'a truncated document' end context 'when the os.architecture length is too long' do - before do allow(app_metadata).to receive(:architecture).and_return('x'*500) end - it 'truncates the document' do - expect( - app_metadata.validated_document.to_bson.to_s.size - ).to be < described_class::MAX_DOCUMENT_SIZE - end + it_behaves_like 'a truncated document' end context 'when the platform length is too long' do - before do allow(app_metadata).to receive(:platform).and_return('x'*500) end - it 'truncates the document' do - expect( - app_metadata.validated_document.to_bson.to_s.size - ).to be < described_class::MAX_DOCUMENT_SIZE - end + it_behaves_like 'a truncated document' end + end - context 'when the driver info is too long' do - require_no_compression + context 'when run outside of a FaaS environment' do + it 'should exclude the :env key from the client document' do + expect(app_metadata.client_document.key?(:env)).to be false + end + end - before do - allow(app_metadata).to receive(:driver_doc).and_return('x'*500) + context 'when run inside of a FaaS environment' do + context 'when the environment is invalid' do + # invalid, because it is missing the other required fields + local_env('AWS_EXECUTION_ENV' => 'AWS_Lambda_ruby2.7') + + it 'should exclude the :env key from the client document' do + expect(app_metadata.client_document.key?(:env)).to be false end + end + + context 'when the environment is valid' do + # valid, because Azure requires only the one field + local_env('FUNCTIONS_WORKER_RUNTIME' => 'ruby') - it 'truncates the document' do - expect( - app_metadata.validated_document.to_bson.to_s.size - ).to be < described_class::MAX_DOCUMENT_SIZE + it 'should include the :env key in the client document' do + expect(app_metadata.client_document.key?(:env)).to be true + expect(app_metadata.client_document[:env][:name]).to be == "azure.func" end end end diff --git a/spec/mongo/socket/ssl_spec.rb b/spec/mongo/socket/ssl_spec.rb index aa02a428d3..c9aabb65b4 100644 --- a/spec/mongo/socket/ssl_spec.rb +++ b/spec/mongo/socket/ssl_spec.rb @@ -592,14 +592,8 @@ ) end - around do |example| - saved = ENV['SSL_CERT_FILE'] - ENV['SSL_CERT_FILE'] = SpecConfig.instance.local_ca_cert_path - begin - example.run - ensure - ENV['SSL_CERT_FILE'] = saved - end + local_env do + { 'SSL_CERT_FILE' => SpecConfig.instance.local_ca_cert_path } end it 'uses the default cert store' do