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