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