From 17ec00fe3df3bf6b662f1f37e51ec6978efdc6cd Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Fri, 19 Jan 2024 08:42:49 +0100 Subject: [PATCH 01/34] basics for dev-container support Signed-off-by: Jonah Iden --- examples/browser/package.json | 1 + examples/electron/package.json | 1 + packages/dev-container/.eslintrc.js | 10 ++ packages/dev-container/README copy.md | 30 ++++ packages/dev-container/package.json | 52 ++++++ .../container-connection-contribution.ts | 42 +++++ .../dev-container-frontent-module.ts | 22 +++ .../remote-container-connection-provider.ts | 22 +++ .../dev-container-backend-module.ts | 30 ++++ .../src/electron-node/docker-cmd-service.ts | 15 ++ .../docker-container-creation-service.ts | 45 ++++++ .../remote-container-connection-provider.ts | 153 ++++++++++++++++++ packages/dev-container/tsconfig.json | 22 +++ .../remote/src/electron-node/remote-types.ts | 13 ++ tsconfig.json | 3 + yarn.lock | 63 +++++++- 16 files changed, 520 insertions(+), 4 deletions(-) create mode 100644 packages/dev-container/.eslintrc.js create mode 100644 packages/dev-container/README copy.md create mode 100644 packages/dev-container/package.json create mode 100644 packages/dev-container/src/electron-browser/container-connection-contribution.ts create mode 100644 packages/dev-container/src/electron-browser/dev-container-frontent-module.ts create mode 100644 packages/dev-container/src/electron-common/remote-container-connection-provider.ts create mode 100644 packages/dev-container/src/electron-node/dev-container-backend-module.ts create mode 100644 packages/dev-container/src/electron-node/docker-cmd-service.ts create mode 100644 packages/dev-container/src/electron-node/docker-container-creation-service.ts create mode 100644 packages/dev-container/src/electron-node/remote-container-connection-provider.ts create mode 100644 packages/dev-container/tsconfig.json 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/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/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 copy.md b/packages/dev-container/README copy.md new file mode 100644 index 0000000000000..22a8c62ac364d --- /dev/null +++ b/packages/dev-container/README copy.md @@ -0,0 +1,30 @@ +
+ +
+ +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). + +## 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..dac70187d7a43 --- /dev/null +++ b/packages/dev-container/package.json @@ -0,0 +1,52 @@ +{ + "name": "@theia/dev-container", + "version": "1.47.0", + "description": "Theia - Editor Preview Extension", + "dependencies": { + "@theia/core": "1.47.0", + "@theia/remote": "1.47.0", + "@theia/workspace": "1.47.0", + "dockerode": "^4.0.2", + "uuid": "^8.0.0" + }, + "publishConfig": { + "access": "public" + }, + "theiaExtensions": [ + { + "frontendElectron": "lib/browser/dev-container-frontend-module", + "backendElectron": "lib/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/electron-browser/container-connection-contribution.ts b/packages/dev-container/src/electron-browser/container-connection-contribution.ts new file mode 100644 index 0000000000000..157b6835ba4b7 --- /dev/null +++ b/packages/dev-container/src/electron-browser/container-connection-contribution.ts @@ -0,0 +1,42 @@ +// ***************************************************************************** +// 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 { RemoteContainerConnectionProvider } from '../electron-common/remote-container-connection-provider'; + +@injectable() +export class ContainerConnectionContribution extends AbstractRemoteRegistryContribution { + + @inject(RemoteContainerConnectionProvider) + protected readonly connectionProvider: RemoteContainerConnectionProvider; + + registerRemoteCommands(registry: RemoteRegistry): void { + registry.registerCommand({ + id: 'dev-container:reopen-in-container', + label: 'Reopen in Container' + }, { + execute: () => this.openInContainer() + }); + + } + + async openInContainer(): Promise { + const port = await this.connectionProvider.connectToContainer(); + this.openRemote(port, false); + } + +} diff --git a/packages/dev-container/src/electron-browser/dev-container-frontent-module.ts b/packages/dev-container/src/electron-browser/dev-container-frontent-module.ts new file mode 100644 index 0000000000000..8b675adde1151 --- /dev/null +++ b/packages/dev-container/src/electron-browser/dev-container-frontent-module.ts @@ -0,0 +1,22 @@ +// ***************************************************************************** +// 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 { ContainerConnectionContribution } from './container-connection-contribution'; + +export default new ContainerModule((bind, unbind, isBound, rebind) => { + bind(RemoteRegistryContribution).to(ContainerConnectionContribution); +}); 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..73aadabde4da6 --- /dev/null +++ b/packages/dev-container/src/electron-common/remote-container-connection-provider.ts @@ -0,0 +1,22 @@ +// ***************************************************************************** +// 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 const RemoteContainerConnectionProviderPath = '/remote/container'; + +export const RemoteContainerConnectionProvider = Symbol('RemoteContainerConnectionProvider'); + +export interface RemoteContainerConnectionProvider { + connectToContainer(): 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..896b49d246d85 --- /dev/null +++ b/packages/dev-container/src/electron-node/dev-container-backend-module.ts @@ -0,0 +1,30 @@ +// ***************************************************************************** +// 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'; + +export const remoteConnectionModule = ConnectionContainerModule.create(({ bind, bindBackendService }) => { + bind(DevContainerConnectionProvider).toSelf().inSingletonScope(); + bind(RemoteContainerConnectionProvider).toService(DevContainerConnectionProvider); + bindBackendService(RemoteContainerConnectionProviderPath, RemoteContainerConnectionProvider); +}); + +export default new ContainerModule((bind, unbind, isBound, rebind) => { + bind(ConnectionContainerModule).toConstantValue(remoteConnectionModule); +}); diff --git a/packages/dev-container/src/electron-node/docker-cmd-service.ts b/packages/dev-container/src/electron-node/docker-cmd-service.ts new file mode 100644 index 0000000000000..0c850ec03be57 --- /dev/null +++ b/packages/dev-container/src/electron-node/docker-cmd-service.ts @@ -0,0 +1,15 @@ +// ***************************************************************************** +// 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 +// ***************************************************************************** diff --git a/packages/dev-container/src/electron-node/docker-container-creation-service.ts b/packages/dev-container/src/electron-node/docker-container-creation-service.ts new file mode 100644 index 0000000000000..279bc05219812 --- /dev/null +++ b/packages/dev-container/src/electron-node/docker-container-creation-service.ts @@ -0,0 +1,45 @@ +// ***************************************************************************** +// 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 { URI } from '@theia/core'; +import { inject, injectable } 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'; + +@injectable() +export class DockerContainerCreationService { + + @inject(WorkspaceServer) + protected readonly workspaceServer: WorkspaceServer; + + async buildContainer(docker: Docker, from?: URI): Promise { + const workspace = from ?? new URI(await this.workspaceServer.getMostRecentlyUsedWorkspace()); + if (!workspace) { + throw new Error('No workspace'); + } + + const devcontainerFile = workspace.resolve('.devcontainer/devcontainer.json'); + const devcontainerConfig = JSON.parse(await fs.readFile(devcontainerFile.path.fsPath(), 'utf-8')); + + // TODO add more config + const container = docker.createContainer({ + Image: devcontainerConfig.image, + }); + + return container; + } +} 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..d26cb35033d34 --- /dev/null +++ b/packages/dev-container/src/electron-node/remote-container-connection-provider.ts @@ -0,0 +1,153 @@ +// ***************************************************************************** +// 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 { RemoteContainerConnectionProvider } from '../electron-common/remote-container-connection-provider'; +import { RemoteConnection, RemoteExecOptions, RemoteExecResult, RemoteExecTester, RemoteStatusReport } from '@theia/remote/lib/electron-node/remote-types'; +import { 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, MessageService } from '@theia/core'; +import { Socket } from 'net'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { v4 } from 'uuid'; +import * as Docker from 'dockerode'; +import { DockerContainerCreationService } from './docker-container-creation-service'; + +@injectable() +export class DevContainerConnectionProvider implements RemoteContainerConnectionProvider { + + @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(DockerContainerCreationService) + protected readonly containerCreationService: DockerContainerCreationService; + + async connectToContainer(): Promise { + const dockerConnection = new Docker(); + const version = await dockerConnection.version(); + + if (!version) { + this.messageService.error('Docker Daemon is not running'); + throw new Error('Docker is not running'); + } + + const progress = await this.messageService.showProgress({ + text: 'create container', + }); + + // create container + progress.report({ message: 'Connecting to container' }); + + const container = await this.containerCreationService.buildContainer(dockerConnection); + + // create actual connection + const report: RemoteStatusReport = message => progress.report({ message }); + report('Connecting to remote system...'); + + const remote = await this.createContainerConnection(container, dockerConnection); + await this.remoteSetup.setup({ + connection: remote, + report, + nodeDownloadTemplate: '' + }); + 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 localPort.toString(); + } + + async createContainerConnection(container: Docker.Container, docker: Docker): Promise { + return Promise.resolve(new RemoteDockerContainerConnection({ + id: v4(), + name: 'dev-container', + type: 'container', + docker, + container + })); + } + +} + +export interface RemoteContainerConnectionOptions { + id: string; + name: string; + type: string; + docker: Docker; + container: Docker.Container; +} + +export class RemoteDockerContainerConnection implements RemoteConnection { + + id: string; + name: string; + type: string; + localPort: number; + remotePort: number; + + docker: Docker; + container: Docker.Container; + + 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; + } + + forwardOut(socket: Socket): void { + throw new Error('Method not implemented.'); + } + + exec(cmd: string, args?: string[], options?: RemoteExecOptions): Promise { + throw new Error('Method not implemented.'); + } + + execPartial(cmd: string, tester: RemoteExecTester, args?: string[], options?: RemoteExecOptions): Promise { + throw new Error('Method not implemented.'); + } + + copy(localPath: string | Buffer | NodeJS.ReadableStream, remotePath: string): Promise { + throw new Error('Method not implemented.'); + } + + dispose(): void { + throw new Error('Method not implemented.'); + } + +} diff --git a/packages/dev-container/tsconfig.json b/packages/dev-container/tsconfig.json new file mode 100644 index 0000000000000..ab5e75f10611a --- /dev/null +++ b/packages/dev-container/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../configs/base.tsconfig", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../core" + }, + { + "path": "../remote" + }, + { + "path": "../workspace" + } + ] +} 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/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== From 5568344cb034a14a7bbaf6269b5b85b1b3bebe27 Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Wed, 31 Jan 2024 12:04:57 +0100 Subject: [PATCH 02/34] basic creating and connecting to container working Signed-off-by: Jonah Iden --- examples/browser/tsconfig.json | 3 + examples/electron/tsconfig.json | 3 + .../{README copy.md => README.md} | 0 packages/dev-container/package.json | 4 +- .../container-connection-contribution.ts | 11 +- ...le.ts => dev-container-frontend-module.ts} | 10 +- .../remote-container-connection-provider.ts | 5 +- .../dev-container-backend-module.ts | 2 + .../docker-container-creation-service.ts | 24 ++- .../remote-container-connection-provider.ts | 183 ++++++++++++++---- packages/remote/package.json | 2 +- .../setup/remote-setup-service.ts | 4 +- 12 files changed, 198 insertions(+), 53 deletions(-) rename packages/dev-container/{README copy.md => README.md} (100%) rename packages/dev-container/src/electron-browser/{dev-container-frontent-module.ts => dev-container-frontend-module.ts} (64%) 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/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/dev-container/README copy.md b/packages/dev-container/README.md similarity index 100% rename from packages/dev-container/README copy.md rename to packages/dev-container/README.md diff --git a/packages/dev-container/package.json b/packages/dev-container/package.json index dac70187d7a43..a007b8cc70505 100644 --- a/packages/dev-container/package.json +++ b/packages/dev-container/package.json @@ -14,8 +14,8 @@ }, "theiaExtensions": [ { - "frontendElectron": "lib/browser/dev-container-frontend-module", - "backendElectron": "lib/node/dev-container-backend-module" + "frontendElectron": "lib/electron-browser/dev-container-frontend-module", + "backendElectron": "lib/electron-node/dev-container-backend-module" } ], "keywords": [ diff --git a/packages/dev-container/src/electron-browser/container-connection-contribution.ts b/packages/dev-container/src/electron-browser/container-connection-contribution.ts index 157b6835ba4b7..fb8c28ff0df38 100644 --- a/packages/dev-container/src/electron-browser/container-connection-contribution.ts +++ b/packages/dev-container/src/electron-browser/container-connection-contribution.ts @@ -17,6 +17,7 @@ import { inject, injectable } from '@theia/core/shared/inversify'; import { AbstractRemoteRegistryContribution, RemoteRegistry } from '@theia/remote/lib/electron-browser/remote-registry-contribution'; import { RemoteContainerConnectionProvider } from '../electron-common/remote-container-connection-provider'; +import { RemotePreferences } from '@theia/remote/lib/electron-browser/remote-preferences'; @injectable() export class ContainerConnectionContribution extends AbstractRemoteRegistryContribution { @@ -24,10 +25,14 @@ export class ContainerConnectionContribution extends AbstractRemoteRegistryContr @inject(RemoteContainerConnectionProvider) protected readonly connectionProvider: RemoteContainerConnectionProvider; + @inject(RemotePreferences) + protected readonly remotePreferences: RemotePreferences; + registerRemoteCommands(registry: RemoteRegistry): void { registry.registerCommand({ id: 'dev-container:reopen-in-container', - label: 'Reopen in Container' + label: 'Reopen in Container', + category: 'Dev Container' }, { execute: () => this.openInContainer() }); @@ -35,7 +40,9 @@ export class ContainerConnectionContribution extends AbstractRemoteRegistryContr } async openInContainer(): Promise { - const port = await this.connectionProvider.connectToContainer(); + const port = await this.connectionProvider.connectToContainer({ + nodeDownloadTemplate: this.remotePreferences['remote.nodeDownloadTemplate'] + }); this.openRemote(port, false); } diff --git a/packages/dev-container/src/electron-browser/dev-container-frontent-module.ts b/packages/dev-container/src/electron-browser/dev-container-frontend-module.ts similarity index 64% rename from packages/dev-container/src/electron-browser/dev-container-frontent-module.ts rename to packages/dev-container/src/electron-browser/dev-container-frontend-module.ts index 8b675adde1151..49ea2d480502b 100644 --- a/packages/dev-container/src/electron-browser/dev-container-frontent-module.ts +++ b/packages/dev-container/src/electron-browser/dev-container-frontend-module.ts @@ -15,8 +15,16 @@ // ***************************************************************************** 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'; export default new ContainerModule((bind, unbind, isBound, rebind) => { - bind(RemoteRegistryContribution).to(ContainerConnectionContribution); + bind(ContainerConnectionContribution).toSelf().inSingletonScope(); + bind(RemoteRegistryContribution).toService(ContainerConnectionContribution); + + bind(RemoteContainerConnectionProvider).toDynamicValue(ctx => + ServiceConnectionProvider.createLocalProxy(ctx.container, RemoteContainerConnectionProviderPath) + ).inSingletonScope(); + }); 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 index 73aadabde4da6..04c0bda7e61a3 100644 --- a/packages/dev-container/src/electron-common/remote-container-connection-provider.ts +++ b/packages/dev-container/src/electron-common/remote-container-connection-provider.ts @@ -17,6 +17,9 @@ export const RemoteContainerConnectionProviderPath = '/remote/container'; export const RemoteContainerConnectionProvider = Symbol('RemoteContainerConnectionProvider'); +export interface ContainerConnectionOptions { + nodeDownloadTemplate?: string; +} export interface RemoteContainerConnectionProvider { - connectToContainer(): Promise; + connectToContainer(options: ContainerConnectionOptions): 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 index 896b49d246d85..5a72f846b599d 100644 --- a/packages/dev-container/src/electron-node/dev-container-backend-module.ts +++ b/packages/dev-container/src/electron-node/dev-container-backend-module.ts @@ -18,6 +18,7 @@ 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 { DockerContainerCreationService } from './docker-container-creation-service'; export const remoteConnectionModule = ConnectionContainerModule.create(({ bind, bindBackendService }) => { bind(DevContainerConnectionProvider).toSelf().inSingletonScope(); @@ -26,5 +27,6 @@ export const remoteConnectionModule = ConnectionContainerModule.create(({ bind, }); export default new ContainerModule((bind, unbind, isBound, rebind) => { + bind(DockerContainerCreationService).toSelf().inSingletonScope(); bind(ConnectionContainerModule).toConstantValue(remoteConnectionModule); }); diff --git a/packages/dev-container/src/electron-node/docker-container-creation-service.ts b/packages/dev-container/src/electron-node/docker-container-creation-service.ts index 279bc05219812..ffc14b64093fb 100644 --- a/packages/dev-container/src/electron-node/docker-container-creation-service.ts +++ b/packages/dev-container/src/electron-node/docker-container-creation-service.ts @@ -26,19 +26,37 @@ export class DockerContainerCreationService { @inject(WorkspaceServer) protected readonly workspaceServer: WorkspaceServer; - async buildContainer(docker: Docker, from?: URI): Promise { + async buildContainer(docker: Docker, port: number, from?: URI): Promise { const workspace = from ?? new URI(await this.workspaceServer.getMostRecentlyUsedWorkspace()); if (!workspace) { throw new Error('No workspace'); } const devcontainerFile = workspace.resolve('.devcontainer/devcontainer.json'); - const devcontainerConfig = JSON.parse(await fs.readFile(devcontainerFile.path.fsPath(), 'utf-8')); + const devcontainerConfig = JSON.parse(await fs.readFile(devcontainerFile.path.fsPath(), 'utf-8').catch(() => '0')); + + if (!devcontainerConfig) { + // TODO add ability for user to create new config + throw new Error('No devcontainer.json'); + } + + await docker.pull(devcontainerConfig.image); // TODO add more config - const container = docker.createContainer({ + const container = await docker.createContainer({ Image: devcontainerConfig.image, + Tty: true, + ExposedPorts: { + [`${port}/tcp`]: {}, + }, + HostConfig: { + PortBindings: { + [`${port}/tcp`]: [{ HostPort: '0' }], + } + } }); + const start = await container.start(); + console.log(start); return container; } 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 index d26cb35033d34..fac99c65f3c22 100644 --- a/packages/dev-container/src/electron-node/remote-container-connection-provider.ts +++ b/packages/dev-container/src/electron-node/remote-container-connection-provider.ts @@ -15,7 +15,7 @@ // ***************************************************************************** import * as net from 'net'; -import { RemoteContainerConnectionProvider } from '../electron-common/remote-container-connection-provider'; +import { ContainerConnectionOptions, RemoteContainerConnectionProvider } from '../electron-common/remote-container-connection-provider'; import { RemoteConnection, RemoteExecOptions, RemoteExecResult, RemoteExecTester, RemoteStatusReport } from '@theia/remote/lib/electron-node/remote-types'; import { RemoteSetupService } from '@theia/remote/lib/electron-node/setup/remote-setup-service'; import { RemoteConnectionService } from '@theia/remote/lib/electron-node/remote-connection-service'; @@ -26,6 +26,10 @@ import { inject, injectable } from '@theia/core/shared/inversify'; import { v4 } from 'uuid'; import * as Docker from 'dockerode'; import { DockerContainerCreationService } from './docker-container-creation-service'; +import { Deferred } from '@theia/core/lib/common/promise-util'; +import { WriteStream } from 'tty'; +import { PassThrough } from 'stream'; +import { exec } from 'child_process'; @injectable() export class DevContainerConnectionProvider implements RemoteContainerConnectionProvider { @@ -45,54 +49,61 @@ export class DevContainerConnectionProvider implements RemoteContainerConnection @inject(DockerContainerCreationService) protected readonly containerCreationService: DockerContainerCreationService; - async connectToContainer(): Promise { + async connectToContainer(options: ContainerConnectionOptions): Promise { const dockerConnection = new Docker(); - const version = await dockerConnection.version(); + 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: 'create container', }); - - // create container - progress.report({ message: 'Connecting to container' }); - - const container = await this.containerCreationService.buildContainer(dockerConnection); - - // create actual connection - const report: RemoteStatusReport = message => progress.report({ message }); - report('Connecting to remote system...'); - - const remote = await this.createContainerConnection(container, dockerConnection); - await this.remoteSetup.setup({ - connection: remote, - report, - nodeDownloadTemplate: '' - }); - 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 localPort.toString(); + try { + const port = Math.floor(Math.random() * (49151 - 10000)) + 10000; + const container = await this.containerCreationService.buildContainer(dockerConnection, port); + + // create actual connection + const report: RemoteStatusReport = message => progress.report({ message }); + report('Connecting to remote system...'); + + const remote = await this.createContainerConnection(container, dockerConnection, port); + await this.remoteSetup.setup({ + connection: remote, + report, + nodeDownloadTemplate: options.nodeDownloadTemplate + }); + 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 localPort.toString(); + } catch (e) { + this.messageService.error(e.message); + console.error(e); + throw e; + } finally { + progress.cancel(); + } } - async createContainerConnection(container: Docker.Container, docker: Docker): Promise { + async createContainerConnection(container: Docker.Container, docker: Docker, port: number): Promise { return Promise.resolve(new RemoteDockerContainerConnection({ id: v4(), name: 'dev-container', type: 'container', docker, - container + container, + port })); } @@ -104,6 +115,14 @@ export interface RemoteContainerConnectionOptions { type: string; docker: Docker; container: Docker.Container; + port: number; +} + +interface ContainerTerminalSession { + execution: Docker.Exec, + stdout: WriteStream, + stderr: WriteStream, + executeCommand(cmd: string, args?: string[]): Promise<{ stdout: string, stderr: string }>; } export class RemoteDockerContainerConnection implements RemoteConnection { @@ -117,6 +136,10 @@ export class RemoteDockerContainerConnection implements RemoteConnection { docker: Docker; container: Docker.Container; + containerInfo: Docker.ContainerInspectInfo | undefined; + + protected activeTerminalSession: ContainerTerminalSession | undefined; + protected readonly onDidDisconnectEmitter = new Emitter(); onDidDisconnect: Event = this.onDidDisconnectEmitter.event; @@ -128,26 +151,104 @@ export class RemoteDockerContainerConnection implements RemoteConnection { this.docker = options.docker; this.container = options.container; + this.remotePort = options.port; } - forwardOut(socket: Socket): void { - throw new Error('Method not implemented.'); + async forwardOut(socket: Socket): Promise { + if (!this.containerInfo) { + this.containerInfo = await this.container.inspect(); + } + const portMapping = this.containerInfo.NetworkSettings.Ports[`${this.remotePort}/tcp`][0]; + const connectSocket = new Socket({ readable: true, writable: true }).connect(parseInt(portMapping.HostPort), portMapping.HostIp); + socket.pipe(connectSocket); + connectSocket.pipe(socket); } - exec(cmd: string, args?: string[], options?: RemoteExecOptions): Promise { - throw new Error('Method not implemented.'); + 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.toString(), stderr: stderrBuffer.toString() })); + } catch (e) { + deferred.reject(e); + } + return deferred.promise; } - execPartial(cmd: string, tester: RemoteExecTester, args?: string[], options?: RemoteExecOptions): Promise { - throw new Error('Method not implemented.'); + 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.toString(), stderr: stderrBuffer.toString() }); + } + }); + 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; } - copy(localPath: string | Buffer | NodeJS.ReadableStream, remotePath: string): Promise { - throw new Error('Method not implemented.'); + 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 { - throw new Error('Method not implemented.'); + this.container.stop(); } } 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-node/setup/remote-setup-service.ts b/packages/remote/src/electron-node/setup/remote-setup-service.ts index 16aee1c0ae085..546695d9ecc15 100644 --- a/packages/remote/src/electron-node/setup/remote-setup-service.ts +++ b/packages/remote/src/electron-node/setup/remote-setup-service.ts @@ -91,7 +91,7 @@ export class RemoteSetupService { 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 +101,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) { From ec639031240f091618a81f13ce367e94b8131e4c Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Wed, 7 Feb 2024 13:03:56 +0100 Subject: [PATCH 03/34] open workspace when opening container Signed-off-by: Jonah Iden --- .../core/src/browser/window/window-service.ts | 4 +-- .../window/electron-window-service.ts | 18 +++++++++-- .../container-connection-contribution.ts | 6 +++- .../docker-container-creation-service.ts | 31 ++++++++++++++++++- .../remote-frontend-contribution.ts | 4 +-- .../remote-registry-contribution.ts | 14 ++++++--- 6 files changed, 62 insertions(+), 15 deletions(-) diff --git a/packages/core/src/browser/window/window-service.ts b/packages/core/src/browser/window/window-service.ts index 34adb43737c22..ff98778fc2ecf 100644 --- a/packages/core/src/browser/window/window-service.ts +++ b/packages/core/src/browser/window/window-service.ts @@ -35,7 +35,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?: { search?: WindowSearchParams, hash?: string }): 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 +64,5 @@ export interface WindowService { /** * Reloads the window according to platform. */ - reload(params?: WindowSearchParams): void; + reload(params?: { search?: WindowSearchParams, hash?: string }): 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..c0ab235d04960 100644 --- a/packages/core/src/electron-browser/window/electron-window-service.ts +++ b/packages/core/src/electron-browser/window/electron-window-service.ts @@ -22,6 +22,10 @@ import { ElectronWindowPreferences } from './electron-window-preferences'; import { ConnectionCloseService } from '../../common/messaging/connection-management'; import { FrontendIdProvider } from '../../browser/messaging/frontend-id-provider'; +export interface WindowReloadOptions { + search?: WindowSearchParams, + hash?: string +} @injectable() export class ElectronWindowService extends DefaultWindowService { @@ -86,12 +90,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/src/electron-browser/container-connection-contribution.ts b/packages/dev-container/src/electron-browser/container-connection-contribution.ts index fb8c28ff0df38..6c3a33c404f4e 100644 --- a/packages/dev-container/src/electron-browser/container-connection-contribution.ts +++ b/packages/dev-container/src/electron-browser/container-connection-contribution.ts @@ -18,6 +18,7 @@ import { inject, injectable } from '@theia/core/shared/inversify'; import { AbstractRemoteRegistryContribution, RemoteRegistry } from '@theia/remote/lib/electron-browser/remote-registry-contribution'; import { RemoteContainerConnectionProvider } from '../electron-common/remote-container-connection-provider'; import { RemotePreferences } from '@theia/remote/lib/electron-browser/remote-preferences'; +import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; @injectable() export class ContainerConnectionContribution extends AbstractRemoteRegistryContribution { @@ -28,6 +29,9 @@ export class ContainerConnectionContribution extends AbstractRemoteRegistryContr @inject(RemotePreferences) protected readonly remotePreferences: RemotePreferences; + @inject(WorkspaceService) + private workspaceService: WorkspaceService; + registerRemoteCommands(registry: RemoteRegistry): void { registry.registerCommand({ id: 'dev-container:reopen-in-container', @@ -43,7 +47,7 @@ export class ContainerConnectionContribution extends AbstractRemoteRegistryContr const port = await this.connectionProvider.connectToContainer({ nodeDownloadTemplate: this.remotePreferences['remote.nodeDownloadTemplate'] }); - this.openRemote(port, false); + this.openRemote(port, false, `/workspaces/${(await this.workspaceService.roots)[0].name}`); } } diff --git a/packages/dev-container/src/electron-node/docker-container-creation-service.ts b/packages/dev-container/src/electron-node/docker-container-creation-service.ts index ffc14b64093fb..f93678b9d33c1 100644 --- a/packages/dev-container/src/electron-node/docker-container-creation-service.ts +++ b/packages/dev-container/src/electron-node/docker-container-creation-service.ts @@ -42,17 +42,26 @@ export class DockerContainerCreationService { await docker.pull(devcontainerConfig.image); + const { exposedPorts, portBindings } = this.getPortBindings(devcontainerConfig.forwardPorts); + // TODO add more config const container = await docker.createContainer({ Image: devcontainerConfig.image, Tty: true, ExposedPorts: { [`${port}/tcp`]: {}, + ...exposedPorts }, HostConfig: { PortBindings: { [`${port}/tcp`]: [{ HostPort: '0' }], - } + ...portBindings + }, + Mounts: [{ + Source: workspace.path.toString(), + Target: `/workspaces/${workspace.path.name}`, + Type: 'bind' + }] } }); const start = await container.start(); @@ -60,4 +69,24 @@ export class DockerContainerCreationService { return container; } + + 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/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..5621ed417ac8c 100644 --- a/packages/remote/src/electron-browser/remote-registry-contribution.ts +++ b/packages/remote/src/electron-browser/remote-registry-contribution.ts @@ -17,7 +17,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 { WindowReloadOptions } from '@theia/core/lib/electron-browser/window/electron-window-service'; export const RemoteRegistryContribution = Symbol('RemoteRegistryContribution'); @@ -33,15 +33,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 { From c4883b7b50e310a6591247a4b188839e65e4ae95 Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Wed, 7 Feb 2024 15:43:09 +0100 Subject: [PATCH 04/34] save and reuse last USed container per workspace Signed-off-by: Jonah Iden --- .../container-connection-contribution.ts | 21 ++++++++++------ .../remote-container-connection-provider.ts | 15 +++++++++++- .../dev-container-backend-module.ts | 4 ++-- ...service.ts => docker-container-service.ts} | 24 ++++++++++++++++++- .../remote-container-connection-provider.ts | 20 +++++++++------- 5 files changed, 65 insertions(+), 19 deletions(-) rename packages/dev-container/src/electron-node/{docker-container-creation-service.ts => docker-container-service.ts} (78%) diff --git a/packages/dev-container/src/electron-browser/container-connection-contribution.ts b/packages/dev-container/src/electron-browser/container-connection-contribution.ts index 6c3a33c404f4e..e884a6c76952d 100644 --- a/packages/dev-container/src/electron-browser/container-connection-contribution.ts +++ b/packages/dev-container/src/electron-browser/container-connection-contribution.ts @@ -16,10 +16,11 @@ import { inject, injectable } from '@theia/core/shared/inversify'; import { AbstractRemoteRegistryContribution, RemoteRegistry } from '@theia/remote/lib/electron-browser/remote-registry-contribution'; -import { RemoteContainerConnectionProvider } from '../electron-common/remote-container-connection-provider'; +import { LastContainerInfo, RemoteContainerConnectionProvider } from '../electron-common/remote-container-connection-provider'; import { RemotePreferences } from '@theia/remote/lib/electron-browser/remote-preferences'; -import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; +import { WorkspaceStorageService } from '@theia/workspace/lib/browser/workspace-storage-service'; +const LAST_USED_CONTAINER = 'lastUsedContainer'; @injectable() export class ContainerConnectionContribution extends AbstractRemoteRegistryContribution { @@ -29,8 +30,8 @@ export class ContainerConnectionContribution extends AbstractRemoteRegistryContr @inject(RemotePreferences) protected readonly remotePreferences: RemotePreferences; - @inject(WorkspaceService) - private workspaceService: WorkspaceService; + @inject(WorkspaceStorageService) + private workspaceStorageService: WorkspaceStorageService; registerRemoteCommands(registry: RemoteRegistry): void { registry.registerCommand({ @@ -44,10 +45,16 @@ export class ContainerConnectionContribution extends AbstractRemoteRegistryContr } async openInContainer(): Promise { - const port = await this.connectionProvider.connectToContainer({ - nodeDownloadTemplate: this.remotePreferences['remote.nodeDownloadTemplate'] + const lastContainerInfo = await this.workspaceStorageService.getData(LAST_USED_CONTAINER); + + const connectionResult = await this.connectionProvider.connectToContainer({ + nodeDownloadTemplate: this.remotePreferences['remote.nodeDownloadTemplate'], + lastContainerInfo }); - this.openRemote(port, false, `/workspaces/${(await this.workspaceService.roots)[0].name}`); + + this.workspaceStorageService.setData(LAST_USED_CONTAINER, { id: connectionResult.containerId, port: connectionResult.containerPort }); + + this.openRemote(connectionResult.port, false, connectionResult.workspacePath); } } 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 index 04c0bda7e61a3..31e318208c340 100644 --- a/packages/dev-container/src/electron-common/remote-container-connection-provider.ts +++ b/packages/dev-container/src/electron-common/remote-container-connection-provider.ts @@ -19,7 +19,20 @@ export const RemoteContainerConnectionProvider = Symbol('RemoteContainerConnecti export interface ContainerConnectionOptions { nodeDownloadTemplate?: string; + lastContainerInfo?: LastContainerInfo +} + +export interface LastContainerInfo { + id: string; + port: number; +} + +export interface ContainerConnectionResult { + port: string; + workspacePath: string; + containerId: string; + containerPort: number; } export interface RemoteContainerConnectionProvider { - connectToContainer(options: ContainerConnectionOptions): Promise; + connectToContainer(options: ContainerConnectionOptions): 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 index 5a72f846b599d..cc9f05bcd7a4e 100644 --- a/packages/dev-container/src/electron-node/dev-container-backend-module.ts +++ b/packages/dev-container/src/electron-node/dev-container-backend-module.ts @@ -18,7 +18,7 @@ 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 { DockerContainerCreationService } from './docker-container-creation-service'; +import { DockerContainerService } from './docker-container-service'; export const remoteConnectionModule = ConnectionContainerModule.create(({ bind, bindBackendService }) => { bind(DevContainerConnectionProvider).toSelf().inSingletonScope(); @@ -27,6 +27,6 @@ export const remoteConnectionModule = ConnectionContainerModule.create(({ bind, }); export default new ContainerModule((bind, unbind, isBound, rebind) => { - bind(DockerContainerCreationService).toSelf().inSingletonScope(); + bind(DockerContainerService).toSelf().inSingletonScope(); bind(ConnectionContainerModule).toConstantValue(remoteConnectionModule); }); diff --git a/packages/dev-container/src/electron-node/docker-container-creation-service.ts b/packages/dev-container/src/electron-node/docker-container-service.ts similarity index 78% rename from packages/dev-container/src/electron-node/docker-container-creation-service.ts rename to packages/dev-container/src/electron-node/docker-container-service.ts index f93678b9d33c1..54054f47d3251 100644 --- a/packages/dev-container/src/electron-node/docker-container-creation-service.ts +++ b/packages/dev-container/src/electron-node/docker-container-service.ts @@ -19,13 +19,35 @@ import { inject, injectable } 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'; @injectable() -export class DockerContainerCreationService { +export class DockerContainerService { @inject(WorkspaceServer) protected readonly workspaceServer: WorkspaceServer; + async getOrCreateContainer(docker: Docker, lastContainerInfo?: LastContainerInfo): Promise<[Docker.Container, number]> { + let port = Math.floor(Math.random() * (49151 - 10000)) + 10000; + let container; + if (lastContainerInfo) { + try { + container = docker.getContainer(lastContainerInfo.id); + if (!(await container.inspect()).State.Running) { + await container.start(); + } + port = lastContainerInfo.port; + } catch (e) { + container = undefined; + console.warn('DevContainer: could not find last used container ', e); + } + } + if (!container) { + container = await this.buildContainer(docker, port); + } + return [container, port]; + } + async buildContainer(docker: Docker, port: number, from?: URI): Promise { const workspace = from ?? new URI(await this.workspaceServer.getMostRecentlyUsedWorkspace()); if (!workspace) { 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 index fac99c65f3c22..97b6921d4af99 100644 --- a/packages/dev-container/src/electron-node/remote-container-connection-provider.ts +++ b/packages/dev-container/src/electron-node/remote-container-connection-provider.ts @@ -15,7 +15,7 @@ // ***************************************************************************** import * as net from 'net'; -import { ContainerConnectionOptions, RemoteContainerConnectionProvider } from '../electron-common/remote-container-connection-provider'; +import { ContainerConnectionOptions, ContainerConnectionResult, RemoteContainerConnectionProvider } from '../electron-common/remote-container-connection-provider'; import { RemoteConnection, RemoteExecOptions, RemoteExecResult, RemoteExecTester, RemoteStatusReport } from '@theia/remote/lib/electron-node/remote-types'; import { RemoteSetupService } from '@theia/remote/lib/electron-node/setup/remote-setup-service'; import { RemoteConnectionService } from '@theia/remote/lib/electron-node/remote-connection-service'; @@ -25,7 +25,7 @@ import { Socket } from 'net'; import { inject, injectable } from '@theia/core/shared/inversify'; import { v4 } from 'uuid'; import * as Docker from 'dockerode'; -import { DockerContainerCreationService } from './docker-container-creation-service'; +import { DockerContainerService } from './docker-container-service'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { WriteStream } from 'tty'; import { PassThrough } from 'stream'; @@ -46,10 +46,10 @@ export class DevContainerConnectionProvider implements RemoteContainerConnection @inject(RemoteProxyServerProvider) protected readonly serverProvider: RemoteProxyServerProvider; - @inject(DockerContainerCreationService) - protected readonly containerCreationService: DockerContainerCreationService; + @inject(DockerContainerService) + protected readonly containerService: DockerContainerService; - async connectToContainer(options: ContainerConnectionOptions): Promise { + async connectToContainer(options: ContainerConnectionOptions): Promise { const dockerConnection = new Docker(); const version = await dockerConnection.version().catch(() => undefined); @@ -63,8 +63,7 @@ export class DevContainerConnectionProvider implements RemoteContainerConnection text: 'create container', }); try { - const port = Math.floor(Math.random() * (49151 - 10000)) + 10000; - const container = await this.containerCreationService.buildContainer(dockerConnection, port); + const [container, port] = await this.containerService.getOrCreateContainer(dockerConnection, options.lastContainerInfo); // create actual connection const report: RemoteStatusReport = message => progress.report({ message }); @@ -86,7 +85,12 @@ export class DevContainerConnectionProvider implements RemoteContainerConnection }); const localPort = (server.address() as net.AddressInfo).port; remote.localPort = localPort; - return localPort.toString(); + return { + containerId: container.id, + containerPort: port, + workspacePath: (await container.inspect()).Mounts[0].Destination, + port: localPort.toString(), + }; } catch (e) { this.messageService.error(e.message); console.error(e); From b45fbc34ffc9c2000b6fd3c0437c42243ed60dd0 Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Wed, 7 Feb 2024 16:18:05 +0100 Subject: [PATCH 05/34] restart container if running Signed-off-by: Jonah Iden --- .../src/electron-node/docker-container-service.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/dev-container/src/electron-node/docker-container-service.ts b/packages/dev-container/src/electron-node/docker-container-service.ts index 54054f47d3251..6de5cddfa1f31 100644 --- a/packages/dev-container/src/electron-node/docker-container-service.ts +++ b/packages/dev-container/src/electron-node/docker-container-service.ts @@ -33,7 +33,9 @@ export class DockerContainerService { if (lastContainerInfo) { try { container = docker.getContainer(lastContainerInfo.id); - if (!(await container.inspect()).State.Running) { + if ((await container.inspect()).State.Running) { + await container.restart(); + } else { await container.start(); } port = lastContainerInfo.port; From 27358880749fe136611960518884e51fce4e1fe1 Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Fri, 9 Feb 2024 12:25:13 +0100 Subject: [PATCH 06/34] better container creation extension features Signed-off-by: Jonah Iden --- .../dev-container-backend-module.ts | 7 +- .../main-container-creation-contributions.ts | 85 ++++ .../src/electron-node/devcontainer-file.ts | 384 ++++++++++++++++++ .../electron-node/docker-container-service.ts | 39 +- 4 files changed, 499 insertions(+), 16 deletions(-) create mode 100644 packages/dev-container/src/electron-node/devcontainer-contributions/main-container-creation-contributions.ts create mode 100644 packages/dev-container/src/electron-node/devcontainer-file.ts 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 index cc9f05bcd7a4e..22b80f199fb20 100644 --- a/packages/dev-container/src/electron-node/dev-container-backend-module.ts +++ b/packages/dev-container/src/electron-node/dev-container-backend-module.ts @@ -18,9 +18,14 @@ 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 { DockerContainerService } from './docker-container-service'; +import { ContainerCreationContribution, DockerContainerService } from './docker-container-service'; +import { bindContributionProvider } from '@theia/core'; +import { registerContainerCreationContributions } from './devcontainer-contributions/main-container-creation-contributions'; export const remoteConnectionModule = ConnectionContainerModule.create(({ bind, bindBackendService }) => { + bindContributionProvider(bind, ContainerCreationContribution); + registerContainerCreationContributions(bind); + bind(DevContainerConnectionProvider).toSelf().inSingletonScope(); bind(RemoteContainerConnectionProvider).toService(DevContainerConnectionProvider); bindBackendService(RemoteContainerConnectionProviderPath, RemoteContainerConnectionProvider); 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..dc323ab97ef9c --- /dev/null +++ b/packages/dev-container/src/electron-node/devcontainer-contributions/main-container-creation-contributions.ts @@ -0,0 +1,85 @@ +// ***************************************************************************** +// 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, ImageContainer } from '../devcontainer-file'; + +export function registerContainerCreationContributions(bind: interfaces.Bind): void { + bind(ContainerCreationContribution).to(ImageFileContribution).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): Promise { + // check if image container + if (containerConfig.image) { + await api.pull(containerConfig.image); + createOptions.Image = containerConfig.image; + } + } +} + +@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.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 + }; + } +} 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..83a6f022e4718 --- /dev/null +++ b/packages/dev-container/src/electron-node/devcontainer-file.ts @@ -0,0 +1,384 @@ +// ***************************************************************************** +// 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 & DevContainerCommon; + +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 + [k: string]: unknown + } & 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 + [k: string]: unknown + } & { + /** + * 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 +} + +// NOT SUPPORTED YET +// 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 index 6de5cddfa1f31..6e0feef9f2953 100644 --- a/packages/dev-container/src/electron-node/docker-container-service.ts +++ b/packages/dev-container/src/electron-node/docker-container-service.ts @@ -14,12 +14,19 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { URI } from '@theia/core'; -import { inject, injectable } from '@theia/core/shared/inversify'; +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'; + +export const ContainerCreationContribution = Symbol('ContainerCreationContributions'); + +export interface ContainerCreationContribution { + handleContainerCreation(createOptions: Docker.ContainerCreateOptions, containerConfig: DevContainerConfiguration, api: Docker): Promise; +} @injectable() export class DockerContainerService { @@ -27,6 +34,9 @@ export class DockerContainerService { @inject(WorkspaceServer) protected readonly workspaceServer: WorkspaceServer; + @inject(ContributionProvider) @named(ContainerCreationContribution) + protected readonly containerCreationContributions: ContributionProvider; + async getOrCreateContainer(docker: Docker, lastContainerInfo?: LastContainerInfo): Promise<[Docker.Container, number]> { let port = Math.floor(Math.random() * (49151 - 10000)) + 10000; let container; @@ -57,37 +67,36 @@ export class DockerContainerService { } const devcontainerFile = workspace.resolve('.devcontainer/devcontainer.json'); - const devcontainerConfig = JSON.parse(await fs.readFile(devcontainerFile.path.fsPath(), 'utf-8').catch(() => '0')); + const devcontainerConfig = JSON.parse(await fs.readFile(devcontainerFile.path.fsPath(), 'utf-8').catch(() => '0')) as DevContainerConfiguration; if (!devcontainerConfig) { // TODO add ability for user to create new config throw new Error('No devcontainer.json'); } - await docker.pull(devcontainerConfig.image); - - const { exposedPorts, portBindings } = this.getPortBindings(devcontainerConfig.forwardPorts); - - // TODO add more config - const container = await docker.createContainer({ - Image: devcontainerConfig.image, + const containerCreateOptions: Docker.ContainerCreateOptions = { Tty: true, ExposedPorts: { [`${port}/tcp`]: {}, - ...exposedPorts }, HostConfig: { PortBindings: { [`${port}/tcp`]: [{ HostPort: '0' }], - ...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); + } + + // TODO add more config + const container = await docker.createContainer(containerCreateOptions); const start = await container.start(); console.log(start); From ad3d13367476e49dacf9ff57e8f11fbccaeb1310 Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Fri, 9 Feb 2024 14:22:33 +0100 Subject: [PATCH 07/34] added dockerfile support Signed-off-by: Jonah Iden --- packages/dev-container/package.json | 3 +- .../main-container-creation-contributions.ts | 33 ++++++++++- .../src/electron-node/devcontainer-file.ts | 55 +++++++++---------- .../electron-node/docker-container-service.ts | 6 +- 4 files changed, 64 insertions(+), 33 deletions(-) diff --git a/packages/dev-container/package.json b/packages/dev-container/package.json index a007b8cc70505..0b2f5414f9846 100644 --- a/packages/dev-container/package.json +++ b/packages/dev-container/package.json @@ -7,7 +7,8 @@ "@theia/remote": "1.47.0", "@theia/workspace": "1.47.0", "dockerode": "^4.0.2", - "uuid": "^8.0.0" + "uuid": "^8.0.0", + "jsonc-parser": "^2.2.0" }, "publishConfig": { "access": "public" 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 index dc323ab97ef9c..d19b1bd612ced 100644 --- 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 @@ -16,10 +16,11 @@ import * as Docker from 'dockerode'; import { injectable, interfaces } from '@theia/core/shared/inversify'; import { ContainerCreationContribution } from '../docker-container-service'; -import { DevContainerConfiguration, ImageContainer } from '../devcontainer-file'; +import { DevContainerConfiguration, DockerfileContainer, ImageContainer } from '../devcontainer-file'; 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(); } @@ -35,6 +36,36 @@ export class ImageFileContribution implements ContainerCreationContribution { } } +@injectable() +export class DockerFileContribution implements ContainerCreationContribution { + async handleContainerCreation(createOptions: Docker.ContainerCreateOptions, containerConfig: DockerfileContainer, api: Docker): 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 ?? containerConfig.location, + 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, ouptuts) => { + if (err) { + rej(err); + } else { + for (let i = ouptuts.length - 1; i >= 0; i--) { + if (ouptuts[i].aux?.ID) { + res(ouptuts[i].aux.ID); + return; + } + } + } + })); + createOptions.Image = imageId; + } + } +} + @injectable() export class ForwardPortsContribution implements ContainerCreationContribution { async handleContainerCreation(createOptions: Docker.ContainerCreateOptions, containerConfig: DevContainerConfiguration, api: Docker): Promise { diff --git a/packages/dev-container/src/electron-node/devcontainer-file.ts b/packages/dev-container/src/electron-node/devcontainer-file.ts index 83a6f022e4718..50ddceea28c32 100644 --- a/packages/dev-container/src/electron-node/devcontainer-file.ts +++ b/packages/dev-container/src/electron-node/devcontainer-file.ts @@ -18,7 +18,7 @@ * Defines a dev container * type generated from https://containers.dev/implementors/json_schema/ and modified */ -export type DevContainerConfiguration = (DockerfileContainer | ImageContainer) & NonComposeContainerBase & DevContainerCommon; +export type DevContainerConfiguration = (DockerfileContainer | ImageContainer) & NonComposeContainerBase & DevContainerCommon & { location?: string }; export type DockerfileContainer = { /** @@ -33,43 +33,40 @@ export type DockerfileContainer = { * 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 - [k: string]: unknown } & 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?: { /** - * The location of the Dockerfile that defines the contents of the container. The path is relative to the folder containing the `devcontainer.json` file. + * Target stage in a multi-stage build. */ - dockerFile: string + target?: string /** - * The location of the context folder for building the Docker image. The path is relative to the folder containing the `devcontainer.json` file. + * Build arguments. */ - context?: string - [k: string]: unknown - } & { + args?: { + [k: string]: string + } /** - * Docker build-related options. + * The image to consider as a cache. Use an array to specify multiple images. */ - 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 - } + cacheFrom?: string | string[] [k: string]: unknown - }); + } + [k: string]: unknown +}; export interface BuildOptions { /** diff --git a/packages/dev-container/src/electron-node/docker-container-service.ts b/packages/dev-container/src/electron-node/docker-container-service.ts index 6e0feef9f2953..b0150169a7921 100644 --- a/packages/dev-container/src/electron-node/docker-container-service.ts +++ b/packages/dev-container/src/electron-node/docker-container-service.ts @@ -17,6 +17,7 @@ import { ContributionProvider, URI } from '@theia/core'; import { inject, injectable, named } from '@theia/core/shared/inversify'; import { WorkspaceServer } from '@theia/workspace/lib/common'; +import { parse } from 'jsonc-parser'; import * as fs from '@theia/core/shared/fs-extra'; import * as Docker from 'dockerode'; import { LastContainerInfo } from '../electron-common/remote-container-connection-provider'; @@ -51,7 +52,7 @@ export class DockerContainerService { port = lastContainerInfo.port; } catch (e) { container = undefined; - console.warn('DevContainer: could not find last used container ', e); + console.warn('DevContainer: could not find last used container'); } } if (!container) { @@ -67,7 +68,8 @@ export class DockerContainerService { } const devcontainerFile = workspace.resolve('.devcontainer/devcontainer.json'); - const devcontainerConfig = JSON.parse(await fs.readFile(devcontainerFile.path.fsPath(), 'utf-8').catch(() => '0')) as DevContainerConfiguration; + const devcontainerConfig = parse(await fs.readFile(devcontainerFile.path.fsPath(), 'utf-8').catch(() => '0')) as DevContainerConfiguration; + devcontainerConfig.location = devcontainerFile.path.dir.fsPath(); if (!devcontainerConfig) { // TODO add ability for user to create new config From 8b977e4aeca98cfed7c232cd196ec594e38f4114 Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Fri, 9 Feb 2024 16:11:29 +0100 Subject: [PATCH 08/34] rebuild container if devcontainer.json has been changed since last use Signed-off-by: Jonah Iden --- .../container-connection-contribution.ts | 6 ++++- .../remote-container-connection-provider.ts | 1 + .../electron-node/docker-container-service.ts | 22 ++++++++++--------- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/packages/dev-container/src/electron-browser/container-connection-contribution.ts b/packages/dev-container/src/electron-browser/container-connection-contribution.ts index e884a6c76952d..b0f9cf53703d3 100644 --- a/packages/dev-container/src/electron-browser/container-connection-contribution.ts +++ b/packages/dev-container/src/electron-browser/container-connection-contribution.ts @@ -52,7 +52,11 @@ export class ContainerConnectionContribution extends AbstractRemoteRegistryContr lastContainerInfo }); - this.workspaceStorageService.setData(LAST_USED_CONTAINER, { id: connectionResult.containerId, port: connectionResult.containerPort }); + this.workspaceStorageService.setData(LAST_USED_CONTAINER, { + id: connectionResult.containerId, + port: connectionResult.containerPort, + lastUsed: Date.now() + }); this.openRemote(connectionResult.port, false, connectionResult.workspacePath); } 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 index 31e318208c340..829986c8d40df 100644 --- a/packages/dev-container/src/electron-common/remote-container-connection-provider.ts +++ b/packages/dev-container/src/electron-common/remote-container-connection-provider.ts @@ -25,6 +25,7 @@ export interface ContainerConnectionOptions { export interface LastContainerInfo { id: string; port: number; + lastUsed: number; } export interface ContainerConnectionResult { diff --git a/packages/dev-container/src/electron-node/docker-container-service.ts b/packages/dev-container/src/electron-node/docker-container-service.ts index b0150169a7921..fae7122c9a5fe 100644 --- a/packages/dev-container/src/electron-node/docker-container-service.ts +++ b/packages/dev-container/src/electron-node/docker-container-service.ts @@ -41,7 +41,15 @@ export class DockerContainerService { async getOrCreateContainer(docker: Docker, lastContainerInfo?: LastContainerInfo): Promise<[Docker.Container, number]> { let port = Math.floor(Math.random() * (49151 - 10000)) + 10000; let container; - if (lastContainerInfo) { + + const workspace = new URI(await this.workspaceServer.getMostRecentlyUsedWorkspace()); + if (!workspace) { + throw new Error('No workspace'); + } + + const devcontainerFile = workspace.resolve('.devcontainer/devcontainer.json'); + + if (lastContainerInfo && fs.statSync(devcontainerFile.path.fsPath()).mtimeMs > lastContainerInfo.lastUsed) { try { container = docker.getContainer(lastContainerInfo.id); if ((await container.inspect()).State.Running) { @@ -56,18 +64,12 @@ export class DockerContainerService { } } if (!container) { - container = await this.buildContainer(docker, port); + container = await this.buildContainer(docker, port, devcontainerFile, workspace); } return [container, port]; } - async buildContainer(docker: Docker, port: number, from?: URI): Promise { - const workspace = from ?? new URI(await this.workspaceServer.getMostRecentlyUsedWorkspace()); - if (!workspace) { - throw new Error('No workspace'); - } - - const devcontainerFile = workspace.resolve('.devcontainer/devcontainer.json'); + protected async buildContainer(docker: Docker, port: number, devcontainerFile: URI, workspace: URI): Promise { const devcontainerConfig = parse(await fs.readFile(devcontainerFile.path.fsPath(), 'utf-8').catch(() => '0')) as DevContainerConfiguration; devcontainerConfig.location = devcontainerFile.path.dir.fsPath(); @@ -105,7 +107,7 @@ export class DockerContainerService { return container; } - getPortBindings(forwardPorts: (string | number)[]): { exposedPorts: {}, portBindings: {} } { + 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; From 5fcf759d4b011d8f344db11958faa41850df8430 Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Fri, 9 Feb 2024 16:17:46 +0100 Subject: [PATCH 09/34] fix build Signed-off-by: Jonah Iden --- packages/dev-container/src/package.spec.ts | 28 ++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 packages/dev-container/src/package.spec.ts 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); +}); From 14c0f1fb6ca4648eda85fdef88f69eec1df09437 Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Tue, 13 Feb 2024 08:59:20 +0100 Subject: [PATCH 10/34] fixed checking if container needs rebuild Signed-off-by: Jonah Iden --- .../dev-container/src/electron-node/docker-container-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dev-container/src/electron-node/docker-container-service.ts b/packages/dev-container/src/electron-node/docker-container-service.ts index fae7122c9a5fe..eee84127f0bf4 100644 --- a/packages/dev-container/src/electron-node/docker-container-service.ts +++ b/packages/dev-container/src/electron-node/docker-container-service.ts @@ -49,7 +49,7 @@ export class DockerContainerService { const devcontainerFile = workspace.resolve('.devcontainer/devcontainer.json'); - if (lastContainerInfo && fs.statSync(devcontainerFile.path.fsPath()).mtimeMs > lastContainerInfo.lastUsed) { + if (lastContainerInfo && fs.statSync(devcontainerFile.path.fsPath()).mtimeMs < lastContainerInfo.lastUsed) { try { container = docker.getContainer(lastContainerInfo.id); if ((await container.inspect()).State.Running) { From d670f497f65315e083f8f78f4dbe77420d6e84b9 Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Tue, 20 Feb 2024 17:10:45 +0100 Subject: [PATCH 11/34] working port forwarding via exec instance Signed-off-by: Jonah Iden --- .../src/generator/webpack-generator.ts | 2 + .../dev-container-server.ts | 53 +++++++++++++++++++ .../electron-node/docker-container-service.ts | 4 +- .../remote-container-connection-provider.ts | 29 ++++++---- .../setup/remote-setup-service.ts | 11 +++- 5 files changed, 87 insertions(+), 12 deletions(-) create mode 100644 packages/dev-container/src/dev-container-server/dev-container-server.ts 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/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-node/docker-container-service.ts b/packages/dev-container/src/electron-node/docker-container-service.ts index eee84127f0bf4..59fa62585317a 100644 --- a/packages/dev-container/src/electron-node/docker-container-service.ts +++ b/packages/dev-container/src/electron-node/docker-container-service.ts @@ -81,11 +81,11 @@ export class DockerContainerService { const containerCreateOptions: Docker.ContainerCreateOptions = { Tty: true, ExposedPorts: { - [`${port}/tcp`]: {}, + // [`${port}/tcp`]: {}, }, HostConfig: { PortBindings: { - [`${port}/tcp`]: [{ HostPort: '0' }], + // [`${port}/tcp`]: [{ HostPort: '0' }], }, Mounts: [{ Source: workspace.path.toString(), 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 index 97b6921d4af99..7b1bf7e069c53 100644 --- a/packages/dev-container/src/electron-node/remote-container-connection-provider.ts +++ b/packages/dev-container/src/electron-node/remote-container-connection-provider.ts @@ -17,7 +17,7 @@ import * as net from 'net'; import { ContainerConnectionOptions, ContainerConnectionResult, RemoteContainerConnectionProvider } from '../electron-common/remote-container-connection-provider'; import { RemoteConnection, RemoteExecOptions, RemoteExecResult, RemoteExecTester, RemoteStatusReport } from '@theia/remote/lib/electron-node/remote-types'; -import { RemoteSetupService } from '@theia/remote/lib/electron-node/setup/remote-setup-service'; +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, MessageService } from '@theia/core'; @@ -70,11 +70,13 @@ export class DevContainerConnectionProvider implements RemoteContainerConnection report('Connecting to remote system...'); const remote = await this.createContainerConnection(container, dockerConnection, port); - await this.remoteSetup.setup({ + 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); @@ -107,7 +109,7 @@ export class DevContainerConnectionProvider implements RemoteContainerConnection type: 'container', docker, container, - port + port, })); } @@ -142,6 +144,8 @@ export class RemoteDockerContainerConnection implements RemoteConnection { containerInfo: Docker.ContainerInspectInfo | undefined; + remoteSetupResult: RemoteSetupResult; + protected activeTerminalSession: ContainerTerminalSession | undefined; protected readonly onDidDisconnectEmitter = new Emitter(); @@ -159,13 +163,20 @@ export class RemoteDockerContainerConnection implements RemoteConnection { } async forwardOut(socket: Socket): Promise { - if (!this.containerInfo) { - this.containerInfo = await this.container.inspect(); + 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); } - const portMapping = this.containerInfo.NetworkSettings.Ports[`${this.remotePort}/tcp`][0]; - const connectSocket = new Socket({ readable: true, writable: true }).connect(parseInt(portMapping.HostPort), portMapping.HostIp); - socket.pipe(connectSocket); - connectSocket.pipe(socket); } async exec(cmd: string, args?: string[], options?: RemoteExecOptions): 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 546695d9ecc15..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,6 +91,10 @@ 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 { From c05bd30c8598619a5a2d326e18bfcda2ad1c3f36 Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Tue, 27 Feb 2024 09:56:06 +0100 Subject: [PATCH 12/34] review changes Signed-off-by: Jonah Iden --- .../core/src/browser/window/window-service.ts | 9 +++++++-- .../window/electron-window-service.ts | 5 +---- .../container-connection-contribution.ts | 15 ++++++++++----- .../main-container-creation-contributions.ts | 8 ++++---- .../src/electron-node/docker-cmd-service.ts | 15 --------------- .../remote-container-connection-provider.ts | 9 ++++----- 6 files changed, 26 insertions(+), 35 deletions(-) delete mode 100644 packages/dev-container/src/electron-node/docker-cmd-service.ts diff --git a/packages/core/src/browser/window/window-service.ts b/packages/core/src/browser/window/window-service.ts index ff98778fc2ecf..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?: { search?: WindowSearchParams, hash?: string }): 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?: { search?: WindowSearchParams, hash?: string }): 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 c0ab235d04960..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,11 +21,8 @@ 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'; -export interface WindowReloadOptions { - search?: WindowSearchParams, - hash?: string -} @injectable() export class ElectronWindowService extends DefaultWindowService { diff --git a/packages/dev-container/src/electron-browser/container-connection-contribution.ts b/packages/dev-container/src/electron-browser/container-connection-contribution.ts index b0f9cf53703d3..ae5547042c169 100644 --- a/packages/dev-container/src/electron-browser/container-connection-contribution.ts +++ b/packages/dev-container/src/electron-browser/container-connection-contribution.ts @@ -19,6 +19,15 @@ import { AbstractRemoteRegistryContribution, RemoteRegistry } from '@theia/remot 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 } from '@theia/core'; + +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() @@ -34,11 +43,7 @@ export class ContainerConnectionContribution extends AbstractRemoteRegistryContr private workspaceStorageService: WorkspaceStorageService; registerRemoteCommands(registry: RemoteRegistry): void { - registry.registerCommand({ - id: 'dev-container:reopen-in-container', - label: 'Reopen in Container', - category: 'Dev Container' - }, { + registry.registerCommand(RemoteContainerCommands.REOPEN_IN_CONTAINER, { execute: () => this.openInContainer() }); 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 index d19b1bd612ced..d52abfed01374 100644 --- 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 @@ -49,13 +49,13 @@ export class DockerFileContribution implements ContainerCreationContribution { 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, ouptuts) => { + const imageId = await new Promise((res, rej) => api.modem.followProgress(buildStream, (err, outputs) => { if (err) { rej(err); } else { - for (let i = ouptuts.length - 1; i >= 0; i--) { - if (ouptuts[i].aux?.ID) { - res(ouptuts[i].aux.ID); + for (let i = outputs.length - 1; i >= 0; i--) { + if (outputs[i].aux?.ID) { + res(outputs[i].aux.ID); return; } } diff --git a/packages/dev-container/src/electron-node/docker-cmd-service.ts b/packages/dev-container/src/electron-node/docker-cmd-service.ts deleted file mode 100644 index 0c850ec03be57..0000000000000 --- a/packages/dev-container/src/electron-node/docker-cmd-service.ts +++ /dev/null @@ -1,15 +0,0 @@ -// ***************************************************************************** -// 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 -// ***************************************************************************** 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 index 7b1bf7e069c53..64b424f926e36 100644 --- a/packages/dev-container/src/electron-node/remote-container-connection-provider.ts +++ b/packages/dev-container/src/electron-node/remote-container-connection-provider.ts @@ -20,10 +20,9 @@ import { RemoteConnection, RemoteExecOptions, RemoteExecResult, RemoteExecTester 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, MessageService } from '@theia/core'; +import { Emitter, Event, generateUuid, MessageService } from '@theia/core'; import { Socket } from 'net'; import { inject, injectable } from '@theia/core/shared/inversify'; -import { v4 } from 'uuid'; import * as Docker from 'dockerode'; import { DockerContainerService } from './docker-container-service'; import { Deferred } from '@theia/core/lib/common/promise-util'; @@ -104,7 +103,7 @@ export class DevContainerConnectionProvider implements RemoteContainerConnection async createContainerConnection(container: Docker.Container, docker: Docker, port: number): Promise { return Promise.resolve(new RemoteDockerContainerConnection({ - id: v4(), + id: generateUuid(), name: 'dev-container', type: 'container', docker, @@ -197,7 +196,7 @@ export class RemoteDockerContainerConnection implements RemoteConnection { stderrBuffer += chunk.toString(); }); execution.modem.demuxStream(stream, stdout, stderr); - stream?.addListener('close', () => deferred.resolve({ stdout: stdoutBuffer.toString(), stderr: stderrBuffer.toString() })); + stream?.addListener('close', () => deferred.resolve({ stdout: stdoutBuffer, stderr: stderrBuffer })); } catch (e) { deferred.reject(e); } @@ -214,7 +213,7 @@ export class RemoteDockerContainerConnection implements RemoteConnection { const stream = await execution?.start({}); stream.on('close', () => { if (deferred.state === 'unresolved') { - deferred.resolve({ stdout: stdoutBuffer.toString(), stderr: stderrBuffer.toString() }); + deferred.resolve({ stdout: stdoutBuffer, stderr: stderrBuffer }); } }); const stdout = new PassThrough(); From b45fb37fb8bb78741a907a5d66f203cb89aaa02d Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Tue, 27 Feb 2024 10:03:30 +0100 Subject: [PATCH 13/34] fix import Signed-off-by: Jonah Iden --- .../src/electron-browser/remote-registry-contribution.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/remote/src/electron-browser/remote-registry-contribution.ts b/packages/remote/src/electron-browser/remote-registry-contribution.ts index 5621ed417ac8c..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 { WindowReloadOptions } from '@theia/core/lib/electron-browser/window/electron-window-service'; +import { WindowService, WindowReloadOptions } from '@theia/core/lib/browser/window/window-service'; export const RemoteRegistryContribution = Symbol('RemoteRegistryContribution'); From 4a18a6f6342bc0ab5f9ca94cb654de88a1b41d77 Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Thu, 29 Feb 2024 14:40:49 +0100 Subject: [PATCH 14/34] smaller fixes and added support for multiple devcontainer configuration files Signed-off-by: Jonah Iden --- .../container-connection-contribution.ts | 40 ++++++++++-- .../remote-container-connection-provider.ts | 9 ++- .../dev-container-backend-module.ts | 3 + .../dev-container-file-service.ts | 63 +++++++++++++++++++ .../main-container-creation-contributions.ts | 3 +- .../electron-node/docker-container-service.ts | 36 ++++------- .../remote-container-connection-provider.ts | 23 ++++--- 7 files changed, 137 insertions(+), 40 deletions(-) create mode 100644 packages/dev-container/src/electron-node/dev-container-file-service.ts diff --git a/packages/dev-container/src/electron-browser/container-connection-contribution.ts b/packages/dev-container/src/electron-browser/container-connection-contribution.ts index ae5547042c169..3089449c46d02 100644 --- a/packages/dev-container/src/electron-browser/container-connection-contribution.ts +++ b/packages/dev-container/src/electron-browser/container-connection-contribution.ts @@ -19,7 +19,8 @@ import { AbstractRemoteRegistryContribution, RemoteRegistry } from '@theia/remot 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 } from '@theia/core'; +import { Command, QuickInputService } from '@theia/core'; +import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; export namespace RemoteContainerCommands { export const REOPEN_IN_CONTAINER = Command.toLocalizedCommand({ @@ -40,7 +41,13 @@ export class ContainerConnectionContribution extends AbstractRemoteRegistryContr protected readonly remotePreferences: RemotePreferences; @inject(WorkspaceStorageService) - private workspaceStorageService: WorkspaceStorageService; + protected readonly workspaceStorageService: WorkspaceStorageService; + + @inject(WorkspaceService) + protected readonly workspaceService: WorkspaceService; + + @inject(QuickInputService) + protected readonly quickInputService: QuickInputService; registerRemoteCommands(registry: RemoteRegistry): void { registry.registerCommand(RemoteContainerCommands.REOPEN_IN_CONTAINER, { @@ -50,20 +57,41 @@ export class ContainerConnectionContribution extends AbstractRemoteRegistryContr } async openInContainer(): Promise { - const lastContainerInfo = await this.workspaceStorageService.getData(LAST_USED_CONTAINER); + const devcontainerFile = await this.getOrSelectDevcontainerFile(); + if (!devcontainerFile) { + return; + } + const lastContainerInfoKey = `${LAST_USED_CONTAINER}:${devcontainerFile}`; + const lastContainerInfo = await this.workspaceStorageService.getData(lastContainerInfoKey); const connectionResult = await this.connectionProvider.connectToContainer({ nodeDownloadTemplate: this.remotePreferences['remote.nodeDownloadTemplate'], - lastContainerInfo + lastContainerInfo, + devcontainerFile }); - this.workspaceStorageService.setData(LAST_USED_CONTAINER, { + this.workspaceStorageService.setData(lastContainerInfoKey, { id: connectionResult.containerId, - port: connectionResult.containerPort, 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-common/remote-container-connection-provider.ts b/packages/dev-container/src/electron-common/remote-container-connection-provider.ts index 829986c8d40df..8f98a48d0393a 100644 --- a/packages/dev-container/src/electron-common/remote-container-connection-provider.ts +++ b/packages/dev-container/src/electron-common/remote-container-connection-provider.ts @@ -20,11 +20,11 @@ export const RemoteContainerConnectionProvider = Symbol('RemoteContainerConnecti export interface ContainerConnectionOptions { nodeDownloadTemplate?: string; lastContainerInfo?: LastContainerInfo + devcontainerFile: string; } export interface LastContainerInfo { id: string; - port: number; lastUsed: number; } @@ -32,8 +32,13 @@ export interface ContainerConnectionResult { port: string; workspacePath: string; containerId: string; - containerPort: number; +} + +export interface DevContainerFile { + name: string; + path: string; } export interface RemoteContainerConnectionProvider { 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 index 22b80f199fb20..37e31dad0e152 100644 --- a/packages/dev-container/src/electron-node/dev-container-backend-module.ts +++ b/packages/dev-container/src/electron-node/dev-container-backend-module.ts @@ -21,6 +21,7 @@ import { RemoteContainerConnectionProvider, RemoteContainerConnectionProviderPat import { ContainerCreationContribution, DockerContainerService } from './docker-container-service'; import { bindContributionProvider } from '@theia/core'; import { registerContainerCreationContributions } from './devcontainer-contributions/main-container-creation-contributions'; +import { DevContainerFileService } from './dev-container-file-service'; export const remoteConnectionModule = ConnectionContainerModule.create(({ bind, bindBackendService }) => { bindContributionProvider(bind, ContainerCreationContribution); @@ -34,4 +35,6 @@ export const remoteConnectionModule = ConnectionContainerModule.create(({ bind, 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..085ad8f9ecba5 --- /dev/null +++ b/packages/dev-container/src/electron-node/dev-container-file-service.ts @@ -0,0 +1,63 @@ +// ***************************************************************************** +// 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 { 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'); + + const files = await fs.readdir(devcontainerPath.fsPath()); + return files.flatMap(filename => { + const fileStat = fs.statSync(devcontainerPath.join(filename).fsPath()); + return fileStat.isDirectory() ? + fs.readdirSync(devcontainerPath.join(filename).fsPath()).map(inner => devcontainerPath.join(filename).join(inner).fsPath()) : + [devcontainerPath.join(filename).fsPath()]; + }) + .filter(file => file.endsWith('devcontainer.json')) + .map(file => ({ + name: parse(fs.readFileSync(file, 'utf-8')).name ?? 'devcontainer', + path: file + })); + + } +} 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 index d52abfed01374..40850f8152433 100644 --- 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 @@ -17,6 +17,7 @@ import * as Docker from 'dockerode'; import { injectable, interfaces } from '@theia/core/shared/inversify'; import { ContainerCreationContribution } from '../docker-container-service'; import { DevContainerConfiguration, DockerfileContainer, ImageContainer } from '../devcontainer-file'; +import { Path } from '@theia/core'; export function registerContainerCreationContributions(bind: interfaces.Bind): void { bind(ContainerCreationContribution).to(ImageFileContribution).inSingletonScope(); @@ -43,7 +44,7 @@ export class DockerFileContribution implements ContainerCreationContribution { if (containerConfig.dockerFile || containerConfig.build?.dockerfile) { const dockerfile = (containerConfig.dockerFile ?? containerConfig.build?.dockerfile) as string; const buildStream = await api.buildImage({ - context: containerConfig.context ?? containerConfig.location, + context: containerConfig.context ?? new Path(containerConfig.location as string).dir.fsPath(), src: [dockerfile], } as Docker.ImageBuildContext, { buildargs: containerConfig.build?.args diff --git a/packages/dev-container/src/electron-node/docker-container-service.ts b/packages/dev-container/src/electron-node/docker-container-service.ts index 59fa62585317a..e7e61efc9f115 100644 --- a/packages/dev-container/src/electron-node/docker-container-service.ts +++ b/packages/dev-container/src/electron-node/docker-container-service.ts @@ -17,11 +17,11 @@ import { ContributionProvider, URI } from '@theia/core'; import { inject, injectable, named } from '@theia/core/shared/inversify'; import { WorkspaceServer } from '@theia/workspace/lib/common'; -import { parse } from 'jsonc-parser'; 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'; export const ContainerCreationContribution = Symbol('ContainerCreationContributions'); @@ -38,18 +38,15 @@ export class DockerContainerService { @inject(ContributionProvider) @named(ContainerCreationContribution) protected readonly containerCreationContributions: ContributionProvider; - async getOrCreateContainer(docker: Docker, lastContainerInfo?: LastContainerInfo): Promise<[Docker.Container, number]> { - let port = Math.floor(Math.random() * (49151 - 10000)) + 10000; + @inject(DevContainerFileService) + protected readonly devContainerFileService: DevContainerFileService; + + async getOrCreateContainer(docker: Docker, devcontainerFile: string, lastContainerInfo?: LastContainerInfo): Promise { let container; const workspace = new URI(await this.workspaceServer.getMostRecentlyUsedWorkspace()); - if (!workspace) { - throw new Error('No workspace'); - } - - const devcontainerFile = workspace.resolve('.devcontainer/devcontainer.json'); - if (lastContainerInfo && fs.statSync(devcontainerFile.path.fsPath()).mtimeMs < lastContainerInfo.lastUsed) { + if (lastContainerInfo && fs.statSync(devcontainerFile).mtimeMs < lastContainerInfo.lastUsed) { try { container = docker.getContainer(lastContainerInfo.id); if ((await container.inspect()).State.Running) { @@ -57,21 +54,19 @@ export class DockerContainerService { } else { await container.start(); } - port = lastContainerInfo.port; } catch (e) { container = undefined; console.warn('DevContainer: could not find last used container'); } } if (!container) { - container = await this.buildContainer(docker, port, devcontainerFile, workspace); + container = await this.buildContainer(docker, devcontainerFile, workspace); } - return [container, port]; + return container; } - protected async buildContainer(docker: Docker, port: number, devcontainerFile: URI, workspace: URI): Promise { - const devcontainerConfig = parse(await fs.readFile(devcontainerFile.path.fsPath(), 'utf-8').catch(() => '0')) as DevContainerConfiguration; - devcontainerConfig.location = devcontainerFile.path.dir.fsPath(); + protected async buildContainer(docker: Docker, devcontainerFile: string, workspace: URI): Promise { + const devcontainerConfig = await this.devContainerFileService.getConfiguration(devcontainerFile); if (!devcontainerConfig) { // TODO add ability for user to create new config @@ -80,13 +75,9 @@ export class DockerContainerService { const containerCreateOptions: Docker.ContainerCreateOptions = { Tty: true, - ExposedPorts: { - // [`${port}/tcp`]: {}, - }, + ExposedPorts: {}, HostConfig: { - PortBindings: { - // [`${port}/tcp`]: [{ HostPort: '0' }], - }, + PortBindings: {}, Mounts: [{ Source: workspace.path.toString(), Target: `/workspaces/${workspace.path.name}`, @@ -101,8 +92,7 @@ export class DockerContainerService { // TODO add more config const container = await docker.createContainer(containerCreateOptions); - const start = await container.start(); - console.log(start); + await container.start(); return container; } 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 index 64b424f926e36..e3849a743fed7 100644 --- a/packages/dev-container/src/electron-node/remote-container-connection-provider.ts +++ b/packages/dev-container/src/electron-node/remote-container-connection-provider.ts @@ -15,7 +15,10 @@ // ***************************************************************************** import * as net from 'net'; -import { ContainerConnectionOptions, ContainerConnectionResult, RemoteContainerConnectionProvider } from '../electron-common/remote-container-connection-provider'; +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'; @@ -29,6 +32,7 @@ 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'; @injectable() export class DevContainerConnectionProvider implements RemoteContainerConnectionProvider { @@ -48,6 +52,9 @@ export class DevContainerConnectionProvider implements RemoteContainerConnection @inject(DockerContainerService) protected readonly containerService: DockerContainerService; + @inject(DevContainerFileService) + protected readonly devContainerFileService: DevContainerFileService; + async connectToContainer(options: ContainerConnectionOptions): Promise { const dockerConnection = new Docker(); const version = await dockerConnection.version().catch(() => undefined); @@ -62,13 +69,13 @@ export class DevContainerConnectionProvider implements RemoteContainerConnection text: 'create container', }); try { - const [container, port] = await this.containerService.getOrCreateContainer(dockerConnection, options.lastContainerInfo); + const container = await this.containerService.getOrCreateContainer(dockerConnection, options.devcontainerFile, options.lastContainerInfo); // create actual connection const report: RemoteStatusReport = message => progress.report({ message }); report('Connecting to remote system...'); - const remote = await this.createContainerConnection(container, dockerConnection, port); + const remote = await this.createContainerConnection(container, dockerConnection); const result = await this.remoteSetup.setup({ connection: remote, report, @@ -88,7 +95,6 @@ export class DevContainerConnectionProvider implements RemoteContainerConnection remote.localPort = localPort; return { containerId: container.id, - containerPort: port, workspacePath: (await container.inspect()).Mounts[0].Destination, port: localPort.toString(), }; @@ -101,14 +107,17 @@ export class DevContainerConnectionProvider implements RemoteContainerConnection } } - async createContainerConnection(container: Docker.Container, docker: Docker, port: number): Promise { + 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, - port, })); } @@ -120,7 +129,6 @@ export interface RemoteContainerConnectionOptions { type: string; docker: Docker; container: Docker.Container; - port: number; } interface ContainerTerminalSession { @@ -158,7 +166,6 @@ export class RemoteDockerContainerConnection implements RemoteConnection { this.docker = options.docker; this.container = options.container; - this.remotePort = options.port; } async forwardOut(socket: Socket): Promise { From f984ca2454414acae9d072daf2e7d8ce1568a299 Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Fri, 15 Mar 2024 11:44:33 +0100 Subject: [PATCH 15/34] basic output window for devcontainer build Signed-off-by: Jonah Iden --- packages/dev-container/package.json | 1 + .../container-connection-contribution.ts | 8 ++++- .../container-output-provider.ts | 36 +++++++++++++++++++ .../dev-container-frontend-module.ts | 11 +++--- .../container-output-provider.ts | 19 ++++++++++ .../remote-container-connection-provider.ts | 6 +++- .../dev-container-backend-module.ts | 11 ++++-- .../main-container-creation-contributions.ts | 33 ++++++++++++++--- .../electron-node/docker-container-service.ts | 15 +++++--- .../remote-container-connection-provider.ts | 19 +++++++--- packages/dev-container/tsconfig.json | 3 ++ 11 files changed, 140 insertions(+), 22 deletions(-) create mode 100644 packages/dev-container/src/electron-browser/container-output-provider.ts create mode 100644 packages/dev-container/src/electron-common/container-output-provider.ts diff --git a/packages/dev-container/package.json b/packages/dev-container/package.json index 0b2f5414f9846..246fa87cc3add 100644 --- a/packages/dev-container/package.json +++ b/packages/dev-container/package.json @@ -4,6 +4,7 @@ "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", diff --git a/packages/dev-container/src/electron-browser/container-connection-contribution.ts b/packages/dev-container/src/electron-browser/container-connection-contribution.ts index 3089449c46d02..415ada831f733 100644 --- a/packages/dev-container/src/electron-browser/container-connection-contribution.ts +++ b/packages/dev-container/src/electron-browser/container-connection-contribution.ts @@ -21,6 +21,7 @@ import { RemotePreferences } from '@theia/remote/lib/electron-browser/remote-pre 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({ @@ -49,11 +50,13 @@ export class ContainerConnectionContribution extends AbstractRemoteRegistryContr @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 { @@ -64,6 +67,8 @@ export class ContainerConnectionContribution extends AbstractRemoteRegistryContr 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, @@ -94,4 +99,5 @@ export class ContainerConnectionContribution extends AbstractRemoteRegistryContr 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 index 49ea2d480502b..77cdd79844b72 100644 --- a/packages/dev-container/src/electron-browser/dev-container-frontend-module.ts +++ b/packages/dev-container/src/electron-browser/dev-container-frontend-module.ts @@ -18,13 +18,16 @@ import { RemoteRegistryContribution } from '@theia/remote/lib/electron-browser/r 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, unbind, isBound, rebind) => { +export default new ContainerModule(bind => { bind(ContainerConnectionContribution).toSelf().inSingletonScope(); bind(RemoteRegistryContribution).toService(ContainerConnectionContribution); - bind(RemoteContainerConnectionProvider).toDynamicValue(ctx => - ServiceConnectionProvider.createLocalProxy(ctx.container, RemoteContainerConnectionProviderPath) - ).inSingletonScope(); + 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 index 8f98a48d0393a..8a422fe0dffdb 100644 --- a/packages/dev-container/src/electron-common/remote-container-connection-provider.ts +++ b/packages/dev-container/src/electron-common/remote-container-connection-provider.ts @@ -12,6 +12,10 @@ // 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'; @@ -38,7 +42,7 @@ export interface DevContainerFile { name: string; path: string; } -export interface RemoteContainerConnectionProvider { +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 index 37e31dad0e152..cac6e5e74ebf4 100644 --- a/packages/dev-container/src/electron-node/dev-container-backend-module.ts +++ b/packages/dev-container/src/electron-node/dev-container-backend-module.ts @@ -19,9 +19,10 @@ import { ConnectionContainerModule } from '@theia/core/lib/node/messaging/connec 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 } from '@theia/core'; +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); @@ -29,7 +30,13 @@ export const remoteConnectionModule = ConnectionContainerModule.create(({ bind, bind(DevContainerConnectionProvider).toSelf().inSingletonScope(); bind(RemoteContainerConnectionProvider).toService(DevContainerConnectionProvider); - bindBackendService(RemoteContainerConnectionProviderPath, RemoteContainerConnectionProvider); + 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) => { 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 index 40850f8152433..044e78e793ed6 100644 --- 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 @@ -18,6 +18,7 @@ import { injectable, interfaces } from '@theia/core/shared/inversify'; import { ContainerCreationContribution } from '../docker-container-service'; import { DevContainerConfiguration, DockerfileContainer, ImageContainer } 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(); @@ -28,10 +29,18 @@ export function registerContainerCreationContributions(bind: interfaces.Bind): v @injectable() export class ImageFileContribution implements ContainerCreationContribution { - async handleContainerCreation(createOptions: Docker.ContainerCreateOptions, containerConfig: ImageContainer, api: Docker): Promise { - // check if image container + async handleContainerCreation(createOptions: Docker.ContainerCreateOptions, containerConfig: ImageContainer, + api: Docker, outputprovider: ContainerOutputProvider): Promise { if (containerConfig.image) { - await api.pull(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; } } @@ -39,7 +48,8 @@ export class ImageFileContribution implements ContainerCreationContribution { @injectable() export class DockerFileContribution implements ContainerCreationContribution { - async handleContainerCreation(createOptions: Docker.ContainerCreateOptions, containerConfig: DockerfileContainer, api: Docker): Promise { + 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; @@ -61,7 +71,7 @@ export class DockerFileContribution implements ContainerCreationContribution { } } } - })); + }, progress => outputprovider.onRemoteOutput(OutputHelper.parseProgress(progress)))); createOptions.Image = imageId; } } @@ -115,3 +125,16 @@ export class MountsContribution implements ContainerCreationContribution { }; } } + +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/docker-container-service.ts b/packages/dev-container/src/electron-node/docker-container-service.ts index e7e61efc9f115..3626e7c00b762 100644 --- a/packages/dev-container/src/electron-node/docker-container-service.ts +++ b/packages/dev-container/src/electron-node/docker-container-service.ts @@ -22,11 +22,15 @@ 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): Promise; + handleContainerCreation(createOptions: Docker.ContainerCreateOptions, + containerConfig: DevContainerConfiguration, + api: Docker, + outputProvider?: ContainerOutputProvider): Promise; } @injectable() @@ -41,7 +45,8 @@ export class DockerContainerService { @inject(DevContainerFileService) protected readonly devContainerFileService: DevContainerFileService; - async getOrCreateContainer(docker: Docker, devcontainerFile: string, lastContainerInfo?: LastContainerInfo): Promise { + async getOrCreateContainer(docker: Docker, devcontainerFile: string, + lastContainerInfo?: LastContainerInfo, outputProvider?: ContainerOutputProvider): Promise { let container; const workspace = new URI(await this.workspaceServer.getMostRecentlyUsedWorkspace()); @@ -60,12 +65,12 @@ export class DockerContainerService { } } if (!container) { - container = await this.buildContainer(docker, devcontainerFile, workspace); + container = await this.buildContainer(docker, devcontainerFile, workspace, outputProvider); } return container; } - protected async buildContainer(docker: Docker, devcontainerFile: string, workspace: URI): Promise { + protected async buildContainer(docker: Docker, devcontainerFile: string, workspace: URI, outputProvider?: ContainerOutputProvider): Promise { const devcontainerConfig = await this.devContainerFileService.getConfiguration(devcontainerFile); if (!devcontainerConfig) { @@ -87,7 +92,7 @@ export class DockerContainerService { }; for (const containerCreateContrib of this.containerCreationContributions.getContributions()) { - await containerCreateContrib.handleContainerCreation(containerCreateOptions, devcontainerConfig, docker); + await containerCreateContrib.handleContainerCreation(containerCreateOptions, devcontainerConfig, docker, outputProvider); } // TODO add more config 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 index e3849a743fed7..c879619ea6665 100644 --- a/packages/dev-container/src/electron-node/remote-container-connection-provider.ts +++ b/packages/dev-container/src/electron-node/remote-container-connection-provider.ts @@ -23,7 +23,7 @@ import { RemoteConnection, RemoteExecOptions, RemoteExecResult, RemoteExecTester 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 } from '@theia/core'; +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'; @@ -33,9 +33,10 @@ 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 { +export class DevContainerConnectionProvider implements RemoteContainerConnectionProvider, RpcServer { @inject(RemoteConnectionService) protected readonly remoteConnectionService: RemoteConnectionService; @@ -55,6 +56,12 @@ export class DevContainerConnectionProvider implements RemoteContainerConnection @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); @@ -66,10 +73,10 @@ export class DevContainerConnectionProvider implements RemoteContainerConnection // create container const progress = await this.messageService.showProgress({ - text: 'create container', + text: 'Creating container', }); try { - const container = await this.containerService.getOrCreateContainer(dockerConnection, options.devcontainerFile, options.lastContainerInfo); + const container = await this.containerService.getOrCreateContainer(dockerConnection, options.devcontainerFile, options.lastContainerInfo, this.outputProvider); // create actual connection const report: RemoteStatusReport = message => progress.report({ message }); @@ -121,6 +128,10 @@ export class DevContainerConnectionProvider implements RemoteContainerConnection })); } + dispose(): void { + + } + } export interface RemoteContainerConnectionOptions { diff --git a/packages/dev-container/tsconfig.json b/packages/dev-container/tsconfig.json index ab5e75f10611a..d7f2cbfb079c4 100644 --- a/packages/dev-container/tsconfig.json +++ b/packages/dev-container/tsconfig.json @@ -12,6 +12,9 @@ { "path": "../core" }, + { + "path": "../output" + }, { "path": "../remote" }, From 1e6dab5bd516ec0a845d025fb9d9953188a68f32 Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Fri, 15 Mar 2024 12:04:09 +0100 Subject: [PATCH 16/34] smaller review changes and nicer dockerfile.json detection code Signed-off-by: Jonah Iden --- .../remote-container-connection-provider.ts | 1 + .../dev-container-file-service.ts | 39 ++++++++----- .../src/electron-node/devcontainer-file.ts | 57 +++++++++---------- .../remote-container-connection-provider.ts | 1 + 4 files changed, 54 insertions(+), 44 deletions(-) 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 index 8a422fe0dffdb..236cb3113cd90 100644 --- a/packages/dev-container/src/electron-common/remote-container-connection-provider.ts +++ b/packages/dev-container/src/electron-common/remote-container-connection-provider.ts @@ -42,6 +42,7 @@ 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-file-service.ts b/packages/dev-container/src/electron-node/dev-container-file-service.ts index 085ad8f9ecba5..00f59186f7710 100644 --- a/packages/dev-container/src/electron-node/dev-container-file-service.ts +++ b/packages/dev-container/src/electron-node/dev-container-file-service.ts @@ -20,7 +20,7 @@ import { DevContainerFile } from '../electron-common/remote-container-connection import { DevContainerConfiguration } from './devcontainer-file'; import { parse } from 'jsonc-parser'; import * as fs from '@theia/core/shared/fs-extra'; -import { URI } from '@theia/core'; +import { Path, URI } from '@theia/core'; @injectable() export class DevContainerFileService { @@ -44,20 +44,29 @@ export class DevContainerFileService { return []; } - const devcontainerPath = new URI(workspace).path.join('.devcontainer'); - - const files = await fs.readdir(devcontainerPath.fsPath()); - return files.flatMap(filename => { - const fileStat = fs.statSync(devcontainerPath.join(filename).fsPath()); - return fileStat.isDirectory() ? - fs.readdirSync(devcontainerPath.join(filename).fsPath()).map(inner => devcontainerPath.join(filename).join(inner).fsPath()) : - [devcontainerPath.join(filename).fsPath()]; - }) - .filter(file => file.endsWith('devcontainer.json')) - .map(file => ({ - name: parse(fs.readFileSync(file, 'utf-8')).name ?? 'devcontainer', - path: file - })); + 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-file.ts b/packages/dev-container/src/electron-node/devcontainer-file.ts index 50ddceea28c32..5bd948dd17a90 100644 --- a/packages/dev-container/src/electron-node/devcontainer-file.ts +++ b/packages/dev-container/src/electron-node/devcontainer-file.ts @@ -18,7 +18,7 @@ * Defines a dev container * type generated from https://containers.dev/implementors/json_schema/ and modified */ -export type DevContainerConfiguration = (DockerfileContainer | ImageContainer) & NonComposeContainerBase & DevContainerCommon & { location?: string }; +export type DevContainerConfiguration = (((DockerfileContainer | ImageContainer) & (NonComposeContainerBase)) | ComposeContainer) & DevContainerCommon & { location?: string }; export type DockerfileContainer = { /** @@ -136,34 +136,33 @@ export interface NonComposeContainerBase { [k: string]: unknown } -// NOT SUPPORTED YET -// 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 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 { /** 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 index c879619ea6665..6c80b89e57c7b 100644 --- a/packages/dev-container/src/electron-node/remote-container-connection-provider.ts +++ b/packages/dev-container/src/electron-node/remote-container-connection-provider.ts @@ -187,6 +187,7 @@ export class RemoteDockerContainerConnection implements RemoteConnection { 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); From c70d6f180bea892658ad8d4979845a2ff0058aa8 Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Fri, 15 Mar 2024 12:15:15 +0100 Subject: [PATCH 17/34] fixed build and docuemented implemented devcontainer.json properties Signed-off-by: Jonah Iden --- packages/dev-container/README.md | 13 +++++++++++++ .../main-container-creation-contributions.ts | 8 ++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/dev-container/README.md b/packages/dev-container/README.md index 22a8c62ac364d..b9ce2d06af2e5 100644 --- a/packages/dev-container/README.md +++ b/packages/dev-container/README.md @@ -15,6 +15,19 @@ 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) 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 index 044e78e793ed6..c0c91473201f0 100644 --- 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 @@ -16,7 +16,7 @@ import * as Docker from 'dockerode'; import { injectable, interfaces } from '@theia/core/shared/inversify'; import { ContainerCreationContribution } from '../docker-container-service'; -import { DevContainerConfiguration, DockerfileContainer, ImageContainer } from '../devcontainer-file'; +import { DevContainerConfiguration, DockerfileContainer, ImageContainer, NonComposeContainerBase } from '../devcontainer-file'; import { Path } from '@theia/core'; import { ContainerOutputProvider } from '../../electron-common/container-output-provider'; @@ -110,10 +110,10 @@ export class MountsContribution implements ContainerCreationContribution { return; } - createOptions.HostConfig!.Mounts!.push(...containerConfig.mounts - .map(mount => typeof mount === 'string' ? + 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' })); + { Source: mount.source, Target: mount.target, Type: mount.type ?? 'bind' }) ?? []); } parseMountString(mount: string): Docker.MountSettings { From 8262c58bf41c640ff6d4d8453479871a76bd3587 Mon Sep 17 00:00:00 2001 From: Alexander Taran Date: Mon, 4 Mar 2024 17:01:24 +0300 Subject: [PATCH 18/34] Fix unneeded URI conversion (#13415) --- packages/core/src/browser/decorations-service.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/browser/decorations-service.ts b/packages/core/src/browser/decorations-service.ts index 0d175dd20b4fb..6c5586ba21870 100644 --- a/packages/core/src/browser/decorations-service.ts +++ b/packages/core/src/browser/decorations-service.ts @@ -78,7 +78,7 @@ class DecorationProviderWrapper { this.data.clear(); } else { for (const uri of uris) { - this.fetchData(new URI(uri.toString())); + this.fetchData(uri); const decoration = await provider.provideDecorations(uri, CancellationToken.None); if (decoration) { this.decorations.set(uri.toString(), decoration); @@ -131,14 +131,14 @@ class DecorationProviderWrapper { private fetchData(uri: URI): Decoration | undefined { // check for pending request and cancel it - const pendingRequest = this.data.get(new URI(uri.toString())); + const pendingRequest = this.data.get(uri); if (pendingRequest instanceof DecorationDataRequest) { pendingRequest.source.cancel(); this.data.delete(uri); } const source = new CancellationTokenSource(); - const dataOrThenable = this.provider.provideDecorations(new URI(uri.toString()), source.token); + const dataOrThenable = this.provider.provideDecorations(uri, source.token); if (!isThenable | undefined>(dataOrThenable)) { // sync -> we have a result now return this.keepItem(uri, dataOrThenable); @@ -197,7 +197,7 @@ export class DecorationsServiceImpl implements DecorationsService { const data: Decoration[] = []; let containsChildren: boolean = false; for (const wrapper of this.data) { - wrapper.getOrRetrieve(new URI(uri.toString()), includeChildren, (deco, isChild) => { + wrapper.getOrRetrieve(uri, includeChildren, (deco, isChild) => { if (!isChild || deco.bubble) { data.push(deco); containsChildren = isChild || containsChildren; From cf1537a1c73e7dc9976b49bcb5066f26634779fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=A4der?= Date: Tue, 5 Mar 2024 17:05:24 +0100 Subject: [PATCH 19/34] Fix quickpick problems found in IDE testing (#13451) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #13450, #13449 contributed on behalf of STMicroelectronics Signed-off-by: Thomas Mäder --- CHANGELOG.md | 6 +- .../src/browser/monaco-quick-input-service.ts | 115 ++++++++++++------ .../src/main/browser/quick-open-main.ts | 11 +- 3 files changed, 91 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1551223c27488..1f1d8b5eb9e55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,13 @@ - [Previous Changelogs](https://github.com/eclipse-theia/theia/tree/master/doc/changelogs/) - +- [component] add here ## v1.47.0 - 02/29/2024 diff --git a/packages/monaco/src/browser/monaco-quick-input-service.ts b/packages/monaco/src/browser/monaco-quick-input-service.ts index 580142ad4d418..7d91adbc85a4f 100644 --- a/packages/monaco/src/browser/monaco-quick-input-service.ts +++ b/packages/monaco/src/browser/monaco-quick-input-service.ts @@ -42,14 +42,6 @@ import { CancellationToken, Event } from '@theia/core'; import { MonacoColorRegistry } from './monaco-color-registry'; import { ThemeService } from '@theia/core/lib/browser/theming'; import { IStandaloneThemeService } from '@theia/monaco-editor-core/esm/vs/editor/standalone/common/standaloneTheme'; -import { - activeContrastBorder, asCssVariable, pickerGroupBorder, pickerGroupForeground, quickInputBackground, quickInputForeground, quickInputListFocusBackground, - quickInputListFocusForeground, quickInputListFocusIconForeground, quickInputTitleBackground, widgetBorder, widgetShadow -} from '@theia/monaco-editor-core/esm/vs/platform/theme/common/colorRegistry'; - -import { - defaultButtonStyles, defaultCountBadgeStyles, defaultInputBoxStyles, defaultKeybindingLabelStyles, defaultProgressBarStyles, defaultToggleStyles, getListStyles -} from '@theia/monaco-editor-core/esm/vs/platform/theme/browser/defaultStyles'; // Copied from @vscode/src/vs/base/parts/quickInput/browser/quickInputList.ts export interface IListElement { @@ -230,34 +222,89 @@ export class MonacoQuickInputImplementation implements IQuickInputService { // Keep the styles up to date with https://github.com/microsoft/vscode/blob/7888ff3a6b104e9e2e3d0f7890ca92dd0828215f/src/vs/platform/quickinput/browser/quickInput.ts#L171. private computeStyles(): IQuickInputStyles { return { - widget: { - quickInputBackground: asCssVariable(quickInputBackground), - quickInputForeground: asCssVariable(quickInputForeground), - quickInputTitleBackground: asCssVariable(quickInputTitleBackground), - widgetBorder: asCssVariable(widgetBorder), - widgetShadow: asCssVariable(widgetShadow), + toggle: { + inputActiveOptionBorder: this.colorRegistry.toCssVariableName('inputOption.activeBorder'), + inputActiveOptionForeground: this.colorRegistry.toCssVariableName('inputOption.activeForeground'), + inputActiveOptionBackground: this.colorRegistry.toCssVariableName('inputOption.activeBackground') }, - inputBox: defaultInputBoxStyles, - toggle: defaultToggleStyles, - countBadge: defaultCountBadgeStyles, - button: defaultButtonStyles, - progressBar: defaultProgressBarStyles, - keybindingLabel: defaultKeybindingLabelStyles, - list: getListStyles({ - listBackground: quickInputBackground, - listFocusBackground: quickInputListFocusBackground, - listFocusForeground: quickInputListFocusForeground, - // Look like focused when inactive. - listInactiveFocusForeground: quickInputListFocusForeground, - listInactiveSelectionIconForeground: quickInputListFocusIconForeground, - listInactiveFocusBackground: quickInputListFocusBackground, - listFocusOutline: activeContrastBorder, - listInactiveFocusOutline: activeContrastBorder, - }), pickerGroup: { - pickerGroupBorder: asCssVariable(pickerGroupBorder), - pickerGroupForeground: asCssVariable(pickerGroupForeground), - } + pickerGroupBorder: this.colorRegistry.toCssVariableName('pickerGroup.Border'), + pickerGroupForeground: this.colorRegistry.toCssVariableName('pickerGroupForeground') + }, + widget: { + quickInputBackground: this.colorRegistry.toCssVariableName('quickInput.background'), + quickInputForeground: this.colorRegistry.toCssVariableName('quickInput.foreground'), + quickInputTitleBackground: this.colorRegistry.toCssVariableName('quickInputTitle.background'), + widgetBorder: this.colorRegistry.toCssVariableName('widget.border'), + widgetShadow: this.colorRegistry.toCssVariableName('widget.shadow') + }, + list: { + listBackground: this.colorRegistry.toCssVariableName('quickInput.background'), + listInactiveFocusForeground: this.colorRegistry.toCssVariableName('quickInputList.focusForeground'), + listInactiveSelectionIconForeground: this.colorRegistry.toCssVariableName('quickInputList.focusIconForeground'), + listInactiveFocusBackground: this.colorRegistry.toCssVariableName('quickInputList.focusBackground'), + listFocusOutline: this.colorRegistry.toCssVariableName('activeContrastBorder'), + listInactiveFocusOutline: this.colorRegistry.toCssVariableName('activeContrastBorder'), + + listFocusBackground: this.colorRegistry.toCssVariableName('list.focusBackground'), + listFocusForeground: this.colorRegistry.toCssVariableName('list.focusForeground'), + listActiveSelectionBackground: this.colorRegistry.toCssVariableName('list.activeSelectionBackground'), + listActiveSelectionForeground: this.colorRegistry.toCssVariableName('list.ActiveSelectionForeground'), + listActiveSelectionIconForeground: this.colorRegistry.toCssVariableName('list.ActiveSelectionIconForeground'), + listFocusAndSelectionOutline: this.colorRegistry.toCssVariableName('list.FocusAndSelectionOutline'), + listFocusAndSelectionBackground: this.colorRegistry.toCssVariableName('list.ActiveSelectionBackground'), + listFocusAndSelectionForeground: this.colorRegistry.toCssVariableName('list.ActiveSelectionForeground'), + listInactiveSelectionBackground: this.colorRegistry.toCssVariableName('list.InactiveSelectionBackground'), + listInactiveSelectionForeground: this.colorRegistry.toCssVariableName('list.InactiveSelectionForeground'), + listHoverBackground: this.colorRegistry.toCssVariableName('list.HoverBackground'), + listHoverForeground: this.colorRegistry.toCssVariableName('list.HoverForeground'), + listDropBackground: this.colorRegistry.toCssVariableName('list.DropBackground'), + listSelectionOutline: this.colorRegistry.toCssVariableName('activeContrastBorder'), + listHoverOutline: this.colorRegistry.toCssVariableName('activeContrastBorder'), + treeIndentGuidesStroke: this.colorRegistry.toCssVariableName('tree.indentGuidesStroke'), + treeInactiveIndentGuidesStroke: this.colorRegistry.toCssVariableName('tree.inactiveIndentGuidesStroke'), + tableColumnsBorder: this.colorRegistry.toCssVariableName('tree.tableColumnsBorder'), + tableOddRowsBackgroundColor: this.colorRegistry.toCssVariableName('tree.tableOddRowsBackground'), + }, + inputBox: { + inputForeground: this.colorRegistry.toCssVariableName('inputForeground'), + inputBackground: this.colorRegistry.toCssVariableName('inputBackground'), + inputBorder: this.colorRegistry.toCssVariableName('inputBorder'), + inputValidationInfoBackground: this.colorRegistry.toCssVariableName('inputValidation.infoBackground'), + inputValidationInfoForeground: this.colorRegistry.toCssVariableName('inputValidation.infoForeground'), + inputValidationInfoBorder: this.colorRegistry.toCssVariableName('inputValidation.infoBorder'), + inputValidationWarningBackground: this.colorRegistry.toCssVariableName('inputValidation.warningBackground'), + inputValidationWarningForeground: this.colorRegistry.toCssVariableName('inputValidation.warningForeground'), + inputValidationWarningBorder: this.colorRegistry.toCssVariableName('inputValidation.warningBorder'), + inputValidationErrorBackground: this.colorRegistry.toCssVariableName('inputValidation.errorBackground'), + inputValidationErrorForeground: this.colorRegistry.toCssVariableName('inputValidation.errorForeground'), + inputValidationErrorBorder: this.colorRegistry.toCssVariableName('inputValidation.errorBorder'), + }, + countBadge: { + badgeBackground: this.colorRegistry.toCssVariableName('badge.background'), + badgeForeground: this.colorRegistry.toCssVariableName('badge.foreground'), + badgeBorder: this.colorRegistry.toCssVariableName('contrastBorder') + }, + button: { + buttonForeground: this.colorRegistry.toCssVariableName('button.foreground'), + buttonBackground: this.colorRegistry.toCssVariableName('button.background'), + buttonHoverBackground: this.colorRegistry.toCssVariableName('button.hoverBackground'), + buttonBorder: this.colorRegistry.toCssVariableName('contrastBorder'), + buttonSeparator: this.colorRegistry.toCssVariableName('button.Separator'), + buttonSecondaryForeground: this.colorRegistry.toCssVariableName('button.secondaryForeground'), + buttonSecondaryBackground: this.colorRegistry.toCssVariableName('button.secondaryBackground'), + buttonSecondaryHoverBackground: this.colorRegistry.toCssVariableName('button.secondaryHoverBackground'), + }, + progressBar: { + progressBarBackground: this.colorRegistry.toCssVariableName('progressBar.background') + }, + keybindingLabel: { + keybindingLabelBackground: this.colorRegistry.toCssVariableName('keybindingLabel.background'), + keybindingLabelForeground: this.colorRegistry.toCssVariableName('keybindingLabel.foreground'), + keybindingLabelBorder: this.colorRegistry.toCssVariableName('keybindingLabel.border'), + keybindingLabelBottomBorder: this.colorRegistry.toCssVariableName('keybindingLabel.bottomBorder'), + keybindingLabelShadow: this.colorRegistry.toCssVariableName('widget.shadow') + }, }; } } diff --git a/packages/plugin-ext/src/main/browser/quick-open-main.ts b/packages/plugin-ext/src/main/browser/quick-open-main.ts index 33777f26b0996..7d380c919a193 100644 --- a/packages/plugin-ext/src/main/browser/quick-open-main.ts +++ b/packages/plugin-ext/src/main/browser/quick-open-main.ts @@ -49,7 +49,7 @@ import { isUriComponents } from '@theia/monaco-editor-core/esm/vs/base/common/ur export interface QuickInputSession { input: QuickInput; - handlesToItems: Map; + handlesToItems: Map; } interface IconPath { @@ -309,10 +309,13 @@ export class QuickOpenMainImpl implements QuickOpenMain, Disposable { } } else if (param === 'items') { handlesToItems.clear(); - params[param].forEach((item: TransferQuickPickItem) => { - handlesToItems.set(item.handle, item); + const items: QuickPickItem[] = []; + params[param].forEach((transferItem: TransferQuickPickItem) => { + const item = this.toQuickPickItem(transferItem); + items.push(item); + handlesToItems.set(transferItem.handle, item); }); - (input as any)[param] = params[param]; + (input as any)[param] = items; } else if (param === 'activeItems' || param === 'selectedItems') { (input as any)[param] = params[param] .filter((handle: number) => handlesToItems.has(handle)) From 3fff276a50cb457c0543d31d1a782c5bd962e47c Mon Sep 17 00:00:00 2001 From: Tobias Ortmayr Date: Wed, 13 Mar 2024 10:47:17 +0100 Subject: [PATCH 20/34] Fix rending of quickpick buttons (#13342) Ensure that the Theia specific wrapper for the MonacoQuickPickItem properly forwards assignments of the "buttons" property to the wrapped item. Fixes #13076 Contributed on behalf of STMicroelectronics --- packages/core/src/common/quick-pick-service.ts | 1 + packages/monaco/src/browser/monaco-quick-input-service.ts | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/packages/core/src/common/quick-pick-service.ts b/packages/core/src/common/quick-pick-service.ts index 935b6a3c80963..d37481d76d658 100644 --- a/packages/core/src/common/quick-pick-service.ts +++ b/packages/core/src/common/quick-pick-service.ts @@ -161,6 +161,7 @@ export interface QuickPick extends QuickInpu matchOnDescription: boolean; matchOnDetail: boolean; keepScrollPosition: boolean; + buttons: ReadonlyArray; readonly onDidAccept: Event<{ inBackground: boolean } | undefined>; readonly onDidChangeValue: Event; readonly onDidTriggerButton: Event; diff --git a/packages/monaco/src/browser/monaco-quick-input-service.ts b/packages/monaco/src/browser/monaco-quick-input-service.ts index 7d91adbc85a4f..777649ceb87b6 100644 --- a/packages/monaco/src/browser/monaco-quick-input-service.ts +++ b/packages/monaco/src/browser/monaco-quick-input-service.ts @@ -596,6 +596,14 @@ class MonacoQuickPick extends MonacoQuickInput implemen }); } + get buttons(): ReadonlyArray { + return this.wrapped.buttons as QuickInputButton[]; + } + + set buttons(buttons: ReadonlyArray) { + this.wrapped.buttons = buttons; + } + set items(itemList: readonly (T | QuickPickSeparator)[]) { // We need to store and apply the currently selected active items. // Since monaco compares these items by reference equality, creating new wrapped items will unmark any active items. From ae692d4308e07c864659103f4bb69df1c28cecc8 Mon Sep 17 00:00:00 2001 From: Olaf Lessenich Date: Wed, 13 Mar 2024 10:55:46 +0100 Subject: [PATCH 21/34] electron: allow accessing the metrics endpoint for performance analysis (#13380) By default, when running Theia in Electron, all endpoints are protected by the ElectronTokenValidator. This patch allows accessing the '/metrics' endpoint without a token, which enables us to collect metrics for performance analysis. For this, ElectronTokenValidator is extended to allow access to the metrics endpoint. All other endpoints are still protected. Contributed on behalf of STMicroelectronics Signed-off-by: Olaf Lessenich --- packages/metrics/package.json | 3 ++ .../electron-metrics-backend-module.ts | 24 ++++++++++++ .../electron-node/electron-token-validator.ts | 37 +++++++++++++++++++ ...etrics-backend-application-contribution.ts | 3 +- 4 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 packages/metrics/src/electron-node/electron-metrics-backend-module.ts create mode 100644 packages/metrics/src/electron-node/electron-token-validator.ts diff --git a/packages/metrics/package.json b/packages/metrics/package.json index f65dfbe191be2..03da14040a9bd 100644 --- a/packages/metrics/package.json +++ b/packages/metrics/package.json @@ -14,6 +14,9 @@ { "frontend": "lib/browser/metrics-frontend-module", "backend": "lib/node/metrics-backend-module" + }, + { + "backendElectron": "lib/electron-node/electron-metrics-backend-module" } ], "keywords": [ diff --git a/packages/metrics/src/electron-node/electron-metrics-backend-module.ts b/packages/metrics/src/electron-node/electron-metrics-backend-module.ts new file mode 100644 index 0000000000000..9daffa3ee5990 --- /dev/null +++ b/packages/metrics/src/electron-node/electron-metrics-backend-module.ts @@ -0,0 +1,24 @@ +// ***************************************************************************** +// Copyright (C) 2023 STMicroelectronics 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 { MetricsElectronTokenValidator } from './electron-token-validator'; +import { ElectronTokenValidator } from '@theia/core/lib/electron-node/token/electron-token-validator'; + +export default new ContainerModule((bind, unbind, isBound, rebind) => { + bind(MetricsElectronTokenValidator).toSelf().inSingletonScope(); + rebind(ElectronTokenValidator).to(MetricsElectronTokenValidator); +}); diff --git a/packages/metrics/src/electron-node/electron-token-validator.ts b/packages/metrics/src/electron-node/electron-token-validator.ts new file mode 100644 index 0000000000000..202e9987dda2e --- /dev/null +++ b/packages/metrics/src/electron-node/electron-token-validator.ts @@ -0,0 +1,37 @@ +// ***************************************************************************** +// Copyright (C) 2023 STMicroelectronics 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, postConstruct } from '@theia/core/shared/inversify'; +import { ElectronTokenValidator } from '@theia/core/lib/electron-node/token/electron-token-validator'; +import { IncomingMessage } from 'http'; +import { MetricsBackendApplicationContribution } from '../node/metrics-backend-application-contribution'; +import { MaybePromise } from '@theia/core'; + +@injectable() +export class MetricsElectronTokenValidator extends ElectronTokenValidator { + @postConstruct() + protected override init(): void { + super.init(); + } + + override allowWsUpgrade(request: IncomingMessage): MaybePromise { + return this.allowRequest(request); + } + + override allowRequest(request: IncomingMessage): boolean { + return request.url === MetricsBackendApplicationContribution.ENDPOINT || super.allowRequest(request); + } +} diff --git a/packages/metrics/src/node/metrics-backend-application-contribution.ts b/packages/metrics/src/node/metrics-backend-application-contribution.ts index b672e2e982bde..cba07eaf38b66 100644 --- a/packages/metrics/src/node/metrics-backend-application-contribution.ts +++ b/packages/metrics/src/node/metrics-backend-application-contribution.ts @@ -24,6 +24,7 @@ import { MetricsContribution } from './metrics-contribution'; @injectable() export class MetricsBackendApplicationContribution implements BackendApplicationContribution { + static ENDPOINT = '/metrics'; constructor( @inject(ContributionProvider) @named(MetricsContribution) protected readonly metricsProviders: ContributionProvider @@ -31,7 +32,7 @@ export class MetricsBackendApplicationContribution implements BackendApplication } configure(app: express.Application): void { - app.get('/metrics', (req, res) => { + app.get(MetricsBackendApplicationContribution.ENDPOINT, (req, res) => { const lastMetrics = this.fetchMetricsFromProviders(); res.send(lastMetrics); }); From 991b4a4b22b92c9cc5d0a0d1ae27f25646d7da31 Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Wed, 13 Mar 2024 11:35:10 +0100 Subject: [PATCH 22/34] fixed renaming and moving of open notebooks (#13467) * fixed renameing of open notebooks Signed-off-by: Jonah Iden * fixed moving of notebook editors to other areas Signed-off-by: Jonah Iden --------- Signed-off-by: Jonah Iden --- .../notebook/src/browser/notebook-editor-widget.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/notebook/src/browser/notebook-editor-widget.tsx b/packages/notebook/src/browser/notebook-editor-widget.tsx index be12463809da9..23696555cad39 100644 --- a/packages/notebook/src/browser/notebook-editor-widget.tsx +++ b/packages/notebook/src/browser/notebook-editor-widget.tsx @@ -172,7 +172,7 @@ export class NotebookEditorWidget extends ReactWidget implements Navigatable, Sa } createMoveToUri(resourceUri: URI): URI | undefined { - return this.props.uri; + return this.model?.uri.withPath(resourceUri.path); } undo(): void { @@ -199,12 +199,8 @@ export class NotebookEditorWidget extends ReactWidget implements Navigatable, Sa } } - protected override onAfterAttach(msg: Message): void { - super.onAfterAttach(msg); - } - - protected override onAfterDetach(msg: Message): void { - super.onAfterDetach(msg); + protected override onCloseRequest(msg: Message): void { + super.onCloseRequest(msg); this.notebookEditorService.removeNotebookEditor(this); } From 72033d015dd423deeb6e51dfc005e1767c9eaa01 Mon Sep 17 00:00:00 2001 From: Marc Dumais Date: Wed, 13 Mar 2024 13:25:18 -0400 Subject: [PATCH 23/34] [playwright] Update documentation Since a recent enhancement/refactoring of @theia/playwright, to permit using it in Theia Electron applications, the way to load an application has changed. This commit is an attempt to update the examples that are part of the documentation. I validated the changes in the "theia-playwright-template" repository, and so I have adapted the sample code to that repo's linting rules (using single quotes instead of double). It's possible that other things have changed, that I have not yet encountered, but this should be a good step forward, at least for those just getting started integrating playwright to test their Theia-based app. Signed-off-by: Marc Dumais --- examples/playwright/docs/EXTENSIBILITY.md | 24 ++++++++++----------- examples/playwright/docs/GETTING_STARTED.md | 20 ++++++++--------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/examples/playwright/docs/EXTENSIBILITY.md b/examples/playwright/docs/EXTENSIBILITY.md index 63024ca66fe8f..ad90f087a23d2 100644 --- a/examples/playwright/docs/EXTENSIBILITY.md +++ b/examples/playwright/docs/EXTENSIBILITY.md @@ -10,11 +10,11 @@ Commands and menu items are handled by their label, so no further customization Simply interact with them via the menu or quick commands. ```typescript -const app = await TheiaApp.load(page); +const app = await TheiaAppLoader.load({ playwright, browser }); const menuBar = app.menuBar; -const yourMenu = await menuBar.openMenu("Your Menu"); -const yourItem = await mainMenu.menuItemByName("Your Item"); +const yourMenu = await menuBar.openMenu('Your Menu'); +const yourItem = await mainMenu.menuItemByName('Your Item'); expect(await yourItem?.hasSubmenu()).toBe(true); ``` @@ -30,14 +30,14 @@ export class MyTheiaApp extends TheiaApp { } export class MyToolbar extends TheiaPageObject { - selector = "div#myToolbar"; + selector = 'div#myToolbar'; async clickItem1(): Promise { await this.page.click(`${this.selector} .item1`); } } -const ws = new TheiaWorkspace(["src/tests/resources/sample-files1"]); -const app = await MyTheiaApp.loadApp(page, MyTheiaApp, ws); +const ws = new TheiaWorkspace(['src/tests/resources/sample-files1']); +const app = await TheiaAppLoader.load({ playwright, browser }, ws, MyTheiaApp); await app.toolbar.clickItem1(); ``` @@ -55,9 +55,9 @@ export class MyView extends TheiaView { constructor(public app: TheiaApp) { super( { - tabSelector: "#shell-tab-my-view", // the id of the tab - viewSelector: "#my-view-container", // the id of the view container - viewName: "My View", // the user visible view name + tabSelector: '#shell-tab-my-view', // the id of the tab + viewSelector: '#my-view-container', // the id of the view container + viewName: 'My View', // the user visible view name }, app ); @@ -66,7 +66,7 @@ export class MyView extends TheiaView { async clickMyButton(): Promise { await this.activate(); const viewElement = await this.viewElement(); - const button = await viewElement?.waitForSelector("#idOfMyButton"); + const button = await viewElement?.waitForSelector('#idOfMyButton'); await button?.click(); } } @@ -83,7 +83,7 @@ As an example, `MyView` above introduces a method that allows to click a button. To use this custom page object in a test, we pass our custom page object as a parameter when opening the view with `app.openView`. ```typescript -const app = await TheiaApp.load(page, ws); +const app = await TheiaAppLoader.load({ playwright, browser }); const myView = await app.openView(MyView); await myView.clickMyButton(); ``` @@ -94,7 +94,7 @@ As a reference for custom views and editors, please refer to the existing page o Custom status indicators are supported with the same mechanism. They are accessed via `TheiaApp.statusBar`. ```typescript -const app = await TheiaApp.load(page); +const app = await TheiaAppLoader.load({ playwright, browser }); const problemIndicator = await app.statusBar.statusIndicator( TheiaProblemIndicator ); diff --git a/examples/playwright/docs/GETTING_STARTED.md b/examples/playwright/docs/GETTING_STARTED.md index 3e5637057a1b6..ee7ec5225ed8b 100644 --- a/examples/playwright/docs/GETTING_STARTED.md +++ b/examples/playwright/docs/GETTING_STARTED.md @@ -43,35 +43,35 @@ Using the `TheiaApp` instance, we open an editor of type `TheiaTextEditor`, whic At any time, we can also get information from the text editor, such as obtaining dirty state and verify whether this information is what we expect. ```typescript -test("should undo and redo text changes and correctly update the dirty state", async () => { +test('should undo and redo text changes and correctly update the dirty state', async ({ playwright, browser }) => { // 1. set up workspace contents and open Theia app - const ws = new TheiaWorkspace(["src/tests/resources/sample-files1"]); - const app = await TheiaApp.load(page, ws); + const ws = new TheiaWorkspace(['src/tests/resources/sample-files1']); + app = await TheiaAppLoader.load( { playwright, browser }, ws); // 2. open Theia text editor const sampleTextEditor = await app.openEditor( - "sample.txt", + 'sample.txt', TheiaTextEditor ); // 3. make a change and verify contents and dirty - await sampleTextEditor.replaceLineWithLineNumber("change", 1); + await sampleTextEditor.replaceLineWithLineNumber('change', 1); expect(await sampleTextEditor.textContentOfLineByLineNumber(1)).toBe( - "change" + 'change' ); expect(await sampleTextEditor.isDirty()).toBe(true); // 4. undo and verify contents and dirty state await sampleTextEditor.undo(2); expect(await sampleTextEditor.textContentOfLineByLineNumber(1)).toBe( - "this is just a sample file" + 'this is just a sample file' ); expect(await sampleTextEditor.isDirty()).toBe(false); // 5. undo and verify contents and dirty state await sampleTextEditor.redo(2); expect(await sampleTextEditor.textContentOfLineByLineNumber(1)).toBe( - "change" + 'change' ); expect(await sampleTextEditor.isDirty()).toBe(true); @@ -81,9 +81,9 @@ test("should undo and redo text changes and correctly update the dirty state", a await sampleTextEditor.close(); // 7. reopen editor and verify dirty state - const reopenedEditor = await app.openEditor("sample.txt", TheiaTextEditor); + const reopenedEditor = await app.openEditor('sample.txt', TheiaTextEditor); expect(await reopenedEditor.textContentOfLineByLineNumber(1)).toBe( - "change" + 'change' ); await reopenedEditor.close(); From 5b03b982075f4c9bce000fef06829d82763a0b87 Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Fri, 19 Jan 2024 08:42:49 +0100 Subject: [PATCH 24/34] basics for dev-container support Signed-off-by: Jonah Iden --- packages/dev-container/README copy.md | 30 +++++++++++++ .../dev-container-frontent-module.ts | 22 +++++++++ .../src/electron-node/docker-cmd-service.ts | 15 +++++++ .../docker-container-creation-service.ts | 45 +++++++++++++++++++ 4 files changed, 112 insertions(+) create mode 100644 packages/dev-container/README copy.md create mode 100644 packages/dev-container/src/electron-browser/dev-container-frontent-module.ts create mode 100644 packages/dev-container/src/electron-node/docker-cmd-service.ts create mode 100644 packages/dev-container/src/electron-node/docker-container-creation-service.ts diff --git a/packages/dev-container/README copy.md b/packages/dev-container/README copy.md new file mode 100644 index 0000000000000..22a8c62ac364d --- /dev/null +++ b/packages/dev-container/README copy.md @@ -0,0 +1,30 @@ +
+ +
+ +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). + +## 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/src/electron-browser/dev-container-frontent-module.ts b/packages/dev-container/src/electron-browser/dev-container-frontent-module.ts new file mode 100644 index 0000000000000..8b675adde1151 --- /dev/null +++ b/packages/dev-container/src/electron-browser/dev-container-frontent-module.ts @@ -0,0 +1,22 @@ +// ***************************************************************************** +// 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 { ContainerConnectionContribution } from './container-connection-contribution'; + +export default new ContainerModule((bind, unbind, isBound, rebind) => { + bind(RemoteRegistryContribution).to(ContainerConnectionContribution); +}); diff --git a/packages/dev-container/src/electron-node/docker-cmd-service.ts b/packages/dev-container/src/electron-node/docker-cmd-service.ts new file mode 100644 index 0000000000000..0c850ec03be57 --- /dev/null +++ b/packages/dev-container/src/electron-node/docker-cmd-service.ts @@ -0,0 +1,15 @@ +// ***************************************************************************** +// 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 +// ***************************************************************************** diff --git a/packages/dev-container/src/electron-node/docker-container-creation-service.ts b/packages/dev-container/src/electron-node/docker-container-creation-service.ts new file mode 100644 index 0000000000000..279bc05219812 --- /dev/null +++ b/packages/dev-container/src/electron-node/docker-container-creation-service.ts @@ -0,0 +1,45 @@ +// ***************************************************************************** +// 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 { URI } from '@theia/core'; +import { inject, injectable } 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'; + +@injectable() +export class DockerContainerCreationService { + + @inject(WorkspaceServer) + protected readonly workspaceServer: WorkspaceServer; + + async buildContainer(docker: Docker, from?: URI): Promise { + const workspace = from ?? new URI(await this.workspaceServer.getMostRecentlyUsedWorkspace()); + if (!workspace) { + throw new Error('No workspace'); + } + + const devcontainerFile = workspace.resolve('.devcontainer/devcontainer.json'); + const devcontainerConfig = JSON.parse(await fs.readFile(devcontainerFile.path.fsPath(), 'utf-8')); + + // TODO add more config + const container = docker.createContainer({ + Image: devcontainerConfig.image, + }); + + return container; + } +} From 8f12cc31dd507c967919761d1bd39a6d174e98db Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Wed, 31 Jan 2024 12:04:57 +0100 Subject: [PATCH 25/34] basic creating and connecting to container working Signed-off-by: Jonah Iden --- packages/dev-container/README copy.md | 30 ------------------- .../dev-container-frontent-module.ts | 22 -------------- .../docker-container-creation-service.ts | 24 +++++++++++++-- .../remote-container-connection-provider.ts | 11 ++++++- 4 files changed, 31 insertions(+), 56 deletions(-) delete mode 100644 packages/dev-container/README copy.md delete mode 100644 packages/dev-container/src/electron-browser/dev-container-frontent-module.ts diff --git a/packages/dev-container/README copy.md b/packages/dev-container/README copy.md deleted file mode 100644 index 22a8c62ac364d..0000000000000 --- a/packages/dev-container/README copy.md +++ /dev/null @@ -1,30 +0,0 @@ -
- -
- -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). - -## 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/src/electron-browser/dev-container-frontent-module.ts b/packages/dev-container/src/electron-browser/dev-container-frontent-module.ts deleted file mode 100644 index 8b675adde1151..0000000000000 --- a/packages/dev-container/src/electron-browser/dev-container-frontent-module.ts +++ /dev/null @@ -1,22 +0,0 @@ -// ***************************************************************************** -// 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 { ContainerConnectionContribution } from './container-connection-contribution'; - -export default new ContainerModule((bind, unbind, isBound, rebind) => { - bind(RemoteRegistryContribution).to(ContainerConnectionContribution); -}); diff --git a/packages/dev-container/src/electron-node/docker-container-creation-service.ts b/packages/dev-container/src/electron-node/docker-container-creation-service.ts index 279bc05219812..ffc14b64093fb 100644 --- a/packages/dev-container/src/electron-node/docker-container-creation-service.ts +++ b/packages/dev-container/src/electron-node/docker-container-creation-service.ts @@ -26,19 +26,37 @@ export class DockerContainerCreationService { @inject(WorkspaceServer) protected readonly workspaceServer: WorkspaceServer; - async buildContainer(docker: Docker, from?: URI): Promise { + async buildContainer(docker: Docker, port: number, from?: URI): Promise { const workspace = from ?? new URI(await this.workspaceServer.getMostRecentlyUsedWorkspace()); if (!workspace) { throw new Error('No workspace'); } const devcontainerFile = workspace.resolve('.devcontainer/devcontainer.json'); - const devcontainerConfig = JSON.parse(await fs.readFile(devcontainerFile.path.fsPath(), 'utf-8')); + const devcontainerConfig = JSON.parse(await fs.readFile(devcontainerFile.path.fsPath(), 'utf-8').catch(() => '0')); + + if (!devcontainerConfig) { + // TODO add ability for user to create new config + throw new Error('No devcontainer.json'); + } + + await docker.pull(devcontainerConfig.image); // TODO add more config - const container = docker.createContainer({ + const container = await docker.createContainer({ Image: devcontainerConfig.image, + Tty: true, + ExposedPorts: { + [`${port}/tcp`]: {}, + }, + HostConfig: { + PortBindings: { + [`${port}/tcp`]: [{ HostPort: '0' }], + } + } }); + const start = await container.start(); + console.log(start); return container; } 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 index 6c80b89e57c7b..daeefa6f41c12 100644 --- a/packages/dev-container/src/electron-node/remote-container-connection-provider.ts +++ b/packages/dev-container/src/electron-node/remote-container-connection-provider.ts @@ -118,7 +118,7 @@ export class DevContainerConnectionProvider implements RemoteContainerConnection return this.devContainerFileService.getAvailableFiles(); } - async createContainerConnection(container: Docker.Container, docker: Docker): Promise { + async createContainerConnection(container: Docker.Container, docker: Docker, port: number): Promise { return Promise.resolve(new RemoteDockerContainerConnection({ id: generateUuid(), name: 'dev-container', @@ -140,6 +140,14 @@ export interface RemoteContainerConnectionOptions { type: string; docker: Docker; container: Docker.Container; + port: number; +} + +interface ContainerTerminalSession { + execution: Docker.Exec, + stdout: WriteStream, + stderr: WriteStream, + executeCommand(cmd: string, args?: string[]): Promise<{ stdout: string, stderr: string }>; } interface ContainerTerminalSession { @@ -177,6 +185,7 @@ export class RemoteDockerContainerConnection implements RemoteConnection { this.docker = options.docker; this.container = options.container; + this.remotePort = options.port; } async forwardOut(socket: Socket): Promise { From 2f8ae4197f02f037f8bedebc26ac57310db60d01 Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Fri, 9 Feb 2024 14:22:33 +0100 Subject: [PATCH 26/34] added dockerfile support Signed-off-by: Jonah Iden --- .../dev-container/src/electron-node/docker-container-service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/dev-container/src/electron-node/docker-container-service.ts b/packages/dev-container/src/electron-node/docker-container-service.ts index 3626e7c00b762..9d8821a0da6f8 100644 --- a/packages/dev-container/src/electron-node/docker-container-service.ts +++ b/packages/dev-container/src/electron-node/docker-container-service.ts @@ -17,6 +17,7 @@ import { ContributionProvider, URI } from '@theia/core'; import { inject, injectable, named } from '@theia/core/shared/inversify'; import { WorkspaceServer } from '@theia/workspace/lib/common'; +import { parse } from 'jsonc-parser'; import * as fs from '@theia/core/shared/fs-extra'; import * as Docker from 'dockerode'; import { LastContainerInfo } from '../electron-common/remote-container-connection-provider'; From aad07e1f7aca7c4423f995c99699820d998ddb12 Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Thu, 29 Feb 2024 11:41:48 +0100 Subject: [PATCH 27/34] added port forwarding inlcuding ui Signed-off-by: Jonah Iden --- .../remote-container-connection-provider.ts | 4 +- .../port-forwading-contribution.ts | 33 +++++ .../port-forwarding-service.ts | 73 ++++++++++ .../port-forwarding-widget.css | 24 ++++ .../port-forwarding-widget.tsx | 131 ++++++++++++++++++ .../remote-frontend-module.ts | 24 +++- .../remote-port-forwarding-provider.ts | 29 ++++ .../electron-node/remote-backend-module.ts | 6 + .../remote-port-forwarding-provider.ts | 51 +++++++ .../remote/src/electron-node/remote-types.ts | 2 +- .../ssh/remote-ssh-connection-provider.ts | 4 +- 11 files changed, 373 insertions(+), 8 deletions(-) create mode 100644 packages/remote/src/electron-browser/port-forwarding/port-forwading-contribution.ts create mode 100644 packages/remote/src/electron-browser/port-forwarding/port-forwarding-service.ts create mode 100644 packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.css create mode 100644 packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.tsx create mode 100644 packages/remote/src/electron-common/remote-port-forwarding-provider.ts create mode 100644 packages/remote/src/electron-node/remote-port-forwarding-provider.ts 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 index daeefa6f41c12..f3100df8f039e 100644 --- a/packages/dev-container/src/electron-node/remote-container-connection-provider.ts +++ b/packages/dev-container/src/electron-node/remote-container-connection-provider.ts @@ -188,12 +188,12 @@ export class RemoteDockerContainerConnection implements RemoteConnection { this.remotePort = options.port; } - async forwardOut(socket: Socket): Promise { + async forwardOut(socket: Socket, port?: number): 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}`], + Cmd: ['sh', '-c', `${node} ${devContainerServer} -target-port=${port ?? this.remotePort}`], AttachStdin: true, AttachStdout: true, AttachStderr: true }); diff --git a/packages/remote/src/electron-browser/port-forwarding/port-forwading-contribution.ts b/packages/remote/src/electron-browser/port-forwarding/port-forwading-contribution.ts new file mode 100644 index 0000000000000..0f45e5465abf1 --- /dev/null +++ b/packages/remote/src/electron-browser/port-forwarding/port-forwading-contribution.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 { nls } from '@theia/core'; +import { AbstractViewContribution } from '@theia/core/lib/browser'; +import { injectable } from '@theia/core/shared/inversify'; +import { PortForwardingWidget, PORT_FORWARDING_WIDGET_ID } from './port-forwarding-widget'; + +@injectable() +export class PortForwardingContribution extends AbstractViewContribution { + constructor() { + super({ + widgetId: PORT_FORWARDING_WIDGET_ID, + widgetName: nls.localizeByDefault('Ports'), + defaultWidgetOptions: { + area: 'bottom' + } + }); + } +} diff --git a/packages/remote/src/electron-browser/port-forwarding/port-forwarding-service.ts b/packages/remote/src/electron-browser/port-forwarding/port-forwarding-service.ts new file mode 100644 index 0000000000000..40b9b2cc9a9ec --- /dev/null +++ b/packages/remote/src/electron-browser/port-forwarding/port-forwarding-service.ts @@ -0,0 +1,73 @@ +// ***************************************************************************** +// 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 { Emitter } from '@theia/core'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { RemotePortForwardingProvider } from '../../electron-common/remote-port-forwarding-provider'; + +export interface ForwardedPort { + localPort?: number; + address?: string; + origin?: string; + editing: boolean; +} + +@injectable() +export class PortForwardingService { + + @inject(RemotePortForwardingProvider) + readonly provider: RemotePortForwardingProvider; + + protected readonly onDidChangePortsEmitter = new Emitter(); + readonly onDidChangePorts = this.onDidChangePortsEmitter.event; + + forwardedPorts: ForwardedPort[] = []; + + forwardNewPort(origin?: string): ForwardedPort { + const index = this.forwardedPorts.push({ editing: true, origin }); + return this.forwardedPorts[index - 1]; + } + + updatePort(port: ForwardedPort, newAdress: string): void { + const connectionPort = new URLSearchParams(location.search).get('port'); + if (!connectionPort) { + // if there is no open remote connection we can't forward a port + return; + } + + const parts = newAdress.split(':'); + if (parts.length === 2) { + port.address = parts[0]; + port.localPort = parseInt(parts[1]); + } else { + port.localPort = parseInt(parts[0]); + } + + port.editing = false; + + this.provider.forwardPort(parseInt(connectionPort), { port: port.localPort!, address: port.address }); + this.onDidChangePortsEmitter.fire(); + } + + removePort(port: ForwardedPort): void { + const index = this.forwardedPorts.indexOf(port); + if (index !== -1) { + this.forwardedPorts.splice(index, 1); + this.provider.portRemoved({ port: port.localPort! }); + this.onDidChangePortsEmitter.fire(); + } + } +} diff --git a/packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.css b/packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.css new file mode 100644 index 0000000000000..48e47359699a0 --- /dev/null +++ b/packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.css @@ -0,0 +1,24 @@ +.port-table { + width: 100%; + margin: calc(var(--theia-ui-padding) * 2); + table-layout: fixed; +} + +.port-table-header { + text-align: left; +} + +.forward-port-button { + margin-left: 0; + width: 100%; +} + +.button-cell { + display: flex; + padding-right: var(--theia-ui-padding); +} + +.forwarded-address:hover { + cursor: pointer; + text-decoration: underline; +} diff --git a/packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.tsx b/packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.tsx new file mode 100644 index 0000000000000..a5d60c7f2b6e0 --- /dev/null +++ b/packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.tsx @@ -0,0 +1,131 @@ +// ***************************************************************************** +// 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 React from '@theia/core/shared/react'; +import { ReactNode } from '@theia/core/shared/react'; +import { OpenerService, ReactWidget } from '@theia/core/lib/browser'; +import { nls, URI } from '@theia/core'; +import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; +import { ForwardedPort, PortForwardingService } from './port-forwarding-service'; +import '../../../src/electron-browser/port-forwarding/port-forwarding-widget.css'; +import { ClipboardService } from '@theia/core/lib/browser/clipboard-service'; + +export const PORT_FORWARDING_WIDGET_ID = 'port-forwarding-widget'; + +@injectable() +export class PortForwardingWidget extends ReactWidget { + + @inject(PortForwardingService) + protected readonly portForwardingService: PortForwardingService; + + @inject(OpenerService) + protected readonly openerService: OpenerService; + + @inject(ClipboardService) + protected readonly clipboardService: ClipboardService; + + @postConstruct() + protected init(): void { + this.id = PORT_FORWARDING_WIDGET_ID; + this.title.label = nls.localizeByDefault('Ports'); + this.title.caption = this.title.label; + this.title.closable = true; + this.update(); + + this.portForwardingService.onDidChangePorts(() => this.update()); + } + + protected render(): ReactNode { + if (this.portForwardingService.forwardedPorts.length === 0) { + return
+

