devcontainer_api.rs

  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}