dev_container.rs

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