deploy: current vibn theia state
Some checks failed
Playwright Tests / Playwright Tests (ubuntu-22.04, Node.js 22.x) (push) Has been cancelled
3PP License Check / 3PP License Check (11, 22.x, ubuntu-22.04) (push) Has been cancelled
Publish packages to NPM / Perform Publishing (push) Has been cancelled

Made-with: Cursor
This commit is contained in:
2026-02-27 12:01:08 -08:00
commit 8bb5110148
3782 changed files with 640947 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
extends: [
'../../configs/build.eslintrc.json'
],
parserOptions: {
tsconfigRootDir: __dirname,
project: 'tsconfig.json'
}
};

View File

@@ -0,0 +1,53 @@
<div align='center'>
<br />
<img src='https://raw.githubusercontent.com/eclipse-theia/theia/master/logo/theia.svg?sanitize=true' alt='theia-ext-logo' width='100px' />
<h2>ECLIPSE THEIA - DEV-CONTAINER EXTENSION</h2>
<hr />
</div>
## Description
The `@theia/dev-container` extension provides functionality to create, start and connect to development containers similiar to the
[vscode Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers).
The full devcontainer.json Schema can be found [here](https://containers.dev/implementors/json_reference/).
Currently not all of the configuration file properties are implemented. The following are implemented:
- name
- Image
- dockerfile/build.dockerfile
- build.context
- location
- forwardPorts
- mounts
- containerEnv
- remoteUser
- shutdownAction
- postCreateCommand
- postStartCommand
see `main-container-creation-contributions.ts` for how to implementations or how to implement additional ones.
Additionally adds support for `composeUpArgs` devcontainer.json property to apply additional arguments for the `docker compose up` call.
Usage: `"composeUpArgs": ["--force-recreate"]`
## Additional Information
- [API documentation for `@theia/dev-container`](https://eclipse-theia.github.io/theia/docs/next/modules/_theia_dev-container.html)
- [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>

View File

@@ -0,0 +1,55 @@
{
"name": "@theia/dev-container",
"version": "1.68.0",
"description": "Theia - Editor Preview Extension",
"dependencies": {
"@theia/core": "1.68.0",
"@theia/output": "1.68.0",
"@theia/remote": "1.68.0",
"@theia/workspace": "1.68.0",
"dockerode": "^4.0.2",
"jsonc-parser": "^2.2.0",
"uuid": "^8.0.0"
},
"publishConfig": {
"access": "public"
},
"theiaExtensions": [
{
"frontendElectron": "lib/electron-browser/dev-container-frontend-module",
"backendElectron": "lib/electron-node/dev-container-backend-module"
}
],
"keywords": [
"theia-extension"
],
"license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0",
"repository": {
"type": "git",
"url": "https://github.com/eclipse-theia/theia.git"
},
"bugs": {
"url": "https://github.com/eclipse-theia/theia/issues"
},
"homepage": "https://github.com/eclipse-theia/theia",
"files": [
"lib",
"src"
],
"scripts": {
"build": "theiaext build",
"clean": "theiaext clean",
"compile": "theiaext compile",
"lint": "theiaext lint",
"test": "theiaext test",
"watch": "theiaext watch"
},
"devDependencies": {
"@theia/ext-scripts": "1.68.0",
"@types/dockerode": "^3.3.23"
},
"nyc": {
"extends": "../../configs/nyc.json"
},
"gitHead": "21358137e41342742707f660b8e222f940a27652"
}

View File

@@ -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);

View File

@@ -0,0 +1,158 @@
// *****************************************************************************
// 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 { DevContainerFile, LastContainerInfo, RemoteContainerConnectionProvider } from '../electron-common/remote-container-connection-provider';
import { WorkspaceStorageService } from '@theia/workspace/lib/browser/workspace-storage-service';
import { Command, MaybePromise, MessageService, nls, QuickInputService, URI } from '@theia/core';
import { WorkspaceInput, WorkspaceOpenHandlerContribution, WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
import { ContainerOutputProvider } from './container-output-provider';
import { WorkspaceServer } from '@theia/workspace/lib/common';
import { DEV_CONTAINER_PATH_QUERY, DEV_CONTAINER_WORKSPACE_SCHEME } from '../electron-common/dev-container-workspaces';
import { RemotePreferences } from '@theia/remote/lib/electron-common/remote-preferences';
export namespace RemoteContainerCommands {
export const REOPEN_IN_CONTAINER = Command.toLocalizedCommand({
id: 'dev-container:reopen-in-container',
label: 'Reopen in Container',
category: 'Dev Container'
}, 'theia/remote/dev-container/connect');
}
const LAST_USED_CONTAINER = 'lastUsedContainer';
@injectable()
export class ContainerConnectionContribution extends AbstractRemoteRegistryContribution implements WorkspaceOpenHandlerContribution {
@inject(RemoteContainerConnectionProvider)
protected readonly connectionProvider: RemoteContainerConnectionProvider;
@inject(RemotePreferences)
protected readonly remotePreferences: RemotePreferences;
@inject(MessageService)
protected readonly messageService: MessageService;
@inject(WorkspaceStorageService)
protected readonly workspaceStorageService: WorkspaceStorageService;
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
@inject(WorkspaceServer)
protected readonly workspaceServer: WorkspaceServer;
@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()
});
}
canHandle(uri: URI): MaybePromise<boolean> {
return uri.scheme === DEV_CONTAINER_WORKSPACE_SCHEME;
}
async openWorkspace(uri: URI, options?: WorkspaceInput | undefined): Promise<void> {
const filePath = new URLSearchParams(uri.query).get(DEV_CONTAINER_PATH_QUERY);
if (!filePath) {
throw new Error('No devcontainer file specified for workspace');
}
const devcontainerFiles = await this.connectionProvider.getDevContainerFiles(uri.path.toString());
const devcontainerFile = devcontainerFiles.find(file => file.path === filePath);
if (!devcontainerFile) {
throw new Error(`Devcontainer file at ${filePath} not found in workspace`);
}
return this.doOpenInContainer(devcontainerFile, uri.path.toString());
}
async getWorkspaceLabel(uri: URI): Promise<string | undefined> {
const containerFilePath = new URLSearchParams(uri.query).get(DEV_CONTAINER_PATH_QUERY);
if (!containerFilePath) {
return;
};
const files = await this.connectionProvider.getDevContainerFiles(uri.path.toString());
const devcontainerFile = files.find(file => file.path === containerFilePath);
return `${uri.path.base} [Dev Container: ${devcontainerFile?.name}]`;
}
async openInContainer(): Promise<void> {
const devcontainerFile = await this.getOrSelectDevcontainerFile();
if (!devcontainerFile) {
return;
}
this.doOpenInContainer(devcontainerFile);
}
async doOpenInContainer(devcontainerFile: DevContainerFile, workspacePath?: string): Promise<void> {
const lastContainerInfoKey = `${LAST_USED_CONTAINER}:${devcontainerFile.path}`;
const lastContainerInfo = await this.workspaceStorageService.getData<LastContainerInfo | undefined>(lastContainerInfoKey);
this.containerOutputProvider.openChannel();
const connectionResult = await this.connectionProvider.connectToContainer({
nodeDownloadTemplate: this.remotePreferences['remote.nodeDownloadTemplate'],
lastContainerInfo,
devcontainerFile: devcontainerFile.path,
workspacePath: workspacePath
});
this.workspaceStorageService.setData<LastContainerInfo>(lastContainerInfoKey, {
id: connectionResult.containerId,
lastUsed: Date.now()
});
this.workspaceServer.setMostRecentlyUsedWorkspace(
`${DEV_CONTAINER_WORKSPACE_SCHEME}:${workspacePath ?? this.workspaceService.workspace?.resource.path}?${DEV_CONTAINER_PATH_QUERY}=${devcontainerFile.path}`);
this.openRemote(connectionResult.port, false, connectionResult.workspacePath);
}
async getOrSelectDevcontainerFile(): Promise<DevContainerFile | undefined> {
const workspace = this.workspaceService.workspace;
if (!workspace) {
return;
}
const devcontainerFiles = await this.connectionProvider.getDevContainerFiles(workspace.resource.path.toString());
if (devcontainerFiles.length === 1) {
return devcontainerFiles[0];
} else if (devcontainerFiles.length === 0) {
// eslint-disable-next-line max-len
this.messageService.error(nls.localize('theia/remote/dev-container/noDevcontainerFiles', 'No devcontainer.json files found in the workspace. Please ensure you have a .devcontainer directory with a devcontainer.json file.'));
return undefined;
}
return (await this.quickInputService.pick(devcontainerFiles.map(file => ({
type: 'item',
label: file.name,
description: file.path,
file: file,
})), {
title: nls.localize('theia/remote/dev-container/selectDevcontainer', 'Select a devcontainer.json file')
}))?.file;
}
}

