dev_container: Detect actual buildx availability instead of assuming Docker has it (#53910)

krisswee , krisswee , and KyleBarton created

`supports_compose_buildkit()` returned `!self.is_podman()`, assuming
every Docker install has buildx. On setups like Colima where the buildx
CLI plugin isn't installed, this causes builds to fail with "classic
builder doesn't support additional contexts" since `DOCKER_BUILDKIT=1`
alone isn't enough without the plugin.

Now probes for `docker buildx version` at construction time and caches
the result. When buildx isn't found, the existing scratch-image fallback
path (same one Podman uses) kicks in instead.

Closes #53890

Release Notes:

- Fixed dev container builds failing on Docker installations without the
buildx plugin.

---------

Co-authored-by: krisswee <krisswee@users.noreply.github.com>
Co-authored-by: KyleBarton <kjb@initialcapacity.io>

Change summary

crates/dev_container/src/devcontainer_manifest.rs | 12 ++++---
crates/dev_container/src/docker.rs                | 25 ++++++++++++++--
2 files changed, 29 insertions(+), 8 deletions(-)

Detailed changes

crates/dev_container/src/devcontainer_manifest.rs 🔗

@@ -2108,9 +2108,9 @@ pub(crate) async fn read_devcontainer_configuration(
     environment: HashMap<String, String>,
 ) -> Result<DevContainer, DevContainerError> {
     let docker = if context.use_podman {
-        Docker::new("podman")
+        Docker::new("podman").await
     } else {
-        Docker::new("docker")
+        Docker::new("docker").await
     };
     let mut dev_container = DevContainerManifest::new(
         context,
@@ -2132,9 +2132,9 @@ pub(crate) async fn spawn_dev_container(
     local_project_path: &Path,
 ) -> Result<DevContainerUp, DevContainerError> {
     let docker = if context.use_podman {
-        Docker::new("podman")
+        Docker::new("podman").await
     } else {
-        Docker::new("docker")
+        Docker::new("docker").await
     };
     let mut devcontainer_manifest = DevContainerManifest::new(
         context,
@@ -4784,12 +4784,14 @@ FROM docker.io/hexpm/elixir:1.21-erlang-28.4.1-debian-trixie-20260316-slim AS de
     pub(crate) struct FakeDocker {
         exec_commands_recorded: Mutex<Vec<RecordedExecCommand>>,
         podman: bool,
+        has_buildx: bool,
     }
 
     impl FakeDocker {
         pub(crate) fn new() -> Self {
             Self {
                 podman: false,
+                has_buildx: true,
                 exec_commands_recorded: Mutex::new(Vec::new()),
             }
         }
@@ -5029,7 +5031,7 @@ FROM docker.io/hexpm/elixir:1.21-erlang-28.4.1-debian-trixie-20260316-slim AS de
             }))
         }
         fn supports_compose_buildkit(&self) -> bool {
-            !self.podman
+            !self.podman && self.has_buildx
         }
         fn docker_cli(&self) -> String {
             if self.podman {

crates/dev_container/src/docker.rs 🔗

@@ -175,6 +175,7 @@ pub(crate) struct DockerComposeConfig {
 
 pub(crate) struct Docker {
     docker_cli: String,
+    has_buildx: bool,
 }
 
 impl DockerInspect {
@@ -184,9 +185,24 @@ impl DockerInspect {
 }
 
 impl Docker {
-    pub(crate) fn new(docker_cli: &str) -> Self {
+    pub(crate) async fn new(docker_cli: &str) -> Self {
+        let has_buildx = if docker_cli == "podman" {
+            false
+        } else {
+            let output = Command::new(docker_cli)
+                .args(["buildx", "version"])
+                .output()
+                .await;
+            output.map(|o| o.status.success()).unwrap_or(false)
+        };
+        if !has_buildx && docker_cli != "podman" {
+            log::info!(
+                "docker buildx not found; dev container builds will use the scratch-image fallback"
+            );
+        }
         Self {
             docker_cli: docker_cli.to_string(),
+            has_buildx,
         }
     }
 
@@ -372,7 +388,7 @@ impl DockerClient for Docker {
     }
 
     fn supports_compose_buildkit(&self) -> bool {
-        !self.is_podman()
+        self.has_buildx
     }
 }
 
@@ -595,7 +611,10 @@ mod test {
 
     #[test]
     fn should_create_docker_inspect_command() {
-        let docker = Docker::new("docker");
+        let docker = Docker {
+            docker_cli: "docker".to_string(),
+            has_buildx: false,
+        };
         let given_id = "given_docker_id";
 
         let command = docker.create_docker_inspect(given_id);