Skip to content

Commit d1c2842

Browse files
authored
RUBY-3152 Collect FaaS platform info in handshake (#2722) (#2726)
* 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
1 parent 84b205f commit d1c2842

File tree

10 files changed

+880
-102
lines changed

10 files changed

+880
-102
lines changed

lib/mongo/server/app_metadata.rb

Lines changed: 29 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
# See the License for the specific language governing permissions and
1616
# limitations under the License.
1717

18+
require 'mongo/server/app_metadata/environment'
19+
require 'mongo/server/app_metadata/truncator'
20+
1821
module Mongo
1922
class Server
2023
# Application metadata that is sent to the server during a handshake,
@@ -26,11 +29,6 @@ class Server
2629
class AppMetadata
2730
extend Forwardable
2831

29-
# The max application metadata document byte size.
30-
#
31-
# @since 2.4.0
32-
MAX_DOCUMENT_SIZE = 512.freeze
33-
3432
# The max application name byte size.
3533
#
3634
# @since 2.4.0
@@ -139,6 +137,23 @@ def validated_document
139137
document
140138
end
141139

140+
# Get BSON::Document to be used as value for `client` key in
141+
# handshake document.
142+
#
143+
# @return [BSON::Document] Document describing client for handshake.
144+
#
145+
# @api private
146+
def client_document
147+
@client_document ||=
148+
BSON::Document.new.tap do |doc|
149+
doc[:application] = { name: @app_name } if @app_name
150+
doc[:driver] = driver_doc
151+
doc[:os] = os_doc
152+
doc[:platform] = platform
153+
env_doc.tap { |env| doc[:env] = env if env }
154+
end
155+
end
156+
142157
private
143158

144159
# Check whether it is possible to build a valid app metadata document
@@ -152,51 +167,18 @@ def validate!
152167
true
153168
end
154169

155-
# Get BSON::Document to be used as value for `client` key in
156-
# handshake document.
157-
#
158-
# @return [BSON::Document] Document describing client for handshake.
159-
def full_client_document
160-
BSON::Document.new.tap do |doc|
161-
doc[:application] = { name: @app_name } if @app_name
162-
doc[:driver] = driver_doc
163-
doc[:os] = os_doc
164-
doc[:platform] = platform
165-
end
166-
end
167-
168-
169170
# Get the metadata as BSON::Document to be sent to
170171
# as part of the handshake. The document should
171172
# be appended to a suitable handshake command.
172173
#
173174
# @return [BSON::Document] Document for connection's handshake.
174175
def document
175176
@document ||= begin
176-
client_document = full_client_document
177-
while client_document.to_bson.to_s.size > MAX_DOCUMENT_SIZE do
178-
if client_document[:os][:name] || client_document[:os][:architecture]
179-
client_document[:os].delete(:name)
180-
client_document[:os].delete(:architecture)
181-
elsif client_document[:platform]
182-
client_document.delete(:platform)
183-
else
184-
client_document = nil
185-
end
177+
client = Truncator.new(client_document).document
178+
BSON::Document.new(compression: @compressors, client: client).tap do |doc|
179+
doc[:saslSupportedMechs] = @request_auth_mech if @request_auth_mech
180+
doc.update(Utils.transform_server_api(@server_api)) if @server_api
186181
end
187-
document = BSON::Document.new(
188-
{
189-
compression: @compressors,
190-
client: client_document,
191-
}
192-
)
193-
document[:saslSupportedMechs] = @request_auth_mech if @request_auth_mech
194-
if @server_api
195-
document.update(
196-
Utils.transform_server_api(@server_api)
197-
)
198-
end
199-
document
200182
end
201183
end
202184

@@ -223,6 +205,11 @@ def os_doc
223205
}
224206
end
225207

