diff --git a/src/cmap/auth/auth_provider.ts b/src/cmap/auth/auth_provider.ts index 3e7fcb23c6..45c94965f3 100644 --- a/src/cmap/auth/auth_provider.ts +++ b/src/cmap/auth/auth_provider.ts @@ -1,8 +1,9 @@ import type { Document } from '../../bson'; import { MongoRuntimeError } from '../../error'; -import type { Callback, ClientMetadataOptions } from '../../utils'; +import type { Callback } from '../../utils'; import type { HandshakeDocument } from '../connect'; import type { Connection, ConnectionOptions } from '../connection'; +import type { ClientMetadataOptions } from '../handshake/client_metadata'; import type { MongoCredentials } from './mongo_credentials'; export type AuthContextOptions = ConnectionOptions & ClientMetadataOptions; diff --git a/src/cmap/connect.ts b/src/cmap/connect.ts index 16028a31ad..822e07928f 100644 --- a/src/cmap/connect.ts +++ b/src/cmap/connect.ts @@ -18,7 +18,7 @@ import { MongoServerError, needsRetryableWriteLabel } from '../error'; -import { Callback, ClientMetadata, HostAddress, ns } from '../utils'; +import { Callback, HostAddress, ns } from '../utils'; import { AuthContext, AuthProvider } from './auth/auth_provider'; import { GSSAPI } from './auth/gssapi'; import { MongoCR } from './auth/mongocr'; @@ -28,6 +28,7 @@ import { AuthMechanism } from './auth/providers'; import { ScramSHA1, ScramSHA256 } from './auth/scram'; import { X509 } from './auth/x509'; import { Connection, ConnectionOptions, CryptoConnection } from './connection'; +import type { ClientMetadata } from './handshake/client_metadata'; import { MAX_SUPPORTED_SERVER_VERSION, MAX_SUPPORTED_WIRE_VERSION, diff --git a/src/cmap/connection.ts b/src/cmap/connection.ts index 7a739a2388..e18bec1658 100644 --- a/src/cmap/connection.ts +++ b/src/cmap/connection.ts @@ -28,7 +28,6 @@ import { applySession, ClientSession, updateSessionFromResponse } from '../sessi import { calculateDurationInMs, Callback, - ClientMetadata, HostAddress, maxWireVersion, MongoDBNamespace, @@ -44,6 +43,7 @@ import { } from './command_monitoring_events'; import { BinMsg, Msg, Query, Response, WriteProtocolMessageType } from './commands'; import type { Stream } from './connect'; +import type { ClientMetadata } from './handshake/client_metadata'; import { MessageStream, OperationDescription } from './message_stream'; import { StreamDescription, StreamDescriptionOptions } from './stream_description'; import { getReadPreference, isSharded } from './wire_protocol/shared'; diff --git a/src/cmap/handshake/client_metadata.ts b/src/cmap/handshake/client_metadata.ts new file mode 100644 index 0000000000..0c00af9a8e --- /dev/null +++ b/src/cmap/handshake/client_metadata.ts @@ -0,0 +1,236 @@ +import * as os from 'os'; +import * as process from 'process'; + +import { BSON, Int32 } from '../../bson'; +import { MongoInvalidArgumentError } from '../../error'; +import type { MongoOptions } from '../../mongo_client'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const NODE_DRIVER_VERSION = require('../../../package.json').version; + +/** + * @public + * @see https://github.com/mongodb/specifications/blob/master/source/mongodb-handshake/handshake.rst#hello-command + */ +export interface ClientMetadata { + driver: { + name: string; + version: string; + }; + os: { + type: string; + name?: NodeJS.Platform; + architecture?: string; + version?: string; + }; + platform: string; + application?: { + name: string; + }; + /** FaaS environment information */ + env?: { + name: 'aws.lambda' | 'gcp.func' | 'azure.func' | 'vercel'; + timeout_sec?: Int32; + memory_mb?: Int32; + region?: string; + url?: string; + }; +} + +/** @public */ +export interface ClientMetadataOptions { + driverInfo?: { + name?: string; + version?: string; + platform?: string; + }; + appName?: string; +} + +/** @internal */ +export class LimitedSizeDocument { + private document = new Map(); + /** BSON overhead: Int32 + Null byte */ + private documentSize = 5; + constructor(private maxSize: number) {} + + /** Only adds key/value if the bsonByteLength is less than MAX_SIZE */ + public ifItFitsItSits(key: string, value: Record | string): boolean { + // The BSON byteLength of the new element is the same as serializing it to its own document + // subtracting the document size int32 and the null terminator. + const newElementSize = BSON.serialize(new Map().set(key, value)).byteLength - 5; + + if (newElementSize + this.documentSize > this.maxSize) { + return false; + } + + this.documentSize += newElementSize; + + this.document.set(key, value); + + return true; + } + + toObject(): ClientMetadata { + return BSON.deserialize(BSON.serialize(this.document), { + promoteLongs: false, + promoteBuffers: false, + promoteValues: false, + useBigInt64: false + }) as ClientMetadata; + } +} + +type MakeClientMetadataOptions = Pick; +/** + * From the specs: + * Implementors SHOULD cumulatively update fields in the following order until the document is under the size limit: + * 1. Omit fields from `env` except `env.name`. + * 2. Omit fields from `os` except `os.type`. + * 3. Omit the `env` document entirely. + * 4. Truncate `platform`. -- special we do not truncate this field + */ +export function makeClientMetadata(options: MakeClientMetadataOptions): ClientMetadata { + const metadataDocument = new LimitedSizeDocument(512); + + const { appName = '' } = options; + // Add app name first, it must be sent + if (appName.length > 0) { + const name = + Buffer.byteLength(appName, 'utf8') <= 128 + ? options.appName + : Buffer.from(appName, 'utf8').subarray(0, 128).toString('utf8'); + metadataDocument.ifItFitsItSits('application', { name }); + } + + const { name = '', version = '', platform = '' } = options.driverInfo; + + const driverInfo = { + name: name.length > 0 ? `nodejs|${name}` : 'nodejs', + version: version.length > 0 ? `${NODE_DRIVER_VERSION}|${version}` : NODE_DRIVER_VERSION + }; + + if (!metadataDocument.ifItFitsItSits('driver', driverInfo)) { + throw new MongoInvalidArgumentError( + 'Unable to include driverInfo name and version, metadata cannot exceed 512 bytes' + ); + } + + const platformInfo = + platform.length > 0 + ? `Node.js ${process.version}, ${os.endianness()}|${platform}` + : `Node.js ${process.version}, ${os.endianness()}`; + + if (!metadataDocument.ifItFitsItSits('platform', platformInfo)) { + throw new MongoInvalidArgumentError( + 'Unable to include driverInfo platform, metadata cannot exceed 512 bytes' + ); + } + + // Note: order matters, os.type is last so it will be removed last if we're at maxSize + const osInfo = new Map() + .set('name', process.platform) + .set('architecture', process.arch) + .set('version', os.release()) + .set('type', os.type()); + + if (!metadataDocument.ifItFitsItSits('os', osInfo)) { + for (const key of osInfo.keys()) { + osInfo.delete(key); + if (osInfo.size === 0) break; + if (metadataDocument.ifItFitsItSits('os', osInfo)) break; + } + } + + const faasEnv = getFAASEnv(); + if (faasEnv != null) { + if (!metadataDocument.ifItFitsItSits('env', faasEnv)) { + for (const key of faasEnv.keys()) { + faasEnv.delete(key); + if (faasEnv.size === 0) break; + if (metadataDocument.ifItFitsItSits('env', faasEnv)) break; + } + } + } + + return metadataDocument.toObject(); +} + +/** + * Collects FaaS metadata. + * - `name` MUST be the last key in the Map returned. + */ +export function getFAASEnv(): Map | null { + const { + AWS_EXECUTION_ENV = '', + AWS_LAMBDA_RUNTIME_API = '', + FUNCTIONS_WORKER_RUNTIME = '', + K_SERVICE = '', + FUNCTION_NAME = '', + VERCEL = '', + AWS_LAMBDA_FUNCTION_MEMORY_SIZE = '', + AWS_REGION = '', + FUNCTION_MEMORY_MB = '', + FUNCTION_REGION = '', + FUNCTION_TIMEOUT_SEC = '', + VERCEL_REGION = '' + } = process.env; + + const isAWSFaaS = AWS_EXECUTION_ENV.length > 0 || AWS_LAMBDA_RUNTIME_API.length > 0; + const isAzureFaaS = FUNCTIONS_WORKER_RUNTIME.length > 0; + const isGCPFaaS = K_SERVICE.length > 0 || FUNCTION_NAME.length > 0; + const isVercelFaaS = VERCEL.length > 0; + + // Note: order matters, name must always be the last key + const faasEnv = new Map(); + + // When isVercelFaaS is true so is isAWSFaaS; Vercel inherits the AWS env + if (isVercelFaaS && !(isAzureFaaS || isGCPFaaS)) { + if (VERCEL_REGION.length > 0) { + faasEnv.set('region', VERCEL_REGION); + } + + faasEnv.set('name', 'vercel'); + return faasEnv; + } + + if (isAWSFaaS && !(isAzureFaaS || isGCPFaaS || isVercelFaaS)) { + if (AWS_REGION.length > 0) { + faasEnv.set('region', AWS_REGION); + } + + if ( + AWS_LAMBDA_FUNCTION_MEMORY_SIZE.length > 0 && + Number.isInteger(+AWS_LAMBDA_FUNCTION_MEMORY_SIZE) + ) { + faasEnv.set('memory_mb', new Int32(AWS_LAMBDA_FUNCTION_MEMORY_SIZE)); + } + + faasEnv.set('name', 'aws.lambda'); + return faasEnv; + } + + if (isAzureFaaS && !(isGCPFaaS || isAWSFaaS || isVercelFaaS)) { + faasEnv.set('name', 'azure.func'); + return faasEnv; + } + + if (isGCPFaaS && !(isAzureFaaS || isAWSFaaS || isVercelFaaS)) { + if (FUNCTION_REGION.length > 0) { + faasEnv.set('region', FUNCTION_REGION); + } + + if (FUNCTION_MEMORY_MB.length > 0 && Number.isInteger(+FUNCTION_MEMORY_MB)) { + faasEnv.set('memory_mb', new Int32(FUNCTION_MEMORY_MB)); + } + + if (FUNCTION_TIMEOUT_SEC.length > 0 && Number.isInteger(+FUNCTION_TIMEOUT_SEC)) { + faasEnv.set('timeout_sec', new Int32(FUNCTION_TIMEOUT_SEC)); + } + + faasEnv.set('name', 'gcp.func'); + return faasEnv; + } + + return null; +} diff --git a/src/connection_string.ts b/src/connection_string.ts index 4343f055ba..322e0c066b 100644 --- a/src/connection_string.ts +++ b/src/connection_string.ts @@ -6,6 +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 { Compressor, CompressorName } from './cmap/wire_protocol/compression'; import { Encrypter } from './encrypter'; import { @@ -34,7 +35,6 @@ import { emitWarningOnce, HostAddress, isRecord, - makeClientMetadata, matchesParentDomain, parseInteger, setDifference diff --git a/src/index.ts b/src/index.ts index 68fdd89b42..24c2ea1cb5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -248,6 +248,7 @@ export type { WaitQueueMember, WithConnectionCallback } from './cmap/connection_pool'; +export type { ClientMetadata, ClientMetadataOptions } from './cmap/handshake/client_metadata'; export type { MessageStream, MessageStreamOptions, @@ -480,8 +481,6 @@ export type { Transaction, TransactionOptions, TxnState } from './transactions'; export type { BufferPool, Callback, - ClientMetadata, - ClientMetadataOptions, EventEmitterWithState, HostAddress, List, diff --git a/src/mongo_client.ts b/src/mongo_client.ts index 1390a6001d..3ee5af70dc 100644 --- a/src/mongo_client.ts +++ b/src/mongo_client.ts @@ -8,6 +8,7 @@ import type { AuthMechanismProperties, MongoCredentials } from './cmap/auth/mong import type { AuthMechanism } from './cmap/auth/providers'; import type { LEGAL_TCP_SOCKET_OPTIONS, LEGAL_TLS_SOCKET_OPTIONS } from './cmap/connect'; import type { Connection } from './cmap/connection'; +import type { ClientMetadata } from './cmap/handshake/client_metadata'; import type { CompressorName } from './cmap/wire_protocol/compression'; import { parseOptions, resolveSRVRecord } from './connection_string'; import { MONGO_CLIENT_EVENTS } from './constants'; @@ -27,7 +28,6 @@ import { Topology, TopologyEvents } from './sdam/topology'; import { ClientSession, ClientSessionOptions, ServerSessionPool } from './sessions'; import { Callback, - ClientMetadata, HostAddress, maybeCallback, MongoDBNamespace, @@ -389,6 +389,7 @@ export class MongoClient extends TypedEventEmitter { }; } + /** @see MongoOptions */ get options(): Readonly { return Object.freeze({ ...this[kOptions] }); } @@ -469,7 +470,7 @@ export class MongoClient extends TypedEventEmitter { topology.once(Topology.OPEN, () => this.emit('open', this)); for (const event of MONGO_CLIENT_EVENTS) { - topology.on(event, (...args: any[]) => this.emit(event, ...(args as any))); + topology.on(event, (...args: any[]): unknown => this.emit(event, ...(args as any))); } const topologyConnect = async () => { @@ -728,7 +729,22 @@ export class MongoClient extends TypedEventEmitter { } /** - * Mongo Client Options + * Parsed Mongo Client Options. + * + * User supplied options are documented by `MongoClientOptions`. + * + * **NOTE:** The client's options parsing is subject to change to support new features. + * This type is provided to aid with inspection of options after parsing, it should not be relied upon programmatically. + * + * Options are sourced from: + * - connection string + * - options object passed to the MongoClient constructor + * - file system (ex. tls settings) + * - environment variables + * - DNS SRV records and TXT records + * + * Not all options may be present after client construction as some are obtained from asynchronous operations. + * * @public */ export interface MongoOptions @@ -787,6 +803,7 @@ export interface MongoOptions proxyPort?: number; proxyUsername?: string; proxyPassword?: string; + /** @internal */ connectionType?: typeof Connection; diff --git a/src/sdam/topology.ts b/src/sdam/topology.ts index 601ae2c382..d1911b4c93 100644 --- a/src/sdam/topology.ts +++ b/src/sdam/topology.ts @@ -6,6 +6,7 @@ import { deserialize, serialize } from '../bson'; import type { MongoCredentials } from '../cmap/auth/mongo_credentials'; import type { ConnectionEvents, DestroyOptions } from '../cmap/connection'; import type { CloseOptions, ConnectionPoolEvents } from '../cmap/connection_pool'; +import type { ClientMetadata } from '../cmap/handshake/client_metadata'; import { DEFAULT_OPTIONS, FEATURE_FLAGS } from '../connection_string'; import { CLOSE, @@ -38,7 +39,6 @@ import type { ClientSession } from '../sessions'; import type { Transaction } from '../transactions'; import { Callback, - ClientMetadata, emitWarning, EventEmitterWithState, HostAddress, diff --git a/src/utils.ts b/src/utils.ts index 9e9c20bdf7..d32812e920 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,5 @@ import * as crypto from 'crypto'; import type { SrvRecord } from 'dns'; -import * as os from 'os'; import { URL } from 'url'; import { Document, ObjectId, resolveBSONOptions } from './bson'; @@ -20,7 +19,7 @@ import { MongoRuntimeError } from './error'; import type { Explain } from './explain'; -import type { MongoClient, MongoOptions } from './mongo_client'; +import type { MongoClient } from './mongo_client'; import type { CommandOperationOptions, OperationParent } from './operations/command'; import type { Hint, OperationOptions } from './operations/operation'; import { PromiseProvider } from './promise_provider'; @@ -657,77 +656,6 @@ export function makeStateMachine(stateTable: StateTable): StateTransitionFunctio }; } -/** - * @public - * @see https://github.com/mongodb/specifications/blob/master/source/mongodb-handshake/handshake.rst#hello-command - */ -export interface ClientMetadata { - driver: { - name: string; - version: string; - }; - os: { - type: string; - name: NodeJS.Platform; - architecture: string; - version: string; - }; - platform: string; - application?: { - name: string; - }; -} - -/** @public */ -export interface ClientMetadataOptions { - driverInfo?: { - name?: string; - version?: string; - platform?: string; - }; - appName?: string; -} - -// eslint-disable-next-line @typescript-eslint/no-var-requires -const NODE_DRIVER_VERSION = require('../package.json').version; - -export function makeClientMetadata( - options: Pick -): ClientMetadata { - const name = options.driverInfo.name ? `nodejs|${options.driverInfo.name}` : 'nodejs'; - const version = options.driverInfo.version - ? `${NODE_DRIVER_VERSION}|${options.driverInfo.version}` - : NODE_DRIVER_VERSION; - const platform = options.driverInfo.platform - ? `Node.js ${process.version}, ${os.endianness()}|${options.driverInfo.platform}` - : `Node.js ${process.version}, ${os.endianness()}`; - - const metadata: ClientMetadata = { - driver: { - name, - version - }, - os: { - type: os.type(), - name: process.platform, - architecture: process.arch, - version: os.release() - }, - platform - }; - - if (options.appName) { - // MongoDB requires the appName not exceed a byte length of 128 - const name = - Buffer.byteLength(options.appName, 'utf8') <= 128 - ? options.appName - : Buffer.from(options.appName, 'utf8').subarray(0, 128).toString('utf8'); - metadata.application = { name }; - } - - return metadata; -} - /** @internal */ export function now(): number { const hrtime = process.hrtime(); diff --git a/test/integration/connection-monitoring-and-pooling/connection.test.ts b/test/integration/connection-monitoring-and-pooling/connection.test.ts index 0945ad066d..c3778e3f13 100644 --- a/test/integration/connection-monitoring-and-pooling/connection.test.ts +++ b/test/integration/connection-monitoring-and-pooling/connection.test.ts @@ -3,9 +3,10 @@ import { expect } from 'chai'; import { MongoClient, MongoServerError, ServerHeartbeatStartedEvent } from '../../../src'; import { connect } from '../../../src/cmap/connect'; import { Connection } from '../../../src/cmap/connection'; +import { makeClientMetadata } from '../../../src/cmap/handshake/client_metadata'; import { LEGACY_HELLO_COMMAND } from '../../../src/constants'; import { Topology } from '../../../src/sdam/topology'; -import { makeClientMetadata, ns } from '../../../src/utils'; +import { ns } from '../../../src/utils'; import { skipBrokenAuthTestBeforeEachHook } from '../../tools/runner/hooks/configuration'; import { assert as test, setupDatabase } from '../shared'; diff --git a/test/integration/mongodb-handshake/mongodb-handshake.prose.test.ts b/test/integration/mongodb-handshake/mongodb-handshake.prose.test.ts new file mode 100644 index 0000000000..cb20e9ac09 --- /dev/null +++ b/test/integration/mongodb-handshake/mongodb-handshake.prose.test.ts @@ -0,0 +1,108 @@ +import { expect } from 'chai'; + +import { MongoClient } from '../../../src'; +import { getFAASEnv } from '../../../src/cmap/handshake/client_metadata'; + +describe('Handshake Prose Tests', function () { + let client: MongoClient; + + afterEach(async function () { + await client?.close(); + }); + + type EnvironmentVariables = Array<[string, string]>; + const tests: Array<{ + context: string; + expectedProvider: string | undefined; + env: EnvironmentVariables; + }> = [ + { + context: '1. Valid AWS', + expectedProvider: 'aws.lambda', + env: [ + ['AWS_EXECUTION_ENV', 'AWS_Lambda_java8'], + ['AWS_REGION', 'us-east-2'], + ['AWS_LAMBDA_FUNCTION_MEMORY_SIZE', '1024'] + ] + }, + { + context: '2. Valid Azure', + expectedProvider: 'azure.func', + env: [['FUNCTIONS_WORKER_RUNTIME', 'node']] + }, + { + context: '3. Valid GCP', + expectedProvider: 'gcp.func', + env: [ + ['K_SERVICE', 'servicename'], + ['FUNCTION_MEMORY_MB', '1024'], + ['FUNCTION_TIMEOUT_SEC', '60'], + ['FUNCTION_REGION', 'us-central1'] + ] + }, + { + context: '4. Valid Vercel', + expectedProvider: 'vercel', + env: [ + ['VERCEL', '1'], + ['VERCEL_REGION', 'cdg1'] + ] + }, + { + expectedProvider: undefined, + context: '5. Invalid - multiple providers', + env: [ + ['AWS_EXECUTION_ENV', 'AWS_Lambda_java8'], + ['FUNCTIONS_WORKER_RUNTIME', 'node'] + ] + }, + { + expectedProvider: 'aws.lambda', + context: '6. Invalid - long string', + env: [ + ['AWS_EXECUTION_ENV', 'AWS_Lambda_java8'], + ['AWS_REGION', 'a'.repeat(1024)] + ] + }, + { + expectedProvider: 'aws.lambda', + context: '7. Invalid - wrong types', + env: [ + ['AWS_EXECUTION_ENV', 'AWS_Lambda_java8'], + ['AWS_LAMBDA_FUNCTION_MEMORY_SIZE', 'big'] + ] + } + ]; + + for (const { context: name, env, expectedProvider } of tests) { + context(name, function () { + before(() => { + for (const [key, value] of env) { + process.env[key] = value; + } + }); + after(() => { + for (const [key] of env) { + delete process.env[key]; + } + }); + + it(`metadata confirmation test for ${name}`, function () { + expect(getFAASEnv()?.get('name')).to.equal( + expectedProvider, + 'determined the wrong cloud provider' + ); + }); + + it('runs a hello successfully', async function () { + client = this.configuration.newClient({ + // if the handshake is not truncated, the `hello`s fail and the client does + // not connect. Lowering the server selection timeout causes the tests + // to fail more quickly in that scenario. + serverSelectionTimeoutMS: 3000 + }); + await client.connect(); + }); + }); + } +}); diff --git a/test/integration/mongodb-handshake/mongodb-handshake.test.ts b/test/integration/mongodb-handshake/mongodb-handshake.test.ts new file mode 100644 index 0000000000..28791c2bd7 --- /dev/null +++ b/test/integration/mongodb-handshake/mongodb-handshake.test.ts @@ -0,0 +1,42 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { MongoServerError, MongoServerSelectionError } from '../../../src'; +import { Connection } from '../../../src/cmap/connection'; +import { LEGACY_HELLO_COMMAND } from '../../../src/constants'; + +describe('MongoDB Handshake', () => { + let client; + + context('when hello is too large', () => { + before(() => { + sinon.stub(Connection.prototype, 'command').callsFake(function (ns, cmd, options, callback) { + // @ts-expect-error: sinon will place wrappedMethod there + const command = Connection.prototype.command.wrappedMethod.bind(this); + + if (cmd.hello || cmd[LEGACY_HELLO_COMMAND]) { + return command( + ns, + { ...cmd, client: { driver: { name: 'a'.repeat(1000) } } }, + options, + callback + ); + } + return command(ns, cmd, options, callback); + }); + }); + + after(() => sinon.restore()); + + it('should fail with an error relating to size', async function () { + client = this.configuration.newClient({ serverSelectionTimeoutMS: 2000 }); + const error = await client.connect().catch(error => error); + if (this.configuration.isLoadBalanced) { + expect(error).to.be.instanceOf(MongoServerError); + } else { + expect(error).to.be.instanceOf(MongoServerSelectionError); + } + expect(error).to.match(/client metadata document must be less/); + }); + }); +}); diff --git a/test/integration/node-specific/topology.test.js b/test/integration/node-specific/topology.test.js index 88c0d33016..5f248da5ce 100644 --- a/test/integration/node-specific/topology.test.js +++ b/test/integration/node-specific/topology.test.js @@ -1,6 +1,6 @@ 'use strict'; const { expect } = require('chai'); -const { makeClientMetadata } = require('../../../src/utils'); +const { makeClientMetadata } = require('../../../src/cmap/handshake/client_metadata'); describe('Topology', function () { it('should correctly track states of a topology', { diff --git a/test/tools/cmap_spec_runner.ts b/test/tools/cmap_spec_runner.ts index 5900bb46a9..d3e2b2267e 100644 --- a/test/tools/cmap_spec_runner.ts +++ b/test/tools/cmap_spec_runner.ts @@ -5,8 +5,9 @@ import { promisify } from 'util'; import { Connection, HostAddress, MongoClient, Server } from '../../src'; import { ConnectionPool, ConnectionPoolOptions } from '../../src/cmap/connection_pool'; +import { makeClientMetadata } from '../../src/cmap/handshake/client_metadata'; import { CMAP_EVENTS } from '../../src/constants'; -import { makeClientMetadata, shuffle } from '../../src/utils'; +import { shuffle } from '../../src/utils'; import { isAnyRequirementSatisfied } from './unified-spec-runner/unified-utils'; import { FailPoint, sleep } from './utils'; diff --git a/test/unit/cmap/handshake/client_metadata.test.ts b/test/unit/cmap/handshake/client_metadata.test.ts index 00ab7fe558..97b2afe2c5 100644 --- a/test/unit/cmap/handshake/client_metadata.test.ts +++ b/test/unit/cmap/handshake/client_metadata.test.ts @@ -1,149 +1,501 @@ +import { Int32, ObjectId } from 'bson'; import { expect } from 'chai'; import * as os from 'os'; +import * as process from 'process'; +import * as sinon from 'sinon'; +import { inspect } from 'util'; -import { makeClientMetadata } from '../../../../src/utils'; - -// eslint-disable-next-line @typescript-eslint/no-var-requires -const NODE_DRIVER_VERSION = require('../../../../package.json').version; - -describe('makeClientMetadata()', () => { - context('when driverInfo.platform is provided', () => { - it('appends driverInfo.platform to the platform field', () => { - const options = { - driverInfo: { platform: 'myPlatform' } - }; - const metadata = makeClientMetadata(options); - expect(metadata).to.deep.equal({ - driver: { - name: 'nodejs', - version: NODE_DRIVER_VERSION - }, - os: { - type: os.type(), - name: process.platform, - architecture: process.arch, - version: os.release() - }, - platform: `Node.js ${process.version}, ${os.endianness()}|myPlatform` - }); +import { version as NODE_DRIVER_VERSION } from '../../../../package.json'; +import { + getFAASEnv, + LimitedSizeDocument, + makeClientMetadata +} from '../../../../src/cmap/handshake/client_metadata'; +import { MongoInvalidArgumentError } from '../../../../src/error'; + +describe('client metadata module', () => { + afterEach(() => sinon.restore()); + + describe('new LimitedSizeDocument()', () => { + // For the sake of testing the size limiter features + // We test document: { _id: ObjectId() } + // 4 bytes + 1 type byte + 4 bytes for key + 12 bytes Oid + 1 null term byte + // = 22 bytes + + it('allows setting a key and value that fit within maxSize', () => { + const doc = new LimitedSizeDocument(22); + expect(doc.ifItFitsItSits('_id', new ObjectId())).to.be.true; + expect(doc.toObject()).to.have.all.keys('_id'); + }); + + it('ignores attempts to set key-value pairs that are over size', () => { + const doc = new LimitedSizeDocument(22); + expect(doc.ifItFitsItSits('_id', new ObjectId())).to.be.true; + expect(doc.ifItFitsItSits('_id2', '')).to.be.false; + expect(doc.toObject()).to.have.all.keys('_id'); }); }); - context('when driverInfo.name is provided', () => { - it('appends driverInfo.name to the driver.name field', () => { - const options = { - driverInfo: { name: 'myName' } - }; - const metadata = makeClientMetadata(options); - expect(metadata).to.deep.equal({ - driver: { - name: 'nodejs|myName', - version: NODE_DRIVER_VERSION - }, - os: { - type: os.type(), - name: process.platform, - architecture: process.arch, - version: os.release() - }, - platform: `Node.js ${process.version}, ${os.endianness()}` + describe('getFAASEnv()', function () { + const tests: Array<[string, string]> = [ + ['AWS_EXECUTION_ENV', 'aws.lambda'], + ['AWS_LAMBDA_RUNTIME_API', 'aws.lambda'], + ['FUNCTIONS_WORKER_RUNTIME', 'azure.func'], + ['K_SERVICE', 'gcp.func'], + ['FUNCTION_NAME', 'gcp.func'], + ['VERCEL', 'vercel'] + ]; + for (const [envVariable, provider] of tests) { + context(`when ${envVariable} is in the environment`, () => { + before(() => { + process.env[envVariable] = 'non empty string'; + }); + after(() => { + delete process.env[envVariable]; + }); + it('determines the correct provider', () => { + expect(getFAASEnv()?.get('name')).to.equal(provider); + }); + }); + } + + context('when there is no FAAS provider data in the env', () => { + it('returns null', () => { + expect(getFAASEnv()).to.be.null; }); }); - }); - context('when driverInfo.version is provided', () => { - it('appends driverInfo.version to the version field', () => { - const options = { - driverInfo: { version: 'myVersion' } - }; - const metadata = makeClientMetadata(options); - expect(metadata).to.deep.equal({ - driver: { - name: 'nodejs', - version: `${NODE_DRIVER_VERSION}|myVersion` - }, - os: { - type: os.type(), - name: process.platform, - architecture: process.arch, - version: os.release() - }, - platform: `Node.js ${process.version}, ${os.endianness()}` + context('when there is data from multiple cloud providers in the env', () => { + context('unrelated environments', () => { + before(() => { + // aws + process.env.AWS_EXECUTION_ENV = 'non-empty-string'; + // azure + process.env.FUNCTIONS_WORKER_RUNTIME = 'non-empty-string'; + }); + after(() => { + delete process.env.AWS_EXECUTION_ENV; + delete process.env.FUNCTIONS_WORKER_RUNTIME; + }); + it('returns null', () => { + expect(getFAASEnv()).to.be.null; + }); + }); + + context('vercel and aws which share env variables', () => { + before(() => { + // vercel + process.env.VERCEL = 'non-empty-string'; + // aws + process.env.AWS_EXECUTION_ENV = 'non-empty-string'; + process.env.AWS_LAMBDA_RUNTIME_API = 'non-empty-string'; + }); + after(() => { + delete process.env.VERCEL; + delete process.env.AWS_EXECUTION_ENV; + delete process.env.AWS_LAMBDA_RUNTIME_API; + }); + + it('parses vercel', () => { + expect(getFAASEnv()?.get('name')).to.equal('vercel'); + }); }); }); }); - context('when no custom driverInfo is provided', () => { - const metadata = makeClientMetadata({ driverInfo: {} }); + describe('makeClientMetadata()', () => { + context('when no FAAS environment is detected', () => { + it('does not append FAAS metadata', () => { + const metadata = makeClientMetadata({ driverInfo: {} }); + expect(metadata).not.to.have.property( + 'env', + 'faas metadata applied in a non-faas environment' + ); + expect(metadata).to.deep.equal({ + driver: { + name: 'nodejs', + version: NODE_DRIVER_VERSION + }, + os: { + type: os.type(), + name: process.platform, + architecture: process.arch, + version: os.release() + }, + platform: `Node.js ${process.version}, ${os.endianness()}` + }); + }); + }); - it('does not append the driver info to the metadata', () => { - expect(metadata).to.deep.equal({ - driver: { - name: 'nodejs', - version: NODE_DRIVER_VERSION - }, - os: { - type: os.type(), - name: process.platform, - architecture: process.arch, - version: os.release() - }, - platform: `Node.js ${process.version}, ${os.endianness()}` + context('when driverInfo.platform is provided', () => { + it('throws an error if driverInfo.platform is too large', () => { + expect(() => makeClientMetadata({ driverInfo: { platform: 'a'.repeat(512) } })).to.throw( + MongoInvalidArgumentError, + /platform/ + ); + }); + + it('appends driverInfo.platform to the platform field', () => { + const options = { + driverInfo: { platform: 'myPlatform' } + }; + const metadata = makeClientMetadata(options); + expect(metadata).to.deep.equal({ + driver: { + name: 'nodejs', + version: NODE_DRIVER_VERSION + }, + os: { + type: os.type(), + name: process.platform, + architecture: process.arch, + version: os.release() + }, + platform: `Node.js ${process.version}, ${os.endianness()}|myPlatform` + }); }); }); - it('does not set the application field', () => { - expect(metadata).not.to.have.property('application'); + context('when driverInfo.name is provided', () => { + it('throws an error if driverInfo.name is too large', () => { + expect(() => makeClientMetadata({ driverInfo: { name: 'a'.repeat(512) } })).to.throw( + MongoInvalidArgumentError, + /name/ + ); + }); + + it('appends driverInfo.name to the driver.name field', () => { + const options = { + driverInfo: { name: 'myName' } + }; + const metadata = makeClientMetadata(options); + expect(metadata).to.deep.equal({ + driver: { + name: 'nodejs|myName', + version: NODE_DRIVER_VERSION + }, + os: { + type: os.type(), + name: process.platform, + architecture: process.arch, + version: os.release() + }, + platform: `Node.js ${process.version}, ${os.endianness()}` + }); + }); }); - }); - context('when app name is provided', () => { - context('when the app name is over 128 bytes', () => { - const longString = 'a'.repeat(300); - const options = { - appName: longString, - driverInfo: {} - }; - const metadata = makeClientMetadata(options); - - it('truncates the application name to <=128 bytes', () => { - expect(metadata.application?.name).to.be.a('string'); - // the above assertion fails if `metadata.application?.name` is undefined, so - // we can safely assert that it exists - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - expect(Buffer.byteLength(metadata.application!.name, 'utf8')).to.equal(128); + context('when driverInfo.version is provided', () => { + it('throws an error if driverInfo.version is too large', () => { + expect(() => makeClientMetadata({ driverInfo: { version: 'a'.repeat(512) } })).to.throw( + MongoInvalidArgumentError, + /version/ + ); + }); + + it('appends driverInfo.version to the version field', () => { + const options = { + driverInfo: { version: 'myVersion' } + }; + const metadata = makeClientMetadata(options); + expect(metadata).to.deep.equal({ + driver: { + name: 'nodejs', + version: `${NODE_DRIVER_VERSION}|myVersion` + }, + os: { + type: os.type(), + name: process.platform, + architecture: process.arch, + version: os.release() + }, + platform: `Node.js ${process.version}, ${os.endianness()}` + }); + }); + }); + + context('when no custom driverInto is provided', () => { + const metadata = makeClientMetadata({ driverInfo: {} }); + + it('does not append the driver info to the metadata', () => { + expect(metadata).to.deep.equal({ + driver: { + name: 'nodejs', + version: NODE_DRIVER_VERSION + }, + os: { + type: os.type(), + name: process.platform, + architecture: process.arch, + version: os.release() + }, + platform: `Node.js ${process.version}, ${os.endianness()}` + }); + }); + + it('does not set the application field', () => { + expect(metadata).not.to.have.property('application'); }); }); - context( - 'TODO(NODE-5150): fix appName truncation when multi-byte unicode charaters straddle byte 128', - () => { - const longString = '€'.repeat(300); + context('when app name is provided', () => { + context('when the app name is over 128 bytes', () => { + const longString = 'a'.repeat(300); const options = { appName: longString, driverInfo: {} }; const metadata = makeClientMetadata(options); - it('truncates the application name to 129 bytes', () => { + it('truncates the application name to <=128 bytes', () => { expect(metadata.application?.name).to.be.a('string'); // the above assertion fails if `metadata.application?.name` is undefined, so // we can safely assert that it exists // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - expect(Buffer.byteLength(metadata.application!.name, 'utf8')).to.equal(129); + expect(Buffer.byteLength(metadata.application!.name, 'utf8')).to.equal(128); + }); + }); + + context( + 'TODO(NODE-5150): fix appName truncation when multi-byte unicode charaters straddle byte 128', + () => { + const longString = '€'.repeat(300); + const options = { + appName: longString, + driverInfo: {} + }; + const metadata = makeClientMetadata(options); + + it('truncates the application name to 129 bytes', () => { + expect(metadata.application?.name).to.be.a('string'); + // the above assertion fails if `metadata.application?.name` is undefined, so + // we can safely assert that it exists + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(Buffer.byteLength(metadata.application!.name, 'utf8')).to.equal(129); + }); + } + ); + + context('when the app name is under 128 bytes', () => { + const options = { + appName: 'myApplication', + driverInfo: {} + }; + const metadata = makeClientMetadata(options); + + it('sets the application name to the value', () => { + expect(metadata.application?.name).to.equal('myApplication'); + }); + }); + }); + }); + + describe('FAAS metadata application to handshake', () => { + const tests = { + aws: [ + { + context: 'no additional metadata', + env: [['AWS_EXECUTION_ENV', 'non-empty string']], + outcome: { + name: 'aws.lambda' + } + }, + { + context: 'AWS_REGION provided', + env: [ + ['AWS_EXECUTION_ENV', 'non-empty string'], + ['AWS_REGION', 'non-null'] + ], + outcome: { + name: 'aws.lambda', + region: 'non-null' + } + }, + { + context: 'AWS_LAMBDA_FUNCTION_MEMORY_SIZE provided', + env: [ + ['AWS_EXECUTION_ENV', 'non-empty string'], + ['AWS_LAMBDA_FUNCTION_MEMORY_SIZE', '3'] + ], + outcome: { + name: 'aws.lambda', + memory_mb: new Int32(3) + } + } + ], + azure: [ + { + context: 'no additional metadata', + env: [['FUNCTIONS_WORKER_RUNTIME', 'non-empty']], + outcome: { + name: 'azure.func' + } + } + ], + gcp: [ + { + context: 'no additional metadata', + env: [['FUNCTION_NAME', 'non-empty']], + outcome: { + name: 'gcp.func' + } + }, + { + context: 'FUNCTION_MEMORY_MB provided', + env: [ + ['FUNCTION_NAME', 'non-empty'], + ['FUNCTION_MEMORY_MB', '1024'] + ], + outcome: { + name: 'gcp.func', + memory_mb: new Int32(1024) + } + }, + { + context: 'FUNCTION_REGION provided', + env: [ + ['FUNCTION_NAME', 'non-empty'], + ['FUNCTION_REGION', 'region'] + ], + outcome: { + name: 'gcp.func', + region: 'region' + } + }, + { + context: 'FUNCTION_TIMEOUT_SEC provided', + env: [ + ['FUNCTION_NAME', 'non-empty'], + ['FUNCTION_TIMEOUT_SEC', '12345'] + ], + outcome: { + name: 'gcp.func', + timeout_sec: new Int32(12345) + } + } + ], + vercel: [ + { + context: 'no additional metadata', + env: [['VERCEL', 'non-empty']], + outcome: { + name: 'vercel' + } + }, + { + context: 'VERCEL_REGION provided', + env: [ + ['VERCEL', 'non-empty'], + ['VERCEL_REGION', 'region'] + ], + outcome: { + name: 'vercel', + region: 'region' + } + } + ] + }; + + for (const [provider, testsForEnv] of Object.entries(tests)) { + for (const { context: title, env: faasVariables, outcome } of testsForEnv) { + context(`${provider} - ${title}`, () => { + beforeEach(() => { + sinon.stub(process, 'env').get(() => Object.fromEntries(faasVariables)); + }); + + it(`returns ${inspect(outcome)} under env property`, () => { + const { env } = makeClientMetadata({ driverInfo: {} }); + expect(env).to.deep.equal(outcome); + }); + + it('places name as the last key in map', () => { + const keys = Array.from(getFAASEnv()?.keys() ?? []); + expect(keys).to.have.property(`${keys.length - 1}`, 'name'); + }); }); } - ); + } + + context('when a numeric FAAS env variable is not numerically parsable', () => { + before(() => { + process.env.AWS_EXECUTION_ENV = 'non-empty-string'; + process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE = '123not numeric'; + }); + + after(() => { + delete process.env.AWS_EXECUTION_ENV; + delete process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE; + }); + + it('does not attach it to the metadata', () => { + expect(makeClientMetadata({ driverInfo: {} })).not.to.have.nested.property('aws.memory_mb'); + }); + }); + }); - context('when the app name is under 128 bytes', () => { - const options = { - appName: 'myApplication', - driverInfo: {} - }; - const metadata = makeClientMetadata(options); + describe('metadata truncation', function () { + context('when faas region is too large', () => { + beforeEach('1. Omit fields from `env` except `env.name`.', () => { + sinon.stub(process, 'env').get(() => ({ + AWS_EXECUTION_ENV: 'iLoveJavaScript', + AWS_REGION: 'a'.repeat(512) + })); + }); + + it('only includes env.name', () => { + const metadata = makeClientMetadata({ driverInfo: {} }); + expect(metadata).to.not.have.nested.property('env.region'); + expect(metadata).to.have.nested.property('env.name', 'aws.lambda'); + expect(metadata.env).to.have.all.keys('name'); + }); + }); + + context('when os information is too large', () => { + context('release too large', () => { + beforeEach('2. Omit fields from `os` except `os.type`.', () => { + sinon.stub(process, 'env').get(() => ({ + AWS_EXECUTION_ENV: 'iLoveJavaScript', + AWS_REGION: 'abc' + })); + sinon.stub(os, 'release').returns('a'.repeat(512)); + }); + + it('only includes env.name', () => { + const metadata = makeClientMetadata({ driverInfo: {} }); + expect(metadata).to.have.property('env'); + expect(metadata).to.have.nested.property('env.region', 'abc'); + expect(metadata.os).to.have.all.keys('type'); + }); + }); + + context('os.type too large', () => { + beforeEach(() => { + sinon.stub(process, 'env').get(() => ({ + AWS_EXECUTION_ENV: 'iLoveJavaScript', + AWS_REGION: 'abc' + })); + sinon.stub(os, 'type').returns('a'.repeat(512)); + }); + + it('omits os information', () => { + const metadata = makeClientMetadata({ driverInfo: {} }); + expect(metadata).to.not.have.property('os'); + }); + }); + }); + + context('when there is no space for FaaS env', () => { + beforeEach('3. Omit the `env` document entirely.', () => { + sinon.stub(process, 'env').get(() => ({ + AWS_EXECUTION_ENV: 'iLoveJavaScript', + AWS_REGION: 'abc' + })); + sinon.stub(os, 'type').returns('a'.repeat(50)); + }); - it('sets the application name to the value', () => { - expect(metadata.application?.name).to.equal('myApplication'); + it('omits the faas env', () => { + const metadata = makeClientMetadata({ driverInfo: { name: 'a'.repeat(350) } }); + expect(metadata).to.not.have.property('env'); }); }); }); diff --git a/test/unit/sdam/topology.test.js b/test/unit/sdam/topology.test.js index 72b8033888..11dd6e247a 100644 --- a/test/unit/sdam/topology.test.js +++ b/test/unit/sdam/topology.test.js @@ -9,13 +9,14 @@ const { MongoClient, MongoServerSelectionError, ReadPreference } = require('../. const { Topology } = require('../../../src/sdam/topology'); const { Server } = require('../../../src/sdam/server'); const { ServerDescription } = require('../../../src/sdam/server_description'); -const { ns, makeClientMetadata, isHello } = require('../../../src/utils'); +const { ns, isHello } = require('../../../src/utils'); const { TopologyDescriptionChangedEvent } = require('../../../src/sdam/events'); const { TopologyDescription } = require('../../../src/sdam/topology_description'); const { TopologyType } = require('../../../src/sdam/common'); const { SrvPoller, SrvPollingEvent } = require('../../../src/sdam/srv_polling'); const { getSymbolFrom } = require('../../tools/utils'); const { LEGACY_NOT_WRITABLE_PRIMARY_ERROR_MESSAGE } = require('../../../src/error'); +const { makeClientMetadata } = require('../../../src/cmap/handshake/client_metadata'); describe('Topology (unit)', function () { let client, topology;