View File

@@ -0,0 +1,84 @@
// *****************************************************************************
// 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 { WindowTitleContribution } from '@theia/core/lib/browser/window/window-title-service';
import { RemoteStatus, RemoteStatusService } from '@theia/remote/lib/electron-common/remote-status-service';
import { FrontendApplicationContribution, LabelProviderContribution } from '@theia/core/lib/browser';
import type { ContainerInspectInfo } from 'dockerode';
import { RemoteContainerConnectionProvider } from '../electron-common/remote-container-connection-provider';
import { PortForwardingService } from '@theia/remote/lib/electron-browser/port-forwarding/port-forwarding-service';
import { DEV_CONTAINER_PATH_QUERY } from '../electron-common/dev-container-workspaces';
import { URI } from '@theia/core';
@injectable()
export class ContainerInfoContribution implements FrontendApplicationContribution, WindowTitleContribution, LabelProviderContribution {
@inject(RemoteContainerConnectionProvider)
protected readonly connectionProvider: RemoteContainerConnectionProvider;
@inject(PortForwardingService)
protected readonly portForwardingService: PortForwardingService;
@inject(RemoteStatusService)
protected readonly remoteStatusService: RemoteStatusService;
protected status: RemoteStatus | undefined;
protected containerInfo: ContainerInspectInfo | undefined;
protected containerFilePath: string | undefined;
async onStart(): Promise<void> {
const containerPort = parseInt(new URLSearchParams(location.search).get('port') ?? '0');
const containerInfo = await this.connectionProvider.getCurrentContainerInfo(containerPort);
this.status = await this.remoteStatusService.getStatus(containerPort);
this.portForwardingService.forwardedPorts.push(...Object.entries(containerInfo?.NetworkSettings.Ports ?? {}).flatMap(([_, ports]) => (
ports.map(port => ({
editing: false,
address: port.HostIp ?? '',
localPort: parseInt(port.HostPort ?? '0'),
origin: 'container'
})))));
}
enhanceTitle(title: string, parts: Map<string, string | undefined>): string {
if (this.status && this.status.alive) {
const devcontainerName = this.status.name;
title = `${title} [Dev Container${devcontainerName ? ': ' + devcontainerName : ''}]`;
}
return title;
}
canHandle(element: object): number {
if ('query' in element) {
let containerFilePath = new URLSearchParams((element as URI).query).get(DEV_CONTAINER_PATH_QUERY);
if (containerFilePath) {
if (containerFilePath.startsWith((element as URI).path.toString())) {
containerFilePath = containerFilePath.replace((element as URI).path.toString(), '');
}
this.containerFilePath = containerFilePath;
return 100;
};
return 0;
}
return 0;
}
getName(element: URI): string | undefined {
const dir = new URI(this.containerFilePath).path.dir.base;
return `${element.path.base} [Dev Container${dir && dir !== '.devcontainer' ? `: ${dir}` : ''}]`;
}
}

View File

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

View File

@@ -0,0 +1,43 @@
// *****************************************************************************
// Copyright (C) 2024 Typefox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ContainerModule } from '@theia/core/shared/inversify';
import { RemoteRegistryContribution } from '@theia/remote/lib/electron-browser/remote-registry-contribution';
import { RemoteContainerConnectionProvider, RemoteContainerConnectionProviderPath } from '../electron-common/remote-container-connection-provider';
import { ContainerConnectionContribution } from './container-connection-contribution';
import { ServiceConnectionProvider } from '@theia/core/lib/browser/messaging/service-connection-provider';
import { ContainerOutputProvider } from './container-output-provider';
import { ContainerInfoContribution } from './container-info-contribution';
import { FrontendApplicationContribution, LabelProviderContribution } from '@theia/core/lib/browser';
import { WorkspaceOpenHandlerContribution } from '@theia/workspace/lib/browser/workspace-service';
import { WindowTitleContribution } from '@theia/core/lib/browser/window/window-title-service';
export default new ContainerModule(bind => {
bind(ContainerConnectionContribution).toSelf().inSingletonScope();
bind(RemoteRegistryContribution).toService(ContainerConnectionContribution);
bind(WorkspaceOpenHandlerContribution).toService(ContainerConnectionContribution);
bind(ContainerOutputProvider).toSelf().inSingletonScope();
bind(RemoteContainerConnectionProvider).toDynamicValue(ctx => {
const outputProvider = ctx.container.get(ContainerOutputProvider);
return ServiceConnectionProvider.createLocalProxy<RemoteContainerConnectionProvider>(ctx.container, RemoteContainerConnectionProviderPath, outputProvider);
}).inSingletonScope();
bind(ContainerInfoContribution).toSelf().inSingletonScope();
bind(FrontendApplicationContribution).toService(ContainerInfoContribution);
bind(WindowTitleContribution).toService(ContainerInfoContribution);
bind(LabelProviderContribution).toService(ContainerInfoContribution);
});

View File

@@ -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;
}

View File

@@ -0,0 +1,18 @@
// *****************************************************************************
// 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 DEV_CONTAINER_WORKSPACE_SCHEME = 'devcontainer';
export const DEV_CONTAINER_PATH_QUERY = 'containerfile';

View File

