diff --git a/dev-packages/application-manager/src/generator/webpack-generator.ts b/dev-packages/application-manager/src/generator/webpack-generator.ts index 0eeb0b38dc269..2db2258a524c6 100644 --- a/dev-packages/application-manager/src/generator/webpack-generator.ts +++ b/dev-packages/application-manager/src/generator/webpack-generator.ts @@ -437,6 +437,8 @@ const config = { ${this.ifPackage('@theia/git', () => `// Ensure the git locator process can the started 'git-locator-host': require.resolve('@theia/git/lib/node/git-locator/git-locator-host'),`)} ${this.ifElectron("'electron-main': require.resolve('./src-gen/backend/electron-main'),")} + ${this.ifPackage('@theia/dev-container', () => `// VS Code Dev-Container communication: + 'dev-container-server': require.resolve('@theia/dev-container/lib/dev-container-server/dev-container-server'),`)} ...commonJsLibraries }, module: { diff --git a/examples/browser/package.json b/examples/browser/package.json index 9da407c65339e..daa632e7b1e47 100644 --- a/examples/browser/package.json +++ b/examples/browser/package.json @@ -27,6 +27,7 @@ "@theia/console": "1.47.0", "@theia/core": "1.47.0", "@theia/debug": "1.47.0", + "@theia/dev-container": "1.47.0", "@theia/editor": "1.47.0", "@theia/editor-preview": "1.47.0", "@theia/file-search": "1.47.0", diff --git a/examples/browser/tsconfig.json b/examples/browser/tsconfig.json index 0debed71dc386..8318b446c62cb 100644 --- a/examples/browser/tsconfig.json +++ b/examples/browser/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../packages/debug" }, + { + "path": "../../packages/dev-container" + }, { "path": "../../packages/editor" }, diff --git a/examples/electron/package.json b/examples/electron/package.json index 19c9ba0c978b3..2e04a9ffcf28b 100644 --- a/examples/electron/package.json +++ b/examples/electron/package.json @@ -27,6 +27,7 @@ "@theia/console": "1.47.0", "@theia/core": "1.47.0", "@theia/debug": "1.47.0", + "@theia/dev-container": "1.47.0", "@theia/editor": "1.47.0", "@theia/editor-preview": "1.47.0", "@theia/electron": "1.47.0", diff --git a/examples/electron/tsconfig.json b/examples/electron/tsconfig.json index c1c637281a2a3..a5073361ded87 100644 --- a/examples/electron/tsconfig.json +++ b/examples/electron/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../packages/debug" }, + { + "path": "../../packages/dev-container" + }, { "path": "../../packages/editor" }, diff --git a/packages/core/src/browser/window/window-service.ts b/packages/core/src/browser/window/window-service.ts index 34adb43737c22..694046fe910a2 100644 --- a/packages/core/src/browser/window/window-service.ts +++ b/packages/core/src/browser/window/window-service.ts @@ -18,6 +18,11 @@ import { StopReason } from '../../common/frontend-application-state'; import { Event } from '../../common/event'; import { NewWindowOptions, WindowSearchParams } from '../../common/window'; +export interface WindowReloadOptions { + search?: WindowSearchParams, + hash?: string +} + /** * Service for opening new browser windows. */ @@ -35,7 +40,7 @@ export interface WindowService { * Opens a new default window. * - In electron and in the browser it will open the default window without a pre-defined content. */ - openNewDefaultWindow(params?: WindowSearchParams): void; + openNewDefaultWindow(params?: WindowReloadOptions): void; /** * Fires when the `window` unloads. The unload event is inevitable. On this event, the frontend application can save its state and release resource. @@ -64,5 +69,5 @@ export interface WindowService { /** * Reloads the window according to platform. */ - reload(params?: WindowSearchParams): void; + reload(params?: WindowReloadOptions): void; } diff --git a/packages/core/src/electron-browser/window/electron-window-service.ts b/packages/core/src/electron-browser/window/electron-window-service.ts index 7777063b67e0c..f2ec5d6ae2edc 100644 --- a/packages/core/src/electron-browser/window/electron-window-service.ts +++ b/packages/core/src/electron-browser/window/electron-window-service.ts @@ -21,6 +21,7 @@ import { ElectronMainWindowService } from '../../electron-common/electron-main-w import { ElectronWindowPreferences } from './electron-window-preferences'; import { ConnectionCloseService } from '../../common/messaging/connection-management'; import { FrontendIdProvider } from '../../browser/messaging/frontend-id-provider'; +import { WindowReloadOptions } from '../../browser/window/window-service'; @injectable() export class ElectronWindowService extends DefaultWindowService { @@ -86,12 +87,20 @@ export class ElectronWindowService extends DefaultWindowService { } } - override reload(params?: WindowSearchParams): void { + override reload(params?: WindowReloadOptions): void { if (params) { - const query = Object.entries(params).map(([name, value]) => `${name}=${value}`).join('&'); - location.search = query; + const newLocation = new URL(location.href); + if (params.search) { + const query = Object.entries(params.search).map(([name, value]) => `${name}=${value}`).join('&'); + newLocation.search = query; + } + if (params.hash) { + newLocation.hash = '#' + params.hash; + } + location.assign(newLocation); } else { window.electronTheiaCore.requestReload(); } } } + diff --git a/packages/dev-container/.eslintrc.js b/packages/dev-container/.eslintrc.js new file mode 100644 index 0000000000000..13089943582b6 --- /dev/null +++ b/packages/dev-container/.eslintrc.js @@ -0,0 +1,10 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: [ + '../../configs/build.eslintrc.json' + ], + parserOptions: { + tsconfigRootDir: __dirname, + project: 'tsconfig.json' + } +}; diff --git a/packages/dev-container/README.md b/packages/dev-container/README.md new file mode 100644 index 0000000000000..b9ce2d06af2e5 --- /dev/null +++ b/packages/dev-container/README.md @@ -0,0 +1,43 @@ +
+ +
+ +theia-ext-logo + +

ECLIPSE THEIA - DEV-CONTAINER EXTENSION

