Skip to content

Commit 55c290a

Browse files
authoredMar 8, 2024
feat(NODE-5967): container and Kubernetes awareness in client metadata (#4011)
1 parent 5732b52 commit 55c290a

File tree

10 files changed

+243
-13
lines changed

10 files changed

+243
-13
lines changed
 

‎src/cmap/connect.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import {
2525
type ConnectionOptions,
2626
CryptoConnection
2727
} from './connection';
28-
import type { ClientMetadata } from './handshake/client_metadata';
2928
import {
3029
MAX_SUPPORTED_SERVER_VERSION,
3130
MAX_SUPPORTED_WIRE_VERSION,
@@ -180,7 +179,7 @@ export interface HandshakeDocument extends Document {
180179
ismaster?: boolean;
181180
hello?: boolean;
182181
helloOk?: boolean;
183-
client: ClientMetadata;
182+
client: Document;
184183
compression: string[];
185184
saslSupportedMechs?: string;
186185
loadBalanced?: boolean;
@@ -197,11 +196,12 @@ export async function prepareHandshakeDocument(
197196
const options = authContext.options;
198197
const compressors = options.compressors ? options.compressors : [];
199198
const { serverApi } = authContext.connection;
199+
const clientMetadata = await options.extendedMetadata;
200200

201201
const handshakeDoc: HandshakeDocument = {
202202
[serverApi?.version ? 'hello' : LEGACY_HELLO_COMMAND]: 1,
203203
helloOk: true,
204-
client: options.metadata,
204+
client: clientMetadata,
205205
compression: compressors
206206
};
207207

‎src/cmap/connection.ts

+2
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,8 @@ export interface ConnectionOptions
133133
socketTimeoutMS?: number;
134134
cancellationToken?: CancellationToken;
135135
metadata: ClientMetadata;
136+
/** @internal */
137+
extendedMetadata: Promise<Document>;
136138
}
137139

138140
/** @internal */

‎src/cmap/handshake/client_metadata.ts

+55-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import { promises as fs } from 'fs';
12
import * as os from 'os';
23
import * as process from 'process';
34

4-
import { BSON, Int32 } from '../../bson';
5+
import { BSON, type Document, Int32 } from '../../bson';
56
import { MongoInvalidArgumentError } from '../../error';
67
import type { MongoOptions } from '../../mongo_client';
78

@@ -71,13 +72,13 @@ export class LimitedSizeDocument {
7172
return true;
7273
}
7374

74-
toObject(): ClientMetadata {
75+
toObject(): Document {
7576
return BSON.deserialize(BSON.serialize(this.document), {
7677
promoteLongs: false,
7778
promoteBuffers: false,
7879
promoteValues: false,
7980
useBigInt64: false
80-
}) as ClientMetadata;
81+
});
8182
}
8283
}
8384

@@ -153,7 +154,57 @@ export function makeClientMetadata(options: MakeClientMetadataOptions): ClientMe
153154
}
154155
}
155156

156-
return metadataDocument.toObject();
157+
return metadataDocument.toObject() as ClientMetadata;
158+
}
159+
160+
let dockerPromise: Promise<boolean>;
161+
/** @internal */
162+
async function getContainerMetadata() {
163+
const containerMetadata: Record<string, any> = {};
164+
dockerPromise ??= fs.access('/.dockerenv').then(
165+
() => true,
166+
() => false
167+
);
168+
const isDocker = await dockerPromise;
169+
170+
const { KUBERNETES_SERVICE_HOST = '' } = process.env;
171+
const isKubernetes = KUBERNETES_SERVICE_HOST.length > 0 ? true : false;
172+
173+
if (isDocker) containerMetadata.runtime = 'docker';
174+
if (isKubernetes) containerMetadata.orchestrator = 'kubernetes';
175+
176+
return containerMetadata;
177+
}
178+
179+
/**
180+
* @internal
181+
* Re-add each metadata value.
182+
* Attempt to add new env container metadata, but keep old data if it does not fit.
183+
*/
184+
export async function addContainerMetadata(originalMetadata: ClientMetadata) {
185+
const containerMetadata = await getContainerMetadata();
186+
if (Object.keys(containerMetadata).length === 0) return originalMetadata;
187+
188+
const extendedMetadata = new LimitedSizeDocument(512);
189+
190+
const extendedEnvMetadata = { ...originalMetadata?.env, container: containerMetadata };
191+
192+
for (const [key, val] of Object.entries(originalMetadata)) {
193+
if (key !== 'env') {
194+
extendedMetadata.ifItFitsItSits(key, val);
195+
} else {
196+
if (!extendedMetadata.ifItFitsItSits('env', extendedEnvMetadata)) {
197+
// add in old data if newer / extended metadata does not fit
198+
extendedMetadata.ifItFitsItSits('env', val);
199+
}
200+
}
201+
}
202+
203+
if (!('env' in originalMetadata)) {
204+
extendedMetadata.ifItFitsItSits('env', extendedEnvMetadata);
205+
}
206+
207+
return extendedMetadata.toObject();
157208
}
158209