@@ -0,0 +1,52 @@
// *****************************************************************************
// Copyright (C) 2024 Typefox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
import { RpcServer } from '@theia/core';
import { ContainerOutputProvider } from './container-output-provider';
import type { ContainerInspectInfo } from 'dockerode';
// *****************************************************************************
export const RemoteContainerConnectionProviderPath = '/remote/container';
export const RemoteContainerConnectionProvider = Symbol('RemoteContainerConnectionProvider');
export interface ContainerConnectionOptions {
nodeDownloadTemplate?: string;
lastContainerInfo?: LastContainerInfo
devcontainerFile: string;
workspacePath?: string;
}
export interface LastContainerInfo {
id: string;
lastUsed: number;
}
export interface ContainerConnectionResult {
port: string;
workspacePath: string;
containerId: string;
}
export interface DevContainerFile {
name: string;
path: string;
}
export interface RemoteContainerConnectionProvider extends RpcServer<ContainerOutputProvider> {
connectToContainer(options: ContainerConnectionOptions): Promise<ContainerConnectionResult>;
getDevContainerFiles(workspacePath: string): Promise<DevContainerFile[]>;
getCurrentContainerInfo(port: number): Promise<ContainerInspectInfo | undefined>;
}

View File

@@ -0,0 +1,69 @@
// *****************************************************************************
// Copyright (C) 2024 Typefox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ContainerModule } from '@theia/core/shared/inversify';
import { ConnectionContainerModule } from '@theia/core/lib/node/messaging/connection-container-module';
import { DevContainerConnectionProvider } from './remote-container-connection-provider';
import { RemoteContainerConnectionProvider, RemoteContainerConnectionProviderPath } from '../electron-common/remote-container-connection-provider';
import { ContainerCreationContribution, DockerContainerService } from './docker-container-service';
import { bindContributionProvider, ConnectionHandler, RpcConnectionHandler } from '@theia/core';
import { registerContainerCreationContributions } from './devcontainer-contributions/main-container-creation-contributions';
import { DevContainerFileService } from './dev-container-file-service';
import { ContainerOutputProvider } from '../electron-common/container-output-provider';
import { ExtensionsContribution, registerTheiaStartOptionsContributions, SettingsContribution } from './devcontainer-contributions/cli-enhancing-creation-contributions';
import { RemoteCliContribution } from '@theia/core/lib/node/remote/remote-cli-contribution';
import { ProfileFileModificationContribution } from './devcontainer-contributions/profile-file-modification-contribution';
import { DevContainerWorkspaceHandler } from './dev-container-workspace-handler';
import { WorkspaceHandlerContribution } from '@theia/workspace/lib/node/default-workspace-server';
import { registerVariableResolverContributions, VariableResolverContribution } from './devcontainer-contributions/variable-resolver-contribution';
import { DockerComposeService } from './docker-compose/compose-service';
export const remoteConnectionModule = ConnectionContainerModule.create(({ bind, bindBackendService }) => {
bindContributionProvider(bind, ContainerCreationContribution);
registerContainerCreationContributions(bind);
registerTheiaStartOptionsContributions(bind);
bindContributionProvider(bind, VariableResolverContribution);
registerVariableResolverContributions(bind);
bind(ProfileFileModificationContribution).toSelf().inSingletonScope();
bind(ContainerCreationContribution).toService(ProfileFileModificationContribution);
bind(DevContainerConnectionProvider).toSelf().inSingletonScope();
bind(RemoteContainerConnectionProvider).toService(DevContainerConnectionProvider);
bind(ConnectionHandler).toDynamicValue(ctx =>
new RpcConnectionHandler<ContainerOutputProvider>(RemoteContainerConnectionProviderPath, client => {
const server = ctx.container.get<RemoteContainerConnectionProvider>(RemoteContainerConnectionProvider);
server.setClient(client);
client.onDidCloseConnection(() => server.dispose());
return server;
}));
});
export default new ContainerModule((bind, unbind, isBound, rebind) => {
bind(DockerContainerService).toSelf().inSingletonScope();
bind(ConnectionContainerModule).toConstantValue(remoteConnectionModule);
bind(DockerComposeService).toSelf().inSingletonScope();
bind(DevContainerFileService).toSelf().inSingletonScope();
bind(ExtensionsContribution).toSelf().inSingletonScope();
bind(SettingsContribution).toSelf().inSingletonScope();
bind(RemoteCliContribution).toService(ExtensionsContribution);
bind(RemoteCliContribution).toService(SettingsContribution);
bind(DevContainerWorkspaceHandler).toSelf().inSingletonScope();
bind(WorkspaceHandlerContribution).toService(DevContainerWorkspaceHandler);
});

View File

