Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(NODE-5968): Container and Kubernetes Awareness #4005

Merged
merged 16 commits into from
Mar 6, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/cmap/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
type ConnectionOptions,
CryptoConnection
} from './connection';
import type { ClientMetadata } from './handshake/client_metadata';
import { addContainerMetadata } from './handshake/client_metadata';
import {
MAX_SUPPORTED_SERVER_VERSION,
MAX_SUPPORTED_WIRE_VERSION,
Expand Down Expand Up @@ -183,7 +183,7 @@ export interface HandshakeDocument extends Document {
ismaster?: boolean;
hello?: boolean;
helloOk?: boolean;
client: ClientMetadata;
client: Document;
baileympearson marked this conversation as resolved.
Show resolved Hide resolved
compression: string[];
saslSupportedMechs?: string;
loadBalanced?: boolean;
Expand All @@ -200,11 +200,12 @@ export async function prepareHandshakeDocument(
const options = authContext.options;
const compressors = options.compressors ? options.compressors : [];
const { serverApi } = authContext.connection;
const clientMetadata = await addContainerMetadata(options.metadata);
baileympearson marked this conversation as resolved.
Show resolved Hide resolved

const handshakeDoc: HandshakeDocument = {
[serverApi?.version || options.loadBalanced === true ? 'hello' : LEGACY_HELLO_COMMAND]: 1,
helloOk: true,
client: options.metadata,
client: clientMetadata,
compression: compressors
};

Expand Down
63 changes: 59 additions & 4 deletions src/cmap/handshake/client_metadata.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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;
});
}
}

Expand Down Expand Up @@ -152,8 +153,62 @@ export function makeClientMetadata(options: MakeClientMetadataOptions): ClientMe
}
}
}
return metadataDocument.toObject() as ClientMetadata;
}

let isDocker: boolean;
let dockerPromise: Promise<void>;
/** @internal */
async function getContainerMetadata() {
const containerMetadata: Record<string, any> = {};
if (isDocker == null) {
dockerPromise ??= fs.access('/.dockerenv');
try {
await dockerPromise;
isDocker = true;
} catch {
isDocker = false;
}
}
baileympearson marked this conversation as resolved.
Show resolved Hide resolved

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') {
nbbeeken marked this conversation as resolved.
Show resolved Hide resolved
extendedMetadata.ifItFitsItSits(key, val);
} else {
if (!extendedMetadata.ifItFitsItSits('env', extendedEnvMetadata)) {
// add in old data if newer / extended metadata does not fit
extendedMetadata.ifItFitsItSits('env', val);
}
}
}

if (!('env' in originalMetadata)) {
extendedMetadata.ifItFitsItSits('env', extendedEnvMetadata);
}

return metadataDocument.toObject();
return extendedMetadata.toObject();
}

/**
Expand Down
121 changes: 115 additions & 6 deletions test/unit/cmap/connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,118 @@ describe('Connect Tests', function () {
expect(error).to.be.instanceOf(MongoNetworkError);
});

context('prepareHandshakeDocument', () => {
describe('prepareHandshakeDocument', () => {
describe('client environment (containers and FAAS)', () => {
const cachedEnv = process.env;

context('when only kubernetes is present', () => {
const authContext = {
connection: {},
options: { ...CONNECT_DEFAULTS }
};

beforeEach(() => {
process.env.KUBERNETES_SERVICE_HOST = 'I exist';
});

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 include { orchestrator: 'kubernetes'} in client.env.container`, async () => {
nbbeeken marked this conversation as resolved.
Show resolved Hide resolved
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 kubernetes and FAAS are both present', () => {
const authContext = {
connection: {},
options: { ...CONNECT_DEFAULTS, metadata: { env: { name: 'aws.lambda' } } }
};

beforeEach(() => {
process.env.KUBERNETES_SERVICE_HOST = 'I exist';
});

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 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 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');
});
});
});
});
nbbeeken marked this conversation as resolved.
Show resolved Hide resolved

context('when serverApi.version is present', () => {
const options = {};
const options = { ...CONNECT_DEFAULTS };
const authContext = {
connection: { serverApi: { version: '1' } },
options
Expand All @@ -200,7 +309,7 @@ describe('Connect Tests', function () {
});

context('when serverApi is not present', () => {
const options = {};
const options = { ...CONNECT_DEFAULTS };
const authContext = {
connection: {},
options
Expand All @@ -216,7 +325,7 @@ describe('Connect Tests', function () {
context('when loadBalanced is not set as an option', () => {
const authContext = {
connection: {},
options: {}
options: { ...CONNECT_DEFAULTS }
};

it('does not set loadBalanced on the handshake document', async () => {
Expand All @@ -238,7 +347,7 @@ describe('Connect Tests', function () {
context('when loadBalanced is set to false', () => {
const authContext = {
connection: {},
options: { loadBalanced: false }
options: { ...CONNECT_DEFAULTS, loadBalanced: false }
};

it('does not set loadBalanced on the handshake document', async () => {
Expand All @@ -260,7 +369,7 @@ describe('Connect Tests', function () {
context('when loadBalanced is set to true', () => {
const authContext = {
connection: {},
options: { loadBalanced: true }
options: { ...CONNECT_DEFAULTS, loadBalanced: true }
};

it('sets loadBalanced on the handshake document', async () => {
Expand Down
Loading