From 69b0ec14d3a21c1650db1dacc242cd62ab6189d6 Mon Sep 17 00:00:00 2001 From: Jamis Buck Date: Fri, 12 Jan 2024 09:17:53 -0700 Subject: [PATCH] RUBY-3298 Add container info to handshake metadata (#2821) * 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 --- .rubocop.yml | 3 + lib/mongo/server/app_metadata.rb | 9 +- lib/mongo/server/app_metadata/environment.rb | 73 ++++++++-- .../server/app_metadata/environment_spec.rb | 135 ++++++++++++++++++ spec/mongo/server/app_metadata_spec.rb | 14 +- 5 files changed, 219 insertions(+), 15 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index c21c088e8c..fa7d04f8c6 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -51,6 +51,9 @@ Layout/SpaceInsideArrayLiteralBrackets: Layout/SpaceInsidePercentLiteralDelimiters: Enabled: false +Metrics/ClassLength: + Max: 200 + Metrics/ModuleLength: Enabled: false diff --git a/lib/mongo/server/app_metadata.rb b/lib/mongo/server/app_metadata.rb index 2d982c4f4b..81e595d9aa 100644 --- a/lib/mongo/server/app_metadata.rb +++ b/lib/mongo/server/app_metadata.rb @@ -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 diff --git a/lib/mongo/server/app_metadata/environment.rb b/lib/mongo/server/app_metadata/environment.rb index fafb1c73e7..029257faec 100644 --- a/lib/mongo/server/app_metadata/environment.rb +++ b/lib/mongo/server/app_metadata/environment.rb @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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. @@ -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 diff --git a/spec/mongo/server/app_metadata/environment_spec.rb b/spec/mongo/server/app_metadata/environment_spec.rb index b63e174e16..0b0b844b59 100644 --- a/spec/mongo/server/app_metadata/environment_spec.rb +++ b/spec/mongo/server/app_metadata/environment_spec.rb @@ -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 @@ -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 @@ -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 diff --git a/spec/mongo/server/app_metadata_spec.rb b/spec/mongo/server/app_metadata_spec.rb index 47da5fa8bb..a3b8619b63 100644 --- a/spec/mongo/server/app_metadata_spec.rb +++ b/spec/mongo/server/app_metadata_spec.rb @@ -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