From 28b70408d0153e6b1118f3dd9cfbcfa30abe29f0 Mon Sep 17 00:00:00 2001 From: Aditi Khare <106987683+aditi-khare-mongoDB@users.noreply.github.com> Date: Wed, 6 Mar 2024 18:18:07 -0500 Subject: [PATCH] feat(NODE-5968): container and Kubernetes awareness in client metadata (#4005) --- src/cmap/connect.ts | 6 +- src/cmap/connection.ts | 2 + src/cmap/connection_pool.ts | 3 +- src/cmap/handshake/client_metadata.ts | 58 +++++- src/connection_string.ts | 6 +- src/mongo_client.ts | 2 + src/sdam/topology.ts | 1 + .../connection.test.ts | 7 +- test/tools/cmap_spec_runner.ts | 9 +- test/unit/cmap/connect.test.ts | 169 +++++++++++++++++- test/unit/cmap/connection_pool.test.js | 3 + 11 files changed, 247 insertions(+), 19 deletions(-) diff --git a/src/cmap/connect.ts b/src/cmap/connect.ts index b6e5ae6c60..24022c9d18 100644 --- a/src/cmap/connect.ts +++ b/src/cmap/connect.ts @@ -25,7 +25,6 @@ import { type ConnectionOptions, CryptoConnection } from './connection'; -import type { ClientMetadata } from './handshake/client_metadata'; import { MAX_SUPPORTED_SERVER_VERSION, MAX_SUPPORTED_WIRE_VERSION, @@ -183,7 +182,7 @@ export interface HandshakeDocument extends Document { ismaster?: boolean; hello?: boolean; helloOk?: boolean; - client: ClientMetadata; + client: Document; compression: string[]; saslSupportedMechs?: string; loadBalanced?: boolean; @@ -200,11 +199,12 @@ export async function prepareHandshakeDocument( const options = authContext.options; const compressors = options.compressors ? options.compressors : []; const { serverApi } = authContext.connection; + const clientMetadata: Document = await options.extendedMetadata; const handshakeDoc: HandshakeDocument = { [serverApi?.version || options.loadBalanced === true ? 'hello' : LEGACY_HELLO_COMMAND]: 1, helloOk: true, - client: options.metadata, + client: clientMetadata, compression: compressors }; diff --git a/src/cmap/connection.ts b/src/cmap/connection.ts index e33b4f835f..f0e373a825 100644 --- a/src/cmap/connection.ts +++ b/src/cmap/connection.ts @@ -119,6 +119,8 @@ export interface ConnectionOptions cancellationToken?: CancellationToken; metadata: ClientMetadata; /** @internal */ + extendedMetadata: Promise; + /** @internal */ mongoLogger?: MongoLogger | undefined; } diff --git a/src/cmap/connection_pool.ts b/src/cmap/connection_pool.ts index 435b66936d..64b89ee120 100644 --- a/src/cmap/connection_pool.ts +++ b/src/cmap/connection_pool.ts @@ -233,8 +233,7 @@ export class ConnectionPool extends TypedEventEmitter { maxIdleTimeMS: options.maxIdleTimeMS ?? 0, waitQueueTimeoutMS: options.waitQueueTimeoutMS ?? 0, minPoolSizeCheckFrequencyMS: options.minPoolSizeCheckFrequencyMS ?? 100, - autoEncrypter: options.autoEncrypter, - metadata: options.metadata + autoEncrypter: options.autoEncrypter }); if (this.options.minPoolSize > this.options.maxPoolSize) { diff --git a/src/cmap/handshake/client_metadata.ts b/src/cmap/handshake/client_metadata.ts index fb1ba40b14..c9589f6e00 100644 --- a/src/cmap/handshake/client_metadata.ts +++ b/src/cmap/handshake/client_metadata.ts @@ -1,7 +1,8 @@ +import { promises as fs } from 'fs'; import * as os from 'os'; import * as process from 'process'; -import { BSON, Int32 } from '../../bson'; +import { BSON, type Document, Int32 } from '../../bson'; import { MongoInvalidArgumentError } from '../../error'; import type { MongoOptions } from '../../mongo_client'; @@ -71,13 +72,13 @@ export class LimitedSizeDocument { return true; } - toObject(): ClientMetadata { + toObject(): Document { return BSON.deserialize(BSON.serialize(this.document), { promoteLongs: false, promoteBuffers: false, promoteValues: false, useBigInt64: false - }) as ClientMetadata; + }); } } @@ -152,8 +153,57 @@ export function makeClientMetadata(options: MakeClientMetadataOptions): ClientMe } } } + return metadataDocument.toObject() as ClientMetadata; +} + +let dockerPromise: Promise; +/** @internal */ +async function getContainerMetadata() { + const containerMetadata: Record = {}; + dockerPromise ??= fs.access('/.dockerenv').then( + () => true, + () => false + ); + const isDocker = await dockerPromise; + + const { KUBERNETES_SERVICE_HOST = '' } = process.env; + const isKubernetes = KUBERNETES_SERVICE_HOST.length > 0 ? true : false; + + if (isDocker) containerMetadata.runtime = 'docker'; + if (isKubernetes) containerMetadata.orchestrator = 'kubernetes'; + + return containerMetadata; +} + +/** + * @internal + * Re-add each metadata value. + * Attempt to add new env container metadata, but keep old data if it does not fit. + */ +export async function addContainerMetadata(originalMetadata: ClientMetadata) { + const containerMetadata = await getContainerMetadata(); + if (Object.keys(containerMetadata).length === 0) return originalMetadata; + + const extendedMetadata = new LimitedSizeDocument(512); + + const extendedEnvMetadata = { ...originalMetadata?.env, container: containerMetadata }; + + for (const [key, val] of Object.entries(originalMetadata)) { + if (key !== 'env') { + extendedMetadata.ifItFitsItSits(key, val); + } else { + if (!extendedMetadata.ifItFitsItSits('env', extendedEnvMetadata)) { + // add in old data if newer / extended metadata does not fit + extendedMetadata.ifItFitsItSits('env', val); + } + } + } + + if (!('env' in originalMetadata)) { + extendedMetadata.ifItFitsItSits('env', extendedEnvMetadata); + } - return metadataDocument.toObject(); + return extendedMetadata.toObject(); } /** diff --git a/src/connection_string.ts b/src/connection_string.ts index e6ae0b82b2..152a4be644 100644 --- a/src/connection_string.ts +++ b/src/connection_string.ts @@ -5,7 +5,7 @@ import { URLSearchParams } from 'url'; import type { Document } from './bson'; import { MongoCredentials } from './cmap/auth/mongo_credentials'; import { AUTH_MECHS_AUTH_SRC_EXTERNAL, AuthMechanism } from './cmap/auth/providers'; -import { makeClientMetadata } from './cmap/handshake/client_metadata'; +import { addContainerMetadata, makeClientMetadata } from './cmap/handshake/client_metadata'; import { Compressor, type CompressorName } from './cmap/wire_protocol/compression'; import { Encrypter } from './encrypter'; import { @@ -552,6 +552,10 @@ export function parseOptions( mongoOptions.metadata = makeClientMetadata(mongoOptions); + mongoOptions.extendedMetadata = addContainerMetadata(mongoOptions.metadata).catch(() => { + /* rejections will be handled later */ + }); + return mongoOptions; } diff --git a/src/mongo_client.ts b/src/mongo_client.ts index be039944a4..5ab24eee9b 100644 --- a/src/mongo_client.ts +++ b/src/mongo_client.ts @@ -827,6 +827,8 @@ export interface MongoOptions dbName: string; metadata: ClientMetadata; /** @internal */ + extendedMetadata: Promise; + /** @internal */ autoEncrypter?: AutoEncrypter; proxyHost?: string; proxyPort?: number; diff --git a/src/sdam/topology.ts b/src/sdam/topology.ts index 400db63870..68d8565738 100644 --- a/src/sdam/topology.ts +++ b/src/sdam/topology.ts @@ -158,6 +158,7 @@ export interface TopologyOptions extends BSONSerializeOptions, ServerOptions { directConnection: boolean; loadBalanced: boolean; metadata: ClientMetadata; + extendedMetadata: Promise; serverMonitoringMode: ServerMonitoringMode; /** MongoDB server API version */ serverApi?: ServerApi; diff --git a/test/integration/connection-monitoring-and-pooling/connection.test.ts b/test/integration/connection-monitoring-and-pooling/connection.test.ts index a1e8f1f957..de1e455c66 100644 --- a/test/integration/connection-monitoring-and-pooling/connection.test.ts +++ b/test/integration/connection-monitoring-and-pooling/connection.test.ts @@ -1,6 +1,7 @@ import { expect } from 'chai'; import { + addContainerMetadata, connect, Connection, type ConnectionOptions, @@ -50,7 +51,8 @@ describe('Connection', function () { ...commonConnectOptions, connectionType: Connection, ...this.configuration.options, - metadata: makeClientMetadata({ driverInfo: {} }) + metadata: makeClientMetadata({ driverInfo: {} }), + extendedMetadata: addContainerMetadata(makeClientMetadata({ driverInfo: {} })) }; let conn; @@ -72,7 +74,8 @@ describe('Connection', function () { connectionType: Connection, ...this.configuration.options, monitorCommands: true, - metadata: makeClientMetadata({ driverInfo: {} }) + metadata: makeClientMetadata({ driverInfo: {} }), + extendedMetadata: addContainerMetadata(makeClientMetadata({ driverInfo: {} })) }; let conn; diff --git a/test/tools/cmap_spec_runner.ts b/test/tools/cmap_spec_runner.ts index 9d0817548f..a497e6e192 100644 --- a/test/tools/cmap_spec_runner.ts +++ b/test/tools/cmap_spec_runner.ts @@ -4,6 +4,7 @@ import { clearTimeout, setTimeout } from 'timers'; import { promisify } from 'util'; import { + addContainerMetadata, CMAP_EVENTS, type Connection, ConnectionPool, @@ -369,6 +370,7 @@ async function runCmapTest(test: CmapTest, threadContext: ThreadContext) { } const metadata = makeClientMetadata({ appName: poolOptions.appName, driverInfo: {} }); + const extendedMetadata = addContainerMetadata(metadata); delete poolOptions.appName; const operations = test.operations; @@ -380,7 +382,12 @@ async function runCmapTest(test: CmapTest, threadContext: ThreadContext) { const mainThread = threadContext.getThread(MAIN_THREAD_KEY); mainThread.start(); - threadContext.createPool({ ...poolOptions, metadata, minPoolSizeCheckFrequencyMS }); + threadContext.createPool({ + ...poolOptions, + metadata, + extendedMetadata, + minPoolSizeCheckFrequencyMS + }); // yield control back to the event loop so that the ConnectionPoolCreatedEvent // has a chance to be fired before any synchronously-emitted events from // the queued operations diff --git a/test/unit/cmap/connect.test.ts b/test/unit/cmap/connect.test.ts index 7697c124fb..65ad159b0b 100644 --- a/test/unit/cmap/connect.test.ts +++ b/test/unit/cmap/connect.test.ts @@ -1,6 +1,7 @@ import { expect } from 'chai'; import { + addContainerMetadata, CancellationToken, type ClientMetadata, connect, @@ -23,6 +24,7 @@ const CONNECT_DEFAULTS = { generation: 1, monitorCommands: false, metadata: {} as ClientMetadata, + extendedMetadata: addContainerMetadata({} as ClientMetadata), loadBalanced: false }; @@ -185,9 +187,164 @@ describe('Connect Tests', function () { expect(error).to.be.instanceOf(MongoNetworkError); }); - context('prepareHandshakeDocument', () => { + describe('prepareHandshakeDocument', () => { + describe('client environment (containers and FAAS)', () => { + const cachedEnv = process.env; + + context('when only kubernetes is present', () => { + let authContext; + + beforeEach(() => { + process.env.KUBERNETES_SERVICE_HOST = 'I exist'; + authContext = { + connection: {}, + options: { + ...CONNECT_DEFAULTS, + extendedMetadata: addContainerMetadata({} as ClientMetadata) + } + }; + }); + + afterEach(() => { + if (cachedEnv.KUBERNETES_SERVICE_HOST != null) { + process.env.KUBERNETES_SERVICE_HOST = cachedEnv.KUBERNETES_SERVICE_HOST; + } else { + delete process.env.KUBERNETES_SERVICE_HOST; + } + authContext = {}; + }); + + it(`should include { orchestrator: 'kubernetes'} in client.env.container`, async () => { + const handshakeDocument = await prepareHandshakeDocument(authContext); + expect(handshakeDocument.client.env.container.orchestrator).to.equal('kubernetes'); + }); + + it(`should not have 'name' property in client.env `, async () => { + const handshakeDocument = await prepareHandshakeDocument(authContext); + expect(handshakeDocument.client.env).to.not.have.property('name'); + }); + + context('when 512 byte size limit is exceeded', async () => { + it(`should not 'env' property in client`, async () => { + // make metadata = 507 bytes, so it takes up entire LimitedSizeDocument + const longAppName = 's'.repeat(493); + const longAuthContext = { + connection: {}, + options: { + ...CONNECT_DEFAULTS, + extendedMetadata: addContainerMetadata({ appName: longAppName }) + } + }; + const handshakeDocument = await prepareHandshakeDocument(longAuthContext); + expect(handshakeDocument.client).to.not.have.property('env'); + }); + }); + }); + + context('when kubernetes and FAAS are both present', () => { + let authContext; + + beforeEach(() => { + process.env.KUBERNETES_SERVICE_HOST = 'I exist'; + authContext = { + connection: {}, + options: { + ...CONNECT_DEFAULTS, + extendedMetadata: addContainerMetadata({ env: { name: 'aws.lambda' } }) + } + }; + }); + + afterEach(() => { + if (cachedEnv.KUBERNETES_SERVICE_HOST != null) { + process.env.KUBERNETES_SERVICE_HOST = cachedEnv.KUBERNETES_SERVICE_HOST; + } else { + delete process.env.KUBERNETES_SERVICE_HOST; + } + authContext = {}; + }); + + it(`should include { orchestrator: 'kubernetes'} in client.env.container`, async () => { + const handshakeDocument = await prepareHandshakeDocument(authContext); + expect(handshakeDocument.client.env.container.orchestrator).to.equal('kubernetes'); + }); + + it(`should still have properly set 'name' property in client.env `, async () => { + const handshakeDocument = await prepareHandshakeDocument(authContext); + expect(handshakeDocument.client.env.name).to.equal('aws.lambda'); + }); + + context('when 512 byte size limit is exceeded', async () => { + it(`should not have 'container' property in client.env`, async () => { + // make metadata = 507 bytes, so it takes up entire LimitedSizeDocument + const longAppName = 's'.repeat(447); + const longAuthContext = { + connection: {}, + options: { + ...CONNECT_DEFAULTS, + extendedMetadata: { + appName: longAppName, + env: { name: 'aws.lambda' } + } as unknown as Promise + } + }; + const handshakeDocument = await prepareHandshakeDocument(longAuthContext); + expect(handshakeDocument.client.env.name).to.equal('aws.lambda'); + expect(handshakeDocument.client.env).to.not.have.property('container'); + }); + }); + }); + + context('when container nor FAAS env is not present (empty string case)', () => { + const authContext = { + connection: {}, + options: { ...CONNECT_DEFAULTS } + }; + + context('when process.env.KUBERNETES_SERVICE_HOST = undefined', () => { + beforeEach(() => { + delete process.env.KUBERNETES_SERVICE_HOST; + }); + + afterEach(() => { + afterEach(() => { + if (cachedEnv.KUBERNETES_SERVICE_HOST != null) { + process.env.KUBERNETES_SERVICE_HOST = cachedEnv.KUBERNETES_SERVICE_HOST; + } else { + delete process.env.KUBERNETES_SERVICE_HOST; + } + }); + }); + + it(`should not have 'env' property in client`, async () => { + const handshakeDocument = await prepareHandshakeDocument(authContext); + expect(handshakeDocument.client).to.not.have.property('env'); + }); + }); + + context('when process.env.KUBERNETES_SERVICE_HOST is an empty string', () => { + beforeEach(() => { + process.env.KUBERNETES_SERVICE_HOST = ''; + }); + + afterEach(() => { + if (cachedEnv.KUBERNETES_SERVICE_HOST != null) { + process.env.KUBERNETES_SERVICE_HOST = cachedEnv.KUBERNETES_SERVICE_HOST; + } else { + delete process.env.KUBERNETES_SERVICE_HOST; + } + }); + + it(`should not have 'env' property in client`, async () => { + const handshakeDocument = await prepareHandshakeDocument(authContext); + expect(handshakeDocument.client).to.not.have.property('env'); + }); + }); + }); + }); + context('when serverApi.version is present', () => { - const options = {}; + const options = { ...CONNECT_DEFAULTS }; const authContext = { connection: { serverApi: { version: '1' } }, options @@ -200,7 +357,7 @@ describe('Connect Tests', function () { }); context('when serverApi is not present', () => { - const options = {}; + const options = { ...CONNECT_DEFAULTS }; const authContext = { connection: {}, options @@ -216,7 +373,7 @@ describe('Connect Tests', function () { context('when loadBalanced is not set as an option', () => { const authContext = { connection: {}, - options: {} + options: { ...CONNECT_DEFAULTS } }; it('does not set loadBalanced on the handshake document', async () => { @@ -238,7 +395,7 @@ describe('Connect Tests', function () { context('when loadBalanced is set to false', () => { const authContext = { connection: {}, - options: { loadBalanced: false } + options: { ...CONNECT_DEFAULTS, loadBalanced: false } }; it('does not set loadBalanced on the handshake document', async () => { @@ -260,7 +417,7 @@ describe('Connect Tests', function () { context('when loadBalanced is set to true', () => { const authContext = { connection: {}, - options: { loadBalanced: true } + options: { ...CONNECT_DEFAULTS, loadBalanced: true } }; it('sets loadBalanced on the handshake document', async () => { diff --git a/test/unit/cmap/connection_pool.test.js b/test/unit/cmap/connection_pool.test.js index b6e408e3d5..a9ea375c7f 100644 --- a/test/unit/cmap/connection_pool.test.js +++ b/test/unit/cmap/connection_pool.test.js @@ -22,6 +22,9 @@ describe('Connection Pool', function () { }, s: { authProviders: new MongoClientAuthProviders() + }, + options: { + extendedMetadata: {} } } }