159210
/**

‎src/connection_string.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { URLSearchParams } from 'url';
66
import type { Document } from './bson';
77
import { MongoCredentials } from './cmap/auth/mongo_credentials';
88
import { AUTH_MECHS_AUTH_SRC_EXTERNAL, AuthMechanism } from './cmap/auth/providers';
9-
import { makeClientMetadata } from './cmap/handshake/client_metadata';
9+
import { addContainerMetadata, makeClientMetadata } from './cmap/handshake/client_metadata';
1010
import { Compressor, type CompressorName } from './cmap/wire_protocol/compression';
1111
import { Encrypter } from './encrypter';
1212
import {
@@ -550,6 +550,9 @@ export function parseOptions(
550550
);
551551

552552
mongoOptions.metadata = makeClientMetadata(mongoOptions);
553+
mongoOptions.extendedMetadata = addContainerMetadata(mongoOptions.metadata).catch(() => {
554+
/* rejections will be handled later */
555+
});
553556

554557
return mongoOptions;
555558
}

‎src/mongo_client.ts

+2
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,8 @@ export interface MongoOptions
757757
writeConcern: WriteConcern;
758758
dbName: string;
759759
metadata: ClientMetadata;
760+
/** @internal */
761+
extendedMetadata: Promise<Document>;
760762
/**
761763
* @deprecated This option will be removed in the next major version.
762764
*/

‎src/sdam/topology.ts