{'No forwarded ports. Forward a port to access your locally running services over the internet'}

+ {this.renderForwardPortButton()} +
; + } + + return
+ + + + + + + + + + + {this.portForwardingService.forwardedPorts.map(port => ( + + {this.renderPortColumn(port)} + {this.renderAddressColumn(port)} + + + + ))} + {!this.portForwardingService.forwardedPorts.some(port => port.editing) && } + +
{nls.localizeByDefault('Port')}{nls.localizeByDefault('Address')}{nls.localizeByDefault('Running Process')}{nls.localizeByDefault('Origin')}
{port.origin}
{this.renderForwardPortButton()}
+
; + } + + protected renderForwardPortButton(): ReactNode { + return ; + } + + protected renderAddressColumn(port: ForwardedPort): ReactNode { + const address = `${port.address ?? 'localhost'}:${port.localPort}`; + return +
+ { + if (e.ctrlKey) { + const uri = new URI(`http://${address}`); + (await this.openerService.getOpener(uri)).open(uri); + } + }} title={nls.localizeByDefault('Follow link') + ' (ctrl/cmd + click)'}> + {port.localPort ? address : ''} + + { + this.clipboardService.writeText(address); + }}> +
+ ; + } + + protected renderPortColumn(port: ForwardedPort): ReactNode { + return port.editing ? + { + if (e.key === 'Enter') { + this.portForwardingService.updatePort(port, e.currentTarget.value); + } + }}> : + +
+ {port.localPort} + { + this.portForwardingService.removePort(port); + this.update(); + }}> +
+ ; + } + +} diff --git a/packages/remote/src/electron-browser/remote-frontend-module.ts b/packages/remote/src/electron-browser/remote-frontend-module.ts index f2ddaf3d9e2d4..6844b40f01dc0 100644 --- a/packages/remote/src/electron-browser/remote-frontend-module.ts +++ b/packages/remote/src/electron-browser/remote-frontend-module.ts @@ -16,7 +16,7 @@ import { bindContributionProvider, CommandContribution } from '@theia/core'; import { ContainerModule } from '@theia/core/shared/inversify'; -import { FrontendApplicationContribution, WebSocketConnectionProvider } from '@theia/core/lib/browser'; +import { bindViewContribution, FrontendApplicationContribution, WidgetFactory } from '@theia/core/lib/browser'; import { RemoteSSHContribution } from './remote-ssh-contribution'; import { RemoteSSHConnectionProvider, RemoteSSHConnectionProviderPath } from '../electron-common/remote-ssh-connection-provider'; import { RemoteFrontendContribution } from './remote-frontend-contribution'; @@ -26,6 +26,11 @@ import { RemoteStatusService, RemoteStatusServicePath } from '../electron-common import { ElectronFileDialogService } from '@theia/filesystem/lib/electron-browser/file-dialog/electron-file-dialog-service'; import { RemoteElectronFileDialogService } from './remote-electron-file-dialog-service'; import { bindRemotePreferences } from './remote-preferences'; +import { PortForwardingWidget, PORT_FORWARDING_WIDGET_ID } from './port-forwarding/port-forwarding-widget'; +import { PortForwardingContribution } from './port-forwarding/port-forwading-contribution'; +import { PortForwardingService } from './port-forwarding/port-forwarding-service'; +import { RemotePortForwardingProvider, RemoteRemotePortForwardingProviderPath } from '../electron-common/remote-port-forwarding-provider'; +import { ServiceConnectionProvider } from '@theia/core/lib/browser/messaging/service-connection-provider'; export default new ContainerModule((bind, _, __, rebind) => { bind(RemoteFrontendContribution).toSelf().inSingletonScope(); @@ -42,8 +47,21 @@ export default new ContainerModule((bind, _, __, rebind) => { bind(RemoteService).toSelf().inSingletonScope(); + bind(PortForwardingWidget).toSelf(); + bind(WidgetFactory).toDynamicValue(context => ({ + id: PORT_FORWARDING_WIDGET_ID, + createWidget: () => context.container.get(PortForwardingWidget) + })); + + bindViewContribution(bind, PortForwardingContribution); + bind(PortForwardingService).toSelf().inSingletonScope(); + bind(RemoteSSHConnectionProvider).toDynamicValue(ctx => - WebSocketConnectionProvider.createLocalProxy(ctx.container, RemoteSSHConnectionProviderPath)).inSingletonScope(); + ServiceConnectionProvider.createLocalProxy(ctx.container, RemoteSSHConnectionProviderPath)).inSingletonScope(); bind(RemoteStatusService).toDynamicValue(ctx => - WebSocketConnectionProvider.createLocalProxy(ctx.container, RemoteStatusServicePath)).inSingletonScope(); + ServiceConnectionProvider.createLocalProxy(ctx.container, RemoteStatusServicePath)).inSingletonScope(); + + bind(RemotePortForwardingProvider).toDynamicValue(ctx => + ServiceConnectionProvider.createLocalProxy(ctx.container, RemoteRemotePortForwardingProviderPath)).inSingletonScope(); + }); diff --git a/packages/remote/src/electron-common/remote-port-forwarding-provider.ts b/packages/remote/src/electron-common/remote-port-forwarding-provider.ts new file mode 100644 index 0000000000000..6f01e01da2ccd --- /dev/null +++ b/packages/remote/src/electron-common/remote-port-forwarding-provider.ts @@ -0,0 +1,29 @@ +// ***************************************************************************** +// 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 const RemoteRemotePortForwardingProviderPath = '/remote/port-forwarding'; + +export const RemotePortForwardingProvider = Symbol('RemoteSSHConnectionProvider'); + +export interface ForwardedPort { + port: number; + address?: string; +} + +export interface RemotePortForwardingProvider { + forwardPort(connectionPort: number, portToForward: ForwardedPort): Promise; + portRemoved(port: ForwardedPort): Promise; +} diff --git a/packages/remote/src/electron-node/remote-backend-module.ts b/packages/remote/src/electron-node/remote-backend-module.ts index 733929479ce44..2197bb28a3d8b 100644 --- a/packages/remote/src/electron-node/remote-backend-module.ts +++ b/packages/remote/src/electron-node/remote-backend-module.ts @@ -37,11 +37,17 @@ import { RemoteCopyContribution, RemoteCopyRegistry } from './setup/remote-copy- import { MainCopyContribution } from './setup/main-copy-contribution'; import { RemoteNativeDependencyContribution } from './setup/remote-native-dependency-contribution'; import { AppNativeDependencyContribution } from './setup/app-native-dependency-contribution'; +import { RemotePortForwardingProviderImpl } from './remote-port-forwarding-provider'; +import { RemotePortForwardingProvider, RemoteRemotePortForwardingProviderPath } from '../electron-common/remote-port-forwarding-provider'; export const remoteConnectionModule = ConnectionContainerModule.create(({ bind, bindBackendService }) => { bind(RemoteSSHConnectionProviderImpl).toSelf().inSingletonScope(); bind(RemoteSSHConnectionProvider).toService(RemoteSSHConnectionProviderImpl); bindBackendService(RemoteSSHConnectionProviderPath, RemoteSSHConnectionProvider); + + bind(RemotePortForwardingProviderImpl).toSelf().inSingletonScope(); + bind(RemotePortForwardingProvider).toService(RemotePortForwardingProviderImpl); + bindBackendService(RemoteRemotePortForwardingProviderPath, RemotePortForwardingProvider); }); export default new ContainerModule((bind, _unbind, _isBound, rebind) => { diff --git a/packages/remote/src/electron-node/remote-port-forwarding-provider.ts b/packages/remote/src/electron-node/remote-port-forwarding-provider.ts new file mode 100644 index 0000000000000..9819d2e7a940c --- /dev/null +++ b/packages/remote/src/electron-node/remote-port-forwarding-provider.ts @@ -0,0 +1,51 @@ +// ***************************************************************************** +// 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 { ForwardedPort, RemotePortForwardingProvider } from '../electron-common/remote-port-forwarding-provider'; +import { createServer, Server } from 'net'; +import { RemoteConnectionService } from './remote-connection-service'; + +@injectable() +export class RemotePortForwardingProviderImpl implements RemotePortForwardingProvider { + + @inject(RemoteConnectionService) + protected readonly connectionService: RemoteConnectionService; + + protected forwardedPorts: Map = new Map(); + + async forwardPort(connectionPort: number, portToForward: ForwardedPort): Promise { + const currentConnection = this.connectionService.getConnectionFromPort(connectionPort); + if (!currentConnection) { + throw new Error(`No connection found for port ${connectionPort}`); + } + + const server = createServer(socket => { + currentConnection?.forwardOut(socket, portToForward.port); + }).listen(portToForward.port, portToForward.address); + this.forwardedPorts.set(portToForward.port, server); + + } + + async portRemoved(forwardedPort: ForwardedPort): Promise { + const proxy = this.forwardedPorts.get(forwardedPort.port); + if (proxy) { + proxy.close(); + this.forwardedPorts.delete(forwardedPort.port); + } + } + +} diff --git a/packages/remote/src/electron-node/remote-types.ts b/packages/remote/src/electron-node/remote-types.ts index e50811629d0cc..9c829ee201499 100644 --- a/packages/remote/src/electron-node/remote-types.ts +++ b/packages/remote/src/electron-node/remote-types.ts @@ -49,7 +49,7 @@ export interface RemoteConnection extends Disposable { localPort: number; remotePort: number; onDidDisconnect: Event; - forwardOut(socket: net.Socket): void; + forwardOut(socket: net.Socket, port?: number): void; /** * execute a single command on the remote machine diff --git a/packages/remote/src/electron-node/ssh/remote-ssh-connection-provider.ts b/packages/remote/src/electron-node/ssh/remote-ssh-connection-provider.ts index a0b20e321c235..7c9342c0dd8ff 100644 --- a/packages/remote/src/electron-node/ssh/remote-ssh-connection-provider.ts +++ b/packages/remote/src/electron-node/ssh/remote-ssh-connection-provider.ts @@ -278,8 +278,8 @@ export class RemoteSSHConnection implements RemoteConnection { return sftpClient; } - forwardOut(socket: net.Socket): void { - this.client.forwardOut(socket.localAddress!, socket.localPort!, '127.0.0.1', this.remotePort, (err, stream) => { + forwardOut(socket: net.Socket, port?: number): void { + this.client.forwardOut(socket.localAddress!, socket.localPort!, '127.0.0.1', port ?? this.remotePort, (err, stream) => { if (err) { console.debug('Proxy message rejected', err); } else { From 37bf7a58d829d885fc953edc9c2f8e43090641e5 Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Fri, 1 Mar 2024 09:30:08 +0100 Subject: [PATCH 28/34] basic port/address validation Signed-off-by: Jonah Iden --- .../port-forwarding-service.ts | 11 +++++++ .../port-forwarding-widget.css | 4 +++ .../port-forwarding-widget.tsx | 29 ++++++++++++------- 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/packages/remote/src/electron-browser/port-forwarding/port-forwarding-service.ts b/packages/remote/src/electron-browser/port-forwarding/port-forwarding-service.ts index 40b9b2cc9a9ec..7512847ee6376 100644 --- a/packages/remote/src/electron-browser/port-forwarding/port-forwarding-service.ts +++ b/packages/remote/src/electron-browser/port-forwarding/port-forwarding-service.ts @@ -70,4 +70,15 @@ export class PortForwardingService { this.onDidChangePortsEmitter.fire(); } } + + isValidAddress(address: string): boolean { + const match = address.match(/^(.*:)?\d+$/); + if (!match) { + return false; + } + + const port = parseInt(address.split(':')[1]); + + return !this.forwardedPorts.some(p => p.localPort === port); + } } diff --git a/packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.css b/packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.css index 48e47359699a0..bbe07013e638c 100644 --- a/packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.css +++ b/packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.css @@ -22,3 +22,7 @@ cursor: pointer; text-decoration: underline; } + +.port-edit-input-error { + outline-color: var(--theia-inputValidation-errorBorder); +} diff --git a/packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.tsx b/packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.tsx index a5d60c7f2b6e0..abd38f4da12e1 100644 --- a/packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.tsx +++ b/packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.tsx @@ -68,7 +68,7 @@ export class PortForwardingWidget extends ReactWidget { {this.portForwardingService.forwardedPorts.map(port => ( - + {this.renderPortColumn(port)} {this.renderAddressColumn(port)} @@ -101,22 +101,19 @@ export class PortForwardingWidget extends ReactWidget { }} title={nls.localizeByDefault('Follow link') + ' (ctrl/cmd + click)'}> {port.localPort ? address : ''} - { - this.clipboardService.writeText(address); - }}> + { + port.localPort && + { + this.clipboardService.writeText(address); + }}> + } ; } protected renderPortColumn(port: ForwardedPort): ReactNode { return port.editing ? - { - if (e.key === 'Enter') { - this.portForwardingService.updatePort(port, e.currentTarget.value); - } - }}> : + :
{port.localPort} @@ -129,3 +126,13 @@ export class PortForwardingWidget extends ReactWidget { } } + +function PortEditingInput({ port, service }: { port: ForwardedPort, service: PortForwardingService }): React.JSX.Element { + const [error, setError] = React.useState(false); + return e.key === 'Enter' && !error && service.updatePort(port, e.currentTarget.value)} + onKeyUp={e => setError(!service.isValidAddress(e.currentTarget.value))}>; + +} From 0afebb307bf8cc1258fa8c9428247df5c3f87257 Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Fri, 1 Mar 2024 10:03:26 +0100 Subject: [PATCH 29/34] fixed allready forwarded port checking Signed-off-by: Jonah Iden --- .../electron-browser/port-forwarding/port-forwarding-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/remote/src/electron-browser/port-forwarding/port-forwarding-service.ts b/packages/remote/src/electron-browser/port-forwarding/port-forwarding-service.ts index 7512847ee6376..109cfb098a30d 100644 --- a/packages/remote/src/electron-browser/port-forwarding/port-forwarding-service.ts +++ b/packages/remote/src/electron-browser/port-forwarding/port-forwarding-service.ts @@ -77,7 +77,7 @@ export class PortForwardingService { return false; } - const port = parseInt(address.split(':')[1]); + const port = parseInt(address.includes(':') ? address.split(':')[1] : address); return !this.forwardedPorts.some(p => p.localPort === port); } From 1ab4a36889603192d2c919961d7201a96f828562 Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Fri, 1 Mar 2024 10:27:35 +0100 Subject: [PATCH 30/34] rebase fixes Signed-off-by: Jonah Iden --- .../src/electron-node/docker-container-service.ts | 1 - .../src/electron-node/remote-container-connection-provider.ts | 4 +--- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/dev-container/src/electron-node/docker-container-service.ts b/packages/dev-container/src/electron-node/docker-container-service.ts index 9d8821a0da6f8..3626e7c00b762 100644 --- a/packages/dev-container/src/electron-node/docker-container-service.ts +++ b/packages/dev-container/src/electron-node/docker-container-service.ts @@ -17,7 +17,6 @@ import { ContributionProvider, URI } from '@theia/core'; import { inject, injectable, named } from '@theia/core/shared/inversify'; import { WorkspaceServer } from '@theia/workspace/lib/common'; -import { parse } from 'jsonc-parser'; import * as fs from '@theia/core/shared/fs-extra'; import * as Docker from 'dockerode'; import { LastContainerInfo } from '../electron-common/remote-container-connection-provider'; 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 index f3100df8f039e..214b98b3a19ad 100644 --- a/packages/dev-container/src/electron-node/remote-container-connection-provider.ts +++ b/packages/dev-container/src/electron-node/remote-container-connection-provider.ts @@ -118,7 +118,7 @@ export class DevContainerConnectionProvider implements RemoteContainerConnection return this.devContainerFileService.getAvailableFiles(); } - async createContainerConnection(container: Docker.Container, docker: Docker, port: number): Promise { + async createContainerConnection(container: Docker.Container, docker: Docker): Promise { return Promise.resolve(new RemoteDockerContainerConnection({ id: generateUuid(), name: 'dev-container', @@ -140,7 +140,6 @@ export interface RemoteContainerConnectionOptions { type: string; docker: Docker; container: Docker.Container; - port: number; } interface ContainerTerminalSession { @@ -185,7 +184,6 @@ export class RemoteDockerContainerConnection implements RemoteConnection { this.docker = options.docker; this.container = options.container; - this.remotePort = options.port; } async forwardOut(socket: Socket, port?: number): Promise { From 170bdc5dfa921cdcd56bcdca48096221f8a9045e Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Fri, 15 Mar 2024 14:12:32 +0100 Subject: [PATCH 31/34] removed unused file Signed-off-by: Jonah Iden --- .../src/electron-node/docker-cmd-service.ts | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 packages/dev-container/src/electron-node/docker-cmd-service.ts diff --git a/packages/dev-container/src/electron-node/docker-cmd-service.ts b/packages/dev-container/src/electron-node/docker-cmd-service.ts deleted file mode 100644 index 0c850ec03be57..0000000000000 --- a/packages/dev-container/src/electron-node/docker-cmd-service.ts +++ /dev/null @@ -1,15 +0,0 @@ -// ***************************************************************************** -// 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 -// ***************************************************************************** From 28e4966fadfe90d5a80b8e1839926ead8dc83bb2 Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Tue, 19 Mar 2024 15:48:07 +0100 Subject: [PATCH 32/34] review changes Signed-off-by: Jonah Iden --- .../docker-container-creation-service.ts | 63 ------------------- .../port-forwarding-widget.css | 28 --------- .../port-forwarding-widget.tsx | 9 +-- .../remote-frontend-module.ts | 1 + .../style/port-forwarding-widget.css | 44 +++++++++++++ .../remote-port-forwarding-provider.ts | 1 - 6 files changed, 50 insertions(+), 96 deletions(-) delete mode 100644 packages/dev-container/src/electron-node/docker-container-creation-service.ts delete mode 100644 packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.css create mode 100644 packages/remote/src/electron-browser/style/port-forwarding-widget.css diff --git a/packages/dev-container/src/electron-node/docker-container-creation-service.ts b/packages/dev-container/src/electron-node/docker-container-creation-service.ts deleted file mode 100644 index ffc14b64093fb..0000000000000 --- a/packages/dev-container/src/electron-node/docker-container-creation-service.ts +++ /dev/null @@ -1,63 +0,0 @@ -// ***************************************************************************** -// 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 { URI } from '@theia/core'; -import { inject, injectable } 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'; - -@injectable() -export class DockerContainerCreationService { - - @inject(WorkspaceServer) - protected readonly workspaceServer: WorkspaceServer; - - async buildContainer(docker: Docker, port: number, from?: URI): Promise { - const workspace = from ?? new URI(await this.workspaceServer.getMostRecentlyUsedWorkspace()); - if (!workspace) { - throw new Error('No workspace'); - } - - const devcontainerFile = workspace.resolve('.devcontainer/devcontainer.json'); - const devcontainerConfig = JSON.parse(await fs.readFile(devcontainerFile.path.fsPath(), 'utf-8').catch(() => '0')); - - if (!devcontainerConfig) { - // TODO add ability for user to create new config - throw new Error('No devcontainer.json'); - } - - await docker.pull(devcontainerConfig.image); - - // TODO add more config - const container = await docker.createContainer({ - Image: devcontainerConfig.image, - Tty: true, - ExposedPorts: { - [`${port}/tcp`]: {}, - }, - HostConfig: { - PortBindings: { - [`${port}/tcp`]: [{ HostPort: '0' }], - } - } - }); - const start = await container.start(); - console.log(start); - - return container; - } -} diff --git a/packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.css b/packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.css deleted file mode 100644 index bbe07013e638c..0000000000000 --- a/packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.css +++ /dev/null @@ -1,28 +0,0 @@ -.port-table { - width: 100%; - margin: calc(var(--theia-ui-padding) * 2); - table-layout: fixed; -} - -.port-table-header { - text-align: left; -} - -.forward-port-button { - margin-left: 0; - width: 100%; -} - -.button-cell { - display: flex; - padding-right: var(--theia-ui-padding); -} - -.forwarded-address:hover { - cursor: pointer; - text-decoration: underline; -} - -.port-edit-input-error { - outline-color: var(--theia-inputValidation-errorBorder); -} diff --git a/packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.tsx b/packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.tsx index abd38f4da12e1..ee1315f43486e 100644 --- a/packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.tsx +++ b/packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.tsx @@ -20,7 +20,6 @@ import { OpenerService, ReactWidget } from '@theia/core/lib/browser'; import { nls, URI } from '@theia/core'; import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import { ForwardedPort, PortForwardingService } from './port-forwarding-service'; -import '../../../src/electron-browser/port-forwarding/port-forwarding-widget.css'; import { ClipboardService } from '@theia/core/lib/browser/clipboard-service'; export const PORT_FORWARDING_WIDGET_ID = 'port-forwarding-widget'; @@ -51,7 +50,9 @@ export class PortForwardingWidget extends ReactWidget { protected render(): ReactNode { if (this.portForwardingService.forwardedPorts.length === 0) { return
-

{'No forwarded ports. Forward a port to access your locally running services over the internet'}

+

+ {nls.localizeByDefault('No forwarded ports. Forward a port to access your locally running services over the internet.\n[Forward a Port]({0})').split('\n')[0]} +

{this.renderForwardPortButton()}
; } @@ -72,7 +73,7 @@ export class PortForwardingWidget extends ReactWidget { {this.renderPortColumn(port)} {this.renderAddressColumn(port)} - {port.origin} + {port.origin ? nls.localizeByDefault(port.origin) : ''} ))} {!this.portForwardingService.forwardedPorts.some(port => port.editing) && {this.renderForwardPortButton()}} @@ -83,7 +84,7 @@ export class PortForwardingWidget extends ReactWidget { protected renderForwardPortButton(): ReactNode { return ; diff --git a/packages/remote/src/electron-browser/remote-frontend-module.ts b/packages/remote/src/electron-browser/remote-frontend-module.ts index 6844b40f01dc0..599c9ef3906d3 100644 --- a/packages/remote/src/electron-browser/remote-frontend-module.ts +++ b/packages/remote/src/electron-browser/remote-frontend-module.ts @@ -31,6 +31,7 @@ import { PortForwardingContribution } from './port-forwarding/port-forwading-con import { PortForwardingService } from './port-forwarding/port-forwarding-service'; import { RemotePortForwardingProvider, RemoteRemotePortForwardingProviderPath } from '../electron-common/remote-port-forwarding-provider'; import { ServiceConnectionProvider } from '@theia/core/lib/browser/messaging/service-connection-provider'; +import '../../src/electron-browser/style/port-forwarding-widget.css'; export default new ContainerModule((bind, _, __, rebind) => { bind(RemoteFrontendContribution).toSelf().inSingletonScope(); diff --git a/packages/remote/src/electron-browser/style/port-forwarding-widget.css b/packages/remote/src/electron-browser/style/port-forwarding-widget.css new file mode 100644 index 0000000000000..7eafdb5e6af4e --- /dev/null +++ b/packages/remote/src/electron-browser/style/port-forwarding-widget.css @@ -0,0 +1,44 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ + +.port-table { + width: 100%; + margin: calc(var(--theia-ui-padding) * 2); + table-layout: fixed; +} + +.port-table-header { + text-align: left; +} + +.forward-port-button { + margin-left: 0; + width: 100%; +} + +.button-cell { + display: flex; + padding-right: var(--theia-ui-padding); +} + +.forwarded-address:hover { + cursor: pointer; + text-decoration: underline; +} + +.port-edit-input-error { + outline-color: var(--theia-inputValidation-errorBorder); +} diff --git a/packages/remote/src/electron-node/remote-port-forwarding-provider.ts b/packages/remote/src/electron-node/remote-port-forwarding-provider.ts index 9819d2e7a940c..23c8e4ec9f579 100644 --- a/packages/remote/src/electron-node/remote-port-forwarding-provider.ts +++ b/packages/remote/src/electron-node/remote-port-forwarding-provider.ts @@ -37,7 +37,6 @@ export class RemotePortForwardingProviderImpl implements RemotePortForwardingPro currentConnection?.forwardOut(socket, portToForward.port); }).listen(portToForward.port, portToForward.address); this.forwardedPorts.set(portToForward.port, server); - } async portRemoved(forwardedPort: ForwardedPort): Promise { From 697091691951387a3e754a046af652d34989ccdd Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Fri, 22 Mar 2024 13:13:27 +0100 Subject: [PATCH 33/34] fixed widget focus and message margin Signed-off-by: Jonah Iden --- .../port-forwarding/port-forwarding-widget.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.tsx b/packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.tsx index ee1315f43486e..7b03ca72dc21c 100644 --- a/packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.tsx +++ b/packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.tsx @@ -39,6 +39,7 @@ export class PortForwardingWidget extends ReactWidget { @postConstruct() protected init(): void { this.id = PORT_FORWARDING_WIDGET_ID; + this.node.tabIndex = -1; this.title.label = nls.localizeByDefault('Ports'); this.title.caption = this.title.label; this.title.closable = true; @@ -50,7 +51,7 @@ export class PortForwardingWidget extends ReactWidget { protected render(): ReactNode { if (this.portForwardingService.forwardedPorts.length === 0) { return
-

+

{nls.localizeByDefault('No forwarded ports. Forward a port to access your locally running services over the internet.\n[Forward a Port]({0})').split('\n')[0]}

{this.renderForwardPortButton()} From 9c145d6a4c0f9424bd68f0b33447d999ed44b8de Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Fri, 22 Mar 2024 13:18:39 +0100 Subject: [PATCH 34/34] default port binding now shows as 0.0.0.0 Signed-off-by: Jonah Iden --- .../electron-browser/port-forwarding/port-forwarding-widget.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.tsx b/packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.tsx index 7b03ca72dc21c..9333e43f628cc 100644 --- a/packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.tsx +++ b/packages/remote/src/electron-browser/port-forwarding/port-forwarding-widget.tsx @@ -92,7 +92,7 @@ export class PortForwardingWidget extends ReactWidget { } protected renderAddressColumn(port: ForwardedPort): ReactNode { - const address = `${port.address ?? 'localhost'}:${port.localPort}`; + const address = `${port.address ?? '0.0.0.0'}:${port.localPort}`; return
{