diff --git a/src/cmap/connect.ts b/src/cmap/connect.ts index 7b2866adf2..7789527f9d 100644 --- a/src/cmap/connect.ts +++ b/src/cmap/connect.ts @@ -17,7 +17,7 @@ import { MongoRuntimeError, 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 { CommandOptions, 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 464f86d9b3..66e80aa95b 100644 --- a/src/cmap/connection.ts +++ b/src/cmap/connection.ts @@ -29,7 +29,6 @@ import { applySession, ClientSession, updateSessionFromResponse } from '../sessi import { calculateDurationInMs, Callback, - ClientMetadata, HostAddress, maxWireVersion, MongoDBNamespace, @@ -46,6 +45,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..c19e8b3776 --- /dev/null +++ b/src/cmap/handshake/client_metadata.ts @@ -0,0 +1,112 @@ +import { calculateObjectSize, Int32 } from 'bson'; +import * as os from 'os'; + +import type { MongoOptions } from '../../mongo_client'; +import { applyFaasEnvMetadata } from './faas_provider'; + +/** + * @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; + }; + + /** Data containing information about the environment, if the driver is running in a FAAS environment. */ + env?: { + name: 'aws.lambda' | 'gcp.func' | 'azure.func' | 'vercel'; + timeout_sec?: Int32; + memory_mb?: Int32; + region?: string; + url?: string; + }; +} + +/** + * @internal + * truncates the client metadata according to the priority outlined here + * https://github.com/mongodb/specifications/blob/master/source/mongodb-handshake/handshake.rst#limitations + */ +export function truncateClientMetadata(metadata: ClientMetadata): ClientMetadata { + if (calculateObjectSize(metadata) <= 512) { + return metadata; + } + // 1. Truncate ``platform``. + // no-op - we don't truncate because the `platform` field is essentially a fixed length in Node + // and there isn't anything we can truncate that without removing useful information. + + // 2. Omit fields from ``env`` except ``env.name``. + if (metadata.env) { + metadata.env = { name: metadata.env.name }; + } + if (calculateObjectSize(metadata) <= 512) { + return metadata; + } + + // 3. Omit the ``env`` document entirely. + delete metadata.env; + + return metadata; +} + +/** @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 truncateClientMetadata(applyFaasEnvMetadata(metadata)); +} diff --git a/src/cmap/handshake/faas_provider.ts b/src/cmap/handshake/faas_provider.ts new file mode 100644 index 0000000000..c1bfdc9ad1 --- /dev/null +++ b/src/cmap/handshake/faas_provider.ts @@ -0,0 +1,93 @@ +import { Int32 } from 'bson'; + +import { identity } from '../../utils'; +import type { ClientMetadata } from './client_metadata'; + +export type FAASProvider = 'aws' | 'gcp' | 'azure' | 'vercel' | 'none'; + +function isNonEmptyString(s: string | undefined): s is string { + return typeof s === 'string' && s.length > 0; +} + +export function determineFAASProvider(): FAASProvider { + const awsPresent = + isNonEmptyString(process.env.AWS_EXECUTION_ENV) || + isNonEmptyString(process.env.AWS_LAMBDA_RUNTIME_API); + const azurePresent = isNonEmptyString(process.env.FUNCTIONS_WORKER_RUNTIME); + const gcpPresent = + isNonEmptyString(process.env.K_SERVICE) || isNonEmptyString(process.env.FUNCTION_NAME); + const vercelPresent = isNonEmptyString(process.env.VERCEL); + + const numberOfProvidersPresent = [awsPresent, azurePresent, gcpPresent, vercelPresent].filter( + identity + ).length; + + if (numberOfProvidersPresent !== 1) { + return 'none'; + } + + if (awsPresent) return 'aws'; + if (azurePresent) return 'azure'; + if (gcpPresent) return 'gcp'; + return 'vercel'; +} + +function applyAzureMetadata(m: ClientMetadata): ClientMetadata { + m.env = { name: 'azure.func' }; + return m; +} + +function applyGCPMetadata(m: ClientMetadata): ClientMetadata { + m.env = { name: 'gcp.func' }; + + const memory_mb = Number(process.env.FUNCTION_MEMORY_MB); + if (Number.isInteger(memory_mb)) { + m.env.memory_mb = new Int32(memory_mb); + } + const timeout_sec = Number(process.env.FUNCTION_TIMEOUT_SEC); + if (Number.isInteger(timeout_sec)) { + m.env.timeout_sec = new Int32(timeout_sec); + } + if (isNonEmptyString(process.env.FUNCTION_REGION)) { + m.env.region = process.env.FUNCTION_REGION; + } + + return m; +} + +function applyAWSMetadata(m: ClientMetadata): ClientMetadata { + m.env = { name: 'aws.lambda' }; + if (isNonEmptyString(process.env.AWS_REGION)) { + m.env.region = process.env.AWS_REGION; + } + const memory_mb = Number(process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE); + if (Number.isInteger(memory_mb)) { + m.env.memory_mb = new Int32(memory_mb); + } + return m; +} + +function applyVercelMetadata(m: ClientMetadata): ClientMetadata { + m.env = { name: 'vercel' }; + if (isNonEmptyString(process.env.VERCEL_URL)) { + m.env.url = process.env.VERCEL_URL; + } + if (isNonEmptyString(process.env.VERCEL_REGION)) { + m.env.region = process.env.VERCEL_REGION; + } + return m; +} + +export function applyFaasEnvMetadata(metadata: ClientMetadata): ClientMetadata { + const handlerMap: Record ClientMetadata> = { + aws: applyAWSMetadata, + gcp: applyGCPMetadata, + azure: applyAzureMetadata, + vercel: applyVercelMetadata, + none: identity + }; + const faasProvider = determineFAASProvider(); + + const faasMetadataProvider = handlerMap[faasProvider]; + return faasMetadataProvider(metadata); +} diff --git a/src/connection_string.ts b/src/connection_string.ts index 9b55437273..c7ba4e028c 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 { @@ -32,7 +33,6 @@ import { emitWarningOnce, HostAddress, isRecord, - makeClientMetadata, parseInteger, setDifference } from './utils'; diff --git a/src/index.ts b/src/index.ts index fac3c9c95b..057047684e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -238,6 +238,7 @@ export type { WaitQueueMember, WithConnectionCallback } from './cmap/connection_pool'; +export type { ClientMetadata, ClientMetadataOptions } from './cmap/handshake/client_metadata'; export type { MessageStream, MessageStreamOptions, @@ -463,8 +464,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 885c980fbf..21bf61618a 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'; @@ -24,7 +25,7 @@ import { readPreferenceServerSelector } from './sdam/server_selection'; import type { SrvPoller } from './sdam/srv_polling'; import { Topology, TopologyEvents } from './sdam/topology'; import { ClientSession, ClientSessionOptions, ServerSessionPool } from './sessions'; -import { ClientMetadata, HostAddress, MongoDBNamespace, ns, resolveOptions } from './utils'; +import { HostAddress, MongoDBNamespace, ns, resolveOptions } from './utils'; import type { W, WriteConcern, WriteConcernSettings } from './write_concern'; /** @public */ @@ -717,6 +718,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 f545cb8847..9a4629b50e 100644 --- a/src/sdam/topology.ts +++ b/src/sdam/topology.ts @@ -5,6 +5,7 @@ import type { BSONSerializeOptions, Document } 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, @@ -37,7 +38,6 @@ import type { ClientSession } from '../sessions'; import type { Transaction } from '../transactions'; import { Callback, - ClientMetadata, EventEmitterWithState, HostAddress, List, @@ -138,7 +138,6 @@ export interface TopologyOptions extends BSONSerializeOptions, ServerOptions { /** The name of the replica set to connect to */ replicaSet?: string; srvHost?: string; - /** @internal */ srvPoller?: SrvPoller; /** Indicates that a client should directly connect to a node without attempting to discover its topology type */ directConnection: boolean; @@ -146,7 +145,6 @@ export interface TopologyOptions extends BSONSerializeOptions, ServerOptions { metadata: ClientMetadata; /** MongoDB server API version */ serverApi?: ServerApi; - /** @internal */ [featureFlag: symbol]: any; } diff --git a/src/utils.ts b/src/utils.ts index dee4bc51ae..7434c7e659 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 { ReadConcern } from './read_concern'; @@ -513,77 +512,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(); @@ -1277,3 +1205,12 @@ export function parseUnsignedInteger(value: unknown): number | null { return parsedInt != null && parsedInt >= 0 ? parsedInt : null; } + +/** + * returns the object that was provided + * + * @internal + */ +export function identity(obj: T): T { + return obj; +} diff --git a/test/integration/mongodb-handshake/.gitkeep b/test/integration/mongodb-handshake/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 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..9f81bf78d9 --- /dev/null +++ b/test/integration/mongodb-handshake/mongodb-handshake.prose.test.ts @@ -0,0 +1,109 @@ +import { expect } from 'chai'; + +import { determineFAASProvider, FAASProvider, MongoClient } from '../../mongodb'; + +context('FAAS Environment Prose Tests', function () { + let client: MongoClient; + + afterEach(async function () { + await client?.close(); + }); + + type EnvironmentVariables = Array<[string, string]>; + const tests: Array<{ + context: string; + expectedProvider: FAASProvider; + env: EnvironmentVariables; + }> = [ + { + context: '1. Valid AWS', + expectedProvider: 'aws', + env: [ + ['AWS_EXECUTION_ENV', 'AWS_Lambda_java8'], + ['AWS_REGION', 'us-east-2'], + ['AWS_LAMBDA_FUNCTION_MEMORY_SIZE', '1024'] + ] + }, + { + context: '2. Valid Azure', + expectedProvider: 'azure', + env: [['FUNCTIONS_WORKER_RUNTIME', 'node']] + }, + { + context: '3. Valid GCP', + expectedProvider: 'gcp', + 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_URL', '*.vercel.app'], + ['VERCEL_REGION', 'cdg1'] + ] + }, + { + expectedProvider: 'none', + context: '5. Invalid - multiple providers', + env: [ + ['AWS_EXECUTION_ENV', 'AWS_Lambda_java8'], + ['FUNCTIONS_WORKER_RUNTIME', 'node'] + ] + }, + { + expectedProvider: 'aws', + context: '6. Invalid - long string', + env: [ + ['AWS_EXECUTION_ENV', 'AWS_Lambda_java8'], + ['AWS_REGION', 'a'.repeat(1024)] + ] + }, + { + expectedProvider: 'aws', + 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(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const [key, _] of env) { + delete process.env[key]; + } + }); + + before(`metadata confirmation test for ${name}`, function () { + expect(determineFAASProvider()).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/mongodb.ts b/test/mongodb.ts index dee4e204a3..79f621d496 100644 --- a/test/mongodb.ts +++ b/test/mongodb.ts @@ -122,6 +122,8 @@ export * from '../src/cmap/connection'; export * from '../src/cmap/connection_pool'; export * from '../src/cmap/connection_pool_events'; export * from '../src/cmap/errors'; +export * from '../src/cmap/handshake/client_metadata'; +export * from '../src/cmap/handshake/faas_provider'; export * from '../src/cmap/message_stream'; export * from '../src/cmap/metrics'; export * from '../src/cmap/stream_description'; diff --git a/test/tools/cmap_spec_runner.ts b/test/tools/cmap_spec_runner.ts index 7f21a8bc34..6292eaae77 100644 --- a/test/tools/cmap_spec_runner.ts +++ b/test/tools/cmap_spec_runner.ts @@ -370,7 +370,10 @@ async function runCmapTest(test: CmapTest, threadContext: ThreadContext) { delete poolOptions.backgroundThreadIntervalMS; } - const metadata = makeClientMetadata({ appName: poolOptions.appName, driverInfo: {} }); + const metadata = makeClientMetadata({ + appName: poolOptions.appName, + driverInfo: {} + }); delete poolOptions.appName; const operations = test.operations; @@ -382,7 +385,11 @@ 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, + 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/handshake/client_metadata.test.ts b/test/unit/cmap/handshake/client_metadata.test.ts index f75c9cecfe..42d9cf5ca7 100644 --- a/test/unit/cmap/handshake/client_metadata.test.ts +++ b/test/unit/cmap/handshake/client_metadata.test.ts @@ -1,150 +1,437 @@ import { expect } from 'chai'; import * as os from 'os'; -import { makeClientMetadata } from '../../../mongodb'; +import { + ClientMetadata, + determineFAASProvider, + FAASProvider, + Int32, + makeClientMetadata, + truncateClientMetadata +} from '../../../mongodb'; // 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` +describe('client metadata module', () => { + describe('determineCloudProvider()', function () { + const tests: Array<[string, FAASProvider]> = [ + ['AWS_EXECUTION_ENV', 'aws'], + ['AWS_LAMBDA_RUNTIME_API', 'aws'], + ['FUNCTIONS_WORKER_RUNTIME', 'azure'], + ['K_SERVICE', 'gcp'], + ['FUNCTION_NAME', 'gcp'], + ['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(determineFAASProvider()).to.equal(provider); + }); }); - }); - }); + } - 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()}` + context('when there is no FAAS provider data in the env', () => { + it('parses no FAAS provider', () => { + expect(determineFAASProvider()).to.equal('none'); }); }); - }); - 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', () => { + before(() => { + process.env.AWS_EXECUTION_ENV = 'non-empty-string'; + process.env.FUNCTIONS_WORKER_RUNTIME = 'non-empty-string'; + }); + after(() => { + delete process.env.AWS_EXECUTION_ENV; + delete process.env.FUNCTIONS_WORKER_RUNTIME; + }); + it('parses no FAAS provider', () => { + expect(determineFAASProvider()).to.equal('none'); }); }); }); - 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()}` + }); + }); + }); + 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` + }); + }); + }); - 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.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()}` + }); }); }); - it('does not set the application field', () => { - expect(metadata).not.to.have.property('application'); + 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 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 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' + } + } + ], + vercel: [ + { + context: 'no additional metadata', + env: [['VERCEL', 'non-empty']], + outcome: { + name: 'vercel' + } + }, + { + context: 'VERCEL_URL provided', + env: [ + ['VERCEL', 'non-empty'], + ['VERCEL_URL', 'provided-url'] + ], + outcome: { + name: 'vercel', + url: 'provided-url' + } + }, + { + context: 'VERCEL_REGION provided', + env: [ + ['VERCEL', 'non-empty'], + ['VERCEL_REGION', 'region'] + ], + outcome: { + name: 'vercel', + region: 'region' + } + } + ] + }; + + for (const [provider, _tests] of Object.entries(tests)) { + context(provider, () => { + for (const { context, env: _env, outcome } of _tests) { + it(context, () => { + for (const [k, v] of _env) { + if (v != null) { + process.env[k] = v; + } + } + + const { env } = makeClientMetadata({ driverInfo: {} }); + expect(env).to.deep.equal(outcome); + + for (const [k] of _env) { + delete process.env[k]; + } + }); + } + }); + } + + 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'] = 'not numeric'; + }); - context('when the app name is under 128 bytes', () => { - const options = { - appName: 'myApplication', - driverInfo: {} - }; - const metadata = makeClientMetadata(options); + after(() => { + delete process.env['AWS_EXECUTION_ENV']; + delete process.env['AWS_LAMBDA_FUNCTION_MEMORY_SIZE']; + }); - it('sets the application name to the value', () => { - expect(metadata.application?.name).to.equal('myApplication'); + it('does not attach it to the metadata', () => { + expect(makeClientMetadata({ driverInfo: {} })).not.to.have.nested.property('aws.memory_mb'); }); }); }); + + describe('metadata truncation', function () { + const longDocument = 'a'.repeat(512); + + const tests: Array<[string, ClientMetadata, ClientMetadata]> = [ + [ + 'removes extra fields in `env` first', + { + driver: { name: 'nodejs', version: '5.1.0' }, + os: { + type: 'Darwin', + name: 'darwin', + architecture: 'x64', + version: '21.6.0' + }, + platform: 'Node.js v16.17.0, LE', + application: { name: 'applicationName' }, + env: { name: 'aws.lambda', region: longDocument } + }, + { + driver: { name: 'nodejs', version: '5.1.0' }, + os: { + type: 'Darwin', + name: 'darwin', + architecture: 'x64', + version: '21.6.0' + }, + platform: 'Node.js v16.17.0, LE', + application: { name: 'applicationName' }, + env: { name: 'aws.lambda' } + } + ], + [ + 'removes `env` entirely next', + { + driver: { name: 'nodejs', version: '5.1.0' }, + os: { + type: 'Darwin', + name: 'darwin', + architecture: 'x64', + version: '21.6.0' + }, + platform: 'Node.js v16.17.0, LE', + application: { name: 'applicationName' }, + env: { + name: longDocument as any + } + }, + { + driver: { name: 'nodejs', version: '5.1.0' }, + os: { + type: 'Darwin', + name: 'darwin', + architecture: 'x64', + version: '21.6.0' + }, + application: { name: 'applicationName' }, + platform: 'Node.js v16.17.0, LE' + } + ] + ]; + + for (const [description, input, expected] of tests) { + it(description, function () { + expect(truncateClientMetadata(input)).to.deep.equal(expected); + }); + } + }); });