208+
def env_doc
209+
env = Environment.new
210+
env.faas? ? env.to_h : nil
211+
end
212+
226213
def type
227214
(RbConfig::CONFIG && RbConfig::CONFIG['host_os']) ?
228215
RbConfig::CONFIG['host_os'].split('_').first[/[a-z]+/i].downcase : 'unknown'
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
# frozen_string_literal: true
2+
3+
# Copyright (C) 2016-2023 MongoDB Inc.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
module Mongo
18+
class Server
19+
class AppMetadata
20+
# Implements the logic from the handshake spec, for deducing and
21+
# reporting the current FaaS environment in which the program is
22+
# executing.
23+
#
24+
# @api private
25+
class Environment
26+
# Error class for reporting that too many discriminators were found
27+
# in the environment. (E.g. if the environment reports that it is
28+
# running under both AWS and Azure.)
29+
class TooManyEnvironments < Mongo::Error; end
30+
31+
# Error class for reporting that a required environment variable is
32+
# missing.
33+
class MissingVariable < Mongo::Error; end
34+
35+
# Error class for reporting that the wrong type was given for a
36+
# field.
37+
class TypeMismatch < Mongo::Error; end
38+
39+
# Error class for reporting that the value for a field is too long.
40+
class ValueTooLong < Mongo::Error; end
41+
42+
# This value is not explicitly specified in the spec, only implied to be
43+
# less than 512.
44+
MAXIMUM_VALUE_LENGTH = 500
45+
46+
# The mapping that determines which FaaS environment is active, based
47+
# on which environment variable(s) are present.
48+
DISCRIMINATORS = {
49+
'AWS_EXECUTION_ENV' => { pattern: /^AWS_Lambda_/, name: 'aws.lambda' },
50+
'AWS_LAMBDA_RUNTIME_API' => { name: 'aws.lambda' },
51+
'FUNCTIONS_WORKER_RUNTIME' => { name: 'azure.func' },
52+
'K_SERVICE' => { name: 'gcp.func' },
53+
'FUNCTION_NAME' => { name: 'gcp.func' },
54+
'VERCEL' => { name: 'vercel' },
55+
}.freeze
56+
57+
# Describes how to coerce values of the specified type.
58+
COERCIONS = {
59+
string: ->(v) { String(v) },
60+
integer: ->(v) { Integer(v) }
61+
}.freeze
62+
63+
# Describes which fields are required for each FaaS environment,
64+
# along with their expected types, and how they should be named in
65+
# the handshake document.
66+
FIELDS = {
67+
'aws.lambda' => {
68+
'AWS_REGION' => { field: :region, type: :string },
69+
'AWS_LAMBDA_FUNCTION_MEMORY_SIZE' => { field: :memory_mb, type: :integer },
70+
},
71+
72+
'azure.func' => {},
73+
74+
'gcp.func' => {
75+
'FUNCTION_MEMORY_MB' => { field: :memory_mb, type: :integer },
76+
'FUNCTION_TIMEOUT_SEC' => { field: :timeout_sec, type: :integer },
77+
'FUNCTION_REGION' => { field: :region, type: :string },
78+
},
79+
80+
'vercel' => {
81+
'VERCEL_URL' => { field: :url, type: :string },
82+
'VERCEL_REGION' => { field: :region, type: :string },
83+
},
84+
}.freeze
85+
86+
# @return [ String | nil ] the name of the FaaS environment that was
87+
# detected, or nil if no valid FaaS environment was detected.
88+
attr_reader :name
89+
90+
# @return [ Hash | nil ] the fields describing the detected FaaS
91+
# environment.
92+
attr_reader :fields
93+
94+
# @return [ String | nil ] the error message explaining why a valid
95+
# FaaS environment was not detected, or nil if no error occurred.
96+
#
97+
# @note These error messagess are not to be propogated to the
98+
# user; they are intended only for troubleshooting and debugging.)
99+
attr_reader :error
100+
101+
# Create a new AppMetadata::Environment object, initializing it from
102+
# the current ENV variables. If no FaaS environment is detected, or
103+
# if the environment contains invalid or contradictory state, it will
104+
# be initialized with {{name}} set to {{nil}}.
105+
def initialize
106+
@error = nil
107+
@name = detect_environment
108+
populate_fields
109+
rescue TooManyEnvironments => e
110+
self.error = "too many environments detected: #{e.message}"
111+
rescue MissingVariable => e
112+
self.error = "missing environment variable: #{e.message}"
113+
rescue TypeMismatch => e
114+
self.error = e.message
115+
rescue ValueTooLong => e
116+
self.error = "value for #{e.message} is too long"
117+
end
118+
119+
# Queries whether the current environment is a valid FaaS environment.
120+
#
121+
# @return [ true | false ] whether the environment is a FaaS
122+
# environment or not.
123+
def faas?
124+
@name != nil
125+
end
126+
127+
# Queries whether the current environment is a valid AWS Lambda
128+
# environment.
129+
#
130+
# @return [ true | false ] whether the environment is a AWS Lambda
131+
# environment or not.
132+
def aws?
133+
@name == 'aws.lambda'
134+
end
135+
136+
# Queries whether the current environment is a valid Azure
137+
# environment.
138+
#
139+
# @return [ true | false ] whether the environment is a Azure
140+
# environment or not.
141+
def azure?
142+
@name == 'azure.func'
143+
end
144+
145+
# Queries whether the current environment is a valid GCP
146+
# environment.
147+
#
148+
# @return [ true | false ] whether the environment is a GCP
149+
# environment or not.
150+
def gcp?
151+
@name == 'gcp.func'
152+
end
153+
154+
# Queries whether the current environment is a valid Vercel
155+
# environment.
156+
#
157+
# @return [ true | false ] whether the environment is a Vercel
158+
# environment or not.
159+
def vercel?
160+
@name == 'vercel'
161+
end
162+
163+
# Compiles the detected environment information into a Hash. It will
164+
# always include a {{name}} key, but may include other keys as well,
165+
# depending on the detected FaaS environment. (See the handshake
166+
# spec for details.)
167+
#
168+
# @return [ Hash ] the detected environment information.
169+
def to_h
170+
fields.merge(name: name)
171+
end
172+
173+
private
174+
175+
# Searches the DESCRIMINATORS list to see which (if any) apply to
176+
# the current environment.
177+
#
178+
# @return [ String | nil ] the name of the detected FaaS provider.
179+
#
180+
# @raise [ TooManyEnvironments ] if the environment contains
181+
# discriminating variables for more than one FaaS provider.
182+
def detect_environment
183+
matches = DISCRIMINATORS.keys.select { |k| discriminator_matches?(k) }
184+
names = matches.map { |m| DISCRIMINATORS[m][:name] }.uniq
185+
186+
raise TooManyEnvironments, names.join(', ') if names.length > 1
187+
188+
names.first
189+
end
190+
191+
# Determines whether the named environment variable exists, and (if
192+
# a pattern has been declared for that descriminator) whether the
193+
# pattern matches the value of the variable.
194+
#
195+
# @param [ String ] var the name of the environment variable
196+
#
197+
# @return [ true | false ] if the variable describes the current
198+
# environment or not.
199+
def discriminator_matches?(var)
200+
return false unless ENV[var]
201+
202+
disc = DISCRIMINATORS[var]
203+
return true unless disc[:pattern]
204+
205+
disc[:pattern].match?(ENV[var])
206+
end
207+
208+
# Extracts environment information from the current environment
209+
# variables, based on the detected FaaS environment. Populates the
210+
# {{@fields}} instance variable.
211+
def populate_fields
212+
return unless name
213+
214+
@fields = FIELDS[name].each_with_object({}) do |(var, defn), fields|
215+
fields[defn[:field]] = extract_field(var, defn)
216+
end
217+
end
218+
219+
# Extracts the named variable from the environment and validates it
220+
# against its declared definition.
221+
#
222+
# @param [ String ] var The name of the environment variable to look
223+
# for.
224+
# @param [ Hash ] definition The definition of the field that applies
225+
# to the named variable.
226+
#
227+
# @return [ Integer | String ] the validated and coerced value of the
228+
# given environment variable.
229+
#
230+
# @raise [ MissingVariable ] if the environment does not include a
231+
# variable required by the current FaaS provider.
232+
# @raise [ ValueTooLong ] if a required variable is too long.
233+
# @raise [ TypeMismatch ] if a required variable cannot be coerced to
234+
# the expected type.
235+
def extract_field(var, definition)
236+
raise MissingVariable, var unless ENV[var]
237+
raise ValueTooLong, var if ENV[var].length > MAXIMUM_VALUE_LENGTH
238+
239+
COERCIONS[definition[:type]].call(ENV[var])
240+
rescue ArgumentError
241+
raise TypeMismatch,
242+
"#{var} must be #{definition[:type]} (got #{ENV[var].inspect})"
243+
end
244+
245+
# Sets the error message to the given value and sets the name to nil.
246+
#
247+
# @param [ String ] msg The error message to store.
248+
def error=(msg)
249+
@name = nil
250+
@error = msg
251+
end
252+
end
253+
end
254+
end
255+
end

0 commit comments

Comments
 (0)