From a1d78d9e601c2fc65ad3577b2f299654745d5d09 Mon Sep 17 00:00:00 2001 From: Sergey Petushkov Date: Mon, 30 Sep 2024 16:38:37 +0200 Subject: [PATCH] chore(compass-web): refactor sandbox to better handle cert issuing logic (#6295) * chore(compass-web): refactor sandbox to better handle cert issuing logic * chore(web): extend connection timeouts for web sandbox in atlas mode * chore(web): adjust the logic that waits for the compass web server to be ready * chore(web): fix typo * chore(web): electron.dock might be undefined * chore: restore accidentally removed else * chore: error logging and typos * chore: remove unnecessary eslint ignores --- configs/webpack-config-compass/src/index.ts | 1 + packages/compass-e2e-tests/index.ts | 40 ++- packages/compass-web/package.json | 2 +- packages/compass-web/sandbox/index.tsx | 7 + .../compass-web/scripts/electron-proxy.js | 330 +++++++++++++----- .../scripts/start-electron-proxy.js | 16 - packages/compass-web/scripts/ws-proxy.js | 6 +- .../compass-web/src/connection-storage.tsx | 48 ++- packages/compass-web/webpack.config.js | 10 +- .../src/components/workspaces.tsx | 2 +- 10 files changed, 328 insertions(+), 134 deletions(-) delete mode 100644 packages/compass-web/scripts/start-electron-proxy.js diff --git a/configs/webpack-config-compass/src/index.ts b/configs/webpack-config-compass/src/index.ts index 0992dcc88f2..2211b985f74 100644 --- a/configs/webpack-config-compass/src/index.ts +++ b/configs/webpack-config-compass/src/index.ts @@ -372,3 +372,4 @@ export { sharedExternals, pluginExternals }; export { webpackArgsWithDefaults, isServe } from './args'; export { default as webpack } from 'webpack'; export { merge } from 'webpack-merge'; +export { default as WebpackDevServer } from 'webpack-dev-server'; diff --git a/packages/compass-e2e-tests/index.ts b/packages/compass-e2e-tests/index.ts index cbbe284c16d..309767330a2 100644 --- a/packages/compass-e2e-tests/index.ts +++ b/packages/compass-e2e-tests/index.ts @@ -23,6 +23,11 @@ import ResultLogger from './helpers/result-logger'; const debug = Debug('compass-e2e-tests'); +const wait = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + const allowedArgs = [ '--test-compass-web', '--no-compile', @@ -75,18 +80,27 @@ async function setup() { }, } ); - // wait for webpack to finish compiling - await new Promise((resolve) => { - let output = ''; - const listener = function (chunk: string) { - output += chunk; - if (/^webpack \d+\.\d+\.\d+ compiled/m.test(output)) { - compassWeb.stdout.off('data', listener); - resolve(); - } - }; - compassWeb.stdout.setEncoding('utf8').on('data', listener); - }); + + compassWeb.stdout.pipe(process.stdout); + compassWeb.stderr.pipe(process.stderr); + + let serverReady = false; + const start = Date.now(); + while (!serverReady) { + if (Date.now() - start >= 120_000) { + throw new Error( + 'The compass-web sandbox is still not running after 120000ms' + ); + } + try { + const res = await fetch('http://localhost:7777'); + serverReady = res.ok; + debug('Web server ready: %s', serverReady); + } catch (err) { + debug('Failed to connect to dev server: %s', (err as any).message); + } + await wait(1000); + } } else { debug('Writing electron-versions.json'); crossSpawn.sync('scripts/write-electron-versions.sh', [], { @@ -131,7 +145,7 @@ function cleanup() { try { if (compassWeb.pid) { debug(`killing compass-web [${compassWeb.pid}]`); - kill(compassWeb.pid); + kill(compassWeb.pid, 'SIGINT'); } else { debug('no pid for compass-web'); } diff --git a/packages/compass-web/package.json b/packages/compass-web/package.json index 786b5a5e5c2..3257fcc935f 100644 --- a/packages/compass-web/package.json +++ b/packages/compass-web/package.json @@ -40,7 +40,7 @@ "webpack": "webpack-compass", "postcompile": "npm run typescript", "typescript": "tsc -p tsconfig-build.json --emitDeclarationOnly", - "start": "npm run webpack serve -- --mode development", + "start": "electron ./scripts/electron-proxy.js", "analyze": "npm run webpack -- --mode production --analyze", "watch": "npm run webpack -- --mode development --watch", "sync": "node scripts/sync-dist-to-mms.js", diff --git a/packages/compass-web/sandbox/index.tsx b/packages/compass-web/sandbox/index.tsx index ff03193f723..c539e9ad9f8 100644 --- a/packages/compass-web/sandbox/index.tsx +++ b/packages/compass-web/sandbox/index.tsx @@ -37,6 +37,13 @@ const App = () => { return ( } */ - async fetchProjectId() { - const cookie = (await this.#getCloudSessionCookies()).join('; '); - const res = await fetch(CLOUD_ORIGIN, { - method: 'HEAD', - redirect: 'manual', - headers: { cookie }, - }); - - const location = res.headers.get('location') ?? ''; - - return this.#isAuthenticatedUrl(location) - ? this.#getProjectIdFromUrl(location) - : null; + async getProjectId() { + const res = await this.#fetch('/user/shared'); + return res?.currentProject?.projectId ?? null; } /** @@ -162,13 +194,13 @@ class AtlasCloudAuthenticator { return (this.#authenticatePromise ??= (async () => { try { await electronApp.whenReady(); - const projectId = await this.fetchProjectId(); + const projectId = await this.getProjectId(); if (projectId) { return { projectId }; } const bw = new BrowserWindow({ - height: 800, width: 600, + height: 800, resizable: false, fullscreenable: false, }); @@ -196,7 +228,6 @@ class AtlasCloudAuthenticator { }).finally(() => { queueMicrotask(() => { abortController.abort(); - electronApp.dock.hide(); bw.close(); }); }), @@ -204,7 +235,7 @@ class AtlasCloudAuthenticator { throw new Error('Window closed before finished signing in'); }), ]); - electronApp.dock.show(); + void electronApp.dock?.show(); void bw.loadURL(`${CLOUD_ORIGIN}/account/login`); return authInfoPromise; } finally { @@ -213,67 +244,120 @@ class AtlasCloudAuthenticator { })()); } - async logout() { - await session.defaultSession.clearStorageData({ storages: ['cookies'] }); + async cleanupAndLogout() { + // When logging out, delete the test user too. If we don't do this now, we + // will lose a chance to do it later due to missing auth + try { + await atlasCloudAuthenticator.deleteTestDBUser(); + } catch (err) { + logger.err('[electron-proxy] failed to remove the test user:', err); + } + await session.defaultSession.clearStorageData({ + storages: ['cookies', 'localstorage'], + }); } - async #createTestDBUser(projectId) { - return await fetch( - `http://localhost:${PROXY_PORT}/cloud-mongodb-com/nds/${projectId}/users`, - { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ - awsIAMType: 'NONE', - db: '$external', - deleteAfterDate: null, - hasScramSha256Auth: false, - hasUserToDNMapping: false, - isEditable: true, - labels: [], - ldapAuthType: 'NONE', - oidcAuthType: 'NONE', - roles: [{ collection: null, db: 'admin', role: 'atlasAdmin' }], - scopes: [], - user: `CN=${TEST_DB_USER}`, - x509Type: 'MANAGED', - }), - } - ).then(handleRes); + #createTestDBUser(projectId) { + return this.#fetch(`/nds/${projectId}/users`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + awsIAMType: 'NONE', + db: '$external', + deleteAfterDate: null, + hasScramSha256Auth: false, + hasUserToDNMapping: false, + isEditable: true, + labels: [], + ldapAuthType: 'NONE', + oidcAuthType: 'NONE', + roles: [{ collection: null, db: 'admin', role: 'atlasAdmin' }], + scopes: [], + user: `CN=${TEST_DB_USER}`, + x509Type: 'MANAGED', + }), + }); } async #ensureTestDBUserExists(projectId) { - const users = await fetch( - `http://localhost:${PROXY_PORT}/cloud-mongodb-com/nds/${projectId}/users` - ).then(handleRes); + const users = await this.#fetch(`/nds/${projectId}/users`); const testCertUser = users.find((user) => { - return user.x509Type === 'MANAGED' && user.user === TEST_DB_USER; + return ( + user.x509Type === 'MANAGED' && + user.user.replace(/^cn=/i, '') === TEST_DB_USER + ); }); - return testCertUser ?? (await this.#createTestDBUser(projectId)); + const user = testCertUser ?? (await this.#createTestDBUser(projectId)); + let mongodbConfigurationInProgress = true; + let attempts = 0; + while (mongodbConfigurationInProgress && attempts <= 10) { + attempts++; + await new Promise((resolve) => { + setTimeout(resolve, 5_000); + }); + const project = await this.#fetch(`/nds/${projectId}`); + const configuringUserOrRoles = project?.plans?.some((plan) => { + return plan.moves?.some((move) => { + return move.name === 'ConfigureMongoDBForProject'; + }); + }); + mongodbConfigurationInProgress = + project?.state === 'UPDATING' && + (!project?.plans || configuringUserOrRoles); + } + return user; } - async getX509Cert() { - const projectId = await this.fetchProjectId(); - const testUser = await this.#ensureTestDBUserExists(projectId); + async deleteTestDBUser() { + const projectId = await this.getProjectId(); const certUsername = encodeURIComponent( - `CN=${testUser.user.replace(/^cn=/i, '')}` + `CN=${TEST_DB_USER.replace(/^cn=/i, '')}` ); - const certAuthDb = testUser.db ?? '$external'; - return fetch( - `http://localhost:${PROXY_PORT}/cloud-mongodb-com/nds/${projectId}/users/${certAuthDb}/${certUsername}/certs?monthsUntilExpiration=1` - ).then((res) => { - return res.text(); + return this.#fetch(`/nds/${projectId}/users/$external/${certUsername}`, { + method: 'DELETE', }); } + + /** + * + * @returns {Promise} + */ + async getX509Cert() { + const projectId = await this.getProjectId(); + if (TEST_X509_CERT_PROMISE.has(projectId)) { + return TEST_X509_CERT_PROMISE.get(projectId); + } + const promise = (async () => { + try { + const testUser = await this.#ensureTestDBUserExists(projectId); + const certUsername = encodeURIComponent( + `CN=${testUser.user.replace(/^cn=/i, '')}` + ); + const certAuthDb = testUser.db ?? '$external'; + return await this.#fetch( + `/nds/${projectId}/users/${certAuthDb}/${certUsername}/certs?monthsUntilExpiration=1` + ); + } catch (err) { + TEST_X509_CERT_PROMISE.delete(projectId, promise); + logger.error( + '[electron-proxy] failed to issue a cert for the test user', + err + ); + throw err; + } + })(); + TEST_X509_CERT_PROMISE.set(projectId, promise); + return promise; + } } const atlasCloudAuthenticator = new AtlasCloudAuthenticator(); // Proxy endpoint that triggers the sign in flow through configured cloud // environment -proxyWebServer.use('/authenticate', async (req, res) => { +expressProxy.use('/authenticate', async (req, res) => { if (req.method !== 'POST') { res.statusCode = 400; res.end(); @@ -282,6 +366,10 @@ proxyWebServer.use('/authenticate', async (req, res) => { try { const { projectId } = await atlasCloudAuthenticator.authenticate(); + // Start issuing the cert to save some time when signing in + void atlasCloudAuthenticator.getX509Cert().catch(() => { + // ignore errors + }); res.setHeader('Content-Type', 'application/json'); res.send(JSON.stringify({ projectId })); } catch (err) { @@ -291,19 +379,19 @@ proxyWebServer.use('/authenticate', async (req, res) => { res.end(); }); -proxyWebServer.use('/logout', async (req, res) => { +expressProxy.use('/logout', async (req, res) => { if (req.method !== 'GET') { res.statusCode = 400; res.end(); return; } - await atlasCloudAuthenticator.logout(); + await atlasCloudAuthenticator.cleanupAndLogout(); res.statusCode = 200; res.end(); }); -proxyWebServer.use('/x509', async (req, res) => { +expressProxy.use('/x509', async (req, res) => { if (req.method !== 'GET') { res.statusCode = 400; res.end(); @@ -322,7 +410,7 @@ proxyWebServer.use('/x509', async (req, res) => { res.end(); }); -proxyWebServer.use('/projectId', async (req, res) => { +expressProxy.use('/projectId', async (req, res) => { if (req.method !== 'GET') { res.statusCode = 400; res.end(); @@ -330,7 +418,7 @@ proxyWebServer.use('/projectId', async (req, res) => { } try { - const projectId = await atlasCloudAuthenticator.fetchProjectId(); + const projectId = await atlasCloudAuthenticator.getProjectId(); if (!projectId) { res.statusCode = 403; } else { @@ -344,7 +432,7 @@ proxyWebServer.use('/projectId', async (req, res) => { res.end(); }); -proxyWebServer.use('/create-cluster', async (req, res) => { +expressProxy.use('/create-cluster', async (req, res) => { if (req.method !== 'GET') { res.statusCode = 400; res.end(); @@ -353,14 +441,14 @@ proxyWebServer.use('/create-cluster', async (req, res) => { res.setHeader( 'Location', - `${CLOUD_ORIGIN}/v2/${await atlasCloudAuthenticator.fetchProjectId()}#/clusters/edit/` + `${CLOUD_ORIGIN}/v2/${await atlasCloudAuthenticator.getProjectId()}#/clusters/edit/` ); res.statusCode = 303; res.end(); }); // Prefixed proxy to the cloud backend -proxyWebServer.use( +expressProxy.use( '/cloud-mongodb-com', proxyMiddleware(CLOUD_ORIGIN, { async proxyReqOptDecorator(req) { @@ -387,23 +475,91 @@ proxyWebServer.use( ); // Everything else is proxied directly to webpack-dev-server -proxyWebServer.use( +expressProxy.use( proxyMiddleware(`http://localhost:${WEBPACK_DEV_SERVER_PORT}`) ); -proxyWebServer.listen(PROXY_PORT, 'localhost'); +void electronApp.dock?.hide(); -electronApp.dock.hide(); +logger.log('[electron-proxy] starting proxy server on port %s', PROXY_PORT); -electronApp.on('window-all-closed', () => { - // We want proxy to keep running even when all the windows are closed -}); +const proxyServer = expressProxy.listen(PROXY_PORT, 'localhost'); -electronApp.on('will-quit', () => { - atlasCloudAuthenticator.logout(); +const websocketProxyServer = createWebSocketProxy(); + +const webpackCompiler = webpack(webpackConfig); + +const webpackDevServer = new WebpackDevServer( + { ...webpackConfig.devServer, setupExitSignals: false }, + webpackCompiler +); + +let cleaningUp = false; + +// If stdio stream was already destroyed while we're cleaning up, these streams +// can throw causing Electron to pop up a modal with an error, so we catch and +// print the error ourselves +[process.stdout, process.stderr].forEach((stream) => { + stream.on('error', (err) => { + logger.error(err); + }); }); -electronApp.whenReady().then(() => { +function cleanupAndExit() { + if (cleaningUp) { + return; + } + cleaningUp = true; + logger.log('[electron-proxy] cleaning up before exit'); + void Promise.allSettled([ + // This will cleanup auth and remove the session test user + atlasCloudAuthenticator.cleanupAndLogout(), + + // close the http proxy server + proxyServer.closeAllConnections(), + new Promise((resolve) => { + proxyServer.close(resolve); + }), + + // close the driver websocket proxy server + Array.from(websocketProxyServer.clients.values()).map((ws) => { + return ws.terminate(); + }), + new Promise((resolve) => { + websocketProxyServer.close(resolve); + }), + + // cleanup everything webpack-server related + new Promise((resolve) => { + webpackDevServer.compiler?.close?.(resolve); + }), + webpackDevServer.stop(), + ]).finally(() => { + logger.log('[electron-proxy] done cleaning up'); + process.exitCode = 0; + process.exit(); + }); +} + +electronApp.whenReady().then(async () => { + electronApp.on('window-all-closed', () => { + // We want proxy to keep running even when all the windows are closed, but + // hide the dock icon because there are not windows associated with it + // anyway + electronApp.dock?.hide(); + }); + + electronApp.on('will-quit', (evt) => { + evt.preventDefault(); + void cleanupAndExit(); + }); + + for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, cleanupAndExit); + } + + await webpackDevServer.start(); + if (process.env.OPEN_BROWSER !== 'false') { shell.openExternal(`http://localhost:${PROXY_PORT}`); } diff --git a/packages/compass-web/scripts/start-electron-proxy.js b/packages/compass-web/scripts/start-electron-proxy.js deleted file mode 100644 index dbbc4e2f1f0..00000000000 --- a/packages/compass-web/scripts/start-electron-proxy.js +++ /dev/null @@ -1,16 +0,0 @@ -'use strict'; -const path = require('path'); -const child_process = require('child_process'); -const electronPath = require('electron'); - -function startElectronProxy() { - const child = child_process.execFile( - electronPath, - [path.resolve(__dirname, 'electron-proxy.js')], - { env: process.env } - ); - child.stdout.pipe(process.stdout); - child.stderr.pipe(process.stderr); -} - -module.exports = { startElectronProxy }; diff --git a/packages/compass-web/scripts/ws-proxy.js b/packages/compass-web/scripts/ws-proxy.js index 4efec52d86d..c3e2e6a2561 100644 --- a/packages/compass-web/scripts/ws-proxy.js +++ b/packages/compass-web/scripts/ws-proxy.js @@ -46,7 +46,7 @@ async function resolveX509Cert({ projectId, clusterName }) { * every follow-up message is directly written to the opened socket stream */ function createWebSocketProxy(port = 1337, logger = console) { - const wsServer = new WebSocketServer({ port }, () => { + const wsServer = new WebSocketServer({ host: 'localhost', port }, () => { logger.log('ws server listening at %s', wsServer.options.port); }); @@ -62,7 +62,6 @@ function createWebSocketProxy(port = 1337, logger = console) { }); ws.on('message', async (data) => { if (socket) { - logger.log('message from client'); socket.write(data, 'binary'); } else { // First message before socket is created is with connection info @@ -117,12 +116,13 @@ function createWebSocketProxy(port = 1337, logger = console) { ws.send(JSON.stringify({ evt: connectEvent })); }); socket.on('data', async (data) => { - logger.log('message from server'); ws.send(data); }); } }); }); + + return wsServer; } module.exports = { createWebSocketProxy }; diff --git a/packages/compass-web/src/connection-storage.tsx b/packages/compass-web/src/connection-storage.tsx index 213a22b919e..274fb2d2920 100644 --- a/packages/compass-web/src/connection-storage.tsx +++ b/packages/compass-web/src/connection-storage.tsx @@ -150,7 +150,8 @@ export function buildConnectionInfoFromClusterDescription( orgId: string, projectId: string, description: ClusterDescriptionWithDataProcessingRegion, - deployment: Deployment + deployment: Deployment, + extraConnectionOptions?: Record ) { const connectionString = new ConnectionString( `mongodb+srv://${description.srvAddress}` @@ -169,6 +170,10 @@ export function buildConnectionInfoFromClusterDescription( connectionString.searchParams.set('srvMaxHosts', '3'); } + for (const [key, value] of Object.entries(extraConnectionOptions ?? {})) { + connectionString.searchParams.set(key, String(value)); + } + const deploymentItem = findDeploymentItemByClusterName( description, deployment @@ -211,7 +216,8 @@ class AtlasCloudConnectionStorage constructor( private atlasService: AtlasService, private orgId: string, - private projectId: string + private projectId: string, + private extraConnectionOptions?: Record ) { super(); } @@ -276,7 +282,8 @@ class AtlasCloudConnectionStorage this.orgId, this.projectId, description, - deployment + deployment, + this.extraConnectionOptions ); }); } @@ -294,13 +301,34 @@ class AtlasCloudConnectionStorage const SandboxConnectionStorageContext = React.createContext(null); +const SandboxExtraConnectionOptionsContext = React.createContext< + Record | undefined +>(undefined); + /** * Only used in the sandbox to provide connection info when connecting to the * non-Atlas deployment * @internal */ -export const SandboxConnectionStorageProviver = - SandboxConnectionStorageContext.Provider; +export const SandboxConnectionStorageProviver = ({ + value, + extraConnectionOptions, + children, +}: { + value: ConnectionStorage | null; + extraConnectionOptions?: Record; + children: React.ReactNode; +}) => { + return ( + + + {children} + + + ); +}; export const AtlasCloudConnectionStorageProvider = createServiceProvider( function AtlasCloudConnectionStorageProvider({ @@ -312,9 +340,17 @@ export const AtlasCloudConnectionStorageProvider = createServiceProvider( projectId: string; children: React.ReactChild; }) { + const extraConnectionOptions = useContext( + SandboxExtraConnectionOptionsContext + ); const atlasService = atlasServiceLocator(); const storage = useRef( - new AtlasCloudConnectionStorage(atlasService, orgId, projectId) + new AtlasCloudConnectionStorage( + atlasService, + orgId, + projectId, + extraConnectionOptions + ) ); const sandboxConnectionStorage = useContext( SandboxConnectionStorageContext diff --git a/packages/compass-web/webpack.config.js b/packages/compass-web/webpack.config.js index d16c68303f5..5a614982eb7 100644 --- a/packages/compass-web/webpack.config.js +++ b/packages/compass-web/webpack.config.js @@ -6,8 +6,6 @@ const { isServe, merge, } = require('@mongodb-js/webpack-config-compass'); -const { startElectronProxy } = require('./scripts/start-electron-proxy'); -const { createWebSocketProxy } = require('./scripts/ws-proxy'); const { execFile } = require('child_process'); const { promisify } = require('util'); @@ -17,7 +15,7 @@ function localPolyfill(name) { return path.resolve(__dirname, 'polyfills', ...name.split('/'), 'index.ts'); } -module.exports = async (env, args) => { +module.exports = (env, args) => { const serve = isServe({ env }); let config = createWebConfig({ @@ -29,6 +27,8 @@ module.exports = async (env, args) => { delete config.externals; config = merge(config, { + context: __dirname, + resolve: { alias: { // Dependencies for the unsupported connection types in data-service @@ -143,10 +143,6 @@ module.exports = async (env, args) => { }); if (serve) { - startElectronProxy(); - // TODO: logs are pretty rough here, should make it better - createWebSocketProxy(); - config.output = { path: config.output.path, filename: config.output.filename, diff --git a/packages/compass-workspaces/src/components/workspaces.tsx b/packages/compass-workspaces/src/components/workspaces.tsx index dd8a902cc63..624b697911c 100644 --- a/packages/compass-workspaces/src/components/workspaces.tsx +++ b/packages/compass-workspaces/src/components/workspaces.tsx @@ -81,7 +81,7 @@ const ActiveTabCloseHandler: React.FunctionComponent = ({ children }) => { // Make sure we don't count clicking on buttons that actually cause the // workspace to change, like using breadcrumbs or clicking on an item in the // Databases / Collections list. There are certain corner-cases this doesn't - // handle, but it's good enough to prevent most cases where users can loose + // handle, but it's good enough to prevent most cases where users can lose // content by accident rafraf(() => { if (mountedRef.current) {