1use std::{
2 collections::{HashMap, HashSet},
3 fmt::Display,
4 path::{Path, PathBuf},
5 sync::Arc,
6};
7
8use gpui::AsyncWindowContext;
9use node_runtime::NodeRuntime;
10use serde::Deserialize;
11use settings::{DevContainerConnection, Settings as _};
12use smol::{fs, process::Command};
13use workspace::Workspace;
14
15use crate::{DevContainerFeature, DevContainerSettings, DevContainerTemplate};
16
17#[derive(Debug, Deserialize)]
18#[serde(rename_all = "camelCase")]
19struct DevContainerUp {
20 _outcome: String,
21 container_id: String,
22 _remote_user: String,
23 remote_workspace_folder: String,
24}
25
26#[derive(Debug, Deserialize)]
27#[serde(rename_all = "camelCase")]
28pub(crate) struct DevContainerApply {
29 pub(crate) files: Vec<String>,
30}
31
32#[derive(Debug, Deserialize)]
33#[serde(rename_all = "camelCase")]
34pub(crate) struct DevContainerConfiguration {
35 name: Option<String>,
36}
37
38#[derive(Debug, Deserialize)]
39pub(crate) struct DevContainerConfigurationOutput {
40 configuration: DevContainerConfiguration,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum DevContainerError {
45 DockerNotAvailable,
46 DevContainerCliNotAvailable,
47 DevContainerTemplateApplyFailed(String),
48 DevContainerUpFailed(String),
49 DevContainerNotFound,
50 DevContainerParseFailed,
51 NodeRuntimeNotAvailable,
52 NotInValidProject,
53}
54
55impl Display for DevContainerError {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 write!(
58 f,
59 "{}",
60 match self {
61 DevContainerError::DockerNotAvailable =>
62 "Docker CLI not found on $PATH".to_string(),
63 DevContainerError::DevContainerCliNotAvailable =>
64 "Docker not found on path".to_string(),
65 DevContainerError::DevContainerUpFailed(message) => {
66 format!("DevContainer creation failed with error: {}", message)
67 }
68 DevContainerError::DevContainerTemplateApplyFailed(message) => {
69 format!("DevContainer template apply failed with error: {}", message)
70 }
71 DevContainerError::DevContainerNotFound =>
72 "No valid dev container definition found in project".to_string(),
73 DevContainerError::DevContainerParseFailed =>
74 "Failed to parse file .devcontainer/devcontainer.json".to_string(),
75 DevContainerError::NodeRuntimeNotAvailable =>
76 "Cannot find a valid node runtime".to_string(),
77 DevContainerError::NotInValidProject => "Not within a valid project".to_string(),
78 }
79 )
80 }
81}
82
83pub(crate) async fn read_devcontainer_configuration_for_project(
84 cx: &mut AsyncWindowContext,
85 node_runtime: &NodeRuntime,
86) -> Result<DevContainerConfigurationOutput, DevContainerError> {
87 let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?;
88
89 let Some(directory) = project_directory(cx) else {
90 return Err(DevContainerError::NotInValidProject);
91 };
92
93 devcontainer_read_configuration(
94 &path_to_devcontainer_cli,
95 found_in_path,
96 node_runtime,
97 &directory,
98 use_podman(cx),
99 )
100 .await
101}
102
103pub(crate) async fn apply_dev_container_template(
104 template: &DevContainerTemplate,
105 options_selected: &HashMap<String, String>,
106 features_selected: &HashSet<DevContainerFeature>,
107 cx: &mut AsyncWindowContext,
108 node_runtime: &NodeRuntime,
109) -> Result<DevContainerApply, DevContainerError> {
110 let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?;
111
112 let Some(directory) = project_directory(cx) else {
113 return Err(DevContainerError::NotInValidProject);
114 };
115
116 devcontainer_template_apply(
117 template,
118 options_selected,
119 features_selected,
120 &path_to_devcontainer_cli,
121 found_in_path,
122 node_runtime,
123 &directory,
124 false, // devcontainer template apply does not use --docker-path option
125 )
126 .await
127}
128
129fn use_podman(cx: &mut AsyncWindowContext) -> bool {
130 cx.update(|_, cx| DevContainerSettings::get_global(cx).use_podman)
131 .unwrap_or(false)
132}
133
134pub async fn start_dev_container(
135 cx: &mut AsyncWindowContext,
136 node_runtime: NodeRuntime,
137) -> Result<(DevContainerConnection, String), DevContainerError> {
138 let use_podman = use_podman(cx);
139 check_for_docker(use_podman).await?;
140
141 let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?;
142
143 let Some(directory) = project_directory(cx) else {
144 return Err(DevContainerError::NotInValidProject);
145 };
146
147 match devcontainer_up(
148 &path_to_devcontainer_cli,
149 found_in_path,
150 &node_runtime,
151 directory.clone(),
152 use_podman,
153 )
154 .await
155 {
156 Ok(DevContainerUp {
157 container_id,
158 remote_workspace_folder,
159 ..
160 }) => {
161 let project_name = match devcontainer_read_configuration(
162 &path_to_devcontainer_cli,
163 found_in_path,
164 &node_runtime,
165 &directory,
166 use_podman,
167 )
168 .await
169 {
170 Ok(DevContainerConfigurationOutput {
171 configuration:
172 DevContainerConfiguration {
173 name: Some(project_name),
174 },
175 }) => project_name,
176 _ => get_backup_project_name(&remote_workspace_folder, &container_id),
177 };
178
179 let connection = DevContainerConnection {
180 name: project_name,
181 container_id: container_id,
182 use_podman,
183 };
184
185 Ok((connection, remote_workspace_folder))
186 }
187 Err(err) => {
188 let message = format!("Failed with nested error: {}", err);
189 Err(DevContainerError::DevContainerUpFailed(message))
190 }
191 }
192}
193
194#[cfg(not(target_os = "windows"))]
195fn dev_container_cli() -> String {
196 "devcontainer".to_string()
197}
198
199#[cfg(target_os = "windows")]
200fn dev_container_cli() -> String {
201 "devcontainer.cmd".to_string()
202}
203
204async fn check_for_docker(use_podman: bool) -> Result<(), DevContainerError> {
205 let mut command = if use_podman {
206 util::command::new_smol_command("podman")
207 } else {
208 util::command::new_smol_command("docker")
209 };
210 command.arg("--version");
211
212 match command.output().await {
213 Ok(_) => Ok(()),
214 Err(e) => {
215 log::error!("Unable to find docker in $PATH: {:?}", e);
216 Err(DevContainerError::DockerNotAvailable)
217 }
218 }
219}
220
221async fn ensure_devcontainer_cli(
222 node_runtime: &NodeRuntime,
223) -> Result<(PathBuf, bool), DevContainerError> {
224 let mut command = util::command::new_smol_command(&dev_container_cli());
225 command.arg("--version");
226
227 if let Err(e) = command.output().await {
228 log::error!(
229 "Unable to find devcontainer CLI in $PATH. Checking for a zed installed version. Error: {:?}",
230 e
231 );
232
233 let Ok(node_runtime_path) = node_runtime.binary_path().await else {
234 return Err(DevContainerError::NodeRuntimeNotAvailable);
235 };
236
237 let datadir_cli_path = paths::devcontainer_dir()
238 .join("node_modules")
239 .join("@devcontainers")
240 .join("cli")
241 .join(format!("{}.js", &dev_container_cli()));
242
243 log::debug!(
244 "devcontainer not found in path, using local location: ${}",
245 datadir_cli_path.display()
246 );
247
248 let mut command =
249 util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string());
250 command.arg(datadir_cli_path.display().to_string());
251 command.arg("--version");
252
253 match command.output().await {
254 Err(e) => log::error!(
255 "Unable to find devcontainer CLI in Data dir. Will try to install. Error: {:?}",
256 e
257 ),
258 Ok(output) => {
259 if output.status.success() {
260 log::info!("Found devcontainer CLI in Data dir");
261 return Ok((datadir_cli_path.clone(), false));
262 } else {
263 log::error!(
264 "Could not run devcontainer CLI from data_dir. Will try once more to install. Output: {:?}",
265 output
266 );
267 }
268 }
269 }
270
271 if let Err(e) = fs::create_dir_all(paths::devcontainer_dir()).await {
272 log::error!("Unable to create devcontainer directory. Error: {:?}", e);
273 return Err(DevContainerError::DevContainerCliNotAvailable);
274 }
275
276 if let Err(e) = node_runtime
277 .npm_install_packages(
278 &paths::devcontainer_dir(),
279 &[("@devcontainers/cli", "latest")],
280 )
281 .await
282 {
283 log::error!(
284 "Unable to install devcontainer CLI to data directory. Error: {:?}",
285 e
286 );
287 return Err(DevContainerError::DevContainerCliNotAvailable);
288 };
289
290 let mut command =
291 util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string());
292 command.arg(datadir_cli_path.display().to_string());
293 command.arg("--version");
294 if let Err(e) = command.output().await {
295 log::error!(
296 "Unable to find devcontainer cli after NPM install. Error: {:?}",
297 e
298 );
299 Err(DevContainerError::DevContainerCliNotAvailable)
300 } else {
301 Ok((datadir_cli_path, false))
302 }
303 } else {
304 log::info!("Found devcontainer cli on $PATH, using it");
305 Ok((PathBuf::from(&dev_container_cli()), true))
306 }
307}
308
309async fn devcontainer_up(
310 path_to_cli: &PathBuf,
311 found_in_path: bool,
312 node_runtime: &NodeRuntime,
313 path: Arc<Path>,
314 use_podman: bool,
315) -> Result<DevContainerUp, DevContainerError> {
316 let Ok(node_runtime_path) = node_runtime.binary_path().await else {
317 log::error!("Unable to find node runtime path");
318 return Err(DevContainerError::NodeRuntimeNotAvailable);
319 };
320
321 let mut command =
322 devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman);
323 command.arg("up");
324 command.arg("--workspace-folder");
325 command.arg(path.display().to_string());
326
327 log::info!("Running full devcontainer up command: {:?}", command);
328
329 match command.output().await {
330 Ok(output) => {
331 if output.status.success() {
332 let raw = String::from_utf8_lossy(&output.stdout);
333 serde_json::from_str::<DevContainerUp>(&raw).map_err(|e| {
334 log::error!(
335 "Unable to parse response from 'devcontainer up' command, error: {:?}",
336 e
337 );
338 DevContainerError::DevContainerParseFailed
339 })
340 } else {
341 let message = format!(
342 "Non-success status running devcontainer up for workspace: out: {:?}, err: {:?}",
343 String::from_utf8_lossy(&output.stdout),
344 String::from_utf8_lossy(&output.stderr)
345 );
346
347 log::error!("{}", &message);
348 Err(DevContainerError::DevContainerUpFailed(message))
349 }
350 }
351 Err(e) => {
352 let message = format!("Error running devcontainer up: {:?}", e);
353 log::error!("{}", &message);
354 Err(DevContainerError::DevContainerUpFailed(message))
355 }
356 }
357}
358async fn devcontainer_read_configuration(
359 path_to_cli: &PathBuf,
360 found_in_path: bool,
361 node_runtime: &NodeRuntime,
362 path: &Arc<Path>,
363 use_podman: bool,
364) -> Result<DevContainerConfigurationOutput, DevContainerError> {
365 let Ok(node_runtime_path) = node_runtime.binary_path().await else {
366 log::error!("Unable to find node runtime path");
367 return Err(DevContainerError::NodeRuntimeNotAvailable);
368 };
369
370 let mut command =
371 devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman);
372 command.arg("read-configuration");
373 command.arg("--workspace-folder");
374 command.arg(path.display().to_string());
375
376 match command.output().await {
377 Ok(output) => {
378 if output.status.success() {
379 let raw = String::from_utf8_lossy(&output.stdout);
380 serde_json::from_str::<DevContainerConfigurationOutput>(&raw).map_err(|e| {
381 log::error!(
382 "Unable to parse response from 'devcontainer read-configuration' command, error: {:?}",
383 e
384 );
385 DevContainerError::DevContainerParseFailed
386 })
387 } else {
388 let message = format!(
389 "Non-success status running devcontainer read-configuration for workspace: out: {:?}, err: {:?}",
390 String::from_utf8_lossy(&output.stdout),
391 String::from_utf8_lossy(&output.stderr)
392 );
393 log::error!("{}", &message);
394 Err(DevContainerError::DevContainerNotFound)
395 }
396 }
397 Err(e) => {
398 let message = format!("Error running devcontainer read-configuration: {:?}", e);
399 log::error!("{}", &message);
400 Err(DevContainerError::DevContainerNotFound)
401 }
402 }
403}
404
405async fn devcontainer_template_apply(
406 template: &DevContainerTemplate,
407 template_options: &HashMap<String, String>,
408 features_selected: &HashSet<DevContainerFeature>,
409 path_to_cli: &PathBuf,
410 found_in_path: bool,
411 node_runtime: &NodeRuntime,
412 path: &Arc<Path>,
413 use_podman: bool,
414) -> Result<DevContainerApply, DevContainerError> {
415 let Ok(node_runtime_path) = node_runtime.binary_path().await else {
416 log::error!("Unable to find node runtime path");
417 return Err(DevContainerError::NodeRuntimeNotAvailable);
418 };
419
420 let mut command =
421 devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman);
422
423 let Ok(serialized_options) = serde_json::to_string(template_options) else {
424 log::error!("Unable to serialize options for {:?}", template_options);
425 return Err(DevContainerError::DevContainerParseFailed);
426 };
427
428 command.arg("templates");
429 command.arg("apply");
430 command.arg("--workspace-folder");
431 command.arg(path.display().to_string());
432 command.arg("--template-id");
433 command.arg(format!(
434 "{}/{}",
435 template
436 .source_repository
437 .as_ref()
438 .unwrap_or(&String::from("")),
439 template.id
440 ));
441 command.arg("--template-args");
442 command.arg(serialized_options);
443 command.arg("--features");
444 command.arg(template_features_to_json(features_selected));
445
446 log::debug!("Running full devcontainer apply command: {:?}", command);
447
448 match command.output().await {
449 Ok(output) => {
450 if output.status.success() {
451 let raw = String::from_utf8_lossy(&output.stdout);
452 serde_json::from_str::<DevContainerApply>(&raw).map_err(|e| {
453 log::error!(
454 "Unable to parse response from 'devcontainer templates apply' command, error: {:?}",
455 e
456 );
457 DevContainerError::DevContainerParseFailed
458 })
459 } else {
460 let message = format!(
461 "Non-success status running devcontainer templates apply for workspace: out: {:?}, err: {:?}",
462 String::from_utf8_lossy(&output.stdout),
463 String::from_utf8_lossy(&output.stderr)
464 );
465
466 log::error!("{}", &message);
467 Err(DevContainerError::DevContainerTemplateApplyFailed(message))
468 }
469 }
470 Err(e) => {
471 let message = format!("Error running devcontainer templates apply: {:?}", e);
472 log::error!("{}", &message);
473 Err(DevContainerError::DevContainerTemplateApplyFailed(message))
474 }
475 }
476}
477
478fn devcontainer_cli_command(
479 path_to_cli: &PathBuf,
480 found_in_path: bool,
481 node_runtime_path: &PathBuf,
482 use_podman: bool,
483) -> Command {
484 let mut command = if found_in_path {
485 util::command::new_smol_command(path_to_cli.display().to_string())
486 } else {
487 let mut command =
488 util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string());
489 command.arg(path_to_cli.display().to_string());
490 command
491 };
492
493 if use_podman {
494 command.arg("--docker-path");
495 command.arg("podman");
496 }
497 command
498}
499
500fn get_backup_project_name(remote_workspace_folder: &str, container_id: &str) -> String {
501 Path::new(remote_workspace_folder)
502 .file_name()
503 .and_then(|name| name.to_str())
504 .map(|string| string.to_string())
505 .unwrap_or_else(|| container_id.to_string())
506}
507
508fn project_directory(cx: &mut AsyncWindowContext) -> Option<Arc<Path>> {
509 let Some(workspace) = cx.window_handle().downcast::<Workspace>() else {
510 return None;
511 };
512
513 match workspace.update(cx, |workspace, _, cx| {
514 workspace.project().read(cx).active_project_directory(cx)
515 }) {
516 Ok(dir) => dir,
517 Err(e) => {
518 log::error!("Error getting project directory from workspace: {:?}", e);
519 None
520 }
521 }
522}
523
524fn template_features_to_json(features_selected: &HashSet<DevContainerFeature>) -> String {
525 let things = features_selected
526 .iter()
527 .map(|feature| {
528 let mut map = HashMap::new();
529 map.insert(
530 "id",
531 format!(
532 "{}/{}:{}",
533 feature
534 .source_repository
535 .as_ref()
536 .unwrap_or(&String::from("")),
537 feature.id,
538 feature.major_version()
539 ),
540 );
541 map
542 })
543 .collect::<Vec<HashMap<&str, String>>>();
544 serde_json::to_string(&things).unwrap()
545}
546
547#[cfg(test)]
548mod tests {
549 use crate::devcontainer_api::DevContainerUp;
550
551 #[test]
552 fn should_parse_from_devcontainer_json() {
553 let json = r#"{"outcome":"success","containerId":"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/zed"}"#;
554 let up: DevContainerUp = serde_json::from_str(json).unwrap();
555 assert_eq!(up._outcome, "success");
556 assert_eq!(
557 up.container_id,
558 "826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a"
559 );
560 assert_eq!(up._remote_user, "vscode");
561 assert_eq!(up.remote_workspace_folder, "/workspaces/zed");
562 }
563}