@@ -0,0 +1,102 @@
// *****************************************************************************
// 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, named } 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 { ContributionProvider, Path, URI } from '@theia/core';
import { VariableResolverContribution } from './devcontainer-contributions/variable-resolver-contribution';
const VARIABLE_REGEX = /^\$\{(.+?)(?::(.+))?\}$/;
@injectable()
export class DevContainerFileService {
@inject(WorkspaceServer)
protected readonly workspaceServer: WorkspaceServer;
@inject(ContributionProvider) @named(VariableResolverContribution)
protected readonly variableResolverContributions: ContributionProvider<VariableResolverContribution>;
protected resolveVariable(value: string): string {
const match = value.match(VARIABLE_REGEX);
if (match) {
const [, type, variable] = match;
for (const contribution of this.variableResolverContributions.getContributions()) {
if (contribution.canResolve(type)) {
return contribution.resolve(variable ?? type);
}
}
}
return value;
}
protected resolveVariablesRecursively<T>(obj: T): T {
if (typeof obj === 'string') {
return this.resolveVariable(obj) as T;
} else if (Array.isArray(obj)) {
return obj.map(item => this.resolveVariablesRecursively(item)) as T;
} else if (obj && typeof obj === 'object') {
const newObj: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
newObj[key] = this.resolveVariablesRecursively(value);
}
return newObj as T;
}
return obj;
}
async getConfiguration(path: string): Promise<DevContainerConfiguration> {
let 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 = this.resolveVariablesRecursively(configuration);
configuration.location = path;
return configuration;
}
async getAvailableFiles(workspace: string): Promise<DevContainerFile[]> {
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<string[]> {
if (depth < 0 || !await fs.pathExists(directory)) {
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;
}
}

View File

@@ -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 { URI } from '@theia/core';
import * as fs from '@theia/core/shared/fs-extra';
import { injectable } from '@theia/core/shared/inversify';
import { WorkspaceHandlerContribution } from '@theia/workspace/lib/node/default-workspace-server';
import { DEV_CONTAINER_PATH_QUERY, DEV_CONTAINER_WORKSPACE_SCHEME } from '../electron-common/dev-container-workspaces';
@injectable()
export class DevContainerWorkspaceHandler implements WorkspaceHandlerContribution {
canHandle(uri: URI): boolean {
return uri.scheme === DEV_CONTAINER_WORKSPACE_SCHEME;
}
async workspaceStillExists(uri: URI): Promise<boolean> {
const devcontainerFile = new URLSearchParams(uri.query).get(DEV_CONTAINER_PATH_QUERY);
return await fs.pathExists(uri.path.fsPath()) && !!devcontainerFile && fs.pathExists(devcontainerFile);
}
}

View File

@@ -0,0 +1,68 @@
// *****************************************************************************
// 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 { RemoteCliContext, RemoteCliContribution } from '@theia/core/lib/node/remote/remote-cli-contribution';
import { ContainerCreationContribution } from '../docker-container-service';
import * as Docker from 'dockerode';
import { DevContainerConfiguration, } from '../devcontainer-file';
import { injectable, interfaces } from '@theia/core/shared/inversify';
export function registerTheiaStartOptionsContributions(bind: interfaces.Bind): void {
bind(ContainerCreationContribution).toService(ExtensionsContribution);
bind(ContainerCreationContribution).toService(SettingsContribution);
}
@injectable()
export class ExtensionsContribution implements RemoteCliContribution, ContainerCreationContribution {
protected currentConfig: DevContainerConfiguration | undefined;
enhanceArgs(context: RemoteCliContext): string[] {
if (!this.currentConfig) {
return [];
}
const extensions = [
...(this.currentConfig.extensions ?? []),
...(this.currentConfig.customizations?.vscode?.extensions ?? [])
];
this.currentConfig = undefined;
return extensions?.map(extension => `--install-plugin=${extension}`);
}
async handleContainerCreation(createOptions: Docker.ContainerCreateOptions, containerConfig: DevContainerConfiguration): Promise<void> {
this.currentConfig = containerConfig;
}
}
@injectable()
export class SettingsContribution implements RemoteCliContribution, ContainerCreationContribution {
protected currentConfig: DevContainerConfiguration | undefined;
enhanceArgs(context: RemoteCliContext): string[] {
if (!this.currentConfig) {
return [];
}
const settings = {
...(this.currentConfig.settings ?? {}),
...(this.currentConfig.customizations?.vscode?.settings ?? [])
};
this.currentConfig = undefined;
return Object.entries(settings).map(([key, value]) => `--set-preference=${key}=${JSON.stringify(value)}`) ?? [];
}
async handleContainerCreation(createOptions: Docker.ContainerCreateOptions, containerConfig: DevContainerConfiguration): Promise<void> {
this.currentConfig = containerConfig;
}
}

View File

@@ -0,0 +1,211 @@
// *****************************************************************************
// 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 { inject, injectable, interfaces } from '@theia/core/shared/inversify';
import { ContainerCreationContribution } from '../docker-container-service';
import { DevContainerConfiguration, DockerfileContainer, ImageContainer, NonComposeContainerBase } from '../devcontainer-file';
import { Path } from '@theia/core';
import { ContainerOutputProvider } from '../../electron-common/container-output-provider';
import * as fs from '@theia/core/shared/fs-extra';
import { RemotePortForwardingProvider } from '@theia/remote/lib/electron-common/remote-port-forwarding-provider';
import { RemoteDockerContainerConnection } from '../remote-container-connection-provider';
export function registerContainerCreationContributions(bind: interfaces.Bind): void {
bind(ContainerCreationContribution).to(ImageFileContribution).inSingletonScope();
bind(ContainerCreationContribution).to(DockerFileContribution).inSingletonScope();
bind(ContainerCreationContribution).to(ForwardPortsContribution).inSingletonScope();
bind(ContainerCreationContribution).to(MountsContribution).inSingletonScope();
bind(ContainerCreationContribution).to(RemoteUserContribution).inSingletonScope();
bind(ContainerCreationContribution).to(PostCreateCommandContribution).inSingletonScope();
bind(ContainerCreationContribution).to(ContainerEnvContribution).inSingletonScope();
}
@injectable()
export class ImageFileContribution implements ContainerCreationContribution {
async handleContainerCreation(createOptions: Docker.ContainerCreateOptions, containerConfig: ImageContainer,
api: Docker, outputprovider: ContainerOutputProvider): Promise<void> {
if (containerConfig.image) {
const platform = process.platform;
const arch = process.arch;
const options = platform === 'darwin' && arch === 'arm64' ? { platform: 'amd64' } : {};
await new Promise<void>((res, rej) => api.pull(containerConfig.image, options, (err, stream) => {
if (err) {
rej(err);
} else if (stream === undefined) {
rej('Stream is undefined');
} else {
api.modem.followProgress(stream!, (error, output) => error ?
rej(error) :
res(), progress => outputprovider.onRemoteOutput(OutputHelper.parseProgress(progress)));
}
}));
createOptions.Image = containerConfig.image;
}
}
}
@injectable()
export class DockerFileContribution implements ContainerCreationContribution {
async handleContainerCreation(createOptions: Docker.ContainerCreateOptions, containerConfig: DockerfileContainer,
api: Docker, outputprovider: ContainerOutputProvider): Promise<void> {
// check if dockerfile container
if (containerConfig.dockerFile || containerConfig.build?.dockerfile) {
const dockerfile = (containerConfig.dockerFile ?? containerConfig.build?.dockerfile) as string;
const context = containerConfig.context ?? new Path(containerConfig.location as string).dir.fsPath();
try {
// ensure dockerfile exists
await fs.lstat(new Path(context as string).join(dockerfile).fsPath());
const buildStream = await api.buildImage({
context,
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<string>((res, rej) => api.modem.followProgress(buildStream!, (err, outputs) => {
if (err) {
rej(err);
} else {
for (let i = outputs.length - 1; i >= 0; i--) {
if (outputs[i].aux?.ID) {
res(outputs[i].aux.ID);
return;
}
}
}
}, progress => outputprovider.onRemoteOutput(OutputHelper.parseProgress(progress))));
createOptions.Image = imageId;
} catch (error) {
outputprovider.onRemoteOutput(`could not build dockerfile "${dockerfile}" reason: ${error.message}`);
throw error;
}
}
}
}
@injectable()
export class ForwardPortsContribution implements ContainerCreationContribution {
@inject(RemotePortForwardingProvider)
protected readonly portForwardingProvider: RemotePortForwardingProvider;
async handlePostConnect(containerConfig: DevContainerConfiguration, connection: RemoteDockerContainerConnection): Promise<void> {
if (!containerConfig.forwardPorts) {
return;
}
for (const forward of containerConfig.forwardPorts) {
let port: number;
let address: string | undefined;
if (typeof forward === 'string') {
const parts = forward.split(':');
address = parts[0];
port = parseInt(parts[1]);
} else {
port = forward;
}
this.portForwardingProvider.forwardPort(connection.localPort, { port, address });
}
}
}
@injectable()
export class MountsContribution implements ContainerCreationContribution {
async handleContainerCreation(createOptions: Docker.ContainerCreateOptions, containerConfig: DevContainerConfiguration, api: Docker): Promise<void> {
if (!containerConfig.mounts) {
return;
}
createOptions.HostConfig!.Mounts!.push(...(containerConfig as NonComposeContainerBase)?.mounts
?.map(mount => typeof mount === 'string' ?
this.parseMountString(mount) :
{ Source: mount.source, Target: mount.target, Type: mount.type ?? 'bind' }) ?? []);
}
parseMountString(mount: string): Docker.MountSettings {
const parts = mount.split(',');
return {
Source: parts.find(part => part.startsWith('source=') || part.startsWith('src='))?.split('=')[1]!,
Target: parts.find(part => part.startsWith('target=') || part.startsWith('dst='))?.split('=')[1]!,
Type: (parts.find(part => part.startsWith('type='))?.split('=')[1] ?? 'bind') as Docker.MountType
};
}
}
@injectable()
export class RemoteUserContribution implements ContainerCreationContribution {
async handleContainerCreation(createOptions: Docker.ContainerCreateOptions, containerConfig: DevContainerConfiguration, api: Docker): Promise<void> {
if (containerConfig.remoteUser) {
createOptions.User = containerConfig.remoteUser;
}
}
}
@injectable()
export class PostCreateCommandContribution implements ContainerCreationContribution {
async handlePostCreate?(containerConfig: DevContainerConfiguration, container: Docker.Container, api: Docker, outputprovider: ContainerOutputProvider): Promise<void> {
if (containerConfig.postCreateCommand) {
const commands = typeof containerConfig.postCreateCommand === 'object' && !(containerConfig.postCreateCommand instanceof Array) ?
Object.values(containerConfig.postCreateCommand) : [containerConfig.postCreateCommand];
for (const command of commands) {
try {
let exec;
if (command instanceof Array) {
exec = await container.exec({ Cmd: command, AttachStderr: true, AttachStdout: true });
} else {
exec = await container.exec({ Cmd: ['sh', '-c', command], AttachStderr: true, AttachStdout: true });
}
const stream = await exec.start({ Tty: true });
stream.on('data', chunk => outputprovider.onRemoteOutput(chunk.toString()));
} catch (error) {
outputprovider.onRemoteOutput('could not execute postCreateCommand ' + JSON.stringify(command) + ' reason:' + error.message);
}
}
}
}
}
@injectable()
export class ContainerEnvContribution implements ContainerCreationContribution {
async handleContainerCreation(createOptions: Docker.ContainerCreateOptions, containerConfig: DevContainerConfiguration): Promise<void> {
if (containerConfig.containerEnv) {
if (createOptions.Env === undefined) {
createOptions.Env = [];
}
for (const [key, value] of Object.entries(containerConfig.containerEnv)) {
createOptions.Env.push(`${key}=${value}`);
}
}
}
}
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 ?? '';
}
}

View File

@@ -0,0 +1,35 @@
// *****************************************************************************
// 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 { DevContainerConfiguration } from '../devcontainer-file';
import { ContainerCreationContribution } from '../docker-container-service';
import * as Docker from 'dockerode';
import { injectable } from '@theia/core/shared/inversify';
import { ContainerOutputProvider } from '../../electron-common/container-output-provider';
/**
* this contribution changes the /etc/profile file so that it won't overwrite the PATH variable set by docker
*/
@injectable()
export class ProfileFileModificationContribution implements ContainerCreationContribution {
async handlePostCreate(containerConfig: DevContainerConfiguration, container: Docker.Container, api: Docker, outputprovider: ContainerOutputProvider): Promise<void> {
const stream = await (await container.exec({
Cmd: ['sh', '-c', 'sed -i \'s|PATH="\\([^"]*\\)"|PATH=${PATH:-"\\1"}|g\' /etc/profile'], User: 'root',
AttachStderr: true, AttachStdout: true
})).start({});
stream.on('data', data => outputprovider.onRemoteOutput(data.toString()));
}
}

View File

@@ -0,0 +1,54 @@
// *****************************************************************************
// Copyright (C) 2025 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, interfaces, LazyServiceIdentifier } from '@theia/core/shared/inversify';
import { DockerContainerService } from '../docker-container-service';
export const VariableResolverContribution = Symbol('VariableResolverContribution');
export interface VariableResolverContribution {
canResolve(variable: string): boolean;
resolve(variable: string): string;
}
export function registerVariableResolverContributions(bind: interfaces.Bind): void {
bind(VariableResolverContribution).to(LocalEnvVariableResolver).inSingletonScope();
bind(VariableResolverContribution).to(ContainerIdResolver).inSingletonScope();
}
@injectable()
export class LocalEnvVariableResolver implements VariableResolverContribution {
canResolve(type: string): boolean {
console.log(`Resolving localEnv variable: ${type}`);
return type === 'localEnv';
}
resolve(variable: string): string {
return process.env[variable] || '';
}
}
@injectable()
export class ContainerIdResolver implements VariableResolverContribution {
@inject(new LazyServiceIdentifier(() => DockerContainerService))
protected readonly dockerContainerService: DockerContainerService;
canResolve(type: string): boolean {
return type === 'devcontainerId' && !!this.dockerContainerService.container;
}
resolve(variable: string): string {
return this.dockerContainerService.container?.id || variable;
}
}

View File

@@ -0,0 +1,415 @@
// *****************************************************************************
// Copyright (C) 2024 Typefox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
/**
* Defines a dev container
* type generated from https://containers.dev/implementors/json_schema/ and modified
*/
export type DevContainerConfiguration = (((DockerfileContainer | ImageContainer) & (NonComposeContainerBase)) | ComposeContainer) & DevContainerCommon & { location?: string };
export type DockerfileContainer = {
/**
* Docker build-related options.
*/
build: {
/**
* The location of the Dockerfile that defines the contents of the container. The path is relative to the folder containing the `devcontainer.json` file.
*/
dockerfile: string
/**
* The location of the context folder for building the Docker image. The path is relative to the folder containing the `devcontainer.json` file.
*/
context?: string
} & BuildOptions
[k: string]: unknown
} | {
/**
* The location of the Dockerfile that defines the contents of the container. The path is relative to the folder containing the `devcontainer.json` file.
*/
dockerFile: string
/**
* The location of the context folder for building the Docker image. The path is relative to the folder containing the `devcontainer.json` file.
*/
context?: string
/**
* Docker build-related options.
*/
build?: {
/**
* Target stage in a multi-stage build.
*/
target?: string
/**
* Build arguments.
*/
args?: {
[k: string]: string
}
/**
* The image to consider as a cache. Use an array to specify multiple images.
*/
cacheFrom?: string | string[]
[k: string]: unknown
}
[k: string]: unknown
};
export interface BuildOptions {
/**
* Target stage in a multi-stage build.
*/
target?: string
/**
* Build arguments.
*/
args?: {
[k: string]: string
}
/**
* The image to consider as a cache. Use an array to specify multiple images.
*/
cacheFrom?: string | string[]
[k: string]: unknown
}
export interface ImageContainer {
/**
* The docker image that will be used to create the container.
*/
image: string
[k: string]: unknown
}
export interface NonComposeContainerBase {
/**
* Application ports that are exposed by the container. This can be a single port or an array of ports. Each port can be a number or a string. A number is mapped to
* the same port on the host. A string is passed to Docker unchanged and can be used to map ports differently, e.g. '8000:8010'.
*/
appPort?: number | string | (number | string)[]
/**
* Container environment variables.
*/
containerEnv?: {
[k: string]: string
}
/**
* The user the container will be started with. The default is the user on the Docker image.
*/
containerUser?: string
/**
* Mount points to set up when creating the container. See Docker's documentation for the --mount option for the supported syntax.
*/
mounts?: (string | MountConfig)[]
/**
* The arguments required when starting in the container.
*/
runArgs?: string[]
/**
* Action to take when the user disconnects from the container in their editor. The default is to stop the container.
*/
shutdownAction?: 'none' | 'stopContainer'
/**
* Whether to overwrite the command specified in the image. The default is true.
*/
overrideCommand?: boolean
/**
* The path of the workspace folder inside the container.
*/
workspaceFolder?: string
/**
* The --mount parameter for docker run. The default is to mount the project folder at /workspaces/$project.
*/
workspaceMount?: string
[k: string]: unknown
}
export interface ComposeContainer {
/**
* The name of the docker-compose file(s) used to start the services.
*/
dockerComposeFile: string | string[]
/**
* The service you want to work on. This is considered the primary container for your dev environment which your editor will connect to.
*/
service: string
/**
* An array of services that should be started and stopped.
*/
runServices?: string[]
/**
* The path of the workspace folder inside the container. This is typically the target path of a volume mount in the docker-compose.yml.
*/
workspaceFolder: string
/**
* Action to take when the user disconnects from the primary container in their editor. The default is to stop all of the compose containers.
*/
shutdownAction?: 'none' | 'stopCompose'
/**
* Whether to overwrite the command specified in the image. The default is false.
*/
overrideCommand?: boolean
/**
* Allows passing additional arguments to the 'docker compose up' command.
*/
composeUpArgs?: string[]
[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
/**
* extensions to install in the container at launch. The expeceted format is publisher.name[@version].
* The default is no extensions being installed.
*/
extensions?: string[]
/**
* settings to set in the container at launch in the settings.json. The expected format is key=value.
* The default is no preferences being set.
*/
settings?: {
[k: string]: unknown
}
/**
* 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,
vscode?: {
/**
* extensions to install in the container at launch. The expeceted format is publisher.name[@version].
* The default is no extensions being installed.
*/
extensions?: string[],
/**
* settings to set in the container at launch in the settings.json. The expected format is key=value.
* The default is no preferences being set.
*/
settings?: {
[k: string]: unknown
}
[k: string]: unknown
}
}
additionalProperties?: {
[k: string]: unknown
}
[k: string]: unknown
}
export interface MountConfig {
source: string,
target: string,
type: 'volume' | 'bind',
}