+1
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ export interface TopologyOptions extends BSONSerializeOptions, ServerOptions {
143143
directConnection: boolean;
144144
loadBalanced: boolean;
145145
metadata: ClientMetadata;
146+
extendedMetadata: Promise<Document>;
146147
/** MongoDB server API version */
147148
serverApi?: ServerApi;
148149
[featureFlag: symbol]: any;

‎test/integration/connection-monitoring-and-pooling/connection.test.ts

+7-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { expect } from 'chai';
22

33
import {
4+
addContainerMetadata,
45
connect,
56
Connection,
67
type ConnectionOptions,
@@ -36,7 +37,8 @@ describe('Connection', function () {
3637
const connectOptions: Partial<ConnectionOptions> = {
3738
connectionType: Connection,
3839
...this.configuration.options,
39-
metadata: makeClientMetadata({ driverInfo: {} })
40+
metadata: makeClientMetadata({ driverInfo: {} }),
41+
extendedMetadata: addContainerMetadata(makeClientMetadata({ driverInfo: {} }))
4042
};
4143

4244
connect(connectOptions as any as ConnectionOptions, (err, conn) => {
@@ -60,7 +62,8 @@ describe('Connection', function () {
6062
connectionType: Connection,
6163
monitorCommands: true,
6264
...this.configuration.options,
63-
metadata: makeClientMetadata({ driverInfo: {} })
65+
metadata: makeClientMetadata({ driverInfo: {} }),
66+
extendedMetadata: addContainerMetadata(makeClientMetadata({ driverInfo: {} }))
6467
};
6568

6669
connect(connectOptions as any as ConnectionOptions, (err, conn) => {
@@ -92,7 +95,8 @@ describe('Connection', function () {
9295
const connectOptions: Partial<ConnectionOptions> = {
9396
connectionType: Connection,
9497
...this.configuration.options,
95-
metadata: makeClientMetadata({ driverInfo: {} })
98+
metadata: makeClientMetadata({ driverInfo: {} }),
99+
extendedMetadata: addContainerMetadata(makeClientMetadata({ driverInfo: {} }))
96100
};
97101

98102
connect(connectOptions as any as ConnectionOptions, (err, conn) => {

‎test/tools/cmap_spec_runner.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { clearTimeout, setTimeout } from 'timers';
44
import { promisify } from 'util';
55

66
import {
7+
addContainerMetadata,
78
CMAP_EVENTS,
89
type Connection,
910
ConnectionPool,
@@ -371,6 +372,7 @@ async function runCmapTest(test: CmapTest, threadContext: ThreadContext) {
371372
}
372373

373374
const metadata = makeClientMetadata({ appName: poolOptions.appName, driverInfo: {} });
375+
const extendedMetadata = addContainerMetadata(metadata);
374376
delete poolOptions.appName;
375377

376378
const operations = test.operations;
@@ -382,7 +384,12 @@ async function runCmapTest(test: CmapTest, threadContext: ThreadContext) {
382384
const mainThread = threadContext.getThread(MAIN_THREAD_KEY);
383385
mainThread.start();
384386

385-
threadContext.createPool({ ...poolOptions, metadata, minPoolSizeCheckFrequencyMS });
387+
threadContext.createPool({
388+
...poolOptions,
389+
metadata,
390+
extendedMetadata,
391+
minPoolSizeCheckFrequencyMS
392+
});
386393
// yield control back to the event loop so that the ConnectionPoolCreatedEvent
387394
// has a chance to be fired before any synchronously-emitted events from
388395
// the queued operations

‎test/unit/cmap/connect.test.ts

+158-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { expect } from 'chai';
22
import { promisify } from 'util';
33

44
import {
5+
addContainerMetadata,
56
CancellationToken,
67
type ClientMetadata,
78
connect,
@@ -24,6 +25,7 @@ const CONNECT_DEFAULTS = {
2425
generation: 1,
2526
monitorCommands: false,
2627
metadata: {} as ClientMetadata,
28+
extendedMetadata: addContainerMetadata({} as ClientMetadata),
2729
loadBalanced: false
2830
};
2931

@@ -207,7 +209,162 @@ describe('Connect Tests', function () {
207209
});
208210
});
209211

210-
context('prepareHandshakeDocument', () => {
212+
describe('prepareHandshakeDocument', () => {
213+
describe('client environment (containers and FAAS)', () => {
214+
const cachedEnv = process.env;
215+
216+
context('when only kubernetes is present', () => {
217+
let authContext;
218+
219+
beforeEach(() => {
220+
process.env.KUBERNETES_SERVICE_HOST = 'I exist';
221+
authContext = {
222+
connection: {},
223+
options: {
224+
...CONNECT_DEFAULTS,
225+
extendedMetadata: addContainerMetadata({} as ClientMetadata)
226+
}
227+
};
228+
});
229+
230+
afterEach(() => {
231+
if (cachedEnv.KUBERNETES_SERVICE_HOST != null) {
232+
process.env.KUBERNETES_SERVICE_HOST = cachedEnv.KUBERNETES_SERVICE_HOST;
233+
} else {
234+
delete process.env.KUBERNETES_SERVICE_HOST;
235+
}
236+
authContext = {};
237+
});
238+
239+
it(`should include { orchestrator: 'kubernetes'} in client.env.container`, async () => {
240+
const handshakeDocument = await prepareHandshakeDocument(authContext);
241+
expect(handshakeDocument.client.env.container.orchestrator).to.equal('kubernetes');
242+
});
243+
244+
it(`should not have 'name' property in client.env `, async () => {
245+
const handshakeDocument = await prepareHandshakeDocument(authContext);
246+
expect(handshakeDocument.client.env).to.not.have.property('name');
247+
});
248+
249+
context('when 512 byte size limit is exceeded', async () => {
250+
it(`should not 'env' property in client`, async () => {
251+
// make metadata = 507 bytes, so it takes up entire LimitedSizeDocument
252+
const longAppName = 's'.repeat(493);
253+
const longAuthContext = {
254+
connection: {},
255+
options: {
256+
...CONNECT_DEFAULTS,
257+
extendedMetadata: addContainerMetadata({ appName: longAppName })
258+
}
259+
};
260+
const handshakeDocument = await prepareHandshakeDocument(longAuthContext);
261+
expect(handshakeDocument.client).to.not.have.property('env');
262+
});
263+
});
264+
});
265+
266+
context('when kubernetes and FAAS are both present', () => {
267+
let authContext;
268+
269+
beforeEach(() => {
270+
process.env.KUBERNETES_SERVICE_HOST = 'I exist';
271+
authContext = {
272+
connection: {},
273+
options: {
274+
...CONNECT_DEFAULTS,
275+
extendedMetadata: addContainerMetadata({ env: { name: 'aws.lambda' } })
276+
}
277+
};
278+
});
279+
280+
afterEach(() => {
281+
if (cachedEnv.KUBERNETES_SERVICE_HOST != null) {
282+
process.env.KUBERNETES_SERVICE_HOST = cachedEnv.KUBERNETES_SERVICE_HOST;
283+
} else {
284+
delete process.env.KUBERNETES_SERVICE_HOST;
285+
}
286+
authContext = {};
287+
});
288+
289+
it(`should include { orchestrator: 'kubernetes'} in client.env.container`, async () => {
290+
const handshakeDocument = await prepareHandshakeDocument(authContext);
291+
expect(handshakeDocument.client.env.container.orchestrator).to.equal('kubernetes');
292+
});
293+
294+
it(`should still have properly set 'name' property in client.env `, async () => {
295+
const handshakeDocument = await prepareHandshakeDocument(authContext);
296+
expect(handshakeDocument.client.env.name).to.equal('aws.lambda');
297+
});
298+
299+
context('when 512 byte size limit is exceeded', async () => {
300+
it(`should not have 'container' property in client.env`, async () => {
301+
// make metadata = 507 bytes, so it takes up entire LimitedSizeDocument
302+
const longAppName = 's'.repeat(447);
303+
const longAuthContext = {
304+
connection: {},
305+
options: {
306+
...CONNECT_DEFAULTS,
307+
extendedMetadata: {
308+
appName: longAppName,
309+
env: { name: 'aws.lambda' }
310+
} as unknown as Promise<Document>
311+
}
312+
};
313+
const handshakeDocument = await prepareHandshakeDocument(longAuthContext);
314+
expect(handshakeDocument.client.env.name).to.equal('aws.lambda');
315+
expect(handshakeDocument.client.env).to.not.have.property('container');
316+
});
317+
});
318+
});
319+
320+
context('when container nor FAAS env is not present (empty string case)', () => {
321+
const authContext = {
322+
connection: {},
323+
options: { ...CONNECT_DEFAULTS }
324+
};
325+
326+
context('when process.env.KUBERNETES_SERVICE_HOST = undefined', () => {
327+
beforeEach(() => {
328+
delete process.env.KUBERNETES_SERVICE_HOST;
329+
});
330+
331+
afterEach(() => {
332+
afterEach(() => {
333+
if (cachedEnv.KUBERNETES_SERVICE_HOST != null) {
334+
process.env.KUBERNETES_SERVICE_HOST = cachedEnv.KUBERNETES_SERVICE_HOST;
335+
} else {
336+
delete process.env.KUBERNETES_SERVICE_HOST;
337+
}
338+
});
339+
});
340+
341+
it(`should not have 'env' property in client`, async () => {
342+
const handshakeDocument = await prepareHandshakeDocument(authContext);
343+
expect(handshakeDocument.client).to.not.have.property('env');
344+
});
345+
});
346+
347+
context('when process.env.KUBERNETES_SERVICE_HOST is an empty string', () => {
348+
beforeEach(() => {
349+
process.env.KUBERNETES_SERVICE_HOST = '';
350+
});
351+
352+
afterEach(() => {
353+
if (cachedEnv.KUBERNETES_SERVICE_HOST != null) {
354+
process.env.KUBERNETES_SERVICE_HOST = cachedEnv.KUBERNETES_SERVICE_HOST;
355+
} else {
356+
delete process.env.KUBERNETES_SERVICE_HOST;
357+
}
358+
});
359+
360+
it(`should not have 'env' property in client`, async () => {
361+
const handshakeDocument = await prepareHandshakeDocument(authContext);
362+
expect(handshakeDocument.client).to.not.have.property('env');
363+
});
364+
});
365+
});
366+
});
367+
211368
context('when serverApi.version is present', () => {
212369
const options = {
213370
authProviders: new MongoClientAuthProviders()

‎test/unit/cmap/connection_pool.test.js

+3
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ describe('Connection Pool', function () {
2222
},
2323
s: {
2424
authProviders: new MongoClientAuthProviders()
25+
},
26+
options: {
27+
extendedMetadata: {}
2528
}
2629
}
2730
}

0 commit comments

Comments
 (0)
Please sign in to comment.