docker.rs

  1use anyhow::Context;
  2use anyhow::Result;
  3use anyhow::anyhow;
  4use async_trait::async_trait;
  5use collections::HashMap;
  6use parking_lot::Mutex;
  7use release_channel::{AppCommitSha, AppVersion, ReleaseChannel};
  8use semver::Version as SemanticVersion;
  9use std::time::Instant;
 10use std::{
 11    path::{Path, PathBuf},
 12    process::Stdio,
 13    sync::Arc,
 14};
 15use util::ResultExt;
 16use util::shell::ShellKind;
 17use util::{
 18    paths::{PathStyle, RemotePathBuf},
 19    rel_path::RelPath,
 20};
 21
 22use futures::channel::mpsc::{Sender, UnboundedReceiver, UnboundedSender};
 23use gpui::{App, AppContext, AsyncApp, Task};
 24use rpc::proto::Envelope;
 25
 26use crate::{
 27    RemoteClientDelegate, RemoteConnection, RemoteConnectionOptions, RemotePlatform,
 28    remote_client::CommandTemplate,
 29};
 30
 31#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
 32pub struct DockerConnectionOptions {
 33    pub name: String,
 34    pub container_id: String,
 35    pub upload_binary_over_docker_exec: bool,
 36}
 37
 38pub(crate) struct DockerExecConnection {
 39    proxy_process: Mutex<Option<u32>>,
 40    remote_dir_for_server: String,
 41    remote_binary_relpath: Option<Arc<RelPath>>,
 42    connection_options: DockerConnectionOptions,
 43    remote_platform: Option<RemotePlatform>,
 44    path_style: Option<PathStyle>,
 45    shell: Option<String>,
 46}
 47
 48impl DockerExecConnection {
 49    pub async fn new(
 50        connection_options: DockerConnectionOptions,
 51        delegate: Arc<dyn RemoteClientDelegate>,
 52        cx: &mut AsyncApp,
 53    ) -> Result<Self> {
 54        let mut this = Self {
 55            proxy_process: Mutex::new(None),
 56            remote_dir_for_server: "/".to_string(),
 57            remote_binary_relpath: None,
 58            connection_options,
 59            remote_platform: None,
 60            path_style: None,
 61            shell: None,
 62        };
 63        let (release_channel, version, commit) = cx.update(|cx| {
 64            (
 65                ReleaseChannel::global(cx),
 66                AppVersion::global(cx),
 67                AppCommitSha::try_global(cx),
 68            )
 69        })?;
 70        let remote_platform = this.check_remote_platform().await?;
 71
 72        this.path_style = match remote_platform.os {
 73            "windows" => Some(PathStyle::Windows),
 74            _ => Some(PathStyle::Posix),
 75        };
 76
 77        this.remote_platform = Some(remote_platform);
 78
 79        this.shell = Some(this.discover_shell().await);
 80
 81        this.remote_dir_for_server = this.docker_user_home_dir().await?.trim().to_string();
 82
 83        this.remote_binary_relpath = Some(
 84            this.ensure_server_binary(
 85                &delegate,
 86                release_channel,
 87                version,
 88                &this.remote_dir_for_server,
 89                commit,
 90                cx,
 91            )
 92            .await?,
 93        );
 94
 95        Ok(this)
 96    }
 97
 98    async fn discover_shell(&self) -> String {
 99        let default_shell = "sh";
100        match self
101            .run_docker_exec("sh", None, &Default::default(), &["-c", "echo $SHELL"])
102            .await
103        {
104            Ok(shell) => match shell.trim() {
105                "" => {
106                    log::error!("$SHELL is not set, falling back to {default_shell}");
107                    default_shell.to_owned()
108                }
109                shell => shell.to_owned(),
110            },
111            Err(e) => {
112                log::error!("Failed to get shell: {e}");
113                default_shell.to_owned()
114            }
115        }
116    }
117
118    async fn check_remote_platform(&self) -> Result<RemotePlatform> {
119        let uname = self
120            .run_docker_exec("uname", None, &Default::default(), &["-sm"])
121            .await?;
122        let Some((os, arch)) = uname.split_once(" ") else {
123            anyhow::bail!("unknown uname: {uname:?}")
124        };
125
126        let os = match os.trim() {
127            "Darwin" => "macos",
128            "Linux" => "linux",
129            _ => anyhow::bail!(
130                "Prebuilt remote servers are not yet available for {os:?}. See https://zed.dev/docs/remote-development"
131            ),
132        };
133        // exclude armv5,6,7 as they are 32-bit.
134        let arch = if arch.starts_with("armv8")
135            || arch.starts_with("armv9")
136            || arch.starts_with("arm64")
137            || arch.starts_with("aarch64")
138        {
139            "aarch64"
140        } else if arch.starts_with("x86") {
141            "x86_64"
142        } else {
143            anyhow::bail!(
144                "Prebuilt remote servers are not yet available for {arch:?}. See https://zed.dev/docs/remote-development"
145            )
146        };
147
148        Ok(RemotePlatform { os, arch })
149    }
150
151    async fn ensure_server_binary(
152        &self,
153        delegate: &Arc<dyn RemoteClientDelegate>,
154        release_channel: ReleaseChannel,
155        version: SemanticVersion,
156        remote_dir_for_server: &str,
157        commit: Option<AppCommitSha>,
158        cx: &mut AsyncApp,
159    ) -> Result<Arc<RelPath>> {
160        let remote_platform = if self.remote_platform.is_some() {
161            self.remote_platform.unwrap()
162        } else {
163            anyhow::bail!("No remote platform defined; cannot proceed.")
164        };
165
166        let version_str = match release_channel {
167            ReleaseChannel::Nightly => {
168                let commit = commit.map(|s| s.full()).unwrap_or_default();
169                format!("{}-{}", version, commit)
170            }
171            ReleaseChannel::Dev => "build".to_string(),
172            _ => version.to_string(),
173        };
174        let binary_name = format!(
175            "zed-remote-server-{}-{}",
176            release_channel.dev_name(),
177            version_str
178        );
179        let dst_path =
180            paths::remote_server_dir_relative().join(RelPath::unix(&binary_name).unwrap());
181
182        #[cfg(debug_assertions)]
183        if let Some(remote_server_path) =
184            super::build_remote_server_from_source(&remote_platform, delegate.as_ref(), cx).await?
185        {
186            let tmp_path = paths::remote_server_dir_relative().join(
187                RelPath::unix(&format!(
188                    "download-{}-{}",
189                    std::process::id(),
190                    remote_server_path.file_name().unwrap().to_string_lossy()
191                ))
192                .unwrap(),
193            );
194            self.upload_local_server_binary(
195                &remote_server_path,
196                &tmp_path,
197                &remote_dir_for_server,
198                delegate,
199                cx,
200            )
201            .await?;
202            self.extract_server_binary(&dst_path, &tmp_path, &remote_dir_for_server, delegate, cx)
203                .await?;
204            return Ok(dst_path);
205        }
206
207        if self
208            .run_docker_exec(
209                &dst_path.display(self.path_style()),
210                Some(&remote_dir_for_server),
211                &Default::default(),
212                &["version"],
213            )
214            .await
215            .is_ok()
216        {
217            return Ok(dst_path);
218        }
219
220        let wanted_version = cx.update(|cx| match release_channel {
221            ReleaseChannel::Nightly => Ok(None),
222            ReleaseChannel::Dev => {
223                anyhow::bail!(
224                    "ZED_BUILD_REMOTE_SERVER is not set and no remote server exists at ({:?})",
225                    dst_path
226                )
227            }
228            _ => Ok(Some(AppVersion::global(cx))),
229        })??;
230
231        let tmp_path_gz = paths::remote_server_dir_relative().join(
232            RelPath::unix(&format!(
233                "{}-download-{}.gz",
234                binary_name,
235                std::process::id()
236            ))
237            .unwrap(),
238        );
239        if !self.connection_options.upload_binary_over_docker_exec
240            && let Some(url) = delegate
241                .get_download_url(remote_platform, release_channel, wanted_version.clone(), cx)
242                .await?
243        {
244            match self
245                .download_binary_on_server(&url, &tmp_path_gz, &remote_dir_for_server, delegate, cx)
246                .await
247            {
248                Ok(_) => {
249                    self.extract_server_binary(
250                        &dst_path,
251                        &tmp_path_gz,
252                        &remote_dir_for_server,
253                        delegate,
254                        cx,
255                    )
256                    .await
257                    .context("extracting server binary")?;
258                    return Ok(dst_path);
259                }
260                Err(e) => {
261                    log::error!(
262                        "Failed to download binary on server, attempting to download locally and then upload it the server: {e:#}",
263                    )
264                }
265            }
266        }
267
268        let src_path = delegate
269            .download_server_binary_locally(remote_platform, release_channel, wanted_version, cx)
270            .await
271            .context("downloading server binary locally")?;
272        self.upload_local_server_binary(
273            &src_path,
274            &tmp_path_gz,
275            &remote_dir_for_server,
276            delegate,
277            cx,
278        )
279        .await
280        .context("uploading server binary")?;
281        self.extract_server_binary(
282            &dst_path,
283            &tmp_path_gz,
284            &remote_dir_for_server,
285            delegate,
286            cx,
287        )
288        .await
289        .context("extracting server binary")?;
290        Ok(dst_path)
291    }
292
293    async fn docker_user_home_dir(&self) -> Result<String> {
294        let inner_program = self.shell();
295        self.run_docker_exec(
296            &inner_program,
297            None,
298            &Default::default(),
299            &["-c", "echo $HOME"],
300        )
301        .await
302    }
303
304    async fn extract_server_binary(
305        &self,
306        dst_path: &RelPath,
307        tmp_path: &RelPath,
308        remote_dir_for_server: &str,
309        delegate: &Arc<dyn RemoteClientDelegate>,
310        cx: &mut AsyncApp,
311    ) -> Result<()> {
312        delegate.set_status(Some("Extracting remote development server"), cx);
313        let server_mode = 0o755;
314
315        let shell_kind = ShellKind::Posix;
316        let orig_tmp_path = tmp_path.display(self.path_style());
317        let server_mode = format!("{:o}", server_mode);
318        let server_mode = shell_kind
319            .try_quote(&server_mode)
320            .context("shell quoting")?;
321        let dst_path = dst_path.display(self.path_style());
322        let dst_path = shell_kind.try_quote(&dst_path).context("shell quoting")?;
323        let script = if let Some(tmp_path) = orig_tmp_path.strip_suffix(".gz") {
324            let orig_tmp_path = shell_kind
325                .try_quote(&orig_tmp_path)
326                .context("shell quoting")?;
327            let tmp_path = shell_kind.try_quote(&tmp_path).context("shell quoting")?;
328            format!(
329                "gunzip -f {orig_tmp_path} && chmod {server_mode} {tmp_path} && mv {tmp_path} {dst_path}",
330            )
331        } else {
332            let orig_tmp_path = shell_kind
333                .try_quote(&orig_tmp_path)
334                .context("shell quoting")?;
335            format!("chmod {server_mode} {orig_tmp_path} && mv {orig_tmp_path} {dst_path}",)
336        };
337        let args = shell_kind.args_for_shell(false, script.to_string());
338        self.run_docker_exec(
339            "sh",
340            Some(&remote_dir_for_server),
341            &Default::default(),
342            &args,
343        )
344        .await
345        .log_err();
346        Ok(())
347    }
348
349    async fn upload_local_server_binary(
350        &self,
351        src_path: &Path,
352        tmp_path_gz: &RelPath,
353        remote_dir_for_server: &str,
354        delegate: &Arc<dyn RemoteClientDelegate>,
355        cx: &mut AsyncApp,
356    ) -> Result<()> {
357        if let Some(parent) = tmp_path_gz.parent() {
358            self.run_docker_exec(
359                "mkdir",
360                Some(remote_dir_for_server),
361                &Default::default(),
362                &["-p", parent.display(self.path_style()).as_ref()],
363            )
364            .await?;
365        }
366
367        let src_stat = smol::fs::metadata(&src_path).await?;
368        let size = src_stat.len();
369
370        let t0 = Instant::now();
371        delegate.set_status(Some("Uploading remote development server"), cx);
372        log::info!(
373            "uploading remote development server to {:?} ({}kb)",
374            tmp_path_gz,
375            size / 1024
376        );
377        self.upload_file(src_path, tmp_path_gz, remote_dir_for_server)
378            .await
379            .context("failed to upload server binary")?;
380        log::info!("uploaded remote development server in {:?}", t0.elapsed());
381        Ok(())
382    }
383
384    async fn upload_file(
385        &self,
386        src_path: &Path,
387        dest_path: &RelPath,
388        remote_dir_for_server: &str,
389    ) -> Result<()> {
390        log::debug!("uploading file {:?} to {:?}", src_path, dest_path);
391
392        let src_path_display = src_path.display().to_string();
393        let dest_path_str = dest_path.display(self.path_style());
394
395        let mut command = util::command::new_smol_command("docker");
396        command.arg("cp");
397        command.arg("-a");
398        command.arg(&src_path_display);
399        command.arg(format!(
400            "{}:{}/{}",
401            &self.connection_options.container_id, remote_dir_for_server, dest_path_str
402        ));
403
404        let output = command.output().await?;
405
406        if output.status.success() {
407            return Ok(());
408        }
409
410        let stderr = String::from_utf8_lossy(&output.stderr);
411        log::debug!(
412            "failed to upload file via docker cp {src_path_display} -> {dest_path_str}: {stderr}",
413        );
414        anyhow::bail!(
415            "failed to upload file via docker cp {} -> {}: {}",
416            src_path_display,
417            dest_path_str,
418            stderr,
419        );
420    }
421
422    async fn run_docker_command(
423        &self,
424        subcommand: &str,
425        args: &[impl AsRef<str>],
426    ) -> Result<String> {
427        let mut command = util::command::new_smol_command("docker");
428        command.arg(subcommand);
429        for arg in args {
430            command.arg(arg.as_ref());
431        }
432        let output = command.output().await?;
433        anyhow::ensure!(
434            output.status.success(),
435            "failed to run command {command:?}: {}",
436            String::from_utf8_lossy(&output.stderr)
437        );
438        Ok(String::from_utf8_lossy(&output.stdout).to_string())
439    }
440
441    async fn run_docker_exec(
442        &self,
443        inner_program: &str,
444        working_directory: Option<&str>,
445        env: &HashMap<String, String>,
446        program_args: &[impl AsRef<str>],
447    ) -> Result<String> {
448        let mut args = match working_directory {
449            Some(dir) => vec!["-w".to_string(), dir.to_string()],
450            None => vec![],
451        };
452
453        for (k, v) in env.iter() {
454            args.push("-e".to_string());
455            let env_declaration = format!("{}={}", k, v);
456            args.push(env_declaration);
457        }
458
459        args.push(self.connection_options.container_id.clone());
460        args.push(inner_program.to_string());
461
462        for arg in program_args {
463            args.push(arg.as_ref().to_owned());
464        }
465        self.run_docker_command("exec", args.as_ref()).await
466    }
467
468    async fn download_binary_on_server(
469        &self,
470        url: &str,
471        tmp_path_gz: &RelPath,
472        remote_dir_for_server: &str,
473        delegate: &Arc<dyn RemoteClientDelegate>,
474        cx: &mut AsyncApp,
475    ) -> Result<()> {
476        if let Some(parent) = tmp_path_gz.parent() {
477            self.run_docker_exec(
478                "mkdir",
479                Some(remote_dir_for_server),
480                &Default::default(),
481                &["-p", parent.display(self.path_style()).as_ref()],
482            )
483            .await?;
484        }
485
486        delegate.set_status(Some("Downloading remote development server on host"), cx);
487
488        match self
489            .run_docker_exec(
490                "curl",
491                Some(remote_dir_for_server),
492                &Default::default(),
493                &[
494                    "-f",
495                    "-L",
496                    url,
497                    "-o",
498                    &tmp_path_gz.display(self.path_style()),
499                ],
500            )
501            .await
502        {
503            Ok(_) => {}
504            Err(e) => {
505                if self
506                    .run_docker_exec("which", None, &Default::default(), &["curl"])
507                    .await
508                    .is_ok()
509                {
510                    return Err(e);
511                }
512
513                log::info!("curl is not available, trying wget");
514                match self
515                    .run_docker_exec(
516                        "wget",
517                        Some(remote_dir_for_server),
518                        &Default::default(),
519                        &[url, "-O", &tmp_path_gz.display(self.path_style())],
520                    )
521                    .await
522                {
523                    Ok(_) => {}
524                    Err(e) => {
525                        if self
526                            .run_docker_exec("which", None, &Default::default(), &["wget"])
527                            .await
528                            .is_ok()
529                        {
530                            return Err(e);
531                        } else {
532                            anyhow::bail!("Neither curl nor wget is available");
533                        }
534                    }
535                }
536            }
537        }
538        Ok(())
539    }
540
541    fn kill_inner(&self) -> Result<()> {
542        if let Some(pid) = self.proxy_process.lock().take() {
543            if let Ok(_) = util::command::new_smol_command("kill")
544                .arg(pid.to_string())
545                .spawn()
546            {
547                Ok(())
548            } else {
549                Err(anyhow::anyhow!("Failed to kill process"))
550            }
551        } else {
552            Ok(())
553        }
554    }
555}
556
557#[async_trait(?Send)]
558impl RemoteConnection for DockerExecConnection {
559    fn has_wsl_interop(&self) -> bool {
560        false
561    }
562    fn start_proxy(
563        &self,
564        unique_identifier: String,
565        reconnect: bool,
566        incoming_tx: UnboundedSender<Envelope>,
567        outgoing_rx: UnboundedReceiver<Envelope>,
568        connection_activity_tx: Sender<()>,
569        delegate: Arc<dyn RemoteClientDelegate>,
570        cx: &mut AsyncApp,
571    ) -> Task<Result<i32>> {
572        // We'll try connecting anew every time we open a devcontainer, so proactively try to kill any old connections.
573        if !self.has_been_killed() {
574            if let Err(e) = self.kill_inner() {
575                return Task::ready(Err(e));
576            };
577        }
578
579        delegate.set_status(Some("Starting proxy"), cx);
580
581        let Some(remote_binary_relpath) = self.remote_binary_relpath.clone() else {
582            return Task::ready(Err(anyhow!("Remote binary path not set")));
583        };
584
585        let mut docker_args = vec![
586            "exec".to_string(),
587            "-w".to_string(),
588            self.remote_dir_for_server.clone(),
589            "-i".to_string(),
590            self.connection_options.container_id.to_string(),
591        ];
592        for env_var in ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"] {
593            if let Some(value) = std::env::var(env_var).ok() {
594                docker_args.push("-e".to_string());
595                docker_args.push(format!("{}='{}'", env_var, value));
596            }
597        }
598        let val = remote_binary_relpath
599            .display(self.path_style())
600            .into_owned();
601        docker_args.push(val);
602        docker_args.push("proxy".to_string());
603        docker_args.push("--identifier".to_string());
604        docker_args.push(unique_identifier);
605        if reconnect {
606            docker_args.push("--reconnect".to_string());
607        }
608        let mut command = util::command::new_smol_command("docker");
609        command
610            .kill_on_drop(true)
611            .stdin(Stdio::piped())
612            .stdout(Stdio::piped())
613            .stderr(Stdio::piped())
614            .args(docker_args);
615
616        let Ok(child) = command.spawn() else {
617            return Task::ready(Err(anyhow::anyhow!(
618                "Failed to start remote server process"
619            )));
620        };
621
622        let mut proxy_process = self.proxy_process.lock();
623        *proxy_process = Some(child.id());
624
625        super::handle_rpc_messages_over_child_process_stdio(
626            child,
627            incoming_tx,
628            outgoing_rx,
629            connection_activity_tx,
630            cx,
631        )
632    }
633
634    fn upload_directory(
635        &self,
636        src_path: PathBuf,
637        dest_path: RemotePathBuf,
638        cx: &App,
639    ) -> Task<Result<()>> {
640        let dest_path_str = dest_path.to_string();
641        let src_path_display = src_path.display().to_string();
642
643        let mut command = util::command::new_smol_command("docker");
644        command.arg("cp");
645        command.arg("-a"); // Archive mode is required to assign the file ownership to the default docker exec user
646        command.arg(src_path_display);
647        command.arg(format!(
648            "{}:{}",
649            self.connection_options.container_id, dest_path_str
650        ));
651
652        cx.background_spawn(async move {
653            let output = command.output().await?;
654
655            if output.status.success() {
656                Ok(())
657            } else {
658                Err(anyhow::anyhow!("Failed to upload directory"))
659            }
660        })
661    }
662
663    async fn kill(&self) -> Result<()> {
664        self.kill_inner()
665    }
666
667    fn has_been_killed(&self) -> bool {
668        self.proxy_process.lock().is_none()
669    }
670
671    fn build_command(
672        &self,
673        program: Option<String>,
674        args: &[String],
675        env: &HashMap<String, String>,
676        working_dir: Option<String>,
677        _port_forward: Option<(u16, String, u16)>,
678    ) -> Result<CommandTemplate> {
679        let mut parsed_working_dir = None;
680
681        let path_style = self.path_style();
682
683        if let Some(working_dir) = working_dir {
684            let working_dir = RemotePathBuf::new(working_dir, path_style).to_string();
685
686            const TILDE_PREFIX: &'static str = "~/";
687            if working_dir.starts_with(TILDE_PREFIX) {
688                let working_dir = working_dir.trim_start_matches("~").trim_start_matches("/");
689                parsed_working_dir = Some(format!("$HOME/{working_dir}"));
690            } else {
691                parsed_working_dir = Some(working_dir);
692            }
693        }
694
695        let mut inner_program = Vec::new();
696
697        if let Some(program) = program {
698            inner_program.push(program);
699            for arg in args {
700                inner_program.push(arg.clone());
701            }
702        } else {
703            inner_program.push(self.shell());
704            inner_program.push("-l".to_string());
705        };
706
707        let mut docker_args = vec!["exec".to_string()];
708
709        if let Some(parsed_working_dir) = parsed_working_dir {
710            docker_args.push("-w".to_string());
711            docker_args.push(parsed_working_dir);
712        }
713
714        for (k, v) in env.iter() {
715            docker_args.push("-e".to_string());
716            docker_args.push(format!("{}={}", k, v));
717        }
718
719        docker_args.push("-it".to_string());
720        docker_args.push(self.connection_options.container_id.to_string());
721
722        docker_args.append(&mut inner_program);
723
724        Ok(CommandTemplate {
725            program: "docker".to_string(),
726            args: docker_args,
727            // Docker-exec pipes in environment via the "-e" argument
728            env: Default::default(),
729        })
730    }
731
732    fn build_forward_ports_command(
733        &self,
734        _forwards: Vec<(u16, String, u16)>,
735    ) -> Result<CommandTemplate> {
736        Err(anyhow::anyhow!("Not currently supported for docker_exec"))
737    }
738
739    fn connection_options(&self) -> RemoteConnectionOptions {
740        RemoteConnectionOptions::Docker(self.connection_options.clone())
741    }
742
743    fn path_style(&self) -> PathStyle {
744        self.path_style.unwrap_or(PathStyle::Posix)
745    }
746
747    fn shell(&self) -> String {
748        match &self.shell {
749            Some(shell) => shell.clone(),
750            None => self.default_system_shell(),
751        }
752    }
753
754    fn default_system_shell(&self) -> String {
755        String::from("/bin/sh")
756    }
757}