View File

@@ -0,0 +1,79 @@
// *****************************************************************************
// Copyright (C) 2025 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 } from '@theia/core/shared/inversify';
import * as Docker from 'dockerode';
import { ComposeContainer, DevContainerConfiguration } from '../devcontainer-file';
import { ContainerOutputProvider } from '../../electron-common/container-output-provider';
import { spawn } from 'child_process';
import path = require('path');
@injectable()
export class DockerComposeService {
async createContainers(
devcontainerConfig: DevContainerConfiguration,
containerCreateOptions: Docker.ContainerCreateOptions,
outputProvider?: ContainerOutputProvider): Promise<string> {
if (!devcontainerConfig.dockerComposeFile || typeof devcontainerConfig.dockerComposeFile !== 'string') {
throw new Error('dockerComposeFile is not defined in devcontainer configuration. Multiple files are not supported currently');
}
const dockerComposeFilePath = resolveComposeFilePath(devcontainerConfig);
const composeUpArgs = Array.isArray(devcontainerConfig.composeUpArgs) ? devcontainerConfig.composeUpArgs : [];
await this.executeComposeCommand(dockerComposeFilePath, 'up', ['--detach', ...composeUpArgs], outputProvider);
return (devcontainerConfig as ComposeContainer).service;
}
protected executeComposeCommand(composeFilePath: string, command: string, args: string[], outputProvider?: ContainerOutputProvider): Promise<string> {
return new Promise<string>((resolve, reject) => {
const process = spawn('docker', ['compose', '-f', composeFilePath, command, ...args]);
process.stdout.on('data', data => {
outputProvider?.onRemoteOutput(data.toString());
});
process.stderr.on('data', data => {
outputProvider?.onRemoteOutput(data.toString());
});
process.on('close', code => {
outputProvider?.onRemoteOutput(`docker compose process exited with code ${code}`);
if (code === 0) {
resolve(''); // TODO return real container ids
} else {
reject(new Error(`docker compose process exited with code ${code}`));
}
});
});
}
}
export function resolveComposeFilePath(devcontainerConfig: DevContainerConfiguration): string {
if (!devcontainerConfig.dockerComposeFile) {
throw new Error('dockerComposeFile is not defined in devcontainer configuration.');
}
if (typeof devcontainerConfig.dockerComposeFile !== 'string') {
throw new Error('Multiple docker compose files are not supported currently.');
}
if (path.isAbsolute(devcontainerConfig.dockerComposeFile)) {
return devcontainerConfig.dockerComposeFile;
} else {
return path.resolve(path.dirname(devcontainerConfig.location!), devcontainerConfig.dockerComposeFile);
}
}

