lib/mongo/server/app_metadata/environment.rb
# 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 environment in which the program is
# executing.
#
# This includes FaaS environment checks, as well as checks for the
# presence of a container (Docker) and/or orchestrator (Kubernetes).
#
# @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
# The name and location of the .dockerenv file that will signal the
# presence of Docker.
DOCKERENV_PATH = '/.dockerenv'
# 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_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
@fields = {}
@error = nil
@name = detect_environment
populate_faas_fields
detect_container
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 the detected container information.
#
# @return [ Hash | nil ] the detected container information, or
# nil if no container was detected.
def container
fields[:container]
end
# Queries whether any environment information was able to be
# detected.
#
# @return [ true | false ] if any environment information was
# detected.
def present?
@name || fields.any?
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.
#
# @return [ Hash ] the detected environment information.
def to_h
name ? fields.merge(name: name) : fields
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
# From the spec:
# When variables for multiple ``client.env.name`` values are present,
# ``vercel`` takes precedence over ``aws.lambda``; any other
# combination MUST cause ``client.env`` to be entirely omitted.
return 'vercel' if names.sort == %w[ aws.lambda vercel ]
raise TooManyEnvironments, names.join(', ') if names.length > 1
names.first
end
# Looks for the presence of a container. Currently can detect
# Docker (by the existence of a .dockerenv file in the root
# directory) and Kubernetes (by the existence of the KUBERNETES_SERVICE_HOST
# environment variable).
def detect_container
runtime = docker_present? && 'docker'
orchestrator = kubernetes_present? && 'kubernetes'
return unless runtime || orchestrator
fields[:container] = {}
fields[:container][:runtime] = runtime if runtime
fields[:container][:orchestrator] = orchestrator if orchestrator
end
# Checks for the existence of a .dockerenv in the root directory.
def docker_present?
File.exist?(dockerenv_path)
end
# Implementing this as a method so that it can be mocked in tests, to
# test the presence or absence of Docker.
def dockerenv_path
DOCKERENV_PATH
end
# Checks for the presence of a non-empty KUBERNETES_SERVICE_HOST
# environment variable.
def kubernetes_present?
!ENV['KUBERNETES_SERVICE_HOST'].to_s.empty?
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_faas_fields
return unless name
FIELDS[name].each_with_object(@fields) 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