diff --git a/src/cmap/connect.ts b/src/cmap/connect.ts index ff143e2747..32c5d81524 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, @@ -180,7 +179,7 @@ export interface HandshakeDocument extends Document { ismaster?: boolean; hello?: boolean; helloOk?: boolean; - client: ClientMetadata; + client: Document; compression: string[]; saslSupportedMechs?: string; loadBalanced?: boolean; @@ -197,11 +196,12 @@ export async function prepareHandshakeDocument( const options = authContext.options; const compressors = options.compressors ? options.compressors : []; const { serverApi } = authContext.connection; + const clientMetadata = await options.extendedMetadata; const handshakeDoc: HandshakeDocument = { [serverApi?.version ? '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 b5aaf3ff8f..a815512597 100644 --- a/src/cmap/connection.ts +++ b/src/cmap/connection.ts @@ -133,6 +133,8 @@ export interface ConnectionOptions socketTimeoutMS?: number; cancellationToken?: CancellationToken; metadata: ClientMetadata; + /** @internal */ + extendedMetadata: Promise; } /** @internal */ diff --git a/src/cmap/handshake/client_metadata.ts b/src/cmap/handshake/client_metadata.ts index fb1ba40b14..8940823c48 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; + }); } } @@ -153,7 +154,57 @@ export function makeClientMetadata(options: MakeClientMetadataOptions): ClientMe } } - return metadataDocument.toObject(); + 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 extendedMetadata.toObject(); } /** diff --git a/src/connection_string.ts b/src/connection_string.ts index 5a197ad4de..bb2fc6e8db 100644 --- a/src/connection_string.ts +++ b/src/connection_string.ts @@ -6,7 +6,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 { @@ -550,6 +550,9 @@ 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 6c37f3de29..6946363827 100644 --- a/src/mongo_client.ts +++ b/src/mongo_client.ts @@ -757,6 +757,8 @@ export interface MongoOptions writeConcern: WriteConcern; dbName: string; metadata: ClientMetadata; + /** @internal */ + extendedMetadata: Promise; /** * @deprecated This option will be removed in the next major version. */ diff --git a/src/sdam/topology.ts b/src/sdam/topology.ts index d45acf6249..3005adb49e 100644 --- a/src/sdam/topology.ts +++ b/src/sdam/topology.ts @@ -143,6 +143,7 @@ export interface TopologyOptions extends BSONSerializeOptions, ServerOptions { directConnection: boolean; loadBalanced: boolean; metadata: ClientMetadata; + extendedMetadata: Promise; /** MongoDB server API version */ serverApi?: ServerApi; [featureFlag: symbol]: any; diff --git a/test/integration/connection-monitoring-and-pooling/connection.test.ts b/test/integration/connection-monitoring-and-pooling/connection.test.ts index 731a141550..a4827dabc8 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, @@ -36,7 +37,8 @@ describe('Connection', function () { const connectOptions: Partial = { connectionType: Connection, ...this.configuration.options, - metadata: makeClientMetadata({ driverInfo: {} }) + metadata: makeClientMetadata({ driverInfo: {} }), + extendedMetadata: addContainerMetadata(makeClientMetadata({ driverInfo: {} })) }; connect(connectOptions as any as ConnectionOptions, (err, conn) => { @@ -60,7 +62,8 @@ describe('Connection', function () { connectionType: Connection, monitorCommands: true, ...this.configuration.options, - metadata: makeClientMetadata({ driverInfo: {} }) + metadata: makeClientMetadata({ driverInfo: {} }), + extendedMetadata: addContainerMetadata(makeClientMetadata({ driverInfo: {} })) }; connect(connectOptions as any as ConnectionOptions, (err, conn) => { @@ -92,7 +95,8 @@ describe('Connection', function () { const connectOptions: Partial = { connectionType: Connection, ...this.configuration.options, - metadata: makeClientMetadata({ driverInfo: {} }) + metadata: makeClientMetadata({ driverInfo: {} }), + extendedMetadata: addContainerMetadata(makeClientMetadata({ driverInfo: {} })) }; connect(connectOptions as any as ConnectionOptions, (err, conn) => { diff --git a/test/tools/cmap_spec_runner.ts b/test/tools/cmap_spec_runner.ts index 4c11736857..76d1d4e4d4 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, @@ -371,6 +372,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; @@ -382,7 +384,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 c636c0cd81..19c44b50ca 100644 --- a/test/unit/cmap/connect.test.ts +++ b/test/unit/cmap/connect.test.ts @@ -2,6 +2,7 @@ import { expect } from 'chai'; import { promisify } from 'util'; import { + addContainerMetadata, CancellationToken, type ClientMetadata, connect, @@ -24,6 +25,7 @@ const CONNECT_DEFAULTS = { generation: 1, monitorCommands: false, metadata: {} as ClientMetadata, + extendedMetadata: addContainerMetadata({} as ClientMetadata), loadBalanced: false }; @@ -207,7 +209,162 @@ describe('Connect Tests', function () { }); }); - 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 = { authProviders: new MongoClientAuthProviders() diff --git a/test/unit/cmap/connection_pool.test.js b/test/unit/cmap/connection_pool.test.js index a08c955d14..1f3f20178f 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: {} } } }