@theia/dev-container 1.72.0-next.59 → 1.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/lib/electron-browser/container-connection-contribution.d.ts +5 -2
  2. package/lib/electron-browser/container-connection-contribution.d.ts.map +1 -1
  3. package/lib/electron-browser/container-connection-contribution.js +130 -30
  4. package/lib/electron-browser/container-connection-contribution.js.map +1 -1
  5. package/lib/electron-browser/container-output-provider.d.ts.map +1 -1
  6. package/lib/electron-browser/container-output-provider.js +3 -1
  7. package/lib/electron-browser/container-output-provider.js.map +1 -1
  8. package/lib/electron-browser/dev-container-frontend-module.d.ts.map +1 -1
  9. package/lib/electron-browser/dev-container-frontend-module.js +5 -0
  10. package/lib/electron-browser/dev-container-frontend-module.js.map +1 -1
  11. package/lib/electron-browser/dev-container-startup-contribution.d.ts +15 -0
  12. package/lib/electron-browser/dev-container-startup-contribution.d.ts.map +1 -0
  13. package/lib/electron-browser/dev-container-startup-contribution.js +94 -0
  14. package/lib/electron-browser/dev-container-startup-contribution.js.map +1 -0
  15. package/lib/electron-common/dev-container-preferences.d.ts +12 -0
  16. package/lib/electron-common/dev-container-preferences.d.ts.map +1 -0
  17. package/lib/electron-common/dev-container-preferences.js +44 -0
  18. package/lib/electron-common/dev-container-preferences.js.map +1 -0
  19. package/lib/electron-common/remote-container-connection-provider.d.ts +20 -1
  20. package/lib/electron-common/remote-container-connection-provider.d.ts.map +1 -1
  21. package/lib/electron-node/dev-container-backend-module.d.ts.map +1 -1
  22. package/lib/electron-node/dev-container-backend-module.js +4 -0
  23. package/lib/electron-node/dev-container-backend-module.js.map +1 -1
  24. package/lib/electron-node/dev-container-cli-contribution.d.ts +19 -0
  25. package/lib/electron-node/dev-container-cli-contribution.d.ts.map +1 -0
  26. package/lib/electron-node/dev-container-cli-contribution.js +66 -0
  27. package/lib/electron-node/dev-container-cli-contribution.js.map +1 -0
  28. package/lib/electron-node/dev-container-cli-contribution.spec.d.ts +2 -0
  29. package/lib/electron-node/dev-container-cli-contribution.spec.d.ts.map +1 -0
  30. package/lib/electron-node/dev-container-cli-contribution.spec.js +91 -0
  31. package/lib/electron-node/dev-container-cli-contribution.spec.js.map +1 -0
  32. package/lib/electron-node/dev-container-file-service.d.ts +4 -4
  33. package/lib/electron-node/dev-container-file-service.d.ts.map +1 -1
  34. package/lib/electron-node/dev-container-file-service.js +9 -9
  35. package/lib/electron-node/dev-container-file-service.js.map +1 -1
  36. package/lib/electron-node/devcontainer-contributions/main-container-creation-contributions.d.ts +6 -2
  37. package/lib/electron-node/devcontainer-contributions/main-container-creation-contributions.d.ts.map +1 -1
  38. package/lib/electron-node/devcontainer-contributions/main-container-creation-contributions.js +24 -4
  39. package/lib/electron-node/devcontainer-contributions/main-container-creation-contributions.js.map +1 -1
  40. package/lib/electron-node/devcontainer-contributions/variable-resolver-contribution.d.ts +7 -6
  41. package/lib/electron-node/devcontainer-contributions/variable-resolver-contribution.d.ts.map +1 -1
  42. package/lib/electron-node/devcontainer-contributions/variable-resolver-contribution.js +4 -9
  43. package/lib/electron-node/devcontainer-contributions/variable-resolver-contribution.js.map +1 -1
  44. package/lib/electron-node/devcontainer-util.d.ts +19 -0
  45. package/lib/electron-node/devcontainer-util.d.ts.map +1 -0
  46. package/lib/electron-node/devcontainer-util.js +48 -0
  47. package/lib/electron-node/devcontainer-util.js.map +1 -0
  48. package/lib/electron-node/devcontainer-util.spec.d.ts +2 -0
  49. package/lib/electron-node/devcontainer-util.spec.d.ts.map +1 -0
  50. package/lib/electron-node/devcontainer-util.spec.js +128 -0
  51. package/lib/electron-node/devcontainer-util.spec.js.map +1 -0
  52. package/lib/electron-node/docker-container-service.d.ts +3 -3
  53. package/lib/electron-node/docker-container-service.d.ts.map +1 -1
  54. package/lib/electron-node/docker-container-service.js +3 -4
  55. package/lib/electron-node/docker-container-service.js.map +1 -1
  56. package/lib/electron-node/remote-container-connection-provider.d.ts +27 -66
  57. package/lib/electron-node/remote-container-connection-provider.d.ts.map +1 -1
  58. package/lib/electron-node/remote-container-connection-provider.js +269 -311
  59. package/lib/electron-node/remote-container-connection-provider.js.map +1 -1
  60. package/lib/electron-node/remote-docker-container-connection.d.ts +50 -0
  61. package/lib/electron-node/remote-docker-container-connection.d.ts.map +1 -0
  62. package/lib/electron-node/remote-docker-container-connection.js +239 -0
  63. package/lib/electron-node/remote-docker-container-connection.js.map +1 -0
  64. package/lib/electron-node/remote-docker-container-connection.spec.d.ts +2 -0
  65. package/lib/electron-node/remote-docker-container-connection.spec.d.ts.map +1 -0
  66. package/lib/electron-node/remote-docker-container-connection.spec.js +217 -0
  67. package/lib/electron-node/remote-docker-container-connection.spec.js.map +1 -0
  68. package/package.json +7 -7
  69. package/src/electron-browser/container-connection-contribution.ts +155 -38
  70. package/src/electron-browser/container-output-provider.ts +3 -1
  71. package/src/electron-browser/dev-container-frontend-module.ts +6 -0
  72. package/src/electron-browser/dev-container-startup-contribution.ts +99 -0
  73. package/src/electron-common/dev-container-preferences.ts +53 -0
  74. package/src/electron-common/remote-container-connection-provider.ts +23 -1
  75. package/src/electron-node/dev-container-backend-module.ts +5 -0
  76. package/src/electron-node/dev-container-cli-contribution.spec.ts +106 -0
  77. package/src/electron-node/dev-container-cli-contribution.ts +68 -0
  78. package/src/electron-node/dev-container-file-service.ts +10 -10
  79. package/src/electron-node/devcontainer-contributions/main-container-creation-contributions.ts +29 -5
  80. package/src/electron-node/devcontainer-contributions/variable-resolver-contribution.ts +11 -11
  81. package/src/electron-node/devcontainer-util.spec.ts +154 -0
  82. package/src/electron-node/devcontainer-util.ts +49 -0
  83. package/src/electron-node/docker-container-service.ts +6 -7
  84. package/src/electron-node/remote-container-connection-provider.ts +274 -366
  85. package/src/electron-node/{remote-container-connection-provider.spec.ts → remote-docker-container-connection.spec.ts} +105 -4
  86. package/src/electron-node/remote-docker-container-connection.ts +290 -0
  87. package/lib/electron-node/remote-container-connection-provider.spec.d.ts +0 -2
  88. package/lib/electron-node/remote-container-connection-provider.spec.d.ts.map +0 -1
  89. package/lib/electron-node/remote-container-connection-provider.spec.js +0 -131
  90. package/lib/electron-node/remote-container-connection-provider.spec.js.map +0 -1