View File

@@ -0,0 +1,149 @@
// *****************************************************************************
// Copyright (C) 2024 Typefox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ContributionProvider, MaybePromise, 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 { ContainerConnectionOptions } 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';
import { RemoteDockerContainerConnection } from './remote-container-connection-provider';
import { DockerComposeService } from './docker-compose/compose-service';
export const ContainerCreationContribution = Symbol('ContainerCreationContributions');
export interface ContainerCreationContribution {
handleContainerCreation?(createOptions: Docker.ContainerCreateOptions,
containerConfig: DevContainerConfiguration,
api: Docker,
outputProvider?: ContainerOutputProvider): MaybePromise<void>;
/**
* executed after creating and starting the container
*/
handlePostCreate?(containerConfig: DevContainerConfiguration,
container: Docker.Container,
api: Docker,
outputProvider?: ContainerOutputProvider): MaybePromise<void>;
/**
* executed after a connection has been established with the container and theia has been setup
*/
handlePostConnect?(containerConfig: DevContainerConfiguration, connection: RemoteDockerContainerConnection,
outputProvider?: ContainerOutputProvider): MaybePromise<void>;
}
@injectable()
export class DockerContainerService {
@inject(WorkspaceServer)
protected readonly workspaceServer: WorkspaceServer;
@inject(ContributionProvider) @named(ContainerCreationContribution)
protected readonly containerCreationContributions: ContributionProvider<ContainerCreationContribution>;
@inject(DevContainerFileService)
protected readonly devContainerFileService: DevContainerFileService;
@inject(DockerComposeService)
protected readonly dockerComposeService: DockerComposeService;
container: Docker.Container | undefined;
async getOrCreateContainer(docker: Docker, options: ContainerConnectionOptions, outputProvider?: ContainerOutputProvider): Promise<Docker.Container> {
let container;
const workspace = new URI(options.workspacePath ?? await this.workspaceServer.getMostRecentlyUsedWorkspace());
if (options.lastContainerInfo && fs.statSync(options.devcontainerFile).mtimeMs < options.lastContainerInfo.lastUsed) {
try {
container = docker.getContainer(options.lastContainerInfo.id);
if ((await container.inspect()).State.Running) {
await container.restart();
} else {
await container.start();
}
} catch (e) {
container = undefined;
console.warn('DevContainer: could not find last used container');
}
}
if (!container) {
container = await this.buildContainer(docker, options.devcontainerFile, workspace, outputProvider);
}
this.container = container;
return container;
}
async postConnect(devcontainerFile: string, connection: RemoteDockerContainerConnection, outputProvider?: ContainerOutputProvider): Promise<void> {
const devcontainerConfig = await this.devContainerFileService.getConfiguration(devcontainerFile);
for (const containerCreateContrib of this.containerCreationContributions.getContributions()) {
await containerCreateContrib.handlePostConnect?.(devcontainerConfig, connection, outputProvider);
}
}
protected async buildContainer(docker: Docker, devcontainerFile: string, workspace: URI, outputProvider?: ContainerOutputProvider): Promise<Docker.Container> {
const devcontainerConfig = await this.devContainerFileService.getConfiguration(devcontainerFile);
if (!devcontainerConfig) {
// TODO add ability for user to create new config
throw new Error('No devcontainer.json');
}
const containerCreateOptions: Docker.ContainerCreateOptions = {
Tty: true,
ExposedPorts: {},
HostConfig: {
PortBindings: {},
Mounts: [{
Source: workspace.path.toString(),
Target: `/workspaces/${workspace.path.name}`,
Type: 'bind'
}],
},
};
for (const containerCreateContrib of this.containerCreationContributions.getContributions()) {
await containerCreateContrib.handleContainerCreation?.(containerCreateOptions, devcontainerConfig, docker, outputProvider);
}
let container: Docker.Container;
if (devcontainerConfig.dockerComposeFile) {
const containerName = await this.dockerComposeService.createContainers(devcontainerConfig, containerCreateOptions, outputProvider);
const services = await docker.listContainers({ filters: { label: [`com.docker.compose.service=${containerName}`] } });
if (services.length === 0) {
throw new Error(`No running container found for docker compose service ${containerName}`);
}
container = docker.getContainer(services[0].Id);
} else {
container = await docker.createContainer(containerCreateOptions);
await container.start();
}
for (const containerCreateContrib of this.containerCreationContributions.getContributions()) {
await containerCreateContrib.handlePostCreate?.(devcontainerConfig, container, docker, outputProvider);
}
return container;
}
}

