Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 2 additions & 1 deletion configs/testing-library-compass/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,8 @@ export class MockDataService
},
build: {
isEnterprise: false,
version: '0.0.0',
// Picking a large version to avoid the end-of-life confirmation modal
version: '100.0.0',
},
host: {},
genuineMongoDB: {
Expand Down
6 changes: 5 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion packages/compass-app-stores/src/provider.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@ describe('NamespaceProvider', function () {
const instance = instanceManager.getMongoDBInstanceForConnection();
sandbox.stub(instance, 'fetchDatabases').callsFake(() => {
instance.databases.add({ _id: 'foo' });
return Promise.resolve();
// Wait a tick before resolving the promise to simulate async behavior
return new Promise((resolve) => {
setTimeout(resolve);
});
});

await renderWithActiveConnection(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ describe('InstanceStore [Store]', function () {
const instance = instancesManager.getMongoDBInstanceForConnection(
connectedConnectionInfoId
);
expect(instance).to.have.nested.property('build.version', '0.0.0');
expect(instance).to.have.nested.property('build.version', '100.0.0');
globalAppRegistry.emit('refresh-data');
await waitForInstanceRefresh(instance);
});
Expand Down
4 changes: 3 additions & 1 deletion packages/compass-connections/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@
"react": "^17.0.2",
"react-redux": "^8.1.3",
"redux": "^4.2.1",
"redux-thunk": "^2.4.2"
"redux-thunk": "^2.4.2",
"semver": "^7.6.2"
},
"devDependencies": {
"@mongodb-js/eslint-config-compass": "^1.3.8",
Expand All @@ -82,6 +83,7 @@
"@types/mocha": "^9.0.0",
"@types/react": "^17.0.5",
"@types/react-dom": "^17.0.10",
"@types/semver": "^7.3.9",
"@types/sinon-chai": "^3.2.5",
"chai": "^4.3.4",
"depcheck": "^1.4.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React from 'react';
import {
css,
Banner,
Link,
spacing,
Body,
BannerVariant,
showConfirmation,
} from '@mongodb-js/compass-components';
import {
getConnectionTitle,
type ConnectionInfo,
} from '@mongodb-js/connection-info';

const modalBodyStyles = css({
marginTop: spacing[400],
marginBottom: spacing[200],
});

export function showEndOfLifeMongoDBWarningModal(
connectionInfo?: ConnectionInfo,
version?: string
) {
return showConfirmation({
title: 'End-of-life MongoDB Detected',
hideCancelButton: true,
description: (
<>
<Banner variant={BannerVariant.Warning}>
{connectionInfo
? `Server or service "${getConnectionTitle(connectionInfo)}"`
: 'This server or service'}{' '}
appears to be running a version of MongoDB that is no longer
supported.
</Banner>
<Body className={modalBodyStyles}>
Server version{version ? ` (${version})` : ''} is considered
end-of-life, consider upgrading to get the latest features and
performance improvements.{' '}
</Body>
<Link
href="https://www.mongodb.com/legal/support-policy/lifecycles"
target="_blank"
data-testid="end-of-life-warning-modal-learn-more-link"
>
Learn more from the MongoDB Lifecycle Schedules.
</Link>
</>
),
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export function showNonGenuineMongoDBWarningModal(
) {
return showConfirmation({
title: 'Non-Genuine MongoDB Detected',
hideCancelButton: true,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest hiding the cancel button on this as well, to keep things aligned and because canceling this modal won't stop connecting anyway.

description: (
<>
<Banner variant={BannerVariant.Warning}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,14 @@ import { adjustConnectionOptionsBeforeConnect } from '@mongodb-js/connection-for
import mongodbBuildInfo, { getGenuineMongoDB } from 'mongodb-build-info';
import EventEmitter from 'events';
import { showNonGenuineMongoDBWarningModal as _showNonGenuineMongoDBWarningModal } from '../components/non-genuine-connection-modal';
import { showEndOfLifeMongoDBWarningModal as _showEndOfLifeMongoDBWarningModal } from '../components/end-of-life-connection-modal';
import ConnectionString from 'mongodb-connection-string-url';
import type { ExtraConnectionData as ExtraConnectionDataForTelemetry } from '@mongodb-js/compass-telemetry';
import { connectable } from '../utils/connection-supports';
import {
getLatestEndOfLifeServerVersion,
isEndOfLifeVersion,
} from '../utils/end-of-life-server';

export type ConnectionsEventMap = {
connected: (
Expand Down Expand Up @@ -1818,6 +1823,28 @@ const connectWithOptions = (
.isGenuine === false
) {
dispatch(showNonGenuineMongoDBWarningModal(connectionInfo.id));
} else if (preferences.getPreferences().networkTraffic) {
void dataService
.instance()
.then(async (instance) => {
const { version } = instance.build;
const latestEndOfLifeServerVersion =
await getLatestEndOfLifeServerVersion();
if (isEndOfLifeVersion(version, latestEndOfLifeServerVersion)) {
dispatch(
showEndOfLifeMongoDBWarningModal(
connectionInfo.id,
instance.build.version
)
);
}
})
.catch((err) => {
debug(
'failed to get instance details to determine if the server version is end-of-life',
err
);
});
}
} catch (err) {
dispatch(connectionAttemptError(connectionInfo, err));
Expand Down Expand Up @@ -2142,6 +2169,17 @@ export const showNonGenuineMongoDBWarningModal = (
};
};

export const showEndOfLifeMongoDBWarningModal = (
connectionId: string,
version: string
): ConnectionsThunkAction<void> => {
return (_dispatch, getState, { track }) => {
const connectionInfo = getCurrentConnectionInfo(getState(), connectionId);
track('Screen', { name: 'end_of_life_mongodb_modal' }, connectionInfo);
void _showEndOfLifeMongoDBWarningModal(connectionInfo, version);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a nit, but I'd personally probably have jumped through hoops to come up with two different names where one showEndOfLifeMongoDBWarningModal is the thunk action creator thingy and one is the function from the component. But I can live with this ;)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH, I didn't pay too much attention to naming there as I was mostly copying from the code above:

export const showNonGenuineMongoDBWarningModal = (
connectionId: string
): ConnectionsThunkAction<void> => {
return (_dispatch, getState, { track }) => {
const connectionInfo = getCurrentConnectionInfo(getState(), connectionId);
track('Screen', { name: 'non_genuine_mongodb_modal' }, connectionInfo);
void _showNonGenuineMongoDBWarningModal(connectionInfo);
};
};

};
};

type ImportConnectionsFn = Required<ConnectionStorage>['importConnections'];

export const importConnections = (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { expect } from 'chai';
import { isEndOfLifeVersion } from './end-of-life-server';

describe('isEndOfLifeVersion', function () {
const LATEST_END_OF_LIFE_VERSION = '4.4.x';

function expectVersions(versions: string[], expected: boolean) {
for (const version of versions) {
expect(isEndOfLifeVersion(version, LATEST_END_OF_LIFE_VERSION)).to.equal(
expected,
`Expected ${version} to be ${
expected ? 'end of life' : 'not end of life'
}`
);
}
}

it('returns true for v4.4 and below', () => {
expectVersions(
['4.4.0', '4.3.0', '4.0', '4.0-beta.0', '1.0.0', '0.0.1', '3.999.0'],
true
);
});

it('returns true for v4.5 and above', () => {
expectVersions(
['4.5.0', '5.0.0', '5.0.25', '6.0.0', '7.0.0', '8.0.0'],
false
);
});
});
83 changes: 83 additions & 0 deletions packages/compass-connections/src/utils/end-of-life-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import semverSatisfies from 'semver/functions/satisfies';
import semverCoerce from 'semver/functions/coerce';

import { createLogger } from '@mongodb-js/compass-logging';

const { mongoLogId, log, debug } = createLogger('END-OF-LIFE-SERVER');

const FALLBACK_END_OF_LIFE_SERVER_VERSION = '4.4';
const {
HADRON_AUTO_UPDATE_ENDPOINT = process.env
.HADRON_AUTO_UPDATE_ENDPOINT_OVERRIDE,
} = process.env;

let latestEndOfLifeServerVersion: Promise<string> | null = null;

export async function getLatestEndOfLifeServerVersion(): Promise<string> {
if (!HADRON_AUTO_UPDATE_ENDPOINT) {
log.debug(
mongoLogId(1_001_000_352),
'getLatestEndOfLifeServerVersion',
'HADRON_AUTO_UPDATE_ENDPOINT is not set'
);
return FALLBACK_END_OF_LIFE_SERVER_VERSION;
}

if (!latestEndOfLifeServerVersion) {
// Setting module scoped variable to avoid repeated fetches.
log.debug(
mongoLogId(1_001_000_353),
'getLatestEndOfLifeServerVersion',
'Fetching EOL server version'
);
latestEndOfLifeServerVersion = fetch(
`${HADRON_AUTO_UPDATE_ENDPOINT}/api/v2/eol-server`
)
.then(async (response) => {
if (response.ok) {
const result = await response.text();
log.debug(
mongoLogId(1_001_000_354),
'getLatestEndOfLifeServerVersion',
'Got EOL server version response',
{ result }
);
return result;
} else {
// Reset the cached value to null so that we can try again next time.
latestEndOfLifeServerVersion = null;
throw new Error(
`Expected an OK response, got ${response.status} '${response.statusText}'`
);
}
})
.catch((error) => {
log.error(
mongoLogId(1_001_000_355),
'getLatestEndOfLifeServerVersion',
'Failed to fetch EOL server version',
{ error }
);
// We don't want any downstream code to fail just because we can't fetch the EOL server version.
return FALLBACK_END_OF_LIFE_SERVER_VERSION;
});
}
// Return a cached or in-flight value
return latestEndOfLifeServerVersion;
}

export function isEndOfLifeVersion(
version: string,
latestEndOfLifeServerVersion: string
) {
try {
const coercedVersion = semverCoerce(version);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm trying to understand under which circumstances this would be falsy?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the docs:

Only text which lacks digits will fail coercion (version one is not valid).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But.. will it throw or be falsy? You seem to be checking/accounting for both and it isn't clear to me that both can happen.

Copy link
Contributor Author

@kraenhansen kraenhansen May 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The try-catch is mostly to be extra safe that these calls won't prevent the caller from progressing - if we're unsure, we'll opt on the safe side instead of propagating errors. This also handles if semverSatisfies or any future code added to the block throws for some reason.

The check for falsy coercedVersion is the known case of a malformed server version. This is mostly to handle that we might fail to determine a non-genuine server with a malformed version or a future MongoDB version which is not able to coerce, for some unknown reason.

return coercedVersion
? semverSatisfies(coercedVersion, `<=${latestEndOfLifeServerVersion}`)
: false;
} catch (error) {
debug('Error comparing versions', { error });
// If the version is not a valid semver, we can't reliably determine if it's EOL
return false;
}
}
3 changes: 2 additions & 1 deletion packages/compass-telemetry/src/telemetry-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2653,7 +2653,8 @@ type ScreenEvent = ConnectionScopedEvent<{
| 'restore_pipeline_modal'
| 'save_pipeline_modal'
| 'shell_info_modal'
| 'update_search_index_modal';
| 'update_search_index_modal'
| 'end_of_life_mongodb_modal';
};
}>;

Expand Down
4 changes: 3 additions & 1 deletion packages/data-service/src/instance-detail-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,9 @@ function adaptHostInfo(rawHostInfo: Partial<HostInfo>): HostInfoDetails {
};
}

function adaptBuildInfo(rawBuildInfo: Partial<BuildInfo>) {
export function adaptBuildInfo(
rawBuildInfo: Partial<BuildInfo>
): BuildInfoDetails {
return {
version: rawBuildInfo.version ?? '',
// Cover both cases of detecting enterprise module, see SERVER-18099.
Expand Down
Loading