@@ -15,27 +15,28 @@
15
15
  // *****************************************************************************
16
16
 
17
17
  import * as net from 'net';
18
+ import { PassThrough } from 'stream';
18
19
  import {
19
- ContainerConnectionOptions, ContainerConnectionResult,
20
- DevContainerFile, RemoteContainerConnectionProvider, RunningContainerInfo
20
+ AttachContainerArgs, AttachContainerOptions, ContainerConnectionOptions, ContainerConnectionResult,
21
+ DevContainerFile, RemoteContainerConnectionProvider, RunningContainerInfo, WorkspaceCandidate
21
22
  } from '../electron-common/remote-container-connection-provider';
22
- import { RemoteConnection, RemoteExecOptions, RemoteExecResult, RemoteExecTester, RemoteStatusReport } from '@theia/remote/lib/electron-node/remote-types';
23
- import { RemoteSetupResult, RemoteSetupService } from '@theia/remote/lib/electron-node/setup/remote-setup-service';
24
23
  import { RemoteConnectionService } from '@theia/remote/lib/electron-node/remote-connection-service';
24
+ import { RemoteSetupService } from '@theia/remote/lib/electron-node/setup/remote-setup-service';
25
25
  import { RemoteProxyServerProvider } from '@theia/remote/lib/electron-node/remote-proxy-server-provider';
26
- import { Emitter, Event, generateUuid, MessageService, RpcServer, ILogger } from '@theia/core';
27
- import { Socket } from 'net';
26
+ import { RemoteStatusReport } from '@theia/remote/lib/electron-node/remote-types';
27
+ import { RpcServer, ILogger, MessageService, generateUuid, URI } from '@theia/core';
28
28
  import { inject, injectable } from '@theia/core/shared/inversify';
29
29
  import * as Docker from 'dockerode';
30
- import { DockerContainerService } from './docker-container-service';
31
- import { Deferred } from '@theia/core/lib/common/promise-util';
32
- import { WriteStream } from 'tty';
33
- import { PassThrough } from 'stream';
34
- import { exec, execSync } from 'child_process';
35
30
  import { DevContainerFileService } from './dev-container-file-service';
31
+ import { DockerContainerService } from './docker-container-service';
36
32
  import { ContainerOutputProvider } from '../electron-common/container-output-provider';
33
+ import { DevContainerCliContribution } from './dev-container-cli-contribution';
34
+ import { RemoteDockerContainerConnection } from './remote-docker-container-connection';
35
+ // Re-export for backward compatibility — these types were moved to remote-docker-container-connection.ts
36
+ export { RemoteDockerContainerConnection, RemoteContainerConnectionOptions } from './remote-docker-container-connection';
37
37
  import { DevContainerConfiguration } from './devcontainer-file';
