// ***************************************************************************** // 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 { PassThrough } from 'stream'; import { AttachContainerArgs, AttachContainerOptions, ContainerConnectionOptions, ContainerConnectionResult, DevContainerFile, RemoteContainerConnectionProvider, RunningContainerInfo, WorkspaceCandidate } from '../electron-common/remote-container-connection-provider'; import { RemoteConnectionService } from '@theia/remote/lib/electron-node/remote-connection-service'; import { RemoteSetupService } from '@theia/remote/lib/electron-node/setup/remote-setup-service'; import { RemoteProxyServerProvider } from '@theia/remote/lib/electron-node/remote-proxy-server-provider'; import { RemoteStatusReport } from '@theia/remote/lib/electron-node/remote-types'; import { RpcServer, ILogger, MessageService, generateUuid, URI } from '@theia/core'; import { inject, injectable, named } from '@theia/core/shared/inversify'; import * as Docker from 'dockerode'; import { DevContainerFileService } from './dev-container-file-service'; import { DockerContainerService } from './docker-container-service'; import { ContainerOutputProvider } from '../electron-common/container-output-provider'; import { DevContainerCliContribution } from './dev-container-cli-contribution'; import { RemoteDockerContainerConnection } from './remote-docker-container-connection'; // Re-export for backward compatibility — these types were moved to remote-docker-container-connection.ts export { RemoteDockerContainerConnection, RemoteContainerConnectionOptions } from './remote-docker-container-connection'; import { DevContainerConfiguration } from './devcontainer-file'; import { getWorkspaceMounts, inferWorkspacePath } from './devcontainer-util'; import { VariableContext } from './devcontainer-contributions/variable-resolver-contribution'; @injectable() export class DevContainerConnectionProvider implements RemoteContainerConnectionProvider, RpcServer { @inject(RemoteConnectionService) protected readonly remoteConnectionService: RemoteConnectionService; @inject(RemoteSetupService) protected readonly remoteSetup: RemoteSetupService; @inject(RemoteProxyServerProvider) protected readonly serverProvider: RemoteProxyServerProvider; @inject(DockerContainerService) protected readonly containerService: DockerContainerService; @inject(DevContainerFileService) protected readonly devContainerFileService: DevContainerFileService; @inject(DevContainerCliContribution) protected readonly cliContribution: DevContainerCliContribution; @inject(ILogger) @named('dev-container:DevContainerConnectionProvider') protected readonly logger: ILogger; @inject(MessageService) protected readonly messageService: MessageService; protected outputProvider: ContainerOutputProvider | undefined; setClient(client: ContainerOutputProvider): void { this.outputProvider = client; } async createDockerConnection(): Promise { 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}`); } const dockerConnection = new Docker(dockerOptions); await dockerConnection.version() .catch(e => { this.logger.error('Docker Error:', e); throw new Error(`Docker is not available: ${e.message ?? e}`); }); return dockerConnection; } async listRunningContainers(docker?: Docker): Promise { docker ??= await this.createDockerConnection(); const containers = await docker.listContainers({ all: false }); return containers.map(container => ({ id: container.Id.substring(0, 12), name: (container.Names[0] || '').replace(/^\//, ''), image: container.Image, status: container.Status, created: container.Created })); } async getWorkspaceCandidates(containerId: string, docker?: Docker): Promise { docker ??= await this.createDockerConnection(); const container = docker.getContainer(containerId); const info = await container.inspect(); const candidates: WorkspaceCandidate[] = []; const seen = new Set(); // Check for devcontainer metadata label (set by tools that created the container) const metadataLabel = info.Config.Labels?.['devcontainer.metadata']; if (metadataLabel) { try { const metadata = JSON.parse(metadataLabel); if (Array.isArray(metadata)) { for (const entry of metadata) { if (entry.remoteWorkspaceFolder && !seen.has(entry.remoteWorkspaceFolder)) { seen.add(entry.remoteWorkspaceFolder); candidates.push({ path: entry.remoteWorkspaceFolder, source: 'devcontainer-label' }); } } } } catch { // ignore malformed metadata } } const localFolderLabel = info.Config.Labels?.['devcontainer.local_folder']; if (localFolderLabel) { const basename = URI.fromFilePath(localFolderLabel).path.base; if (basename) { const workspacePath = `/workspaces/${basename}`; if (!seen.has(workspacePath)) { seen.add(workspacePath); candidates.push({ path: workspacePath, source: 'devcontainer-label' }); } } } if (info.Config.WorkingDir && info.Config.WorkingDir !== '/' && !seen.has(info.Config.WorkingDir)) { seen.add(info.Config.WorkingDir); candidates.push({ path: info.Config.WorkingDir, source: 'working-dir' }); } for (const mount of getWorkspaceMounts(info.Mounts ?? [])) { if (!seen.has(mount.Destination)) { seen.add(mount.Destination); candidates.push({ path: mount.Destination, source: 'bind-mount' }); } } if (!seen.has('/')) { candidates.push({ path: '/', source: 'fallback' }); } return candidates; } async scanForDevContainerConfig(containerId: string, workspacePath: string, docker?: Docker): Promise { docker ??= await this.createDockerConnection(); const container = docker.getContainer(containerId); // Search all three standard devcontainer.json locations: // /.devcontainer/devcontainer.json // /.devcontainer.json // /.devcontainer//devcontainer.json // Uses a single find command to avoid multiple exec round-trips. try { // Use Cmd array form instead of sh -c to avoid shell injection. // stderr (e.g. "No such file or directory" when .devcontainer doesn't exist) // is demuxed to a separate stream and discarded. const execution = await container.exec({ Cmd: [ 'find', `${workspacePath}/.devcontainer`, `${workspacePath}/.devcontainer.json`, '-maxdepth', '2', '(', '-name', 'devcontainer.json', '-o', '-name', '.devcontainer.json', ')', '-type', 'f' ], AttachStdout: true, AttachStderr: true }); let stdout = ''; const stream = await execution.start({}); const stdoutPassthrough = new PassThrough(); const stderrPassthrough = new PassThrough(); stdoutPassthrough.on('data', (chunk: Buffer) => { stdout += chunk.toString(); }); execution.modem.demuxStream(stream, stdoutPassthrough, stderrPassthrough); await new Promise((resolve, reject) => { stream.on('end', () => resolve()); stream.on('error', reject); }); stdoutPassthrough.destroy(); stderrPassthrough.destroy(); const found = stdout.trim().split('\n').filter(line => line.length > 0); if (found.length === 0) { return undefined; } // Prefer the standard location, then root-level, then subfolder configs const standardPath = `${workspacePath}/.devcontainer/devcontainer.json`; const rootPath = `${workspacePath}/.devcontainer.json`; for (const preferred of [standardPath, rootPath]) { if (found.includes(preferred)) { return preferred; } } // Return the first subfolder config found return found[0]; } catch (e) { // find/sh might not be available in minimal containers this.logger.debug('Failed to scan for devcontainer.json in container:', e); return undefined; } } async connectToContainer(options: ContainerConnectionOptions): Promise { const progress = await this.messageService.showProgress({ text: 'Creating container' }); try { const report: RemoteStatusReport = message => progress.report({ message }); const docker = await this.createDockerConnection(); let remote: RemoteDockerContainerConnection | undefined; try { const container = await this.containerService.getOrCreateContainer(docker, options, this.outputProvider); const context: VariableContext = { containerId: container.id }; const devContainerConfig = await this.devContainerFileService.getConfiguration(options.devcontainerFile, context); report('Connecting to remote system...'); const result = await this.setupRemoteConnection(container, docker, devContainerConfig, options.nodeDownloadTemplate, report); remote = result.remote; await this.containerService.postConnect(options.devcontainerFile, remote, this.outputProvider, context); return { containerId: container.id, workspacePath: devContainerConfig.workspaceFolder ?? inferWorkspacePath(await container.inspect()), port: result.localPort.toString(), }; } catch (e) { remote?.dispose(); this.logger.error(e); throw e; } } catch (e) { this.messageService.error(e.message); throw e; } finally { progress.cancel(); } } getDevContainerFiles(workspacePath: string): Promise { return this.devContainerFileService.getAvailableFiles(workspacePath); } async getAttachContainerArgs(): Promise { const containerId = this.cliContribution.consumeAttachContainerId(); if (!containerId) { return undefined; } return { containerId, scanForDevJson: this.cliContribution.shouldScanForDevJson() }; } async attachToContainer(options: AttachContainerOptions): Promise { const progress = await this.messageService.showProgress({ text: 'Attaching to container' }); try { const report: RemoteStatusReport = message => progress.report({ message }); const docker = await this.createDockerConnection(); const container = docker.getContainer(options.containerId); const containerInfo = await container.inspect(); if (!containerInfo.State.Running) { throw new Error(`Container ${options.containerId} is not running`); } const context: VariableContext = { containerId: options.containerId }; let config: DevContainerConfiguration; if (options.devcontainerFile) { config = await this.devContainerFileService.getConfiguration(options.devcontainerFile, context); config = { ...config, shutdownAction: 'none' }; } else { config = { name: containerInfo.Name.replace(/^\//, ''), shutdownAction: 'none' } as DevContainerConfiguration; } let remote: RemoteDockerContainerConnection | undefined; try { report('Connecting to remote system...'); const result = await this.setupRemoteConnection(container, docker, config, options.nodeDownloadTemplate, report); remote = result.remote; if (options.devcontainerFile) { await this.containerService.postConnect(options.devcontainerFile, remote, this.outputProvider, context); } return { containerId: container.id, workspacePath: options.workspacePath, port: result.localPort.toString(), }; } catch (e) { remote?.dispose(); this.logger.error(e); throw e; } } catch (e) { this.messageService.error(e.message); throw e; } finally { progress.cancel(); } } async getCurrentContainerInfo(port: number): Promise { const connection = this.remoteConnectionService.getConnectionFromPort(port); if (!connection || !(connection instanceof RemoteDockerContainerConnection)) { return undefined; } return connection.container.inspect(); } async removeContainer(containerId: string): Promise { return this.doRemoveContainer(containerId); } protected async doRemoveContainer(containerId: string, docker?: Docker): Promise { docker ??= await this.createDockerConnection(); const container = docker.getContainer(containerId); try { const info = await container.inspect(); if (info.State.Running) { await container.stop(); } await container.remove(); } catch (e) { this.logger.error('Failed to remove container:', e); throw e; } } /** * Creates a remote connection, runs setup (injecting the Theia backend into the container), * registers the connection, and starts a local proxy server. * * @returns the local proxy port and the remote connection */ protected async setupRemoteConnection( container: Docker.Container, docker: Docker, config: DevContainerConfiguration, nodeDownloadTemplate: string | undefined, report: RemoteStatusReport ): Promise<{ localPort: number; remote: RemoteDockerContainerConnection }> { const remote = new RemoteDockerContainerConnection({ id: generateUuid(), name: config.name ?? 'dev-container', type: 'Dev Container', docker, container, config, logger: this.logger }); let result; try { result = await this.remoteSetup.setup({ connection: remote, report, nodeDownloadTemplate }); } catch (e) { remote.dispose(); throw e; } remote.remoteSetupResult = result; let registration: { dispose(): void } | undefined; let server: net.Server | undefined; try { registration = this.remoteConnectionService.register(remote); server = await this.serverProvider.getProxyServer(socket => { remote.forwardOut(socket); }); } catch (e) { server?.close(); registration?.dispose(); remote.dispose(); throw e; } remote.onDidDisconnect(() => { server.close(); registration.dispose(); }); const localPort = (server.address() as net.AddressInfo).port; remote.localPort = localPort; return { localPort, remote }; } dispose(): void { this.outputProvider = undefined; } }