devcontainer_api.rs

  1use std::{
  2    collections::{HashMap, HashSet},
  3    fmt::Display,
  4    path::{Path, PathBuf},
  5    sync::Arc,
  6};
  7
  8use futures::TryFutureExt;
  9use gpui::{AsyncWindowContext, Entity};
 10use project::Worktree;
 11use serde::Deserialize;
 12use settings::{DevContainerConnection, infer_json_indent_size, replace_value_in_json_text};
 13use util::rel_path::RelPath;
 14use walkdir::WalkDir;
 15use workspace::Workspace;
 16use worktree::Snapshot;
 17
 18use crate::{
 19    DevContainerContext, DevContainerFeature, DevContainerTemplate,
 20    devcontainer_json::DevContainer,
 21    devcontainer_manifest::{read_devcontainer_configuration, spawn_dev_container},
 22    devcontainer_templates_repository, get_latest_oci_manifest, get_oci_token, ghcr_registry,
 23    oci::download_oci_tarball,
 24};
 25
 26/// Represents a discovered devcontainer configuration
 27#[derive(Debug, Clone, PartialEq, Eq)]
 28pub struct DevContainerConfig {
 29    /// Display name for the configuration (subfolder name or "default")
 30    pub name: String,
 31    /// Relative path to the devcontainer.json file from the project root
 32    pub config_path: PathBuf,
 33}
 34
 35impl DevContainerConfig {
 36    pub fn default_config() -> Self {
 37        Self {
 38            name: "default".to_string(),
 39            config_path: PathBuf::from(".devcontainer/devcontainer.json"),
 40        }
 41    }
 42
 43    pub fn root_config() -> Self {
 44        Self {
 45            name: "root".to_string(),
 46            config_path: PathBuf::from(".devcontainer.json"),
 47        }
 48    }
 49}
 50
 51#[derive(Debug, Deserialize)]
 52#[serde(rename_all = "camelCase")]
 53pub(crate) struct DevContainerUp {
 54    pub(crate) container_id: String,
 55    pub(crate) remote_user: String,
 56    pub(crate) remote_workspace_folder: String,
 57    #[serde(default)]
 58    pub(crate) extension_ids: Vec<String>,
 59    #[serde(default)]
 60    pub(crate) remote_env: HashMap<String, String>,
 61}
 62
 63#[derive(Debug)]
 64pub(crate) struct DevContainerApply {
 65    pub(crate) project_files: Vec<Arc<RelPath>>,
 66}
 67
 68#[derive(Debug, Clone, PartialEq, Eq)]
 69pub enum DevContainerError {
 70    CommandFailed(String),
 71    DockerNotAvailable,
 72    ContainerNotValid(String),
 73    DevContainerTemplateApplyFailed(String),
 74    DevContainerScriptsFailed,
 75    DevContainerUpFailed(String),
 76    DevContainerNotFound,
 77    DevContainerParseFailed,
 78    FilesystemError,
 79    ResourceFetchFailed,
 80    NotInValidProject,
 81}
 82
 83impl Display for DevContainerError {
 84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 85        write!(
 86            f,
 87            "{}",
 88            match self {
 89                DevContainerError::DockerNotAvailable =>
 90                    "docker CLI not found on $PATH".to_string(),
 91                DevContainerError::ContainerNotValid(id) => format!(
 92                    "docker image {id} did not have expected configuration for a dev container"
 93                ),
 94                DevContainerError::DevContainerScriptsFailed =>
 95                    "lifecycle scripts could not execute for dev container".to_string(),
 96                DevContainerError::DevContainerUpFailed(_) => {
 97                    "DevContainer creation failed".to_string()
 98                }
 99                DevContainerError::DevContainerTemplateApplyFailed(_) => {
100                    "DevContainer template apply failed".to_string()
101                }
102                DevContainerError::DevContainerNotFound =>
103                    "No valid dev container definition found in project".to_string(),
104                DevContainerError::DevContainerParseFailed =>
105                    "Failed to parse file .devcontainer/devcontainer.json".to_string(),
106                DevContainerError::NotInValidProject => "Not within a valid project".to_string(),
107                DevContainerError::CommandFailed(program) =>
108                    format!("Failure running external program {program}"),
109                DevContainerError::FilesystemError =>
110                    "Error downloading resources locally".to_string(),
111                DevContainerError::ResourceFetchFailed =>
112                    "Failed to fetch resources from template or feature repository".to_string(),
113            }
114        )
115    }
116}
117
118pub(crate) async fn read_default_devcontainer_configuration(
119    cx: &DevContainerContext,
120    environment: HashMap<String, String>,
121) -> Result<DevContainer, DevContainerError> {
122    let default_config = DevContainerConfig::default_config();
123
124    read_devcontainer_configuration(default_config, cx, environment)
125        .await
126        .map_err(|e| {
127            log::error!("Default configuration not found: {:?}", e);
128            DevContainerError::DevContainerNotFound
129        })
130}
131
132/// Finds all available devcontainer configurations in the project.
133///
134/// See [`find_configs_in_snapshot`] for the locations that are scanned.
135pub fn find_devcontainer_configs(workspace: &Workspace, cx: &gpui::App) -> Vec<DevContainerConfig> {
136    let project = workspace.project().read(cx);
137
138    let worktree = project
139        .visible_worktrees(cx)
140        .find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree));
141
142    let Some(worktree) = worktree else {
143        log::debug!("find_devcontainer_configs: No worktree found");
144        return Vec::new();
145    };
146
147    let worktree = worktree.read(cx);
148    find_configs_in_snapshot(worktree)
149}
150
151/// Scans a worktree snapshot for devcontainer configurations.
152///
153/// Scans for configurations in these locations:
154/// 1. `.devcontainer/devcontainer.json` (the default location)
155/// 2. `.devcontainer.json` in the project root
156/// 3. `.devcontainer/<subfolder>/devcontainer.json` (named configurations)
157///
158/// All found configurations are returned so the user can pick between them.
159pub fn find_configs_in_snapshot(snapshot: &Snapshot) -> Vec<DevContainerConfig> {
160    let mut configs = Vec::new();
161
162    let devcontainer_dir_path = RelPath::unix(".devcontainer").expect("valid path");
163
164    if let Some(devcontainer_entry) = snapshot.entry_for_path(devcontainer_dir_path) {
165        if devcontainer_entry.is_dir() {
166            log::debug!("find_configs_in_snapshot: Scanning .devcontainer directory");
167            let devcontainer_json_path =
168                RelPath::unix(".devcontainer/devcontainer.json").expect("valid path");
169            for entry in snapshot.child_entries(devcontainer_dir_path) {
170                log::debug!(
171                    "find_configs_in_snapshot: Found entry: {:?}, is_file: {}, is_dir: {}",
172                    entry.path.as_unix_str(),
173                    entry.is_file(),
174                    entry.is_dir()
175                );
176
177                if entry.is_file() && entry.path.as_ref() == devcontainer_json_path {
178                    log::debug!("find_configs_in_snapshot: Found default devcontainer.json");
179                    configs.push(DevContainerConfig::default_config());
180                } else if entry.is_dir() {
181                    let subfolder_name = entry
182                        .path
183                        .file_name()
184                        .map(|n| n.to_string())
185                        .unwrap_or_default();
186
187                    let config_json_path =
188                        format!("{}/devcontainer.json", entry.path.as_unix_str());
189                    if let Ok(rel_config_path) = RelPath::unix(&config_json_path) {
190                        if snapshot.entry_for_path(rel_config_path).is_some() {
191                            log::debug!(
192                                "find_configs_in_snapshot: Found config in subfolder: {}",
193                                subfolder_name
194                            );
195                            configs.push(DevContainerConfig {
196                                name: subfolder_name,
197                                config_path: PathBuf::from(&config_json_path),
198                            });
199                        } else {
200                            log::debug!(
201                                "find_configs_in_snapshot: Subfolder {} has no devcontainer.json",
202                                subfolder_name
203                            );
204                        }
205                    }
206                }
207            }
208        }
209    }
210
211    // Always include `.devcontainer.json` so the user can pick it from the UI
212    // even when `.devcontainer/devcontainer.json` also exists.
213    let root_config_path = RelPath::unix(".devcontainer.json").expect("valid path");
214    if snapshot
215        .entry_for_path(root_config_path)
216        .is_some_and(|entry| entry.is_file())
217    {
218        log::debug!("find_configs_in_snapshot: Found .devcontainer.json in project root");
219        configs.push(DevContainerConfig::root_config());
220    }
221
222    log::info!(
223        "find_configs_in_snapshot: Found {} configurations",
224        configs.len()
225    );
226
227    configs.sort_by(|a, b| {
228        let a_is_primary = a.name == "default" || a.name == "root";
229        let b_is_primary = b.name == "default" || b.name == "root";
230        match (a_is_primary, b_is_primary) {
231            (true, false) => std::cmp::Ordering::Less,
232            (false, true) => std::cmp::Ordering::Greater,
233            _ => a.name.cmp(&b.name),
234        }
235    });
236
237    configs
238}
239
240pub async fn start_dev_container_with_config(
241    context: DevContainerContext,
242    config: Option<DevContainerConfig>,
243    environment: HashMap<String, String>,
244) -> Result<(DevContainerConnection, String), DevContainerError> {
245    check_for_docker(context.use_podman).await?;
246
247    let Some(actual_config) = config.clone() else {
248        return Err(DevContainerError::NotInValidProject);
249    };
250
251    match spawn_dev_container(
252        &context,
253        environment.clone(),
254        actual_config.clone(),
255        context.project_directory.clone().as_ref(),
256    )
257    .await
258    {
259        Ok(DevContainerUp {
260            container_id,
261            remote_workspace_folder,
262            remote_user,
263            extension_ids,
264            remote_env,
265            ..
266        }) => {
267            let project_name =
268                match read_devcontainer_configuration(actual_config, &context, environment).await {
269                    Ok(DevContainer {
270                        name: Some(name), ..
271                    }) => name,
272                    _ => get_backup_project_name(&remote_workspace_folder, &container_id),
273                };
274
275            let connection = DevContainerConnection {
276                name: project_name,
277                container_id,
278                use_podman: context.use_podman,
279                remote_user,
280                extension_ids,
281                remote_env: remote_env.into_iter().collect(),
282            };
283
284            Ok((connection, remote_workspace_folder))
285        }
286        Err(err) => {
287            let message = format!("Failed with nested error: {:?}", err);
288            Err(DevContainerError::DevContainerUpFailed(message))
289        }
290    }
291}
292
293async fn check_for_docker(use_podman: bool) -> Result<(), DevContainerError> {
294    let mut command = if use_podman {
295        util::command::new_command("podman")
296    } else {
297        util::command::new_command("docker")
298    };
299    command.arg("--version");
300
301    match command.output().await {
302        Ok(_) => Ok(()),
303        Err(e) => {
304            log::error!("Unable to find docker in $PATH: {:?}", e);
305            Err(DevContainerError::DockerNotAvailable)
306        }
307    }
308}
309
310pub(crate) async fn apply_devcontainer_template(
311    worktree: Entity<Worktree>,
312    template: &DevContainerTemplate,
313    template_options: &HashMap<String, String>,
314    features_selected: &HashSet<DevContainerFeature>,
315    context: &DevContainerContext,
316    cx: &mut AsyncWindowContext,
317) -> Result<DevContainerApply, DevContainerError> {
318    let token = get_oci_token(
319        ghcr_registry(),
320        devcontainer_templates_repository(),
321        &context.http_client,
322    )
323    .map_err(|e| {
324        log::error!("Failed to get OCI auth token: {e}");
325        DevContainerError::ResourceFetchFailed
326    })
327    .await?;
328    let manifest = get_latest_oci_manifest(
329        &token.token,
330        ghcr_registry(),
331        devcontainer_templates_repository(),
332        &context.http_client,
333        Some(&template.id),
334    )
335    .map_err(|e| {
336        log::error!("Failed to fetch template from OCI repository: {e}");
337        DevContainerError::ResourceFetchFailed
338    })
339    .await?;
340
341    let layer = &manifest.layers.get(0).ok_or_else(|| {
342        log::error!("Given manifest has no layers to query for blob. Aborting");
343        DevContainerError::ResourceFetchFailed
344    })?;
345
346    let timestamp = std::time::SystemTime::now()
347        .duration_since(std::time::UNIX_EPOCH)
348        .map(|d| d.as_millis())
349        .unwrap_or(0);
350    let extract_dir = std::env::temp_dir()
351        .join(&template.id)
352        .join(format!("extracted-{timestamp}"));
353
354    context.fs.create_dir(&extract_dir).await.map_err(|e| {
355        log::error!("Could not create temporary directory: {e}");
356        DevContainerError::FilesystemError
357    })?;
358
359    download_oci_tarball(
360        &token.token,
361        ghcr_registry(),
362        devcontainer_templates_repository(),
363        &layer.digest,
364        "application/vnd.oci.image.manifest.v1+json",
365        &extract_dir,
366        &context.http_client,
367        &context.fs,
368        Some(&template.id),
369    )
370    .map_err(|e| {
371        log::error!("Error downloading tarball: {:?}", e);
372        DevContainerError::ResourceFetchFailed
373    })
374    .await?;
375
376    let downloaded_devcontainer_folder = &extract_dir.join(".devcontainer/");
377    let mut project_files = Vec::new();
378    for entry in WalkDir::new(downloaded_devcontainer_folder) {
379        let Ok(entry) = entry else {
380            continue;
381        };
382        if !entry.file_type().is_file() {
383            continue;
384        }
385        let relative_path = entry.path().strip_prefix(&extract_dir).map_err(|e| {
386            log::error!("Can't create relative path: {e}");
387            DevContainerError::FilesystemError
388        })?;
389        let rel_path = RelPath::unix(relative_path)
390            .map_err(|e| {
391                log::error!("Can't create relative path: {e}");
392                DevContainerError::FilesystemError
393            })?
394            .into_arc();
395        let content = context.fs.load(entry.path()).await.map_err(|e| {
396            log::error!("Unable to read file: {e}");
397            DevContainerError::FilesystemError
398        })?;
399
400        let mut content = expand_template_options(content, template_options);
401        if let Some("devcontainer.json") = &rel_path.file_name() {
402            content = insert_features_into_devcontainer_json(&content, features_selected)
403        }
404        worktree
405            .update(cx, |worktree, cx| {
406                worktree.create_entry(rel_path.clone(), false, Some(content.into_bytes()), cx)
407            })
408            .await
409            .map_err(|e| {
410                log::error!("Unable to create entry in worktree: {e}");
411                DevContainerError::NotInValidProject
412            })?;
413        project_files.push(rel_path);
414    }
415
416    Ok(DevContainerApply { project_files })
417}
418
419fn insert_features_into_devcontainer_json(
420    content: &str,
421    features: &HashSet<DevContainerFeature>,
422) -> String {
423    if features.is_empty() {
424        return content.to_string();
425    }
426
427    let features_value: serde_json::Value = features
428        .iter()
429        .map(|f| {
430            let key = format!(
431                "{}/{}:{}",
432                f.source_repository.as_deref().unwrap_or(""),
433                f.id,
434                f.major_version()
435            );
436            (key, serde_json::Value::Object(Default::default()))
437        })
438        .collect::<serde_json::Map<String, serde_json::Value>>()
439        .into();
440
441    let tab_size = infer_json_indent_size(content);
442    let (range, replacement) = replace_value_in_json_text(
443        content,
444        &["features"],
445        tab_size,
446        Some(&features_value),
447        None,
448    );
449
450    let mut result = content.to_string();
451    result.replace_range(range, &replacement);
452    result
453}
454
455fn expand_template_options(content: String, template_options: &HashMap<String, String>) -> String {
456    let mut replaced_content = content;
457    for (key, val) in template_options {
458        replaced_content = replaced_content.replace(&format!("${{templateOption:{key}}}"), val)
459    }
460    replaced_content
461}
462
463fn get_backup_project_name(remote_workspace_folder: &str, container_id: &str) -> String {
464    Path::new(remote_workspace_folder)
465        .file_name()
466        .and_then(|name| name.to_str())
467        .map(|string| string.to_string())
468        .unwrap_or_else(|| container_id.to_string())
469}
470
471#[cfg(test)]
472mod tests {
473    use std::path::PathBuf;
474
475    use crate::devcontainer_api::{DevContainerConfig, find_configs_in_snapshot};
476    use fs::FakeFs;
477    use gpui::TestAppContext;
478    use project::Project;
479    use serde_json::json;
480    use settings::SettingsStore;
481    use util::path;
482
483    fn init_test(cx: &mut TestAppContext) {
484        cx.update(|cx| {
485            let settings_store = SettingsStore::test(cx);
486            cx.set_global(settings_store);
487        });
488    }
489
490    #[gpui::test]
491    async fn test_find_configs_root_devcontainer_json(cx: &mut TestAppContext) {
492        init_test(cx);
493        let fs = FakeFs::new(cx.executor());
494        fs.insert_tree(
495            path!("/project"),
496            json!({
497                ".devcontainer.json": "{}"
498            }),
499        )
500        .await;
501
502        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
503        cx.run_until_parked();
504
505        let configs = project.read_with(cx, |project, cx| {
506            let worktree = project
507                .visible_worktrees(cx)
508                .next()
509                .expect("should have a worktree");
510            find_configs_in_snapshot(worktree.read(cx))
511        });
512
513        assert_eq!(configs.len(), 1);
514        assert_eq!(configs[0].name, "root");
515        assert_eq!(configs[0].config_path, PathBuf::from(".devcontainer.json"));
516    }
517
518    #[gpui::test]
519    async fn test_find_configs_default_devcontainer_dir(cx: &mut TestAppContext) {
520        init_test(cx);
521        let fs = FakeFs::new(cx.executor());
522        fs.insert_tree(
523            path!("/project"),
524            json!({
525                ".devcontainer": {
526                    "devcontainer.json": "{}"
527                }
528            }),
529        )
530        .await;
531
532        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
533        cx.run_until_parked();
534
535        let configs = project.read_with(cx, |project, cx| {
536            let worktree = project
537                .visible_worktrees(cx)
538                .next()
539                .expect("should have a worktree");
540            find_configs_in_snapshot(worktree.read(cx))
541        });
542
543        assert_eq!(configs.len(), 1);
544        assert_eq!(configs[0], DevContainerConfig::default_config());
545    }
546
547    #[gpui::test]
548    async fn test_find_configs_dir_and_root_both_included(cx: &mut TestAppContext) {
549        init_test(cx);
550        let fs = FakeFs::new(cx.executor());
551        fs.insert_tree(
552            path!("/project"),
553            json!({
554                ".devcontainer.json": "{}",
555                ".devcontainer": {
556                    "devcontainer.json": "{}"
557                }
558            }),
559        )
560        .await;
561
562        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
563        cx.run_until_parked();
564
565        let configs = project.read_with(cx, |project, cx| {
566            let worktree = project
567                .visible_worktrees(cx)
568                .next()
569                .expect("should have a worktree");
570            find_configs_in_snapshot(worktree.read(cx))
571        });
572
573        assert_eq!(configs.len(), 2);
574        assert_eq!(configs[0], DevContainerConfig::default_config());
575        assert_eq!(configs[1], DevContainerConfig::root_config());
576    }
577
578    #[gpui::test]
579    async fn test_find_configs_subfolder_configs(cx: &mut TestAppContext) {
580        init_test(cx);
581        let fs = FakeFs::new(cx.executor());
582        fs.insert_tree(
583            path!("/project"),
584            json!({
585                ".devcontainer": {
586                    "rust": {
587                        "devcontainer.json": "{}"
588                    },
589                    "python": {
590                        "devcontainer.json": "{}"
591                    }
592                }
593            }),
594        )
595        .await;
596
597        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
598        cx.run_until_parked();
599
600        let configs = project.read_with(cx, |project, cx| {
601            let worktree = project
602                .visible_worktrees(cx)
603                .next()
604                .expect("should have a worktree");
605            find_configs_in_snapshot(worktree.read(cx))
606        });
607
608        assert_eq!(configs.len(), 2);
609        let names: Vec<&str> = configs.iter().map(|c| c.name.as_str()).collect();
610        assert!(names.contains(&"python"));
611        assert!(names.contains(&"rust"));
612    }
613
614    #[gpui::test]
615    async fn test_find_configs_default_and_subfolder(cx: &mut TestAppContext) {
616        init_test(cx);
617        let fs = FakeFs::new(cx.executor());
618        fs.insert_tree(
619            path!("/project"),
620            json!({
621                ".devcontainer": {
622                    "devcontainer.json": "{}",
623                    "gpu": {
624                        "devcontainer.json": "{}"
625                    }
626                }
627            }),
628        )
629        .await;
630
631        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
632        cx.run_until_parked();
633
634        let configs = project.read_with(cx, |project, cx| {
635            let worktree = project
636                .visible_worktrees(cx)
637                .next()
638                .expect("should have a worktree");
639            find_configs_in_snapshot(worktree.read(cx))
640        });
641
642        assert_eq!(configs.len(), 2);
643        assert_eq!(configs[0].name, "default");
644        assert_eq!(configs[1].name, "gpu");
645    }
646
647    #[gpui::test]
648    async fn test_find_configs_no_devcontainer(cx: &mut TestAppContext) {
649        init_test(cx);
650        let fs = FakeFs::new(cx.executor());
651        fs.insert_tree(
652            path!("/project"),
653            json!({
654                "src": {
655                    "main.rs": "fn main() {}"
656                }
657            }),
658        )
659        .await;
660
661        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
662        cx.run_until_parked();
663
664        let configs = project.read_with(cx, |project, cx| {
665            let worktree = project
666                .visible_worktrees(cx)
667                .next()
668                .expect("should have a worktree");
669            find_configs_in_snapshot(worktree.read(cx))
670        });
671
672        assert!(configs.is_empty());
673    }
674
675    #[gpui::test]
676    async fn test_find_configs_root_json_and_subfolder_configs(cx: &mut TestAppContext) {
677        init_test(cx);
678        let fs = FakeFs::new(cx.executor());
679        fs.insert_tree(
680            path!("/project"),
681            json!({
682                ".devcontainer.json": "{}",
683                ".devcontainer": {
684                    "rust": {
685                        "devcontainer.json": "{}"
686                    }
687                }
688            }),
689        )
690        .await;
691
692        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
693        cx.run_until_parked();
694
695        let configs = project.read_with(cx, |project, cx| {
696            let worktree = project
697                .visible_worktrees(cx)
698                .next()
699                .expect("should have a worktree");
700            find_configs_in_snapshot(worktree.read(cx))
701        });
702
703        assert_eq!(configs.len(), 2);
704        assert_eq!(configs[0].name, "root");
705        assert_eq!(configs[0].config_path, PathBuf::from(".devcontainer.json"));
706        assert_eq!(configs[1].name, "rust");
707        assert_eq!(
708            configs[1].config_path,
709            PathBuf::from(".devcontainer/rust/devcontainer.json")
710        );
711    }
712
713    #[gpui::test]
714    async fn test_find_configs_empty_devcontainer_dir_falls_back_to_root(cx: &mut TestAppContext) {
715        init_test(cx);
716        let fs = FakeFs::new(cx.executor());
717        fs.insert_tree(
718            path!("/project"),
719            json!({
720                ".devcontainer.json": "{}",
721                ".devcontainer": {}
722            }),
723        )
724        .await;
725
726        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
727        cx.run_until_parked();
728
729        let configs = project.read_with(cx, |project, cx| {
730            let worktree = project
731                .visible_worktrees(cx)
732                .next()
733                .expect("should have a worktree");
734            find_configs_in_snapshot(worktree.read(cx))
735        });
736
737        assert_eq!(configs.len(), 1);
738        assert_eq!(configs[0], DevContainerConfig::root_config());
739    }
740}