Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dev container improvements #13714

Merged
merged 14 commits into from
May 29, 2024
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// *****************************************************************************
// 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 { FrontendApplicationContribution } 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';

@injectable()
export class ContainerInfoContribution implements FrontendApplicationContribution {

@inject(RemoteContainerConnectionProvider)
protected readonly connectionProvider: RemoteContainerConnectionProvider;

@inject(PortForwardingService)
protected readonly portForwardingService: PortForwardingService;

containerInfo: ContainerInspectInfo | undefined;

async onStart(): Promise<void> {
this.containerInfo = await this.connectionProvider.getCurrentContainerInfo(parseInt(new URLSearchParams(location.search).get('port') ?? '0'));

this.portForwardingService.forwardedPorts = Object.entries(this.containerInfo?.NetworkSettings.Ports ?? {}).flatMap(([_, ports]) => (
ports.map(port => ({
editing: false,
address: port.HostIp ?? '',
localPort: parseInt(port.HostPort ?? '0'),
origin: 'container'
}))));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { RemoteContainerConnectionProvider, RemoteContainerConnectionProviderPat
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 } from '@theia/core/lib/browser';

export default new ContainerModule(bind => {
bind(ContainerConnectionContribution).toSelf().inSingletonScope();
Expand All @@ -30,4 +32,7 @@ export default new ContainerModule(bind => {
const outputProvider = ctx.container.get(ContainerOutputProvider);
return ServiceConnectionProvider.createLocalProxy<RemoteContainerConnectionProvider>(ctx.container, RemoteContainerConnectionProviderPath, outputProvider);
}).inSingletonScope();

bind(ContainerInfoContribution).toSelf().inSingletonScope();
bind(FrontendApplicationContribution).toService(ContainerInfoContribution);
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import { RpcServer } from '@theia/core';
import { ContainerOutputProvider } from './container-output-provider';
import type { ContainerInspectInfo } from 'dockerode';

// *****************************************************************************
export const RemoteContainerConnectionProviderPath = '/remote/container';
Expand Down Expand Up @@ -46,4 +47,5 @@ export interface DevContainerFile {
export interface RemoteContainerConnectionProvider extends RpcServer<ContainerOutputProvider> {
connectToContainer(options: ContainerConnectionOptions): Promise<ContainerConnectionResult>;
getDevContainerFiles(): Promise<DevContainerFile[]>;
getCurrentContainerInfo(port: number): Promise<ContainerInspectInfo | undefined>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,16 @@ import { bindContributionProvider, ConnectionHandler, RpcConnectionHandler } fro
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';

export const remoteConnectionModule = ConnectionContainerModule.create(({ bind, bindBackendService }) => {
bindContributionProvider(bind, ContainerCreationContribution);
registerContainerCreationContributions(bind);
registerTheiaStartOptionsContributions(bind);
bind(ProfileFileModificationContribution).toSelf().inSingletonScope();
bind(ContainerCreationContribution).toService(ProfileFileModificationContribution);

bind(DevContainerConnectionProvider).toSelf().inSingletonScope();
bind(RemoteContainerConnectionProvider).toService(DevContainerConnectionProvider);
Expand All @@ -44,4 +50,9 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
bind(ConnectionContainerModule).toConstantValue(remoteConnectionModule);

bind(DevContainerFileService).toSelf().inSingletonScope();

bind(ExtensionsContribution).toSelf().inSingletonScope();
bind(SettingsContribution).toSelf().inSingletonScope();
bind(RemoteCliContribution).toService(ExtensionsContribution);
bind(RemoteCliContribution).toService(SettingsContribution);
});
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,22 @@
// 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 { 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();
}

@injectable()
Expand Down Expand Up @@ -53,50 +58,62 @@ export class DockerFileContribution implements ContainerCreationContribution {
// check if dockerfile container
if (containerConfig.dockerFile || containerConfig.build?.dockerfile) {
const dockerfile = (containerConfig.dockerFile ?? containerConfig.build?.dockerfile) as string;
const buildStream = await api.buildImage({
context: containerConfig.context ?? new Path(containerConfig.location as string).dir.fsPath(),
src: [dockerfile],
} as Docker.ImageBuildContext, {
buildargs: containerConfig.build?.args
});
// TODO probably have some console windows showing the output of the build
const imageId = await new Promise<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;
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;
}, 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 {
async handleContainerCreation(createOptions: Docker.ContainerCreateOptions, containerConfig: DevContainerConfiguration, api: Docker): Promise<void> {

@inject(RemotePortForwardingProvider)
protected readonly portForwardingProvider: RemotePortForwardingProvider;

async handlePostConnect(containerConfig: DevContainerConfiguration, connection: RemoteDockerContainerConnection): Promise<void> {
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];
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 {
portKey = `${port}/tcp`;
hostPort = port.toString();
port = forward;
}
createOptions.ExposedPorts![portKey] = {};
createOptions.HostConfig!.PortBindings[portKey] = [{ HostPort: hostPort }];

this.portForwardingProvider.forwardPort(connection.localPort, { port, address });
}

}
Expand Down Expand Up @@ -126,6 +143,40 @@ export class MountsContribution implements ContainerCreationContribution {
}
}

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

export namespace OutputHelper {
export interface Progress {
id?: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -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()));
}
}
Loading
Loading