dev_container.rs

  1use std::path::{Path, PathBuf};
  2use std::sync::Arc;
  3
  4use gpui::AsyncWindowContext;
  5use node_runtime::NodeRuntime;
  6use serde::Deserialize;
  7use settings::DevContainerConnection;
  8use smol::fs;
  9use workspace::Workspace;
 10
 11use crate::remote_connections::Connection;
 12
 13#[derive(Debug, Deserialize)]
 14#[serde(rename_all = "camelCase")]
 15struct DevContainerUp {
 16    _outcome: String,
 17    container_id: String,
 18    _remote_user: String,
 19    remote_workspace_folder: String,
 20}
 21
 22#[derive(Debug, Deserialize)]
 23#[serde(rename_all = "camelCase")]
 24struct DevContainerConfiguration {
 25    name: Option<String>,
 26}
 27
 28#[derive(Debug, Deserialize)]
 29struct DevContainerConfigurationOutput {
 30    configuration: DevContainerConfiguration,
 31}
 32
 33#[cfg(not(target_os = "windows"))]
 34fn dev_container_cli() -> String {
 35    "devcontainer".to_string()
 36}
 37
 38#[cfg(target_os = "windows")]
 39fn dev_container_cli() -> String {
 40    "devcontainer.cmd".to_string()
 41}
 42
 43async fn check_for_docker() -> Result<(), DevContainerError> {
 44    let mut command = util::command::new_smol_command("docker");
 45    command.arg("--version");
 46
 47    match command.output().await {
 48        Ok(_) => Ok(()),
 49        Err(e) => {
 50            log::error!("Unable to find docker in $PATH: {:?}", e);
 51            Err(DevContainerError::DockerNotAvailable)
 52        }
 53    }
 54}
 55
 56async fn ensure_devcontainer_cli(node_runtime: NodeRuntime) -> Result<PathBuf, DevContainerError> {
 57    let mut command = util::command::new_smol_command(&dev_container_cli());
 58    command.arg("--version");
 59
 60    if let Err(e) = command.output().await {
 61        log::error!(
 62            "Unable to find devcontainer CLI in $PATH. Checking for a zed installed version. Error: {:?}",
 63            e
 64        );
 65
 66        let datadir_cli_path = paths::devcontainer_dir()
 67            .join("node_modules")
 68            .join(".bin")
 69            .join(&dev_container_cli());
 70
 71        let mut command =
 72            util::command::new_smol_command(&datadir_cli_path.as_os_str().display().to_string());
 73        command.arg("--version");
 74
 75        if let Err(e) = command.output().await {
 76            log::error!(
 77                "Unable to find devcontainer CLI in Data dir. Will try to install. Error: {:?}",
 78                e
 79            );
 80        } else {
 81            log::info!("Found devcontainer CLI in Data dir");
 82            return Ok(datadir_cli_path.clone());
 83        }
 84
 85        if let Err(e) = fs::create_dir_all(paths::devcontainer_dir()).await {
 86            log::error!("Unable to create devcontainer directory. Error: {:?}", e);
 87            return Err(DevContainerError::DevContainerCliNotAvailable);
 88        }
 89
 90        if let Err(e) = node_runtime
 91            .npm_install_packages(
 92                &paths::devcontainer_dir(),
 93                &[("@devcontainers/cli", "latest")],
 94            )
 95            .await
 96        {
 97            log::error!(
 98                "Unable to install devcontainer CLI to data directory. Error: {:?}",
 99                e
100            );
101            return Err(DevContainerError::DevContainerCliNotAvailable);
102        };
103
104        let mut command = util::command::new_smol_command(&datadir_cli_path.display().to_string());
105        command.arg("--version");
106        if let Err(e) = command.output().await {
107            log::error!(
108                "Unable to find devcontainer cli after NPM install. Error: {:?}",
109                e
110            );
111            Err(DevContainerError::DevContainerCliNotAvailable)
112        } else {
113            Ok(datadir_cli_path)
114        }
115    } else {
116        log::info!("Found devcontainer cli on $PATH, using it");
117        Ok(PathBuf::from(&dev_container_cli()))
118    }
119}
120
121async fn devcontainer_up(
122    path_to_cli: &PathBuf,
123    path: Arc<Path>,
124) -> Result<DevContainerUp, DevContainerError> {
125    let mut command = util::command::new_smol_command(path_to_cli.display().to_string());
126    command.arg("up");
127    command.arg("--workspace-folder");
128    command.arg(path.display().to_string());
129
130    match command.output().await {
131        Ok(output) => {
132            if output.status.success() {
133                let raw = String::from_utf8_lossy(&output.stdout);
134                serde_json::from_str::<DevContainerUp>(&raw).map_err(|e| {
135                    log::error!(
136                        "Unable to parse response from 'devcontainer up' command, error: {:?}",
137                        e
138                    );
139                    DevContainerError::DevContainerParseFailed
140                })
141            } else {
142                log::error!(
143                    "Non-success status running devcontainer up for workspace: out: {:?}, err: {:?}",
144                    String::from_utf8_lossy(&output.stdout),
145                    String::from_utf8_lossy(&output.stderr)
146                );
147                Err(DevContainerError::DevContainerUpFailed)
148            }
149        }
150        Err(e) => {
151            log::error!("Error running devcontainer up: {:?}", e);
152            Err(DevContainerError::DevContainerUpFailed)
153        }
154    }
155}
156
157async fn devcontainer_read_configuration(
158    path_to_cli: &PathBuf,
159    path: Arc<Path>,
160) -> Result<DevContainerConfigurationOutput, DevContainerError> {
161    let mut command = util::command::new_smol_command(path_to_cli.display().to_string());
162    command.arg("read-configuration");
163    command.arg("--workspace-folder");
164    command.arg(path.display().to_string());
165    match command.output().await {
166        Ok(output) => {
167            if output.status.success() {
168                let raw = String::from_utf8_lossy(&output.stdout);
169                serde_json::from_str::<DevContainerConfigurationOutput>(&raw).map_err(|e| {
170                    log::error!(
171                        "Unable to parse response from 'devcontainer read-configuration' command, error: {:?}",
172                        e
173                    );
174                    DevContainerError::DevContainerParseFailed
175                })
176            } else {
177                log::error!(
178                    "Non-success status running devcontainer read-configuration for workspace: out: {:?}, err: {:?}",
179                    String::from_utf8_lossy(&output.stdout),
180                    String::from_utf8_lossy(&output.stderr)
181                );
182                Err(DevContainerError::DevContainerUpFailed)
183            }
184        }
185        Err(e) => {
186            log::error!("Error running devcontainer read-configuration: {:?}", e);
187            Err(DevContainerError::DevContainerUpFailed)
188        }
189    }
190}
191
192// Name the project with two fallbacks
193async fn get_project_name(
194    path_to_cli: &PathBuf,
195    path: Arc<Path>,
196    remote_workspace_folder: String,
197    container_id: String,
198) -> Result<String, DevContainerError> {
199    if let Ok(dev_container_configuration) =
200        devcontainer_read_configuration(path_to_cli, path).await
201        && let Some(name) = dev_container_configuration.configuration.name
202    {
203        // Ideally, name the project after the name defined in devcontainer.json
204        Ok(name)
205    } else {
206        // Otherwise, name the project after the remote workspace folder name
207        Ok(Path::new(&remote_workspace_folder)
208            .file_name()
209            .and_then(|name| name.to_str())
210            .map(|string| string.into())
211            // Finally, name the project after the container ID as a last resort
212            .unwrap_or_else(|| container_id.clone()))
213    }
214}
215
216fn project_directory(cx: &mut AsyncWindowContext) -> Option<Arc<Path>> {
217    let Some(workspace) = cx.window_handle().downcast::<Workspace>() else {
218        return None;
219    };
220
221    match workspace.update(cx, |workspace, _, cx| {
222        workspace.project().read(cx).active_project_directory(cx)
223    }) {
224        Ok(dir) => dir,
225        Err(e) => {
226            log::error!("Error getting project directory from workspace: {:?}", e);
227            None
228        }
229    }
230}
231
232pub(crate) async fn start_dev_container(
233    cx: &mut AsyncWindowContext,
234    node_runtime: NodeRuntime,
235) -> Result<(Connection, String), DevContainerError> {
236    check_for_docker().await?;
237
238    let path_to_devcontainer_cli = ensure_devcontainer_cli(node_runtime).await?;
239
240    let Some(directory) = project_directory(cx) else {
241        return Err(DevContainerError::DevContainerNotFound);
242    };
243
244    if let Ok(DevContainerUp {
245        container_id,
246        remote_workspace_folder,
247        ..
248    }) = devcontainer_up(&path_to_devcontainer_cli, directory.clone()).await
249    {
250        let project_name = get_project_name(
251            &path_to_devcontainer_cli,
252            directory,
253            remote_workspace_folder.clone(),
254            container_id.clone(),
255        )
256        .await?;
257
258        let connection = Connection::DevContainer(DevContainerConnection {
259            name: project_name.into(),
260            container_id: container_id.into(),
261        });
262
263        Ok((connection, remote_workspace_folder))
264    } else {
265        Err(DevContainerError::DevContainerUpFailed)
266    }
267}
268
269#[derive(Debug)]
270pub(crate) enum DevContainerError {
271    DockerNotAvailable,
272    DevContainerCliNotAvailable,
273    DevContainerUpFailed,
274    DevContainerNotFound,
275    DevContainerParseFailed,
276}
277
278#[cfg(test)]
279mod test {
280
281    use crate::dev_container::DevContainerUp;
282
283    #[test]
284    fn should_parse_from_devcontainer_json() {
285        let json = r#"{"outcome":"success","containerId":"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/zed"}"#;
286        let up: DevContainerUp = serde_json::from_str(json).unwrap();
287        assert_eq!(up._outcome, "success");
288        assert_eq!(
289            up.container_id,
290            "826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a"
291        );
292        assert_eq!(up._remote_user, "vscode");
293        assert_eq!(up.remote_workspace_folder, "/workspaces/zed");
294    }
295}