38
- import { resolveComposeFilePath } from './docker-compose/compose-service';
38
+ import { getWorkspaceMounts, inferWorkspacePath } from './devcontainer-util';
39
+ import { VariableContext } from './devcontainer-contributions/variable-resolver-contribution';
39
40
 
40
41
  @injectable()
41
42
  export class DevContainerConnectionProvider implements RemoteContainerConnectionProvider, RpcServer<ContainerOutputProvider> {
@@ -46,9 +47,6 @@ export class DevContainerConnectionProvider implements RemoteContainerConnection
46
47
  @inject(RemoteSetupService)
47
48
  protected readonly remoteSetup: RemoteSetupService;
48
49
 
49
- @inject(MessageService)
50
- protected readonly messageService: MessageService;
51
-
52
50
  @inject(RemoteProxyServerProvider)
53
51
  protected readonly serverProvider: RemoteProxyServerProvider;
54
52
 
@@ -58,19 +56,22 @@ export class DevContainerConnectionProvider implements RemoteContainerConnection
58
56
  @inject(DevContainerFileService)
59
57
  protected readonly devContainerFileService: DevContainerFileService;
60
58
 
61
- @inject(RemoteConnectionService)
62
- protected readonly remoteService: RemoteConnectionService;
59
+ @inject(DevContainerCliContribution)
60
+ protected readonly cliContribution: DevContainerCliContribution;
63
61
 
64
62
  @inject(ILogger)
65
63
  protected readonly logger: ILogger;
66
64
 
65
+ @inject(MessageService)
66
+ protected readonly messageService: MessageService;
67
+
67
68
  protected outputProvider: ContainerOutputProvider | undefined;
68
69
 
69
70
  setClient(client: ContainerOutputProvider): void {
70
71
  this.outputProvider = client;
71
72
  }
72
73
 
73
- async connectToContainer(options: ContainerConnectionOptions): Promise<ContainerConnectionResult> {
74
+ async createDockerConnection(): Promise<Docker> {
74
75
  const dockerOptions: Docker.DockerOptions = {};
75
76
  const dockerHost = process.env.DOCKER_HOST;
76
77
 
@@ -96,62 +97,181 @@ export class DevContainerConnectionProvider implements RemoteContainerConnection
96
97
  }
97
98
  } catch (_) {
98
99
  this.logger.warn(`Ignoring invalid DOCKER_HOST=${dockerHost}`);
99
- this.messageService.warn(`Ignoring invalid DOCKER_HOST=${dockerHost}`);
100
100
  }
101
101
 
102
102
  const dockerConnection = new Docker(dockerOptions);
103
- const version = await dockerConnection.version()
103
+ await dockerConnection.version()
104
104
  .catch(e => {
105
- console.error('Docker Error:', e);
106
- this.messageService.error('Docker Error: ' + e.message);
105
+ this.logger.error('Docker Error:', e);
106
+ throw new Error(`Docker is not available: ${e.message ?? e}`);
107
107
  });
108
108
 
109
- if (!version) {
110
- this.messageService.error('Docker Daemon is not running');
111
- throw new Error('Docker is not running');
109
+ return dockerConnection;
110
+ }
111
+
112
+ async listRunningContainers(docker?: Docker): Promise<RunningContainerInfo[]> {
113
+ docker ??= await this.createDockerConnection();
114
+ const containers = await docker.listContainers({ all: false });
115
+
116
+ return containers.map(container => ({
117
+ id: container.Id.substring(0, 12),
118
+ name: (container.Names[0] || '').replace(/^\//, ''),
119
+ image: container.Image,
120
+ status: container.Status,
121
+ created: container.Created
122
+ }));
123
+ }
124
+
125
+ async getWorkspaceCandidates(containerId: string, docker?: Docker): Promise<WorkspaceCandidate[]> {
126
+ docker ??= await this.createDockerConnection();
127
+ const container = docker.getContainer(containerId);
128
+ const info = await container.inspect();
129
+ const candidates: WorkspaceCandidate[] = [];
130
+ const seen = new Set<string>();
131
+
132
+ // Check for devcontainer metadata label (set by tools that created the container)
133
+ const metadataLabel = info.Config.Labels?.['devcontainer.metadata'];
134
+ if (metadataLabel) {
135
+ try {
136
+ const metadata = JSON.parse(metadataLabel);
137
+ if (Array.isArray(metadata)) {
138
+ for (const entry of metadata) {
139
+ if (entry.remoteWorkspaceFolder && !seen.has(entry.remoteWorkspaceFolder)) {
140
+ seen.add(entry.remoteWorkspaceFolder);
141
+ candidates.push({ path: entry.remoteWorkspaceFolder, source: 'devcontainer-label' });
142
+ }
143
+ }
144
+ }
145
+ } catch {
146
+ // ignore malformed metadata
147
+ }
112
148
  }
113
149
 
114
- // create container
115
- const progress = await this.messageService.showProgress({
116
- text: 'Creating container',
117
- });
118
- try {
119
- const container = await this.containerService.getOrCreateContainer(dockerConnection, options, this.outputProvider);
120
- const devContainerConfig = await this.devContainerFileService.getConfiguration(options.devcontainerFile);
150
+ const localFolderLabel = info.Config.Labels?.['devcontainer.local_folder'];
151
+ if (localFolderLabel) {
152
+ const basename = URI.fromFilePath(localFolderLabel).path.base;
153
+ if (basename) {
154
+ const workspacePath = `/workspaces/${basename}`;
155
+ if (!seen.has(workspacePath)) {
156
+ seen.add(workspacePath);
157
+ candidates.push({ path: workspacePath, source: 'devcontainer-label' });
158
+ }
159
+ }
160
+ }
121
161
 
122
- // create actual connection
123
- const report: RemoteStatusReport = message => progress.report({ message });
124
- report('Connecting to remote system...');
162
+ if (info.Config.WorkingDir && info.Config.WorkingDir !== '/' && !seen.has(info.Config.WorkingDir)) {
163
+ seen.add(info.Config.WorkingDir);
164
+ candidates.push({ path: info.Config.WorkingDir, source: 'working-dir' });
165
+ }
125
166
 
126
- const remote = await this.createContainerConnection(container, dockerConnection, devContainerConfig);
127
- const result = await this.remoteSetup.setup({
128
- connection: remote,
129
- report,
130
- nodeDownloadTemplate: options.nodeDownloadTemplate
167
+ for (const mount of getWorkspaceMounts(info.Mounts ?? [])) {
168
+ if (!seen.has(mount.Destination)) {
169
+ seen.add(mount.Destination);
170
+ candidates.push({ path: mount.Destination, source: 'bind-mount' });
171
+ }
172
+ }
173
+
174
+ if (!seen.has('/')) {
175
+ candidates.push({ path: '/', source: 'fallback' });
176
+ }
177
+
178
+ return candidates;
179
+ }
180
+
181
+ async scanForDevContainerConfig(containerId: string, workspacePath: string, docker?: Docker): Promise<string | undefined> {
182
+ docker ??= await this.createDockerConnection();
183
+ const container = docker.getContainer(containerId);
184
+
185
+ // Search all three standard devcontainer.json locations:
186
+ // <workspace>/.devcontainer/devcontainer.json
187
+ // <workspace>/.devcontainer.json
188
+ // <workspace>/.devcontainer/<subfolder>/devcontainer.json
189
+ // Uses a single find command to avoid multiple exec round-trips.
190
+ try {
191
+ // Use Cmd array form instead of sh -c to avoid shell injection.
192
+ // stderr (e.g. "No such file or directory" when .devcontainer doesn't exist)
193
+ // is demuxed to a separate stream and discarded.
194
+ const execution = await container.exec({
195
+ Cmd: [
196
+ 'find',
197
+ `${workspacePath}/.devcontainer`, `${workspacePath}/.devcontainer.json`,
198
+ '-maxdepth', '2',
199
+ '(', '-name', 'devcontainer.json', '-o', '-name', '.devcontainer.json', ')',
200
+ '-type', 'f'
201
+ ],
202
+ AttachStdout: true,
203
+ AttachStderr: true
131
204
  });
132
- remote.remoteSetupResult = result;
133
205
 
134
- const registration = this.remoteConnectionService.register(remote);
135
- const server = await this.serverProvider.getProxyServer(socket => {
136
- remote.forwardOut(socket);
206
+ let stdout = '';
207
+ const stream = await execution.start({});
208
+ const stdoutPassthrough = new PassThrough();
209
+ const stderrPassthrough = new PassThrough();
210
+ stdoutPassthrough.on('data', (chunk: Buffer) => {
211
+ stdout += chunk.toString();
137
212
  });
138
- remote.onDidDisconnect(() => {
139
- server.close();
140
- registration.dispose();
213
+ execution.modem.demuxStream(stream, stdoutPassthrough, stderrPassthrough);
214
+
215
+ await new Promise<void>((resolve, reject) => {
216
+ stream.on('end', () => resolve());
217
+ stream.on('error', reject);
141
218
  });
142
- const localPort = (server.address() as net.AddressInfo).port;
143
- remote.localPort = localPort;
219
+ stdoutPassthrough.destroy();
220
+ stderrPassthrough.destroy();
144
221
 
145
- await this.containerService.postConnect(options.devcontainerFile, remote, this.outputProvider);
222
+ const found = stdout.trim().split('\n').filter(line => line.length > 0);
223
+ if (found.length === 0) {
224
+ return undefined;
225
+ }
146
226
 
147
- return {
148
- containerId: container.id,
149
- workspacePath: devContainerConfig.workspaceFolder ?? this.inferWorkspacePath(await container.inspect()),
150
- port: localPort.toString(),
151
- };
227
+ // Prefer the standard location, then root-level, then subfolder configs
228
+ const standardPath = `${workspacePath}/.devcontainer/devcontainer.json`;
229
+ const rootPath = `${workspacePath}/.devcontainer.json`;
230
+ for (const preferred of [standardPath, rootPath]) {
231
+ if (found.includes(preferred)) {
232
+ return preferred;
233
+ }
234
+ }
235
+ // Return the first subfolder config found
236
+ return found[0];
237
+ } catch (e) {
238
+ // find/sh might not be available in minimal containers
239
+ this.logger.debug('Failed to scan for devcontainer.json in container:', e);
240
+ return undefined;
241
+ }
242
+ }
243
+
244
+ async connectToContainer(options: ContainerConnectionOptions): Promise<ContainerConnectionResult> {
245
+ const progress = await this.messageService.showProgress({ text: 'Creating container' });
246
+ try {
247
+ const report: RemoteStatusReport = message => progress.report({ message });
248
+ const docker = await this.createDockerConnection();
249
+
250
+ let remote: RemoteDockerContainerConnection | undefined;
251
+ try {
252
+ const container = await this.containerService.getOrCreateContainer(docker, options, this.outputProvider);
253
+ const context: VariableContext = { containerId: container.id };
254
+ const devContainerConfig = await this.devContainerFileService.getConfiguration(options.devcontainerFile, context);
255
+
256
+ report('Connecting to remote system...');
257
+
258
+ const result = await this.setupRemoteConnection(container, docker, devContainerConfig, options.nodeDownloadTemplate, report);
259
+ remote = result.remote;
260
+
261
+ await this.containerService.postConnect(options.devcontainerFile, remote, this.outputProvider, context);
262
+
263
+ return {
264
+ containerId: container.id,
265
+ workspacePath: devContainerConfig.workspaceFolder ?? inferWorkspacePath(await container.inspect()),
266
+ port: result.localPort.toString(),
267
+ };
268
+ } catch (e) {
269
+ remote?.dispose();
270
+ this.logger.error(e);
271
+ throw e;
272
+ }
152
273
  } catch (e) {
153
274
  this.messageService.error(e.message);
154
- console.error(e);
155
275
  throw e;
156
276
  } finally {
157
277
  progress.cancel();
@@ -162,363 +282,151 @@ export class DevContainerConnectionProvider implements RemoteContainerConnection
162
282
  return this.devContainerFileService.getAvailableFiles(workspacePath);
163
283
  }
164
284
 
165
- async createContainerConnection(container: Docker.Container, docker: Docker, config: DevContainerConfiguration): Promise<RemoteDockerContainerConnection> {
166
- return Promise.resolve(new RemoteDockerContainerConnection({
167
- id: generateUuid(),
168
- name: config.name ?? 'dev-container',
169
- type: 'Dev Container',
170
- docker,
171
- container,
172
- config,
173
- logger: this.logger
174
- }));
175
- }
176
-
177
- async getCurrentContainerInfo(port: number): Promise<Docker.ContainerInspectInfo | undefined> {
178
- const connection = this.remoteConnectionService.getConnectionFromPort(port);
179
- if (!connection || !(connection instanceof RemoteDockerContainerConnection)) {
285
+ async getAttachContainerArgs(): Promise<AttachContainerArgs | undefined> {
286
+ const containerId = this.cliContribution.consumeAttachContainerId();
287
+ if (!containerId) {
180
288
  return undefined;
181
289
  }
182
- return connection.container.inspect();
290
+ return {
291
+ containerId,
292
+ scanForDevJson: this.cliContribution.shouldScanForDevJson()
293
+ };
183
294
  }
184
295
 
185
- async listRunningContainers(): Promise<RunningContainerInfo[]> {
296
+ async attachToContainer(options: AttachContainerOptions): Promise<ContainerConnectionResult> {
297
+ const progress = await this.messageService.showProgress({ text: 'Attaching to container' });
186
298
  try {
187
- const docker = new Docker();
188
- const containers = await docker.listContainers({ all: false });
189
- return containers.map(container => ({
190
- id: container.Id,
191
- name: (container.Names[0] ?? '').replace(/^\//, ''),
192
- image: container.Image,
193
- status: container.Status
194
- }));
195
- } catch (e) {
196
- console.error('Failed to list running containers:', e);
197
- return [];
198
- }
199
- }
299
+ const report: RemoteStatusReport = message => progress.report({ message });
300
+ const docker = await this.createDockerConnection();
301
+ const container = docker.getContainer(options.containerId);
200
302
 
201
- async attachToContainer(containerId: string): Promise<ContainerConnectionResult> {
202
- const docker = new Docker();
203
- const container = docker.getContainer(containerId);
204
- const containerInfo = await container.inspect();
303
+ const containerInfo = await container.inspect();
304
+ if (!containerInfo.State.Running) {
305
+ throw new Error(`Container ${options.containerId} is not running`);
306
+ }
205
307
 
206
- const progress = await this.messageService.showProgress({
207
- text: 'Attaching to container',
208
- });
209
- try {
210
- const report: RemoteStatusReport = message => progress.report({ message });
211
- report('Connecting to remote system...');
212
-
213
- const remote = new RemoteDockerContainerConnection({
214
- id: generateUuid(),
215
- name: containerInfo.Name.replace(/^\//, ''),
216
- type: 'Dev Container',
217
- docker,
218
- container,
219
- config: DevContainerConfiguration.empty(),
220
- logger: this.logger
221
- });
308
+ const context: VariableContext = { containerId: options.containerId };
309
+ let config: DevContainerConfiguration;
310
+ if (options.devcontainerFile) {
311
+ config = await this.devContainerFileService.getConfiguration(options.devcontainerFile, context);
312
+ config = { ...config, shutdownAction: 'none' };
313
+ } else {
314
+ config = { name: containerInfo.Name.replace(/^\//, ''), shutdownAction: 'none' } as DevContainerConfiguration;
315
+ }
222
316
 
223
- const result = await this.remoteSetup.setup({
224
- connection: remote,
225
- report,
226
- });
227
- remote.remoteSetupResult = result;
317
+ let remote: RemoteDockerContainerConnection | undefined;
318
+ try {
319
+ report('Connecting to remote system...');
228
320
 
229
- const registration = this.remoteConnectionService.register(remote);
230
- const server = await this.serverProvider.getProxyServer(socket => {
231
- remote.forwardOut(socket);
232
- });
233
- remote.onDidDisconnect(() => {
234
- server.close();
235
- registration.dispose();
236
- });
237
- const localPort = (server.address() as net.AddressInfo).port;
238
- remote.localPort = localPort;
321
+ const result = await this.setupRemoteConnection(container, docker, config, options.nodeDownloadTemplate, report);
322
+ remote = result.remote;
239
323
 
240
- const workspacePath = this.inferWorkspacePath(containerInfo);
324
+ if (options.devcontainerFile) {
325
+ await this.containerService.postConnect(options.devcontainerFile, remote, this.outputProvider, context);
326
+ }
241
327
 
242
- return {
243
- containerId: container.id,
244
- workspacePath,
245
- port: localPort.toString(),
246
- };
328
+ return {
329
+ containerId: container.id,
330
+ workspacePath: options.workspacePath,
331
+ port: result.localPort.toString(),
332
+ };
333
+ } catch (e) {
334
+ remote?.dispose();
335
+ this.logger.error(e);
336
+ throw e;
337
+ }
247
338
  } catch (e) {
248
339
  this.messageService.error(e.message);
249
- console.error(e);
250
340
  throw e;
251
341
  } finally {
252
342
  progress.cancel();
253
343
  }
254
344
  }
255
345
 
256
- protected inferWorkspacePath(containerInfo: Docker.ContainerInspectInfo): string {
257
- // Skip mounts that are injected by HostConfigSharingContribution
258
- // (SSH dir, gitconfig) these are not workspace mounts.
259
- const workspaceMount = containerInfo.Mounts.find(m =>
260
- !m.Destination.endsWith('/.ssh') &&
261
- !m.Destination.endsWith('/.gitconfig') &&
262
- m.Destination !== '/tmp/host_gitconfig'
263
- );
264
- return (workspaceMount?.Destination ?? containerInfo.Config.WorkingDir) || '/';
346
+ async getCurrentContainerInfo(port: number): Promise<Docker.ContainerInspectInfo | undefined> {
347
+ const connection = this.remoteConnectionService.getConnectionFromPort(port);
348
+ if (!connection || !(connection instanceof RemoteDockerContainerConnection)) {
349
+ return undefined;
350
+ }
351
+ return connection.container.inspect();
265
352
  }
266
353
 
267
354
  async removeContainer(containerId: string): Promise<void> {
355
+ return this.doRemoveContainer(containerId);
356
+ }
357
+
358
+ protected async doRemoveContainer(containerId: string, docker?: Docker): Promise<void> {
359
+ docker ??= await this.createDockerConnection();
360
+ const container = docker.getContainer(containerId);
268
361
  try {
269
- const docker = new Docker();
270
- const container = docker.getContainer(containerId);
271
362
  const info = await container.inspect();
272
363
  if (info.State.Running) {
273
364
  await container.stop();
274
365
  }
275
366
  await container.remove();
276
367
  } catch (e) {
277
- console.error('Failed to remove container:', e);
368
+ this.logger.error('Failed to remove container:', e);
278
369
  throw e;
279
370
  }
280
371
  }
281
372
 
282
- dispose(): void {
283
-
284
- }
285
-
286
- }
287
-
288
- export interface RemoteContainerConnectionOptions {
289
- id: string;
290
- name: string;
291
- type: string;
292
- docker: Docker;
293
- container: Docker.Container;
294
- config: DevContainerConfiguration;
295
- logger: ILogger;
296
- }
297
-
298
- interface ContainerTerminalSession {
299
- execution: Docker.Exec,
300
- stdout: WriteStream,
301
- stderr: WriteStream,
302
- executeCommand(cmd: string, args?: string[]): Promise<{ stdout: string, stderr: string }>;
303
- }
304
-
305
- interface ContainerTerminalSession {
306
- execution: Docker.Exec,
307
- stdout: WriteStream,
308
- stderr: WriteStream,
309
- executeCommand(cmd: string, args?: string[]): Promise<{ stdout: string, stderr: string }>;
310
- }
311
-
312
- export class RemoteDockerContainerConnection implements RemoteConnection {
313
-
314
- id: string;
315
- name: string;
316
- type: string;
317
- localPort: number;
318
- remotePort: number;
319
-
320
- docker: Docker;
321
- container: Docker.Container;
322
-
323
- remoteSetupResult: RemoteSetupResult;
324
-
325
- protected readonly logger: ILogger;
326
-
327
- protected config: DevContainerConfiguration;
328
-
329
- protected activeTerminalSession: ContainerTerminalSession | undefined;
330
-
331
- protected readonly onDidDisconnectEmitter = new Emitter<void>();
332
- onDidDisconnect: Event<void> = this.onDidDisconnectEmitter.event;
333
-
334
- constructor(options: RemoteContainerConnectionOptions) {
335
- this.id = options.id;
336
- this.type = options.type;
337
- this.name = options.name;
338
-
339
- this.docker = options.docker;
340
- this.container = options.container;
341
-
342
- this.config = options.config;
343
-
344
- this.docker.getEvents({ filters: { container: [this.container.id], event: ['stop'] } }).then(stream => {
345
- stream.on('data', () => this.onDidDisconnectEmitter.fire());
373
+ /**
374
+ * Creates a remote connection, runs setup (injecting the Theia backend into the container),
375
+ * registers the connection, and starts a local proxy server.
376
+ *
377
+ * @returns the local proxy port and the remote connection
378
+ */
379
+ protected async setupRemoteConnection(
380
+ container: Docker.Container, docker: Docker, config: DevContainerConfiguration,
381
+ nodeDownloadTemplate: string | undefined, report: RemoteStatusReport
382
+ ): Promise<{ localPort: number; remote: RemoteDockerContainerConnection }> {
383
+ const remote = new RemoteDockerContainerConnection({
384
+ id: generateUuid(),
385
+ name: config.name ?? 'dev-container',
386
+ type: 'Dev Container',
387
+ docker,
388
+ container,
389
+ config,
390
+ logger: this.logger
346
391
  });
347
392
 
348
- this.logger = options.logger;
349
- }
350
-
351
- protected getRemoteEnv(): string[] | undefined {
352
- const remoteEnv = this.config.remoteEnv;
353
- if (!remoteEnv || Object.keys(remoteEnv).length === 0) {
354
- return undefined;
355
- }
356
- return Object.entries(remoteEnv)
357
- .filter(([, value]) => value !== undefined)
358
- .map(([key, value]) => `${key}=${value}`);
359
- }
360
-
361
- async forwardOut(socket: Socket, port?: number): Promise<void> {
362
- const node = `${this.remoteSetupResult.nodeDirectory}/bin/node`;
363
- const devContainerServer = `${this.remoteSetupResult.applicationDirectory}/backend/dev-container-server.js`;
364
- try {
365
- const ttySession = await this.container.exec({
366
- Cmd: ['sh', '-c', `${node} ${devContainerServer} -target-port=${port ?? this.remotePort}`],
367
- Env: this.getRemoteEnv(),
368
- AttachStdin: true, AttachStdout: true, AttachStderr: true
369
- });
370
-
371
- const stream = await ttySession.start({ hijack: true, stdin: true });
372
-
373
- socket.pipe(stream);
374
- ttySession.modem.demuxStream(stream, socket, socket);
375
- } catch (e) {
376
- console.error(e);
377
- }
378
- }
379
-
380
- async exec(cmd: string, args?: string[], options?: RemoteExecOptions): Promise<RemoteExecResult> {
381
- // return (await this.getOrCreateTerminalSession()).executeCommand(cmd, args);
382
- const deferred = new Deferred<RemoteExecResult>();
393
+ let result;
383
394
  try {
384
- // TODO add windows container support
385
- const execution = await this.container.exec({
386
- Cmd: ['sh', '-c', `${cmd} ${args?.join(' ') ?? ''}`], Env: this.getRemoteEnv(), AttachStdout: true, AttachStderr: true
387
- });
388
- let stdoutBuffer = '';
389
- let stderrBuffer = '';
390
- const stream = await execution?.start({});
391
- const stdout = new PassThrough();
392
- stdout.on('data', (chunk: Buffer) => {
393
- stdoutBuffer += chunk.toString();
394
- });
395
- const stderr = new PassThrough();
396
- stderr.on('data', (chunk: Buffer) => {
397
- stderrBuffer += chunk.toString();
395
+ result = await this.remoteSetup.setup({
396
+ connection: remote,
397
+ report,
398
+ nodeDownloadTemplate
398
399
  });
399
- execution.modem.demuxStream(stream, stdout, stderr);
400
- stream?.addListener('close', () => deferred.resolve({ stdout: stdoutBuffer, stderr: stderrBuffer }));
401
400
  } catch (e) {
402
- deferred.reject(e);
401
+ remote.dispose();
402
+ throw e;
403
403
  }
404
- return deferred.promise;
405
- }
404
+ remote.remoteSetupResult = result;
406
405
 
407
- async execPartial(cmd: string, tester: RemoteExecTester, args?: string[], options?: RemoteExecOptions): Promise<RemoteExecResult> {
408
- const deferred = new Deferred<RemoteExecResult>();
406
+ let registration: { dispose(): void } | undefined;
407
+ let server: net.Server | undefined;
409
408
  try {
410
- // TODO add windows container support
411
- const execution = await this.container.exec({
412
- Cmd: ['sh', '-c', `${cmd} ${args?.join(' ') ?? ''}`], Env: this.getRemoteEnv(), AttachStdout: true, AttachStderr: true
413
- });
414
- let stdoutBuffer = '';
415
- let stderrBuffer = '';
416
- const stream = await execution?.start({});
417
- stream.on('close', () => {
418
- if (deferred.state === 'unresolved') {
419
- deferred.resolve({ stdout: stdoutBuffer, stderr: stderrBuffer });
420
- }
421
- });
422
- const stdout = new PassThrough();
423
- stdout.on('data', (data: Buffer) => {
424
- this.logger.debug('REMOTE STDOUT:', data.toString());
425
- if (deferred.state === 'unresolved') {
426
- stdoutBuffer += data.toString();
427
-
428
- if (tester(stdoutBuffer, stderrBuffer)) {
429
- deferred.resolve({ stdout: stdoutBuffer, stderr: stderrBuffer });
430
- }
431
- }
432
- });
433
- const stderr = new PassThrough();
434
- stderr.on('data', (data: Buffer) => {
435
- this.logger.debug('REMOTE STDERR:', data.toString());
436
- if (deferred.state === 'unresolved') {
437
- stderrBuffer += data.toString();
438
-
439
- if (tester(stdoutBuffer, stderrBuffer)) {
440
- deferred.resolve({ stdout: stdoutBuffer, stderr: stderrBuffer });
441
- }
442
- }
409
+ registration = this.remoteConnectionService.register(remote);
410
+ server = await this.serverProvider.getProxyServer(socket => {
411
+ remote.forwardOut(socket);
443
412
  });
444
- execution.modem.demuxStream(stream, stdout, stderr);
445
413
  } catch (e) {
446
- deferred.reject(e);
447
- }
448
- return deferred.promise;
449
- }
450
-
451
- getDockerHost(): string {
452
- const dockerHost = process.env.DOCKER_HOST;
453
- let remoteHost = '';
454
- try {
455
- if (dockerHost) {
456
- const dockerHostURL = new URL(dockerHost);
457
- if (dockerHostURL.protocol === 'http:' || dockerHostURL.protocol === 'https:') {
458
- dockerHostURL.protocol = 'tcp:';
459
- }
460
- remoteHost = `-H ${dockerHostURL.href} `;
461
- }
462
- } catch (e) {
463
- console.error(e);
414
+ server?.close();
415
+ registration?.dispose();
416
+ remote.dispose();
417
+ throw e;
464
418
  }
465
-
466
- return remoteHost;
467
- }
468
-
469
- async copy(localPath: string | Buffer | NodeJS.ReadableStream, remotePath: string): Promise<void> {
470
- const deferred = new Deferred<void>();
471
- const remoteHost = this.getDockerHost();
472
-
473
- const subprocess = exec(`docker ${remoteHost}cp -a ${localPath.toString()} ${this.container.id}:${remotePath}`);
474
-
475
- let stderr = '';
476
- subprocess.stderr?.on('data', data => {
477
- stderr += data.toString();
478
- });
479
- subprocess.on('close', code => {
480
- if (code === 0) {
481
- deferred.resolve();
482
- } else {
483
- deferred.reject(stderr);
484
- }
419
+ remote.onDidDisconnect(() => {
420
+ server.close();
421
+ registration.dispose();
485
422
  });
486
- return deferred.promise;
487
- }
488
423
 
489
- disposeSync(): void {
490
- // cant use dockerrode here since this needs to happen on one tick
491
- this.shutdownContainer(true);
424
+ const localPort = (server.address() as net.AddressInfo).port;
425
+ remote.localPort = localPort;
426
+ return { localPort, remote };
492
427
  }
493
428
 
494
- async dispose(): Promise<void> {
495
- await this.shutdownContainer(false);
496
- }
497
-
498
- protected async shutdownContainer(sync: boolean): Promise<unknown> {
499
- const remoteHost = this.getDockerHost();
500
-
501
- const shutdownAction = this.config.shutdownAction ?? (this.config.dockerComposeFile ? 'stopCompose' : 'stopContainer');
502
-
503
- if (shutdownAction === 'stopContainer') {
504
- return sync ? execSync(`docker ${remoteHost}stop ${this.container.id}`) : this.container.stop();
505
- } else if (shutdownAction === 'stopCompose') {
506
- if (!this.config.dockerComposeFile) {
507
- console.warn('shutdownAction is stopCompose but dockerComposeFile is not defined, falling back to stopContainer');
508
- return sync ? execSync(`docker ${remoteHost}stop ${this.container.id}`) : this.container.stop();
509
- }
510
- const composeFilePath = resolveComposeFilePath(this.config);
511
- return sync ? execSync(`docker ${remoteHost}compose -f ${composeFilePath} stop`) :
512
- new Promise<void>((res, rej) => exec(`docker ${remoteHost}compose -f ${composeFilePath} stop`, err => {
513
- if (err) {
514
- console.error(err);
515
- rej(err);
516
- } else {
517
- res();
518
- }
519
- }));
520
- }
521
-
429
+ dispose(): void {
430
+ this.outputProvider = undefined;
522
431
  }
523
-
524
432
  }