Use an object for docker compose ports rather than raw string (#53090)

KyleBarton created

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Closes #53048

Release Notes:

- Fixed serialization error with Docker Compose for dev containers

Change summary

crates/dev_container/src/devcontainer_manifest.rs | 77 ++++++++++++++--
crates/dev_container/src/docker.rs                | 76 ++++++++++++++++
2 files changed, 137 insertions(+), 16 deletions(-)

Detailed changes

crates/dev_container/src/devcontainer_manifest.rs 🔗

@@ -20,7 +20,8 @@ use crate::{
     },
     docker::{
         Docker, DockerClient, DockerComposeConfig, DockerComposeService, DockerComposeServiceBuild,
-        DockerComposeVolume, DockerInspect, DockerPs, get_remote_dir_from_config,
+        DockerComposeServicePort, DockerComposeVolume, DockerInspect, DockerPs,
+        get_remote_dir_from_config,
     },
     features::{DevContainerFeatureJson, FeatureManifest, parse_oci_feature_ref},
     get_oci_token,
@@ -1137,18 +1138,30 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true
                 // If the main service uses a different service's network bridge, append to that service's ports instead
                 if let Some(network_service_name) = network_mode_service {
                     if let Some(service) = service_declarations.get_mut(network_service_name) {
-                        service.ports.push(format!("{port}:{port}"));
+                        service.ports.push(DockerComposeServicePort {
+                            target: port.clone(),
+                            published: port.clone(),
+                            ..Default::default()
+                        });
                     } else {
                         service_declarations.insert(
                             network_service_name.to_string(),
                             DockerComposeService {
-                                ports: vec![format!("{port}:{port}")],
+                                ports: vec![DockerComposeServicePort {
+                                    target: port.clone(),
+                                    published: port.clone(),
+                                    ..Default::default()
+                                }],
                                 ..Default::default()
                             },
                         );
                     }
                 } else {
-                    main_service.ports.push(format!("{port}:{port}"));
+                    main_service.ports.push(DockerComposeServicePort {
+                        target: port.clone(),
+                        published: port.clone(),
+                        ..Default::default()
+                    });
                 }
             }
             let other_service_ports: Vec<(&str, &str)> = forward_ports
