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(
 57    node_runtime: &NodeRuntime,
 58) -> Result<(PathBuf, bool), DevContainerError> {
 59    let mut command = util::command::new_smol_command(&dev_container_cli());
 60    command.arg("--version");
 61
 62    if let Err(e) = command.output().await {
 63        log::error!(
 64            "Unable to find devcontainer CLI in $PATH. Checking for a zed installed version. Error: {:?}",
 65            e
 66        );
 67
 68        let Ok(node_runtime_path) = node_runtime.binary_path().await else {
 69            return Err(DevContainerError::NodeRuntimeNotAvailable);
 70        };
 71
 72        let datadir_cli_path = paths::devcontainer_dir()
 73            .join("node_modules")
 74            .join("@devcontainers")
 75            .join("cli")
 76            .join(format!("{}.js", &dev_container_cli()));
 77
 78        log::debug!(
 79            "devcontainer not found in path, using local location: ${}",
 80            datadir_cli_path.display()
 81        );
 82
 83        let mut command =
 84            util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string());
 85        command.arg(datadir_cli_path.display().to_string());
 86        command.arg("--version");
 87
 88        match command.output().await {
 89            Err(e) => log::error!(
 90                "Unable to find devcontainer CLI in Data dir. Will try to install. Error: {:?}",
 91                e
 92            ),
 93            Ok(output) => {
 94                if output.status.success() {
 95                    log::info!("Found devcontainer CLI in Data dir");
 96                    return Ok((datadir_cli_path.clone(), false));
 97                } else {
 98                    log::error!(
 99                        "Could not run devcontainer CLI from data_dir. Will try once more to install. Output: {:?}",
100                        output
101                    );
102                }
103            }
104        }
105
106        if let Err(e) = fs::create_dir_all(paths::devcontainer_dir()).await {
107            log::error!("Unable to create devcontainer directory. Error: {:?}", e);
108            return Err(DevContainerError::DevContainerCliNotAvailable);
109        }
110
111        if let Err(e) = node_runtime
112            .npm_install_packages(
113                &paths::devcontainer_dir(),
114                &[("@devcontainers/cli", "latest")],
115            )
116            .await
117        {
118            log::error!(
119                "Unable to install devcontainer CLI to data directory. Error: {:?}",
120                e
121            );
122            return Err(DevContainerError::DevContainerCliNotAvailable);
123        };
124
125        let mut command =
126            util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string());
127        command.arg(datadir_cli_path.display().to_string());
128        command.arg("--version");
129        if let Err(e) = command.output().await {
130            log::error!(
131                "Unable to find devcontainer cli after NPM install. Error: {:?}",
132                e
133            );
134            Err(DevContainerError::DevContainerCliNotAvailable)
135        } else {
136            Ok((datadir_cli_path, false))
137        }
138    } else {
139        log::info!("Found devcontainer cli on $PATH, using it");
140        Ok((PathBuf::from(&dev_container_cli()), true))
141    }
142}
143
144async fn devcontainer_up(
145    path_to_cli: &PathBuf,
146    found_in_path: bool,
147    node_runtime: &NodeRuntime,
148    path: Arc<Path>,
149) -> Result<DevContainerUp, DevContainerError> {
150    let Ok(node_runtime_path) = node_runtime.binary_path().await else {
151        log::error!("Unable to find node runtime path");
152        return Err(DevContainerError::NodeRuntimeNotAvailable);
153    };
154
155    let mut command = if found_in_path {
156        let mut command = util::command::new_smol_command(path_to_cli.display().to_string());
157        command.arg("up");
158        command.arg("--workspace-folder");
159        command.arg(path.display().to_string());
160        command
161    } else {
162        let mut command =
163            util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string());
164        command.arg(path_to_cli.display().to_string());
165        command.arg("up");
166        command.arg("--workspace-folder");
167        command.arg(path.display().to_string());
168        command
169    };
170
171    log::debug!("Running full devcontainer up command: {:?}", command);
172
173    match command.output().await {
174        Ok(output) => {
175            if output.status.success() {
176                let raw = String::from_utf8_lossy(&output.stdout);
177                serde_json::from_str::<DevContainerUp>(&raw).map_err(|e| {
178                    log::error!(
179                        "Unable to parse response from 'devcontainer up' command, error: {:?}",
180                        e
181                    );
182                    DevContainerError::DevContainerParseFailed
183                })
184            } else {
185                log::error!(
186                    "Non-success status running devcontainer up for workspace: out: {:?}, err: {:?}",
187                    String::from_utf8_lossy(&output.stdout),
188                    String::from_utf8_lossy(&output.stderr)
189                );
190                Err(DevContainerError::DevContainerUpFailed)
191            }
192        }
193        Err(e) => {
194            log::error!("Error running devcontainer up: {:?}", e);
195            Err(DevContainerError::DevContainerUpFailed)
196        }
197    }
198}
199
200async fn devcontainer_read_configuration(
201    path_to_cli: &PathBuf,
202    path: Arc<Path>,
203) -> Result<DevContainerConfigurationOutput, DevContainerError> {
204    let mut command = util::command::new_smol_command(path_to_cli.display().to_string());
205    command.arg("read-configuration");
206    command.arg("--workspace-folder");
207    command.arg(path.display().to_string());
208    match command.output().await {
209        Ok(output) => {
210            if output.status.success() {
211                let raw = String::from_utf8_lossy(&output.stdout);
212                serde_json::from_str::<DevContainerConfigurationOutput>(&raw).map_err(|e| {
213                    log::error!(
214                        "Unable to parse response from 'devcontainer read-configuration' command, error: {:?}",
215                        e
216                    );
217                    DevContainerError::DevContainerParseFailed
218                })
219            } else {
220                log::error!(
221                    "Non-success status running devcontainer read-configuration for workspace: out: {:?}, err: {:?}",
222                    String::from_utf8_lossy(&output.stdout),
223                    String::from_utf8_lossy(&output.stderr)
224                );
225                Err(DevContainerError::DevContainerUpFailed)
226            }
227        }
228        Err(e) => {
229            log::error!("Error running devcontainer read-configuration: {:?}", e);
230            Err(DevContainerError::DevContainerUpFailed)
231        }
232    }
233}
234
235// Name the project with two fallbacks
236async fn get_project_name(
237    path_to_cli: &PathBuf,
238    path: Arc<Path>,
239    remote_workspace_folder: String,
240    container_id: String,
241) -> Result<String, DevContainerError> {
242    if let Ok(dev_container_configuration) =
243        devcontainer_read_configuration(path_to_cli, path).await
244        && let Some(name) = dev_container_configuration.configuration.name
245    {
246        // Ideally, name the project after the name defined in devcontainer.json
247        Ok(name)
248    } else {
249        // Otherwise, name the project after the remote workspace folder name
250        Ok(Path::new(&remote_workspace_folder)
251            .file_name()
252            .and_then(|name| name.to_str())
253            .map(|string| string.into())
254            // Finally, name the project after the container ID as a last resort
255            .unwrap_or_else(|| container_id.clone()))
256    }
257}
258
259fn project_directory(cx: &mut AsyncWindowContext) -> Option<Arc<Path>> {
260    let Some(workspace) = cx.window_handle().downcast::<Workspace>() else {
261        return None;
262    };
263
264    match workspace.update(cx, |workspace, _, cx| {
265        workspace.project().read(cx).active_project_directory(cx)
266    }) {
267        Ok(dir) => dir,
268        Err(e) => {
269            log::error!("Error getting project directory from workspace: {:?}", e);
270            None
271        }
272    }
273}
274
275pub(crate) async fn start_dev_container(
276    cx: &mut AsyncWindowContext,
277    node_runtime: NodeRuntime,
278) -> Result<(Connection, String), DevContainerError> {
279    check_for_docker().await?;
280
281    let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?;
282
283    let Some(directory) = project_directory(cx) else {
284        return Err(DevContainerError::DevContainerNotFound);
285    };
286
287    if let Ok(DevContainerUp {
288        container_id,
289        remote_workspace_folder,
290        ..
291    }) = devcontainer_up(
292        &path_to_devcontainer_cli,
293        found_in_path,
294        &node_runtime,
295        directory.clone(),
296    )
297    .await
298    {
299        let project_name = get_project_name(
300            &path_to_devcontainer_cli,
301            directory,
302            remote_workspace_folder.clone(),
303            container_id.clone(),
304        )
305        .await?;
306
307        let connection = Connection::DevContainer(DevContainerConnection {
308            name: project_name.into(),
309            container_id: container_id.into(),
310        });
311
312        Ok((connection, remote_workspace_folder))
313    } else {
314        Err(DevContainerError::DevContainerUpFailed)
315    }
316}
317
318#[derive(Debug)]
319pub(crate) enum DevContainerError {
320    DockerNotAvailable,
321    DevContainerCliNotAvailable,
322    DevContainerUpFailed,
323    DevContainerNotFound,
324    DevContainerParseFailed,
325    NodeRuntimeNotAvailable,
326}
327
328#[cfg(test)]
329mod test {
330
331    use crate::dev_container::DevContainerUp;
332
333    #[test]
334    fn should_parse_from_devcontainer_json() {
335        let json = r#"{"outcome":"success","containerId":"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/zed"}"#;
336        let up: DevContainerUp = serde_json::from_str(json).unwrap();
337        assert_eq!(up._outcome, "success");
338        assert_eq!(
339            up.container_id,
340            "826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a"
341        );
342        assert_eq!(up._remote_user, "vscode");
343        assert_eq!(up.remote_workspace_folder, "/workspaces/zed");
344    }
345}