Skip to content

Commit

Permalink
RUBY-3298 Add container info to handshake metadata (#2821)
Browse files Browse the repository at this point in the history
* RUBY-3298 include container info in handshake metadata

* make sure it works even when faas info is not present

* look for .dockerenv, not Dockerfile

* add a test to check the value of DOCKERENV_PATH

* linter appeasement
  • Loading branch information
jamis authored Jan 12, 2024
1 parent e06e03a commit 69b0ec1
Show file tree
Hide file tree
Showing 5 changed files with 219 additions and 15 deletions.
3 changes: 3 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ Layout/SpaceInsideArrayLiteralBrackets:
Layout/SpaceInsidePercentLiteralDelimiters:
Enabled: false

Metrics/ClassLength:
Max: 200

Metrics/ModuleLength:
Enabled: false

Expand Down
9 changes: 5 additions & 4 deletions lib/mongo/server/app_metadata.rb
Original file line number Diff line number Diff line change
Expand Up @@ -187,13 +187,14 @@ def os_doc
}
end

# Returns the environment doc describing the current FaaS environment.
# Returns the environment doc describing the current execution
# environment.
#
# @return [ Hash | nil ] the environment doc (or nil if not in a FaaS
# environment).
# @return [ Hash | nil ] the environment doc (or nil if no relevant
# environment info was detected)
def env_doc
env = Environment.new
env.faas? ? env.to_h : nil
env.present? ? env.to_h : nil
end

def type
Expand Down
73 changes: 64 additions & 9 deletions lib/mongo/server/app_metadata/environment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,12 @@ 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
# 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
Expand All @@ -39,6 +42,10 @@ 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
Expand Down Expand Up @@ -102,9 +109,11 @@ class ValueTooLong < Mongo::Error; end
# 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_fields
populate_faas_fields
detect_container
rescue TooManyEnvironments => e
self.error = "too many environments detected: #{e.message}"
rescue MissingVariable => e
Expand All @@ -115,6 +124,23 @@ def initialize
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
Expand Down Expand Up @@ -159,14 +185,11 @@ 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.)
# Compiles the detected environment information into a Hash.
#
# @return [ Hash ] the detected environment information.
def to_h
fields.merge(name: name)
name ? fields.merge(name: name) : fields
end

private
Expand All @@ -192,6 +215,38 @@ def detect_environment
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.
Expand All @@ -212,10 +267,10 @@ def discriminator_matches?(var)
# Extracts environment information from the current environment
# variables, based on the detected FaaS environment. Populates the
# {{@fields}} instance variable.
def populate_fields
def populate_faas_fields
return unless name

@fields = FIELDS[name].each_with_object({}) do |(var, defn), fields|
FIELDS[name].each_with_object(@fields) do |(var, defn), fields|
fields[defn[:field]] = extract_field(var, defn)
end
end
Expand Down
135 changes: 135 additions & 0 deletions spec/mongo/server/app_metadata/environment_spec.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,52 @@
# frozen_string_literal: true
# rubocop:todo all

require 'spec_helper'
require 'fileutils'

MOCKED_DOCKERENV_PATH = File.expand_path(File.join(Dir.pwd, '.dockerenv-mocked'))

module ContainerChecking
def mock_dockerenv_path
before do
allow_any_instance_of(Mongo::Server::AppMetadata::Environment)
.to receive(:dockerenv_path)
.and_return(MOCKED_DOCKERENV_PATH)
end
end

def with_docker
mock_dockerenv_path

around do |example|
File.write(MOCKED_DOCKERENV_PATH, 'placeholder')
example.run
ensure
File.delete(MOCKED_DOCKERENV_PATH)
end
end

def without_docker
mock_dockerenv_path

around do |example|
FileUtils.rm_f(MOCKED_DOCKERENV_PATH)
example.run
end
end

def with_kubernetes
local_env 'KUBERNETES_SERVICE_HOST' => 'kubernetes.default.svc.cluster.local'
end

def without_kubernetes
local_env 'KUBERNETES_SERVICE_HOST' => nil
end
end

describe Mongo::Server::AppMetadata::Environment do
extend ContainerChecking

let(:env) { described_class.new }

shared_examples_for 'running in a FaaS environment' do
Expand All @@ -17,6 +61,36 @@
end
end

shared_examples_for 'not running in a Docker container' do
it 'does not detect Docker' do
expect(env.container || {}).not_to include :runtime
end
end

shared_examples_for 'not running under Kubernetes' do
it 'does not detect Kubernetes' do
expect(env.container || {}).not_to include :orchestrator
end
end

shared_examples_for 'running under Kubernetes' do
it 'detects that Kubernetes is present' do
expect(env.container[:orchestrator]).to be == 'kubernetes'
end
end

shared_examples_for 'running in a Docker container' do
it 'detects that Docker is present' do
expect(env.container[:runtime]).to be == 'docker'
end
end

shared_examples_for 'running under Kerbenetes' do
it 'detects that kubernetes is present' do
expect(env.container['orchestrator']).to be == 'kubernetes'
end
end

context 'when run outside of a FaaS environment' do
it_behaves_like 'running outside a FaaS environment'
end
Expand Down Expand Up @@ -204,6 +278,67 @@
timeout_sec: 60, region: 'us-central1',
}
end

context 'when a container is present' do
with_kubernetes
with_docker

it 'includes a container key' do
expect(env.to_h[:container]).to be == {
runtime: 'docker',
orchestrator: 'kubernetes'
}
end
end

context 'when no container is present' do
without_kubernetes
without_docker

it 'does not include a container key' do
expect(env.to_h).not_to include(:container)
end
end
end
end

# have a specific test for this, since the tests that check
# for Docker use a mocked value for the .dockerenv path.
it 'should look for dockerenv in root directory' do
expect(described_class::DOCKERENV_PATH).to be == '/.dockerenv'
end

context 'when no container is present' do
without_kubernetes
without_docker

it_behaves_like 'not running in a Docker container'
it_behaves_like 'not running under Kubernetes'
end

context 'when container is present' do
context 'when kubernetes is present' do
without_docker
with_kubernetes

it_behaves_like 'not running in a Docker container'
it_behaves_like 'running under Kubernetes'
end

context 'when docker is present' do
with_docker
without_kubernetes

it_behaves_like 'running in a Docker container'
it_behaves_like 'not running under Kubernetes'
end

context 'when both kubernetes and docker are present' do
with_docker
with_kubernetes

it_behaves_like 'running in a Docker container'
it_behaves_like 'running under Kubernetes'
end
end
end
14 changes: 12 additions & 2 deletions spec/mongo/server/app_metadata_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,18 @@
end

context 'when run outside of a FaaS environment' do
it 'excludes the :env key from the client document' do
expect(app_metadata.client_document.key?(:env)).to be false
context 'when a container is present' do
local_env 'KUBERNETES_SERVICE_HOST' => 'something'

it 'includes the :env key in the client document' do
expect(app_metadata.client_document.key?(:env)).to be true
end
end

context 'when no container is present' do
it 'excludes the :env key from the client document' do
expect(app_metadata.client_document.key?(:env)).to be false
end
end
end

Expand Down

0 comments on commit 69b0ec1

Please # to comment.