@@ -1171,12 +1184,20 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true
                 .collect();
             for (service_name, port) in other_service_ports {
                 if let Some(service) = service_declarations.get_mut(service_name) {
-                    service.ports.push(format!("{port}:{port}"));
+                    service.ports.push(DockerComposeServicePort {
+                        target: port.to_string(),
+                        published: port.to_string(),
+                        ..Default::default()
+                    });
                 } else {
                     service_declarations.insert(
                         service_name.to_string(),
                         DockerComposeService {
-                            ports: vec![format!("{port}:{port}")],
+                            ports: vec![DockerComposeServicePort {
+                                target: port.to_string(),
+                                published: port.to_string(),
+                                ..Default::default()
+                            }],
                             ..Default::default()
                         },
                     );
@@ -1186,18 +1207,30 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true
         if let Some(port) = &self.dev_container().app_port {
             if let Some(network_service_name) = network_mode_service {
                 if let Some(service) = service_declarations.get_mut(network_service_name) {
-                    service.ports.push(format!("{port}:{port}"));
+                    service.ports.push(DockerComposeServicePort {
+                        target: port.clone(),
+                        published: port.clone(),
+                        ..Default::default()
+                    });
                 } else {
                     service_declarations.insert(
                         network_service_name.to_string(),
                         DockerComposeService {
-                            ports: vec![format!("{port}:{port}")],
+                            ports: vec![DockerComposeServicePort {
+                                target: port.clone(),
+                                published: port.clone(),
+                                ..Default::default()
+                            }],
                             ..Default::default()
                         },
                     );
                 }
             } else {
-                main_service.ports.push(format!("{port}:{port}"));
+                main_service.ports.push(DockerComposeServicePort {
+                    target: port.clone(),
+                    published: port.clone(),
+                    ..Default::default()
+                });
             }
         }
 
@@ -3278,6 +3311,8 @@ chmod +x ./install.sh
     #[cfg(not(target_os = "windows"))]
     #[gpui::test]
     async fn test_spawns_devcontainer_with_docker_compose(cx: &mut TestAppContext) {
+        use crate::docker::DockerComposeServicePort;
+
         cx.executor().allow_parking();
         env_logger::try_init().ok();
         let given_devcontainer_contents = r#"
@@ -3540,10 +3575,26 @@ ENV DOCKER_BUILDKIT=1
                     "db".to_string(),
                     DockerComposeService {
                         ports: vec![
-                            "8083:8083".to_string(),
-                            "5432:5432".to_string(),
-                            "1234:1234".to_string(),
-                            "8084:8084".to_string()
+                            DockerComposeServicePort {
+                                target: "8083".to_string(),
+                                published: "8083".to_string(),
+                                ..Default::default()
+                            },
+                            DockerComposeServicePort {
+                                target: "5432".to_string(),
+                                published: "5432".to_string(),
+                                ..Default::default()
+                            },
+                            DockerComposeServicePort {
+                                target: "1234".to_string(),
+                                published: "1234".to_string(),
+                                ..Default::default()
+                            },
+                            DockerComposeServicePort {
+                                target: "8084".to_string(),
+                                published: "8084".to_string(),
+                                ..Default::default()
+                            },
                         ],
                         ..Default::default()
                     },

crates/dev_container/src/docker.rs 🔗

@@ -86,6 +86,43 @@ pub(crate) struct DockerComposeServiceBuild {
     pub(crate) additional_contexts: Option<HashMap<String, String>>,
 }
 
+#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
+pub(crate) struct DockerComposeServicePort {
+    #[serde(deserialize_with = "deserialize_string_or_int")]
+    pub(crate) target: String,
+    #[serde(deserialize_with = "deserialize_string_or_int")]
+    pub(crate) published: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub(crate) mode: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub(crate) protocol: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub(crate) host_ip: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub(crate) app_protocol: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub(crate) name: Option<String>,
+}
+
+fn deserialize_string_or_int<'de, D>(deserializer: D) -> Result<String, D::Error>
+where
+    D: serde::Deserializer<'de>,
+{
+    use serde::Deserialize;
+
+    #[derive(Deserialize)]
+    #[serde(untagged)]
+    enum StringOrInt {
+        String(String),
+        Int(u32),
+    }
+
+    match StringOrInt::deserialize(deserializer)? {
+        StringOrInt::String(s) => Ok(s),
+        StringOrInt::Int(b) => Ok(b.to_string()),
+    }
+}
+
 #[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
 pub(crate) struct DockerComposeService {
     pub(crate) image: Option<String>,
@@ -109,7 +146,7 @@ pub(crate) struct DockerComposeService {
     #[serde(skip_serializing_if = "Option::is_none")]
     pub(crate) env_file: Option<Vec<String>>,
     #[serde(default, skip_serializing_if = "Vec::is_empty")]
-    pub(crate) ports: Vec<String>,
+    pub(crate) ports: Vec<DockerComposeServicePort>,
     #[serde(skip_serializing_if = "Option::is_none")]
     pub(crate) network_mode: Option<String>,
 }
@@ -491,8 +528,8 @@ mod test {
         command_json::deserialize_json_output,
         devcontainer_json::MountDefinition,
         docker::{
-            Docker, DockerComposeConfig, DockerComposeService, DockerComposeVolume, DockerInspect,
-            DockerPs, get_remote_dir_from_config,
+            Docker, DockerComposeConfig, DockerComposeService, DockerComposeServicePort,
+            DockerComposeVolume, DockerInspect, DockerPs, get_remote_dir_from_config,
         },
     };
 
@@ -879,6 +916,22 @@ mod test {
                 "POSTGRES_PORT": "5432",
                 "POSTGRES_USER": "postgres"
                 },
+                "ports": [
+                    {
+                        "target": "5443",
+                        "published": "5442"
+                    },
+                    {
+                        "name": "custom port",
+                        "protocol": "udp",
+                        "host_ip": "127.0.0.1",
+                        "app_protocol": "http",
+                        "mode": "host",
+                        "target": "8081",
+                        "published": "8083"
+
+                    }
+                ],
                 "image": "mcr.microsoft.com/devcontainers/rust:2-1-bookworm",
                 "network_mode": "service:db",
                 "volumes": [
@@ -943,6 +996,23 @@ mod test {
                             target: "/workspaces".to_string(),
                         }],
                         network_mode: Some("service:db".to_string()),
+
+                        ports: vec![
+                            DockerComposeServicePort {
+                                target: "5443".to_string(),
+                                published: "5442".to_string(),
+                                ..Default::default()
+                            },
+                            DockerComposeServicePort {
+                                target: "8081".to_string(),
+                                published: "8083".to_string(),
+                                mode: Some("host".to_string()),
+                                protocol: Some("udp".to_string()),
+                                host_ip: Some("127.0.0.1".to_string()),
+                                app_protocol: Some("http".to_string()),
+                                name: Some("custom port".to_string()),
+                            },
+                        ],
                         ..Default::default()
                     },
                 ),