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