View File

@@ -0,0 +1,408 @@
// *****************************************************************************
// Copyright (C) 2024 Typefox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as net from 'net';
import {
ContainerConnectionOptions, ContainerConnectionResult,
DevContainerFile, RemoteContainerConnectionProvider
} from '../electron-common/remote-container-connection-provider';
import { RemoteConnection, RemoteExecOptions, RemoteExecResult, RemoteExecTester, RemoteStatusReport } from '@theia/remote/lib/electron-node/remote-types';
import { RemoteSetupResult, RemoteSetupService } from '@theia/remote/lib/electron-node/setup/remote-setup-service';
import { RemoteConnectionService } from '@theia/remote/lib/electron-node/remote-connection-service';
import { RemoteProxyServerProvider } from '@theia/remote/lib/electron-node/remote-proxy-server-provider';
import { Emitter, Event, generateUuid, MessageService, RpcServer, ILogger } from '@theia/core';
import { Socket } from 'net';
import { inject, injectable } from '@theia/core/shared/inversify';
import * as Docker from 'dockerode';
import { DockerContainerService } from './docker-container-service';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { WriteStream } from 'tty';
import { PassThrough } from 'stream';
import { exec, execSync } from 'child_process';
import { DevContainerFileService } from './dev-container-file-service';
import { ContainerOutputProvider } from '../electron-common/container-output-provider';
import { DevContainerConfiguration } from './devcontainer-file';
import { resolveComposeFilePath } from './docker-compose/compose-service';
@injectable()
export class DevContainerConnectionProvider implements RemoteContainerConnectionProvider, RpcServer<ContainerOutputProvider> {
@inject(RemoteConnectionService)
protected readonly remoteConnectionService: RemoteConnectionService;
@inject(RemoteSetupService)
protected readonly remoteSetup: RemoteSetupService;
@inject(MessageService)
protected readonly messageService: MessageService;
@inject(RemoteProxyServerProvider)
protected readonly serverProvider: RemoteProxyServerProvider;
@inject(DockerContainerService)
protected readonly containerService: DockerContainerService;
@inject(DevContainerFileService)
protected readonly devContainerFileService: DevContainerFileService;
@inject(RemoteConnectionService)
protected readonly remoteService: RemoteConnectionService;
@inject(ILogger)
protected readonly logger: ILogger;
protected outputProvider: ContainerOutputProvider | undefined;
setClient(client: ContainerOutputProvider): void {
this.outputProvider = client;
}
async connectToContainer(options: ContainerConnectionOptions): Promise<ContainerConnectionResult> {
const dockerOptions: Docker.DockerOptions = {};
const dockerHost = process.env.DOCKER_HOST;
try {
if (dockerHost) {
const dockerHostURL = new URL(dockerHost);
if (dockerHostURL.protocol === 'unix:') {
dockerOptions.socketPath = dockerHostURL.pathname;
} else {
if (dockerHostURL.protocol === 'http:') {
dockerOptions.protocol = 'http';
} else if (dockerHostURL.protocol === 'https:') {
dockerOptions.protocol = 'https';
} else if (dockerHostURL.protocol === 'ssh:') {
dockerOptions.protocol = 'ssh';
} else {
dockerOptions.protocol = undefined;
}
dockerOptions.port = parseInt(dockerHostURL.port) || undefined;
dockerOptions.username = dockerHostURL.username || undefined;
}
}
} catch (_) {
this.logger.warn(`Ignoring invalid DOCKER_HOST=${dockerHost}`);
this.messageService.warn(`Ignoring invalid DOCKER_HOST=${dockerHost}`);
}
const dockerConnection = new Docker(dockerOptions);
const version = await dockerConnection.version()
.catch(e => {
console.error('Docker Error:', e);
this.messageService.error('Docker Error: ' + e.message);
});
if (!version) {
this.messageService.error('Docker Daemon is not running');
throw new Error('Docker is not running');
}
// create container
const progress = await this.messageService.showProgress({
text: 'Creating container',
});
try {
const container = await this.containerService.getOrCreateContainer(dockerConnection, options, this.outputProvider);
const devContainerConfig = await this.devContainerFileService.getConfiguration(options.devcontainerFile);
// create actual connection
const report: RemoteStatusReport = message => progress.report({ message });
report('Connecting to remote system...');
const remote = await this.createContainerConnection(container, dockerConnection, devContainerConfig);
const result = await this.remoteSetup.setup({
connection: remote,
report,
nodeDownloadTemplate: options.nodeDownloadTemplate
});
remote.remoteSetupResult = result;
const registration = this.remoteConnectionService.register(remote);
const server = await this.serverProvider.getProxyServer(socket => {
remote.forwardOut(socket);
});
remote.onDidDisconnect(() => {
server.close();
registration.dispose();
});
const localPort = (server.address() as net.AddressInfo).port;
remote.localPort = localPort;
await this.containerService.postConnect(options.devcontainerFile, remote, this.outputProvider);
return {
containerId: container.id,
workspacePath: (await container.inspect()).Mounts[0].Destination,
port: localPort.toString(),
};
} catch (e) {
this.messageService.error(e.message);
console.error(e);
throw e;
} finally {
progress.cancel();
}
}
getDevContainerFiles(workspacePath: string): Promise<DevContainerFile[]> {
return this.devContainerFileService.getAvailableFiles(workspacePath);
}
async createContainerConnection(container: Docker.Container, docker: Docker, config: DevContainerConfiguration): Promise<RemoteDockerContainerConnection> {
return Promise.resolve(new RemoteDockerContainerConnection({
id: generateUuid(),
name: config.name ?? 'dev-container',
type: 'Dev Container',
docker,
container,
config,
logger: this.logger
}));
}
async getCurrentContainerInfo(port: number): Promise<Docker.ContainerInspectInfo | undefined> {
const connection = this.remoteConnectionService.getConnectionFromPort(port);
if (!connection || !(connection instanceof RemoteDockerContainerConnection)) {
return undefined;
}
return connection.container.inspect();
}
dispose(): void {
}
}
export interface RemoteContainerConnectionOptions {
id: string;
name: string;
type: string;
docker: Docker;
container: Docker.Container;
config: DevContainerConfiguration;
logger: ILogger;
}
interface ContainerTerminalSession {
execution: Docker.Exec,
stdout: WriteStream,
stderr: WriteStream,
executeCommand(cmd: string, args?: string[]): Promise<{ stdout: string, stderr: string }>;
}
interface ContainerTerminalSession {
execution: Docker.Exec,
stdout: WriteStream,
stderr: WriteStream,
executeCommand(cmd: string, args?: string[]): Promise<{ stdout: string, stderr: string }>;
}
export class RemoteDockerContainerConnection implements RemoteConnection {
id: string;
name: string;
type: string;
localPort: number;
remotePort: number;
docker: Docker;
container: Docker.Container;
remoteSetupResult: RemoteSetupResult;
protected readonly logger: ILogger;
protected config: DevContainerConfiguration;
protected activeTerminalSession: ContainerTerminalSession | undefined;
protected readonly onDidDisconnectEmitter = new Emitter<void>();
onDidDisconnect: Event<void> = this.onDidDisconnectEmitter.event;
constructor(options: RemoteContainerConnectionOptions) {
this.id = options.id;
this.type = options.type;
this.name = options.name;
this.docker = options.docker;
this.container = options.container;
this.config = options.config;
this.docker.getEvents({ filters: { container: [this.container.id], event: ['stop'] } }).then(stream => {
stream.on('data', () => this.onDidDisconnectEmitter.fire());
});
this.logger = options.logger;
}
async forwardOut(socket: Socket, port?: number): Promise<void> {
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=${port ?? this.remotePort}`],
AttachStdin: true, AttachStdout: true, AttachStderr: true
});
const stream = await ttySession.start({ hijack: true, stdin: true });
socket.pipe(stream);
ttySession.modem.demuxStream(stream, socket, socket);
} catch (e) {
console.error(e);
}
}
async exec(cmd: string, args?: string[], options?: RemoteExecOptions): Promise<RemoteExecResult> {
// return (await this.getOrCreateTerminalSession()).executeCommand(cmd, args);
const deferred = new Deferred<RemoteExecResult>();
try {
// TODO add windows container support
const execution = await this.container.exec({ Cmd: ['sh', '-c', `${cmd} ${args?.join(' ') ?? ''}`], AttachStdout: true, AttachStderr: true });
let stdoutBuffer = '';
let stderrBuffer = '';
const stream = await execution?.start({});
const stdout = new PassThrough();
stdout.on('data', (chunk: Buffer) => {
stdoutBuffer += chunk.toString();
});
const stderr = new PassThrough();
stderr.on('data', (chunk: Buffer) => {
stderrBuffer += chunk.toString();
});
execution.modem.demuxStream(stream, stdout, stderr);
stream?.addListener('close', () => deferred.resolve({ stdout: stdoutBuffer, stderr: stderrBuffer }));
} catch (e) {
deferred.reject(e);
}
return deferred.promise;
}
async execPartial(cmd: string, tester: RemoteExecTester, args?: string[], options?: RemoteExecOptions): Promise<RemoteExecResult> {
const deferred = new Deferred<RemoteExecResult>();
try {
// TODO add windows container support
const execution = await this.container.exec({ Cmd: ['sh', '-c', `${cmd} ${args?.join(' ') ?? ''}`], AttachStdout: true, AttachStderr: true });
let stdoutBuffer = '';
let stderrBuffer = '';
const stream = await execution?.start({});
stream.on('close', () => {
if (deferred.state === 'unresolved') {
deferred.resolve({ stdout: stdoutBuffer, stderr: stderrBuffer });
}
});
const stdout = new PassThrough();
stdout.on('data', (data: Buffer) => {
this.logger.debug('REMOTE STDOUT:', data.toString());
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) => {
this.logger.debug('REMOTE STDERR:', data.toString());
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;
}
getDockerHost(): string {
const dockerHost = process.env.DOCKER_HOST;
let remoteHost = '';
try {
if (dockerHost) {
const dockerHostURL = new URL(dockerHost);
if (dockerHostURL.protocol === 'http:' || dockerHostURL.protocol === 'https:') {
dockerHostURL.protocol = 'tcp:';
}
remoteHost = `-H ${dockerHostURL.href} `;
}
} catch (e) {
console.error(e);
}
return remoteHost;
}
async copy(localPath: string | Buffer | NodeJS.ReadableStream, remotePath: string): Promise<void> {
const deferred = new Deferred<void>();
const remoteHost = this.getDockerHost();
const subprocess = exec(`docker ${remoteHost}cp -a ${localPath.toString()} ${this.container.id}:${remotePath}`);
let stderr = '';
subprocess.stderr?.on('data', data => {
stderr += data.toString();
});
subprocess.on('close', code => {
if (code === 0) {
deferred.resolve();
} else {
deferred.reject(stderr);
}
});
return deferred.promise;
}
disposeSync(): void {
// cant use dockerrode here since this needs to happen on one tick
this.shutdownContainer(true);
}
async dispose(): Promise<void> {
await this.shutdownContainer(false);
}
protected async shutdownContainer(sync: boolean): Promise<unknown> {
const remoteHost = this.getDockerHost();
const shutdownAction = this.config.shutdownAction ?? this.config.dockerComposeFile ? 'stopCompose' : 'stopContainer';
if (shutdownAction === 'stopContainer') {
return sync ? execSync(`docker ${remoteHost}stop ${this.container.id}`) : this.container.stop();
} else if (shutdownAction === 'stopCompose') {
const composeFilePath = resolveComposeFilePath(this.config);
return sync ? execSync(`docker ${remoteHost}compose -f ${composeFilePath} stop`) :
new Promise<void>((res, rej) => exec(`docker ${remoteHost}compose -f ${composeFilePath} stop`, err => {
if (err) {
console.error(err);
rej(err);
} else {
res();
}
}));
}
}
}

View File

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

View File

@@ -0,0 +1,25 @@
{
"extends": "../../configs/base.tsconfig",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "lib"
},
"include": [
"src"
],
"references": [
{
"path": "../core"
},
{
"path": "../output"
},
{
"path": "../remote"
},
{
"path": "../workspace"
}
]
}