+ +
+ +
+ +## Description + +The `@theia/dev-container` extension provides functionality to create, start and connect to development containers similiar to the +[vscode Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers). + +The full devcontainer.json Schema can be found [here](https://containers.dev/implementors/json_reference/). +Currently only a small number of configuration file properties are implemented. Those include the following: +- name +- Image +- dockerfile/build.dockerfile +- build.context +- location +- forwardPorts +- mounts + +see `main-container-creation-contributions.ts` for how to implementations or how to implement additional ones. + + +## Additional Information + +- [Theia - GitHub](https://github.com/eclipse-theia/theia) +- [Theia - Website](https://theia-ide.org/) + +## License + +- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/) +- [δΈ€ (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp) + +## Trademark +"Theia" is a trademark of the Eclipse Foundation +https://www.eclipse.org/theia diff --git a/packages/dev-container/package.json b/packages/dev-container/package.json new file mode 100644 index 0000000000000..246fa87cc3add --- /dev/null +++ b/packages/dev-container/package.json @@ -0,0 +1,54 @@ +{ + "name": "@theia/dev-container", + "version": "1.47.0", + "description": "Theia - Editor Preview Extension", + "dependencies": { + "@theia/core": "1.47.0", + "@theia/output": "1.47.0", + "@theia/remote": "1.47.0", + "@theia/workspace": "1.47.0", + "dockerode": "^4.0.2", + "uuid": "^8.0.0", + "jsonc-parser": "^2.2.0" + }, + "publishConfig": { + "access": "public" + }, + "theiaExtensions": [ + { + "frontendElectron": "lib/electron-browser/dev-container-frontend-module", + "backendElectron": "lib/electron-node/dev-container-backend-module" + } + ], + "keywords": [ + "theia-extension" + ], + "license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0", + "repository": { + "type": "git", + "url": "https://github.com/eclipse-theia/theia.git" + }, + "bugs": { + "url": "https://github.com/eclipse-theia/theia/issues" + }, + "homepage": "https://github.com/eclipse-theia/theia", + "files": [ + "lib", + "src" + ], + "scripts": { + "build": "theiaext build", + "clean": "theiaext clean", + "compile": "theiaext compile", + "lint": "theiaext lint", + "test": "theiaext test", + "watch": "theiaext watch" + }, + "devDependencies": { + "@theia/ext-scripts": "1.47.0", + "@types/dockerode": "^3.3.23" + }, + "nyc": { + "extends": "../../configs/nyc.json" + } +} diff --git a/packages/dev-container/src/dev-container-server/dev-container-server.ts b/packages/dev-container/src/dev-container-server/dev-container-server.ts new file mode 100644 index 0000000000000..0e477150539a3 --- /dev/null +++ b/packages/dev-container/src/dev-container-server/dev-container-server.ts @@ -0,0 +1,53 @@ +// ***************************************************************************** +// Copyright (C) 2024 Typefox and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { createConnection } from 'net'; +import { stdin, argv, stdout } from 'process'; + +/** + * this node.js Program is supposed to be executed by an docker exec session inside a docker container. + * It uses a tty session to listen on stdin and send on stdout all communication with the theia backend running inside the container. + */ + +let backendPort: number | undefined = undefined; +argv.slice(2).forEach(arg => { + if (arg.startsWith('-target-port')) { + backendPort = parseInt(arg.split('=')[1]); + } +}); + +if (!backendPort) { + throw new Error('please start with -target-port={port number}'); +} +if (stdin.isTTY) { + stdin.setRawMode(true); +} +const connection = createConnection(backendPort, '0.0.0.0'); + +connection.pipe(stdout); +stdin.pipe(connection); + +connection.on('error', error => { + console.error('connection error', error); +}); + +connection.on('close', () => { + console.log('connection closed'); + process.exit(0); +}); + +// keep the process running +setInterval(() => { }, 1 << 30); diff --git a/packages/dev-container/src/electron-browser/container-connection-contribution.ts b/packages/dev-container/src/electron-browser/container-connection-contribution.ts new file mode 100644 index 0000000000000..415ada831f733 --- /dev/null +++ b/packages/dev-container/src/electron-browser/container-connection-contribution.ts @@ -0,0 +1,103 @@ +// ***************************************************************************** +// Copyright (C) 2024 Typefox and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { inject, injectable } from '@theia/core/shared/inversify'; +import { AbstractRemoteRegistryContribution, RemoteRegistry } from '@theia/remote/lib/electron-browser/remote-registry-contribution'; +import { LastContainerInfo, RemoteContainerConnectionProvider } from '../electron-common/remote-container-connection-provider'; +import { RemotePreferences } from '@theia/remote/lib/electron-browser/remote-preferences'; +import { WorkspaceStorageService } from '@theia/workspace/lib/browser/workspace-storage-service'; +import { Command, QuickInputService } from '@theia/core'; +import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; +import { ContainerOutputProvider } from './container-output-provider'; + +export namespace RemoteContainerCommands { + export const REOPEN_IN_CONTAINER = Command.toLocalizedCommand({ + id: 'dev-container:reopen-in-container', + label: 'Reopen in Container', + category: 'Dev Container' + }, 'theia/dev-container/connect'); +} + +const LAST_USED_CONTAINER = 'lastUsedContainer'; +@injectable() +export class ContainerConnectionContribution extends AbstractRemoteRegistryContribution { + + @inject(RemoteContainerConnectionProvider) + protected readonly connectionProvider: RemoteContainerConnectionProvider; + + @inject(RemotePreferences) + protected readonly remotePreferences: RemotePreferences; + + @inject(WorkspaceStorageService) + protected readonly workspaceStorageService: WorkspaceStorageService; + + @inject(WorkspaceService) + protected readonly workspaceService: WorkspaceService; + + @inject(QuickInputService) + protected readonly quickInputService: QuickInputService; + + @inject(ContainerOutputProvider) + protected readonly containerOutputProvider: ContainerOutputProvider; + + registerRemoteCommands(registry: RemoteRegistry): void { + registry.registerCommand(RemoteContainerCommands.REOPEN_IN_CONTAINER, { + execute: () => this.openInContainer() + }); + } + + async openInContainer(): Promise { + const devcontainerFile = await this.getOrSelectDevcontainerFile(); + if (!devcontainerFile) { + return; + } + const lastContainerInfoKey = `${LAST_USED_CONTAINER}:${devcontainerFile}`; + const lastContainerInfo = await this.workspaceStorageService.getData(lastContainerInfoKey); + + this.containerOutputProvider.openChannel(); + + const connectionResult = await this.connectionProvider.connectToContainer({ + nodeDownloadTemplate: this.remotePreferences['remote.nodeDownloadTemplate'], + lastContainerInfo, + devcontainerFile + }); + + this.workspaceStorageService.setData(lastContainerInfoKey, { + id: connectionResult.containerId, + lastUsed: Date.now() + }); + + this.openRemote(connectionResult.port, false, connectionResult.workspacePath); + } + + async getOrSelectDevcontainerFile(): Promise { + const devcontainerFiles = await this.connectionProvider.getDevContainerFiles(); + + if (devcontainerFiles.length === 1) { + return devcontainerFiles[0].path; + } + + return (await this.quickInputService.pick(devcontainerFiles.map(file => ({ + type: 'item', + label: file.name, + description: file.path, + file: file.path, + })), { + title: 'Select a devcontainer.json file' + }))?.file; + } + +} diff --git a/packages/dev-container/src/electron-browser/container-output-provider.ts b/packages/dev-container/src/electron-browser/container-output-provider.ts new file mode 100644 index 0000000000000..f0891c943d085 --- /dev/null +++ b/packages/dev-container/src/electron-browser/container-output-provider.ts @@ -0,0 +1,36 @@ +// ***************************************************************************** +// Copyright (C) 2024 Typefox and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { injectable, inject } from '@theia/core/shared/inversify'; +import { OutputChannel, OutputChannelManager } from '@theia/output/lib/browser/output-channel'; + +@injectable() +export class ContainerOutputProvider implements ContainerOutputProvider { + + @inject(OutputChannelManager) + protected readonly outputChannelManager: OutputChannelManager; + + protected currentChannel?: OutputChannel; + + openChannel(): void { + this.currentChannel = this.outputChannelManager.getChannel('Container'); + this.currentChannel.show(); + }; + + onRemoteOutput(output: string): void { + this.currentChannel?.appendLine(output); + } +} diff --git a/packages/dev-container/src/electron-browser/dev-container-frontend-module.ts b/packages/dev-container/src/electron-browser/dev-container-frontend-module.ts new file mode 100644 index 0000000000000..77cdd79844b72 --- /dev/null +++ b/packages/dev-container/src/electron-browser/dev-container-frontend-module.ts @@ -0,0 +1,33 @@ +// ***************************************************************************** +// Copyright (C) 2024 Typefox and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** +import { ContainerModule } from '@theia/core/shared/inversify'; +import { RemoteRegistryContribution } from '@theia/remote/lib/electron-browser/remote-registry-contribution'; +import { RemoteContainerConnectionProvider, RemoteContainerConnectionProviderPath } from '../electron-common/remote-container-connection-provider'; +import { ContainerConnectionContribution } from './container-connection-contribution'; +import { ServiceConnectionProvider } from '@theia/core/lib/browser/messaging/service-connection-provider'; +import { ContainerOutputProvider } from './container-output-provider'; + +export default new ContainerModule(bind => { + bind(ContainerConnectionContribution).toSelf().inSingletonScope(); + bind(RemoteRegistryContribution).toService(ContainerConnectionContribution); + + bind(ContainerOutputProvider).toSelf().inSingletonScope(); + + bind(RemoteContainerConnectionProvider).toDynamicValue(ctx => { + const outputProvider = ctx.container.get(ContainerOutputProvider); + return ServiceConnectionProvider.createLocalProxy(ctx.container, RemoteContainerConnectionProviderPath, outputProvider); + }).inSingletonScope(); +}); diff --git a/packages/dev-container/src/electron-common/container-output-provider.ts b/packages/dev-container/src/electron-common/container-output-provider.ts new file mode 100644 index 0000000000000..ae7d1b712e788 --- /dev/null +++ b/packages/dev-container/src/electron-common/container-output-provider.ts @@ -0,0 +1,19 @@ +// ***************************************************************************** +// Copyright (C) 2024 Typefox and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +export interface ContainerOutputProvider { + onRemoteOutput(output: string): void; +} diff --git a/packages/dev-container/src/electron-common/remote-container-connection-provider.ts b/packages/dev-container/src/electron-common/remote-container-connection-provider.ts new file mode 100644 index 0000000000000..236cb3113cd90 --- /dev/null +++ b/packages/dev-container/src/electron-common/remote-container-connection-provider.ts @@ -0,0 +1,49 @@ +// ***************************************************************************** +// Copyright (C) 2024 Typefox and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 + +import { RpcServer } from '@theia/core'; +import { ContainerOutputProvider } from './container-output-provider'; + +// ***************************************************************************** +export const RemoteContainerConnectionProviderPath = '/remote/container'; + +export const RemoteContainerConnectionProvider = Symbol('RemoteContainerConnectionProvider'); + +export interface ContainerConnectionOptions { + nodeDownloadTemplate?: string; + lastContainerInfo?: LastContainerInfo + devcontainerFile: string; +} + +export interface LastContainerInfo { + id: string; + lastUsed: number; +} + +export interface ContainerConnectionResult { + port: string; + workspacePath: string; + containerId: string; +} + +export interface DevContainerFile { + name: string; + path: string; +} + +export interface RemoteContainerConnectionProvider extends RpcServer { + connectToContainer(options: ContainerConnectionOptions): Promise; + getDevContainerFiles(): Promise; +} diff --git a/packages/dev-container/src/electron-node/dev-container-backend-module.ts b/packages/dev-container/src/electron-node/dev-container-backend-module.ts new file mode 100644 index 0000000000000..cac6e5e74ebf4 --- /dev/null +++ b/packages/dev-container/src/electron-node/dev-container-backend-module.ts @@ -0,0 +1,47 @@ +// ***************************************************************************** +// Copyright (C) 2024 Typefox and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { ContainerModule } from '@theia/core/shared/inversify'; +import { ConnectionContainerModule } from '@theia/core/lib/node/messaging/connection-container-module'; +import { DevContainerConnectionProvider } from './remote-container-connection-provider'; +import { RemoteContainerConnectionProvider, RemoteContainerConnectionProviderPath } from '../electron-common/remote-container-connection-provider'; +import { ContainerCreationContribution, DockerContainerService } from './docker-container-service'; +import { bindContributionProvider, ConnectionHandler, RpcConnectionHandler } from '@theia/core'; +import { registerContainerCreationContributions } from './devcontainer-contributions/main-container-creation-contributions'; +import { DevContainerFileService } from './dev-container-file-service'; +import { ContainerOutputProvider } from '../electron-common/container-output-provider'; + +export const remoteConnectionModule = ConnectionContainerModule.create(({ bind, bindBackendService }) => { + bindContributionProvider(bind, ContainerCreationContribution); + registerContainerCreationContributions(bind); + + bind(DevContainerConnectionProvider).toSelf().inSingletonScope(); + bind(RemoteContainerConnectionProvider).toService(DevContainerConnectionProvider); + bind(ConnectionHandler).toDynamicValue(ctx => + new RpcConnectionHandler(RemoteContainerConnectionProviderPath, client => { + const server = ctx.container.get(RemoteContainerConnectionProvider); + server.setClient(client); + client.onDidCloseConnection(() => server.dispose()); + return server; + })); +}); + +export default new ContainerModule((bind, unbind, isBound, rebind) => { + bind(DockerContainerService).toSelf().inSingletonScope(); + bind(ConnectionContainerModule).toConstantValue(remoteConnectionModule); + + bind(DevContainerFileService).toSelf().inSingletonScope(); +}); diff --git a/packages/dev-container/src/electron-node/dev-container-file-service.ts b/packages/dev-container/src/electron-node/dev-container-file-service.ts new file mode 100644 index 0000000000000..00f59186f7710 --- /dev/null +++ b/packages/dev-container/src/electron-node/dev-container-file-service.ts @@ -0,0 +1,72 @@ +// ***************************************************************************** +// Copyright (C) 2024 Typefox and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { inject, injectable } from '@theia/core/shared/inversify'; +import { WorkspaceServer } from '@theia/workspace/lib/common'; +import { DevContainerFile } from '../electron-common/remote-container-connection-provider'; +import { DevContainerConfiguration } from './devcontainer-file'; +import { parse } from 'jsonc-parser'; +import * as fs from '@theia/core/shared/fs-extra'; +import { Path, URI } from '@theia/core'; + +@injectable() +export class DevContainerFileService { + + @inject(WorkspaceServer) + protected readonly workspaceServer: WorkspaceServer; + + async getConfiguration(path: string): Promise { + const configuration: DevContainerConfiguration = parse(await fs.readFile(path, 'utf-8').catch(() => '0')) as DevContainerConfiguration; + if (!configuration) { + throw new Error(`devcontainer file ${path} could not be parsed`); + } + + configuration.location = path; + return configuration; + } + + async getAvailableFiles(): Promise { + const workspace = await this.workspaceServer.getMostRecentlyUsedWorkspace(); + if (!workspace) { + return []; + } + + const devcontainerPath = new URI(workspace).path.join('.devcontainer').fsPath(); + + return (await this.searchForDevontainerJsonFiles(devcontainerPath, 1)).map(file => ({ + name: parse(fs.readFileSync(file, 'utf-8')).name ?? 'devcontainer', + path: file + })); + + } + + protected async searchForDevontainerJsonFiles(directory: string, depth: number): Promise { + if (depth < 0) { + return []; + } + const filesPaths = (await fs.readdir(directory)).map(file => new Path(directory).join(file).fsPath()); + + const devcontainerFiles = []; + for (const file of filesPaths) { + if (file.endsWith('devcontainer.json')) { + devcontainerFiles.push(file); + } else if ((await fs.stat(file)).isDirectory()) { + devcontainerFiles.push(...await this.searchForDevontainerJsonFiles(file, depth - 1)); + } + } + return devcontainerFiles; + } +} diff --git a/packages/dev-container/src/electron-node/devcontainer-contributions/main-container-creation-contributions.ts b/packages/dev-container/src/electron-node/devcontainer-contributions/main-container-creation-contributions.ts new file mode 100644 index 0000000000000..c0c91473201f0 --- /dev/null +++ b/packages/dev-container/src/electron-node/devcontainer-contributions/main-container-creation-contributions.ts @@ -0,0 +1,140 @@ +// ***************************************************************************** +// Copyright (C) 2024 Typefox and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** +import * as Docker from 'dockerode'; +import { injectable, interfaces } from '@theia/core/shared/inversify'; +import { ContainerCreationContribution } from '../docker-container-service'; +import { DevContainerConfiguration, DockerfileContainer, ImageContainer, NonComposeContainerBase } from '../devcontainer-file'; +import { Path } from '@theia/core'; +import { ContainerOutputProvider } from '../../electron-common/container-output-provider'; + +export function registerContainerCreationContributions(bind: interfaces.Bind): void { + bind(ContainerCreationContribution).to(ImageFileContribution).inSingletonScope(); + bind(ContainerCreationContribution).to(DockerFileContribution).inSingletonScope(); + bind(ContainerCreationContribution).to(ForwardPortsContribution).inSingletonScope(); + bind(ContainerCreationContribution).to(MountsContribution).inSingletonScope(); +} + +@injectable() +export class ImageFileContribution implements ContainerCreationContribution { + async handleContainerCreation(createOptions: Docker.ContainerCreateOptions, containerConfig: ImageContainer, + api: Docker, outputprovider: ContainerOutputProvider): Promise { + if (containerConfig.image) { + await new Promise((res, rej) => api.pull(containerConfig.image, {}, (err, stream) => { + if (err) { + rej(err); + } else { + api.modem.followProgress(stream, (error, output) => error ? + rej(error) : + res(), progress => outputprovider.onRemoteOutput(OutputHelper.parseProgress(progress))); + } + })); + createOptions.Image = containerConfig.image; + } + } +} + +@injectable() +export class DockerFileContribution implements ContainerCreationContribution { + async handleContainerCreation(createOptions: Docker.ContainerCreateOptions, containerConfig: DockerfileContainer, + api: Docker, outputprovider: ContainerOutputProvider): Promise { + // check if dockerfile container + if (containerConfig.dockerFile || containerConfig.build?.dockerfile) { + const dockerfile = (containerConfig.dockerFile ?? containerConfig.build?.dockerfile) as string; + const buildStream = await api.buildImage({ + context: containerConfig.context ?? new Path(containerConfig.location as string).dir.fsPath(), + src: [dockerfile], + } as Docker.ImageBuildContext, { + buildargs: containerConfig.build?.args + }); + // TODO probably have some console windows showing the output of the build + const imageId = await new Promise((res, rej) => api.modem.followProgress(buildStream, (err, outputs) => { + if (err) { + rej(err); + } else { + for (let i = outputs.length - 1; i >= 0; i--) { + if (outputs[i].aux?.ID) { + res(outputs[i].aux.ID); + return; + } + } + } + }, progress => outputprovider.onRemoteOutput(OutputHelper.parseProgress(progress)))); + createOptions.Image = imageId; + } + } +} + +@injectable() +export class ForwardPortsContribution implements ContainerCreationContribution { + async handleContainerCreation(createOptions: Docker.ContainerCreateOptions, containerConfig: DevContainerConfiguration, api: Docker): Promise { + if (!containerConfig.forwardPorts) { + return; + } + + for (const port of containerConfig.forwardPorts) { + let portKey: string; + let hostPort: string; + if (typeof port === 'string') { + const parts = port.split(':'); + portKey = isNaN(+parts[0]) ? parts[0] : `${parts[0]}/tcp`; + hostPort = parts[1] ?? parts[0]; + } else { + portKey = `${port}/tcp`; + hostPort = port.toString(); + } + createOptions.ExposedPorts![portKey] = {}; + createOptions.HostConfig!.PortBindings[portKey] = [{ HostPort: hostPort }]; + } + + } + +} + +@injectable() +export class MountsContribution implements ContainerCreationContribution { + async handleContainerCreation(createOptions: Docker.ContainerCreateOptions, containerConfig: DevContainerConfiguration, api: Docker): Promise { + if (!containerConfig.mounts) { + return; + } + + createOptions.HostConfig!.Mounts!.push(...(containerConfig as NonComposeContainerBase)?.mounts + ?.map(mount => typeof mount === 'string' ? + this.parseMountString(mount) : + { Source: mount.source, Target: mount.target, Type: mount.type ?? 'bind' }) ?? []); + } + + parseMountString(mount: string): Docker.MountSettings { + const parts = mount.split(','); + return { + Source: parts.find(part => part.startsWith('source=') || part.startsWith('src='))?.split('=')[1]!, + Target: parts.find(part => part.startsWith('target=') || part.startsWith('dst='))?.split('=')[1]!, + Type: (parts.find(part => part.startsWith('type='))?.split('=')[1] ?? 'bind') as Docker.MountType + }; + } +} + +export namespace OutputHelper { + export interface Progress { + id?: string; + stream: string; + status?: string; + progress?: string; + } + + export function parseProgress(progress: Progress): string { + return progress.stream ?? progress.progress ?? progress.status ?? ''; + } +} diff --git a/packages/dev-container/src/electron-node/devcontainer-file.ts b/packages/dev-container/src/electron-node/devcontainer-file.ts new file mode 100644 index 0000000000000..5bd948dd17a90 --- /dev/null +++ b/packages/dev-container/src/electron-node/devcontainer-file.ts @@ -0,0 +1,380 @@ +// ***************************************************************************** +// Copyright (C) 2024 Typefox and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +/** + * Defines a dev container + * type generated from https://containers.dev/implementors/json_schema/ and modified + */ +export type DevContainerConfiguration = (((DockerfileContainer | ImageContainer) & (NonComposeContainerBase)) | ComposeContainer) & DevContainerCommon & { location?: string }; + +export type DockerfileContainer = { + /** + * Docker build-related options. + */ + build: { + /** + * The location of the Dockerfile that defines the contents of the container. The path is relative to the folder containing the `devcontainer.json` file. + */ + dockerfile: string + /** + * The location of the context folder for building the Docker image. The path is relative to the folder containing the `devcontainer.json` file. + */ + context?: string + } & BuildOptions + [k: string]: unknown +} | { + /** + * The location of the Dockerfile that defines the contents of the container. The path is relative to the folder containing the `devcontainer.json` file. + */ + dockerFile: string + /** + * The location of the context folder for building the Docker image. The path is relative to the folder containing the `devcontainer.json` file. + */ + context?: string + + /** + * Docker build-related options. + */ + build?: { + /** + * Target stage in a multi-stage build. + */ + target?: string + /** + * Build arguments. + */ + args?: { + [k: string]: string + } + /** + * The image to consider as a cache. Use an array to specify multiple images. + */ + cacheFrom?: string | string[] + [k: string]: unknown + } + [k: string]: unknown +}; + +export interface BuildOptions { + /** + * Target stage in a multi-stage build. + */ + target?: string + /** + * Build arguments. + */ + args?: { + [k: string]: string + } + /** + * The image to consider as a cache. Use an array to specify multiple images. + */ + cacheFrom?: string | string[] + [k: string]: unknown +} +export interface ImageContainer { + /** + * The docker image that will be used to create the container. + */ + image: string + [k: string]: unknown +} + +export interface NonComposeContainerBase { + /** + * Application ports that are exposed by the container. This can be a single port or an array of ports. Each port can be a number or a string. A number is mapped to + * the same port on the host. A string is passed to Docker unchanged and can be used to map ports differently, e.g. '8000:8010'. + */ + appPort?: number | string | (number | string)[] + /** + * Container environment variables. + */ + containerEnv?: { + [k: string]: string + } + /** + * The user the container will be started with. The default is the user on the Docker image. + */ + containerUser?: string + /** + * Mount points to set up when creating the container. See Docker's documentation for the --mount option for the supported syntax. + */ + mounts?: (string | MountConfig)[] + /** + * The arguments required when starting in the container. + */ + runArgs?: string[] + /** + * Action to take when the user disconnects from the container in their editor. The default is to stop the container. + */ + shutdownAction?: 'none' | 'stopContainer' + /** + * Whether to overwrite the command specified in the image. The default is true. + */ + overrideCommand?: boolean + /** + * The path of the workspace folder inside the container. + */ + workspaceFolder?: string + /** + * The --mount parameter for docker run. The default is to mount the project folder at /workspaces/$project. + */ + workspaceMount?: string + [k: string]: unknown +} + +export interface ComposeContainer { + /** + * The name of the docker-compose file(s) used to start the services. + */ + dockerComposeFile: string | string[] + /** + * The service you want to work on. This is considered the primary container for your dev environment which your editor will connect to. + */ + service: string + /** + * An array of services that should be started and stopped. + */ + runServices?: string[] + /** + * The path of the workspace folder inside the container. This is typically the target path of a volume mount in the docker-compose.yml. + */ + workspaceFolder: string + /** + * Action to take when the user disconnects from the primary container in their editor. The default is to stop all of the compose containers. + */ + shutdownAction?: 'none' | 'stopCompose' + /** + * Whether to overwrite the command specified in the image. The default is false. + */ + overrideCommand?: boolean + [k: string]: unknown +} + +export interface DevContainerCommon { + /** + * A name for the dev container which can be displayed to the user. + */ + name?: string + /** + * Features to add to the dev container. + */ + features?: { + [k: string]: unknown + } + /** + * Array consisting of the Feature id (without the semantic version) of Features in the order the user wants them to be installed. + */ + overrideFeatureInstallOrder?: string[] + /** + * Ports that are forwarded from the container to the local machine. Can be an integer port number, or a string of the format 'host:port_number'. + */ + forwardPorts?: (number | string)[] + portsAttributes?: { + /** + * A port, range of ports (ex. '40000-55000'), or regular expression (ex. '.+\\/server.js'). + * For a port number or range, the attributes will apply to that port number or range of port numbers. + * Attributes which use a regular expression will apply to ports whose associated process command line matches the expression. + * + * This interface was referenced by `undefined`'s JSON-Schema definition + * via the `patternProperty` '(^\d+(-\d+)?$)|(.+)'. + */ + [k: string]: { + /** + * Defines the action that occurs when the port is discovered for automatic forwarding + */ + onAutoForward?: + | 'notify' + | 'openBrowser' + | 'openBrowserOnce' + | 'openPreview' + | 'silent' + | 'ignore' + /** + * Automatically prompt for elevation (if needed) when this port is forwarded. Elevate is required if the local port is a privileged port. + */ + elevateIfNeeded?: boolean + /** + * Label that will be shown in the UI for this port. + */ + label?: string + requireLocalPort?: boolean + /** + * The protocol to use when forwarding this port. + */ + protocol?: 'http' | 'https' + [k: string]: unknown + } + } + otherPortsAttributes?: { + /** + * Defines the action that occurs when the port is discovered for automatic forwarding + */ + onAutoForward?: + | 'notify' + | 'openBrowser' + | 'openPreview' + | 'silent' + | 'ignore' + /** + * Automatically prompt for elevation (if needed) when this port is forwarded. Elevate is required if the local port is a privileged port. + */ + elevateIfNeeded?: boolean + /** + * Label that will be shown in the UI for this port. + */ + label?: string + requireLocalPort?: boolean + /** + * The protocol to use when forwarding this port. + */ + protocol?: 'http' | 'https' + } + /** + * Controls whether on Linux the container's user should be updated with the local user's UID and GID. On by default when opening from a local folder. + */ + updateRemoteUserUID?: boolean + /** + * Remote environment variables to set for processes spawned in the container including lifecycle scripts and any remote editor/IDE server process. + */ + remoteEnv?: { + [k: string]: string | null + } + /** + * The username to use for spawning processes in the container including lifecycle scripts and any remote editor/IDE server process. + * The default is the same user as the container. + */ + remoteUser?: string + /** + * A command to run locally before anything else. This command is run before 'onCreateCommand'. + * If this is a single string, it will be run in a shell. If this is an array of strings, it will be run as a single command without shell. + */ + initializeCommand?: string | string[] + /** + * A command to run when creating the container. This command is run after 'initializeCommand' and before 'updateContentCommand'. + * If this is a single string, it will be run in a shell. If this is an array of strings, it will be run as a single command without shell. + */ + onCreateCommand?: + | string + | string[] + | { + [k: string]: string | string[] + } + /** + * A command to run when creating the container and rerun when the workspace content was updated while creating the container. + * This command is run after 'onCreateCommand' and before 'postCreateCommand'. If this is a single string, it will be run in a shell. + * If this is an array of strings, it will be run as a single command without shell. + */ + updateContentCommand?: + | string + | string[] + | { + [k: string]: string | string[] + } + /** + * A command to run after creating the container. This command is run after 'updateContentCommand' and before 'postStartCommand'. + * If this is a single string, it will be run in a shell. If this is an array of strings, it will be run as a single command without shell. + */ + postCreateCommand?: + | string + | string[] + | { + [k: string]: string | string[] + } + /** + * A command to run after starting the container. This command is run after 'postCreateCommand' and before 'postAttachCommand'. + * If this is a single string, it will be run in a shell. If this is an array of strings, it will be run as a single command without shell. + */ + postStartCommand?: + | string + | string[] + | { + [k: string]: string | string[] + } + /** + * A command to run when attaching to the container. This command is run after 'postStartCommand'. If this is a single string, it will be run in a shell. + * If this is an array of strings, it will be run as a single command without shell. + */ + postAttachCommand?: + | string + | string[] + | { + [k: string]: string | string[] + } + /** + * The user command to wait for before continuing execution in the background while the UI is starting up. The default is 'updateContentCommand'. + */ + waitFor?: + | 'initializeCommand' + | 'onCreateCommand' + | 'updateContentCommand' + | 'postCreateCommand' + | 'postStartCommand' + /** + * User environment probe to run. The default is 'loginInteractiveShell'. + */ + userEnvProbe?: + | 'none' + | 'loginShell' + | 'loginInteractiveShell' + | 'interactiveShell' + /** + * Host hardware requirements. + */ + hostRequirements?: { + /** + * Number of required CPUs. + */ + cpus?: number + /** + * Amount of required RAM in bytes. Supports units tb, gb, mb and kb. + */ + memory?: string + /** + * Amount of required disk space in bytes. Supports units tb, gb, mb and kb. + */ + storage?: string + gpu?: + | (true | false | 'optional') + | { + /** + * Number of required cores. + */ + cores?: number + /** + * Amount of required RAM in bytes. Supports units tb, gb, mb and kb. + */ + memory?: string + } + [k: string]: unknown + } + /** + * Tool-specific configuration. Each tool should use a JSON object subproperty with a unique name to group its customizations. + */ + customizations?: { + [k: string]: unknown + } + additionalProperties?: { + [k: string]: unknown + } + [k: string]: unknown +} + +export interface MountConfig { + source: string, + target: string, + type: 'volume' | 'bind', +} diff --git a/packages/dev-container/src/electron-node/docker-container-service.ts b/packages/dev-container/src/electron-node/docker-container-service.ts new file mode 100644 index 0000000000000..3626e7c00b762 --- /dev/null +++ b/packages/dev-container/src/electron-node/docker-container-service.ts @@ -0,0 +1,124 @@ +// ***************************************************************************** +// Copyright (C) 2024 Typefox and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { ContributionProvider, URI } from '@theia/core'; +import { inject, injectable, named } from '@theia/core/shared/inversify'; +import { WorkspaceServer } from '@theia/workspace/lib/common'; +import * as fs from '@theia/core/shared/fs-extra'; +import * as Docker from 'dockerode'; +import { LastContainerInfo } from '../electron-common/remote-container-connection-provider'; +import { DevContainerConfiguration } from './devcontainer-file'; +import { DevContainerFileService } from './dev-container-file-service'; +import { ContainerOutputProvider } from '../electron-common/container-output-provider'; + +export const ContainerCreationContribution = Symbol('ContainerCreationContributions'); + +export interface ContainerCreationContribution { + handleContainerCreation(createOptions: Docker.ContainerCreateOptions, + containerConfig: DevContainerConfiguration, + api: Docker, + outputProvider?: ContainerOutputProvider): Promise; +} + +@injectable() +export class DockerContainerService { + + @inject(WorkspaceServer) + protected readonly workspaceServer: WorkspaceServer; + + @inject(ContributionProvider) @named(ContainerCreationContribution) + protected readonly containerCreationContributions: ContributionProvider; + + @inject(DevContainerFileService) + protected readonly devContainerFileService: DevContainerFileService; + + async getOrCreateContainer(docker: Docker, devcontainerFile: string, + lastContainerInfo?: LastContainerInfo, outputProvider?: ContainerOutputProvider): Promise { + let container; + + const workspace = new URI(await this.workspaceServer.getMostRecentlyUsedWorkspace()); + + if (lastContainerInfo && fs.statSync(devcontainerFile).mtimeMs < lastContainerInfo.lastUsed) { + try { + container = docker.getContainer(lastContainerInfo.id); + if ((await container.inspect()).State.Running) { + await container.restart(); + } else { + await container.start(); + } + } catch (e) { + container = undefined; + console.warn('DevContainer: could not find last used container'); + } + } + if (!container) { + container = await this.buildContainer(docker, devcontainerFile, workspace, outputProvider); + } + return container; + } + + protected async buildContainer(docker: Docker, devcontainerFile: string, workspace: URI, outputProvider?: ContainerOutputProvider): Promise { + const devcontainerConfig = await this.devContainerFileService.getConfiguration(devcontainerFile); + + if (!devcontainerConfig) { + // TODO add ability for user to create new config + throw new Error('No devcontainer.json'); + } + + const containerCreateOptions: Docker.ContainerCreateOptions = { + Tty: true, + ExposedPorts: {}, + HostConfig: { + PortBindings: {}, + Mounts: [{ + Source: workspace.path.toString(), + Target: `/workspaces/${workspace.path.name}`, + Type: 'bind' + }], + }, + }; + + for (const containerCreateContrib of this.containerCreationContributions.getContributions()) { + await containerCreateContrib.handleContainerCreation(containerCreateOptions, devcontainerConfig, docker, outputProvider); + } + + // TODO add more config + const container = await docker.createContainer(containerCreateOptions); + await container.start(); + + return container; + } + + protected getPortBindings(forwardPorts: (string | number)[]): { exposedPorts: {}, portBindings: {} } { + const res: { exposedPorts: { [key: string]: {} }, portBindings: { [key: string]: {} } } = { exposedPorts: {}, portBindings: {} }; + for (const port of forwardPorts) { + let portKey: string; + let hostPort: string; + if (typeof port === 'string') { + const parts = port.split(':'); + portKey = isNaN(+parts[0]) ? parts[0] : `${parts[0]}/tcp`; + hostPort = parts[1] ?? parts[0]; + } else { + portKey = `${port}/tcp`; + hostPort = port.toString(); + } + res.exposedPorts[portKey] = {}; + res.portBindings[portKey] = [{ HostPort: hostPort }]; + } + + return res; + } +} diff --git a/packages/dev-container/src/electron-node/remote-container-connection-provider.ts b/packages/dev-container/src/electron-node/remote-container-connection-provider.ts new file mode 100644 index 0000000000000..6c80b89e57c7b --- /dev/null +++ b/packages/dev-container/src/electron-node/remote-container-connection-provider.ts @@ -0,0 +1,287 @@ +// ***************************************************************************** +// Copyright (C) 2024 Typefox and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import * as net from 'net'; +import { + ContainerConnectionOptions, ContainerConnectionResult, + DevContainerFile, RemoteContainerConnectionProvider +} from '../electron-common/remote-container-connection-provider'; +import { RemoteConnection, RemoteExecOptions, RemoteExecResult, RemoteExecTester, RemoteStatusReport } from '@theia/remote/lib/electron-node/remote-types'; +import { RemoteSetupResult, RemoteSetupService } from '@theia/remote/lib/electron-node/setup/remote-setup-service'; +import { RemoteConnectionService } from '@theia/remote/lib/electron-node/remote-connection-service'; +import { RemoteProxyServerProvider } from '@theia/remote/lib/electron-node/remote-proxy-server-provider'; +import { Emitter, Event, generateUuid, MessageService, RpcServer } from '@theia/core'; +import { Socket } from 'net'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import * as Docker from 'dockerode'; +import { DockerContainerService } from './docker-container-service'; +import { Deferred } from '@theia/core/lib/common/promise-util'; +import { WriteStream } from 'tty'; +import { PassThrough } from 'stream'; +import { exec } from 'child_process'; +import { DevContainerFileService } from './dev-container-file-service'; +import { ContainerOutputProvider } from '../electron-common/container-output-provider'; + +@injectable() +export class DevContainerConnectionProvider implements RemoteContainerConnectionProvider, RpcServer { + + @inject(RemoteConnectionService) + protected readonly remoteConnectionService: RemoteConnectionService; + + @inject(RemoteSetupService) + protected readonly remoteSetup: RemoteSetupService; + + @inject(MessageService) + protected readonly messageService: MessageService; + + @inject(RemoteProxyServerProvider) + protected readonly serverProvider: RemoteProxyServerProvider; + + @inject(DockerContainerService) + protected readonly containerService: DockerContainerService; + + @inject(DevContainerFileService) + protected readonly devContainerFileService: DevContainerFileService; + + protected outputProvider: ContainerOutputProvider | undefined; + + setClient(client: ContainerOutputProvider): void { + this.outputProvider = client; + } + + async connectToContainer(options: ContainerConnectionOptions): Promise { + const dockerConnection = new Docker(); + const version = await dockerConnection.version().catch(() => undefined); + + if (!version) { + this.messageService.error('Docker Daemon is not running'); + throw new Error('Docker is not running'); + } + + // create container + const progress = await this.messageService.showProgress({ + text: 'Creating container', + }); + try { + const container = await this.containerService.getOrCreateContainer(dockerConnection, options.devcontainerFile, options.lastContainerInfo, this.outputProvider); + + // create actual connection + const report: RemoteStatusReport = message => progress.report({ message }); + report('Connecting to remote system...'); + + const remote = await this.createContainerConnection(container, dockerConnection); + const result = await this.remoteSetup.setup({ + connection: remote, + report, + nodeDownloadTemplate: options.nodeDownloadTemplate + }); + remote.remoteSetupResult = result; + + const registration = this.remoteConnectionService.register(remote); + const server = await this.serverProvider.getProxyServer(socket => { + remote.forwardOut(socket); + }); + remote.onDidDisconnect(() => { + server.close(); + registration.dispose(); + }); + const localPort = (server.address() as net.AddressInfo).port; + remote.localPort = localPort; + return { + containerId: container.id, + workspacePath: (await container.inspect()).Mounts[0].Destination, + port: localPort.toString(), + }; + } catch (e) { + this.messageService.error(e.message); + console.error(e); + throw e; + } finally { + progress.cancel(); + } + } + + getDevContainerFiles(): Promise { + return this.devContainerFileService.getAvailableFiles(); + } + + async createContainerConnection(container: Docker.Container, docker: Docker): Promise { + return Promise.resolve(new RemoteDockerContainerConnection({ + id: generateUuid(), + name: 'dev-container', + type: 'container', + docker, + container, + })); + } + + dispose(): void { + + } + +} + +export interface RemoteContainerConnectionOptions { + id: string; + name: string; + type: string; + docker: Docker; + container: Docker.Container; +} + +interface ContainerTerminalSession { + execution: Docker.Exec, + stdout: WriteStream, + stderr: WriteStream, + executeCommand(cmd: string, args?: string[]): Promise<{ stdout: string, stderr: string }>; +} + +export class RemoteDockerContainerConnection implements RemoteConnection { + + id: string; + name: string; + type: string; + localPort: number; + remotePort: number; + + docker: Docker; + container: Docker.Container; + + containerInfo: Docker.ContainerInspectInfo | undefined; + + remoteSetupResult: RemoteSetupResult; + + protected activeTerminalSession: ContainerTerminalSession | undefined; + + protected readonly onDidDisconnectEmitter = new Emitter(); + onDidDisconnect: Event = this.onDidDisconnectEmitter.event; + + constructor(options: RemoteContainerConnectionOptions) { + this.id = options.id; + this.type = options.type; + this.name = options.name; + this.onDidDisconnect(() => this.dispose()); + + this.docker = options.docker; + this.container = options.container; + } + + async forwardOut(socket: Socket): Promise { + const node = `${this.remoteSetupResult.nodeDirectory}/bin/node`; + const devContainerServer = `${this.remoteSetupResult.applicationDirectory}/backend/dev-container-server.js`; + try { + const ttySession = await this.container.exec({ + Cmd: ['sh', '-c', `${node} ${devContainerServer} -target-port=${this.remotePort}`], + AttachStdin: true, AttachStdout: true, AttachStderr: true + }); + + const stream = await ttySession.start({ hijack: true, stdin: true }); + + socket.pipe(stream); + ttySession.modem.demuxStream(stream, socket, socket); + } catch (e) { + console.error(e); + } + } + + async exec(cmd: string, args?: string[], options?: RemoteExecOptions): Promise { + // return (await this.getOrCreateTerminalSession()).executeCommand(cmd, args); + const deferred = new Deferred(); + try { + // TODO add windows container support + const execution = await this.container.exec({ Cmd: ['sh', '-c', `${cmd} ${args?.join(' ') ?? ''}`], AttachStdout: true, AttachStderr: true }); + let stdoutBuffer = ''; + let stderrBuffer = ''; + const stream = await execution?.start({}); + const stdout = new PassThrough(); + stdout.on('data', (chunk: Buffer) => { + stdoutBuffer += chunk.toString(); + }); + const stderr = new PassThrough(); + stderr.on('data', (chunk: Buffer) => { + stderrBuffer += chunk.toString(); + }); + execution.modem.demuxStream(stream, stdout, stderr); + stream?.addListener('close', () => deferred.resolve({ stdout: stdoutBuffer, stderr: stderrBuffer })); + } catch (e) { + deferred.reject(e); + } + return deferred.promise; + } + + async execPartial(cmd: string, tester: RemoteExecTester, args?: string[], options?: RemoteExecOptions): Promise { + const deferred = new Deferred(); + try { + // TODO add windows container support + const execution = await this.container.exec({ Cmd: ['sh', '-c', `${cmd} ${args?.join(' ') ?? ''}`], AttachStdout: true, AttachStderr: true }); + let stdoutBuffer = ''; + let stderrBuffer = ''; + const stream = await execution?.start({}); + stream.on('close', () => { + if (deferred.state === 'unresolved') { + deferred.resolve({ stdout: stdoutBuffer, stderr: stderrBuffer }); + } + }); + const stdout = new PassThrough(); + stdout.on('data', (data: Buffer) => { + if (deferred.state === 'unresolved') { + stdoutBuffer += data.toString(); + + if (tester(stdoutBuffer, stderrBuffer)) { + deferred.resolve({ stdout: stdoutBuffer, stderr: stderrBuffer }); + } + } + }); + const stderr = new PassThrough(); + stderr.on('data', (data: Buffer) => { + if (deferred.state === 'unresolved') { + stderrBuffer += data.toString(); + + if (tester(stdoutBuffer, stderrBuffer)) { + deferred.resolve({ stdout: stdoutBuffer, stderr: stderrBuffer }); + } + } + }); + execution.modem.demuxStream(stream, stdout, stderr); + } catch (e) { + deferred.reject(e); + } + return deferred.promise; + } + + async copy(localPath: string | Buffer | NodeJS.ReadableStream, remotePath: string): Promise { + const deferred = new Deferred(); + const process = exec(`docker cp -qa ${localPath.toString()} ${this.container.id}:${remotePath}`); + + let stderr = ''; + process.stderr?.on('data', data => { + stderr += data.toString(); + }); + process.on('close', code => { + if (code === 0) { + deferred.resolve(); + } else { + deferred.reject(stderr); + } + }); + return deferred.promise; + } + + dispose(): void { + this.container.stop(); + } + +} diff --git a/packages/dev-container/src/package.spec.ts b/packages/dev-container/src/package.spec.ts new file mode 100644 index 0000000000000..759142013d5a6 --- /dev/null +++ b/packages/dev-container/src/package.spec.ts @@ -0,0 +1,28 @@ +// ***************************************************************************** +// Copyright (C) 2017 Ericsson and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +/* note: this bogus test file is required so that + we are able to run mocha unit tests on this + package, without having any actual unit tests in it. + This way a coverage report will be generated, + showing 0% coverage, instead of no report. + This file can be removed once we have real unit + tests in place. */ + +describe('dev-container package', () => { + + it('support code coverage statistics', () => true); +}); diff --git a/packages/dev-container/tsconfig.json b/packages/dev-container/tsconfig.json new file mode 100644 index 0000000000000..d7f2cbfb079c4 --- /dev/null +++ b/packages/dev-container/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../configs/base.tsconfig", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../core" + }, + { + "path": "../output" + }, + { + "path": "../remote" + }, + { + "path": "../workspace" + } + ] +} diff --git a/packages/remote/package.json b/packages/remote/package.json index 6ca865c3c6b39..5a287e315834f 100644 --- a/packages/remote/package.json +++ b/packages/remote/package.json @@ -12,7 +12,7 @@ "decompress-unzip": "^4.0.1", "express-http-proxy": "^1.6.3", "glob": "^8.1.0", - "ssh2": "^1.12.0", + "ssh2": "^1.15.0", "ssh2-sftp-client": "^9.1.0", "socket.io": "^4.5.3", "socket.io-client": "^4.5.3", diff --git a/packages/remote/src/electron-browser/remote-frontend-contribution.ts b/packages/remote/src/electron-browser/remote-frontend-contribution.ts index 9db5ed2a9f199..8e8d2f13d2fde 100644 --- a/packages/remote/src/electron-browser/remote-frontend-contribution.ts +++ b/packages/remote/src/electron-browser/remote-frontend-contribution.ts @@ -108,9 +108,7 @@ export class RemoteFrontendContribution implements CommandContribution, Frontend protected disconnectRemote(): void { const port = new URLSearchParams(location.search).get('localPort'); if (port) { - this.windowService.reload({ - port - }); + this.windowService.reload({ search: { port } }); } } diff --git a/packages/remote/src/electron-browser/remote-registry-contribution.ts b/packages/remote/src/electron-browser/remote-registry-contribution.ts index c936aeb833886..735b612f29564 100644 --- a/packages/remote/src/electron-browser/remote-registry-contribution.ts +++ b/packages/remote/src/electron-browser/remote-registry-contribution.ts @@ -16,8 +16,7 @@ import { Command, CommandHandler, Emitter, Event } from '@theia/core'; import { inject, injectable } from '@theia/core/shared/inversify'; -import { WindowService } from '@theia/core/lib/browser/window/window-service'; -import { WindowSearchParams } from '@theia/core/lib/common/window'; +import { WindowService, WindowReloadOptions } from '@theia/core/lib/browser/window/window-service'; export const RemoteRegistryContribution = Symbol('RemoteRegistryContribution'); @@ -33,15 +32,19 @@ export abstract class AbstractRemoteRegistryContribution implements RemoteRegist abstract registerRemoteCommands(registry: RemoteRegistry): void; - protected openRemote(port: string, newWindow: boolean): void { + protected openRemote(port: string, newWindow: boolean, workspace?: string): void { const searchParams = new URLSearchParams(location.search); const localPort = searchParams.get('localPort') || searchParams.get('port'); - const options: WindowSearchParams = { - port + const options: WindowReloadOptions = { + search: { port } }; if (localPort) { - options.localPort = localPort; + options.search!.localPort = localPort; } + if (workspace) { + options.hash = workspace; + } + if (newWindow) { this.windowService.openNewDefaultWindow(options); } else { diff --git a/packages/remote/src/electron-node/remote-types.ts b/packages/remote/src/electron-node/remote-types.ts index 7499fd682004d..e50811629d0cc 100644 --- a/packages/remote/src/electron-node/remote-types.ts +++ b/packages/remote/src/electron-node/remote-types.ts @@ -50,7 +50,20 @@ export interface RemoteConnection extends Disposable { remotePort: number; onDidDisconnect: Event; forwardOut(socket: net.Socket): void; + + /** + * execute a single command on the remote machine + */ exec(cmd: string, args?: string[], options?: RemoteExecOptions): Promise; + + /** + * execute a command on the remote machine and wait for a specific output + * @param tester function which returns true if the output is as expected + */ execPartial(cmd: string, tester: RemoteExecTester, args?: string[], options?: RemoteExecOptions): Promise; + + /** + * copy files from local to remote + */ copy(localPath: string | Buffer | NodeJS.ReadableStream, remotePath: string): Promise; } diff --git a/packages/remote/src/electron-node/setup/remote-setup-service.ts b/packages/remote/src/electron-node/setup/remote-setup-service.ts index 16aee1c0ae085..951f62429da8f 100644 --- a/packages/remote/src/electron-node/setup/remote-setup-service.ts +++ b/packages/remote/src/electron-node/setup/remote-setup-service.ts @@ -29,6 +29,11 @@ export interface RemoteSetupOptions { nodeDownloadTemplate?: string; } +export interface RemoteSetupResult { + applicationDirectory: string; + nodeDirectory: string; +} + @injectable() export class RemoteSetupService { @@ -47,7 +52,7 @@ export class RemoteSetupService { @inject(ApplicationPackage) protected readonly applicationPackage: ApplicationPackage; - async setup(options: RemoteSetupOptions): Promise { + async setup(options: RemoteSetupOptions): Promise { const { connection, report, @@ -86,12 +91,16 @@ export class RemoteSetupService { report('Starting application on remote...'); const port = await this.startApplication(connection, platform, applicationDirectory, remoteNodeDirectory); connection.remotePort = port; + return { + applicationDirectory: libDir, + nodeDirectory: remoteNodeDirectory + }; } protected async startApplication(connection: RemoteConnection, platform: RemotePlatform, remotePath: string, nodeDir: string): Promise { const nodeExecutable = this.scriptService.joinPath(platform, nodeDir, ...(platform.os === OS.Type.Windows ? ['node.exe'] : ['bin', 'node'])); const mainJsFile = this.scriptService.joinPath(platform, remotePath, 'lib', 'backend', 'main.js'); - const localAddressRegex = /listening on http:\/\/127.0.0.1:(\d+)/; + const localAddressRegex = /listening on http:\/\/0.0.0.0:(\d+)/; let prefix = ''; if (platform.os === OS.Type.Windows) { // We might to switch to PowerShell beforehand on Windows @@ -101,7 +110,7 @@ export class RemoteSetupService { // This way, our current working directory is set as expected const result = await connection.execPartial(`${prefix}cd "${remotePath}";${nodeExecutable}`, stdout => localAddressRegex.test(stdout), - [mainJsFile, '--hostname=127.0.0.1', '--port=0', '--remote']); + [mainJsFile, '--hostname=0.0.0.0', `--port=${connection.remotePort ?? 0}`, '--remote']); const match = localAddressRegex.exec(result.stdout); if (!match) { diff --git a/tsconfig.json b/tsconfig.json index 3b0eb7ae410c0..fec10e328dcec 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -69,6 +69,9 @@ { "path": "packages/debug" }, + { + "path": "packages/dev-container" + }, { "path": "packages/editor" }, diff --git a/yarn.lock b/yarn.lock index 1098b78f1f71a..7ffee9d042681 100644 --- a/yarn.lock +++ b/yarn.lock @@ -977,6 +977,11 @@ "@babel/helper-validator-identifier" "^7.22.20" to-fast-properties "^2.0.0" +"@balena/dockerignore@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@balena/dockerignore/-/dockerignore-1.0.2.tgz#9ffe4726915251e8eb69f44ef3547e0da2c03e0d" + integrity sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q== + "@discoveryjs/json-ext@^0.5.0": version "0.5.7" resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" @@ -1860,6 +1865,22 @@ resolved "https://registry.yarnpkg.com/@types/diff/-/diff-3.5.8.tgz#24434e47c2ef1cbcdf96afa43c6ea2fd8e4add93" integrity sha512-CZ5vepL87+M8PxRIvJjR181Erahch2w7Jev/XJm+Iot/SOvJh8QqH/N79b+vsKtYF6fFzoPieiiq2c5tzmXR9A== +"@types/docker-modem@*": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/docker-modem/-/docker-modem-3.0.6.tgz#1f9262fcf85425b158ca725699a03eb23cddbf87" + integrity sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg== + dependencies: + "@types/node" "*" + "@types/ssh2" "*" + +"@types/dockerode@^3.3.23": + version "3.3.23" + resolved "https://registry.yarnpkg.com/@types/dockerode/-/dockerode-3.3.23.tgz#07b2084013d01e14d5d97856446f4d9c9f27c223" + integrity sha512-Lz5J+NFgZS4cEVhquwjIGH4oQwlVn2h7LXD3boitujBnzOE5o7s9H8hchEjoDK2SlRsJTogdKnQeiJgPPKLIEw== + dependencies: + "@types/docker-modem" "*" + "@types/node" "*" + "@types/dompurify@^2.2.2": version "2.4.0" resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-2.4.0.tgz#fd9706392a88e0e0e6d367f3588482d817df0ab9" @@ -4724,6 +4745,25 @@ dir-glob@^3.0.1: dependencies: path-type "^4.0.0" +docker-modem@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/docker-modem/-/docker-modem-5.0.3.tgz#50c06f11285289f58112b5c4c4d89824541c41d0" + integrity sha512-89zhop5YVhcPEt5FpUFGr3cDyceGhq/F9J+ZndQ4KfqNvfbJpPMfgeixFgUj5OjCYAboElqODxY5Z1EBsSa6sg== + dependencies: + debug "^4.1.1" + readable-stream "^3.5.0" + split-ca "^1.0.1" + ssh2 "^1.15.0" + +dockerode@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/dockerode/-/dockerode-4.0.2.tgz#dedc8529a1db3ac46d186f5912389899bc309f7d" + integrity sha512-9wM1BVpVMFr2Pw3eJNXrYYt6DT9k0xMcsSCjtPvyQ+xa1iPg/Mo3T/gUcwI0B2cczqCeCYRPF8yFYDwtFXT0+w== + dependencies: + "@balena/dockerignore" "^1.0.2" + docker-modem "^5.0.3" + tar-fs "~2.0.1" + doctrine@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" @@ -9934,7 +9974,7 @@ readable-stream@^2.0.0, readable-stream@^2.0.2, readable-stream@^2.0.5, readable string_decoder "~1.1.1" util-deprecate "~1.0.1" -readable-stream@^3.0.0, readable-stream@^3.0.2, readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: +readable-stream@^3.0.0, readable-stream@^3.0.2, readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.5.0, readable-stream@^3.6.0: version "3.6.2" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== @@ -10759,6 +10799,11 @@ spdx-license-ids@^3.0.0: resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.16.tgz#a14f64e0954f6e25cc6587bd4f392522db0d998f" integrity sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw== +split-ca@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/split-ca/-/split-ca-1.0.1.tgz#6c83aff3692fa61256e0cd197e05e9de157691a6" + integrity sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ== + split2@^3.2.2: version "3.2.2" resolved "https://registry.yarnpkg.com/split2/-/split2-3.2.2.tgz#bf2cf2a37d838312c249c89206fd7a17dd12365f" @@ -10799,7 +10844,7 @@ ssh2-sftp-client@^9.1.0: promise-retry "^2.0.1" ssh2 "^1.12.0" -ssh2@^1.12.0, ssh2@^1.5.0: +ssh2@^1.12.0, ssh2@^1.15.0, ssh2@^1.5.0: version "1.15.0" resolved "https://registry.yarnpkg.com/ssh2/-/ssh2-1.15.0.tgz#2f998455036a7f89e0df5847efb5421748d9871b" integrity sha512-C0PHgX4h6lBxYx7hcXwu3QWdh4tg6tZZsTfXcdvc5caW/EMxaB4H9dWsl7qk+F7LAW762hp8VbXOX7x4xUYvEw== @@ -11112,6 +11157,16 @@ tar-fs@^1.16.2: pump "^1.0.0" tar-stream "^1.1.2" +tar-fs@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.0.1.tgz#e44086c1c60d31a4f0cf893b1c4e155dabfae9e2" + integrity sha512-6tzWDMeroL87uF/+lin46k+Q+46rAJ0SyPGz7OW7wTgblI273hsBqk2C1j0/xNadNLKDTUL9BukSjB7cwgmlPA== + dependencies: + chownr "^1.1.1" + mkdirp-classic "^0.5.2" + pump "^3.0.0" + tar-stream "^2.0.0" + tar-stream@^1.1.2, tar-stream@^1.5.2: version "1.6.2" resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-1.6.2.tgz#8ea55dab37972253d9a9af90fdcd559ae435c555" @@ -11125,7 +11180,7 @@ tar-stream@^1.1.2, tar-stream@^1.5.2: to-buffer "^1.1.1" xtend "^4.0.0" -tar-stream@^2.1.4, tar-stream@^2.2.0, tar-stream@~2.2.0: +tar-stream@^2.0.0, tar-stream@^2.1.4, tar-stream@^2.2.0, tar-stream@~2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287" integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ== @@ -11771,7 +11826,7 @@ uuid@^7.0.3: resolved "https://registry.yarnpkg.com/uuid/-/uuid-7.0.3.tgz#c5c9f2c8cf25dc0a372c4df1441c41f5bd0c680b" integrity sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg== -uuid@^8.3.2: +uuid@^8.0.0, uuid@^8.3.2: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==