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 = 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        let shell_kind = ShellKind::Posix;
305        let orig_tmp_path = tmp_path.display(self.path_style());
306        let server_mode = format!("{:o}", server_mode);
307        let server_mode = shell_kind
308            .try_quote(&server_mode)
309            .context("shell quoting")?;
310        let dst_path = dst_path.display(self.path_style());
311        let dst_path = shell_kind.try_quote(&dst_path).context("shell quoting")?;
312        let script = if let Some(tmp_path) = orig_tmp_path.strip_suffix(".gz") {
313            let orig_tmp_path = shell_kind
314                .try_quote(&orig_tmp_path)
315                .context("shell quoting")?;
316            let tmp_path = shell_kind.try_quote(&tmp_path).context("shell quoting")?;
317            format!(
318                "gunzip -f {orig_tmp_path} && chmod {server_mode} {tmp_path} && mv {tmp_path} {dst_path}",
319            )
320        } else {
321            let orig_tmp_path = shell_kind
322                .try_quote(&orig_tmp_path)
323                .context("shell quoting")?;
324            format!("chmod {server_mode} {orig_tmp_path} && mv {orig_tmp_path} {dst_path}",)
325        };
326        let args = shell_kind.args_for_shell(false, script.to_string());
327        self.run_docker_exec(
328            "sh",
329            Some(&remote_dir_for_server),
330            &Default::default(),
331            &args,
332        )
333        .await
334        .log_err();
335        Ok(())
336    }
337
338    async fn upload_local_server_binary(
339        &self,
340        src_path: &Path,
341        tmp_path_gz: &RelPath,
342        remote_dir_for_server: &str,
343        delegate: &Arc<dyn RemoteClientDelegate>,
344        cx: &mut AsyncApp,
345    ) -> Result<()> {
346        if let Some(parent) = tmp_path_gz.parent() {
347            self.run_docker_exec(
348                "mkdir",
349                Some(remote_dir_for_server),
350                &Default::default(),
351                &["-p", parent.display(self.path_style()).as_ref()],
352            )
353            .await?;
354        }
355
356        let src_stat = smol::fs::metadata(&src_path).await?;
357        let size = src_stat.len();
358
359        let t0 = Instant::now();
360        delegate.set_status(Some("Uploading remote development server"), cx);
361        log::info!(
362            "uploading remote development server to {:?} ({}kb)",
363            tmp_path_gz,
364            size / 1024
365        );
366        self.upload_file(src_path, tmp_path_gz, remote_dir_for_server)
367            .await
368            .context("failed to upload server binary")?;
369        log::info!("uploaded remote development server in {:?}", t0.elapsed());
370        Ok(())
371    }
372
373    async fn upload_file(
374        &self,
375        src_path: &Path,
376        dest_path: &RelPath,
377        remote_dir_for_server: &str,
378    ) -> Result<()> {
379        log::debug!("uploading file {:?} to {:?}", src_path, dest_path);
380
381        let src_path_display = src_path.display().to_string();
382        let dest_path_str = dest_path.display(self.path_style());
383
384        let mut command = util::command::new_smol_command(self.docker_cli());
385        command.arg("cp");
386        command.arg("-a");
387        command.arg(&src_path_display);
388        command.arg(format!(
389            "{}:{}/{}",
390            &self.connection_options.container_id, remote_dir_for_server, dest_path_str
391        ));
392
393        let output = command.output().await?;
394
395        if output.status.success() {
396            return Ok(());
397        }
398
399        let stderr = String::from_utf8_lossy(&output.stderr);
400        log::debug!(
401            "failed to upload file via docker cp {src_path_display} -> {dest_path_str}: {stderr}",
402        );
403        anyhow::bail!(
404            "failed to upload file via docker cp {} -> {}: {}",
405            src_path_display,
406            dest_path_str,
407            stderr,
408        );
409    }
410
411    async fn run_docker_command(
412        &self,
413        subcommand: &str,
414        args: &[impl AsRef<str>],
415    ) -> Result<String> {
416        let mut command = util::command::new_smol_command(self.docker_cli());
417        command.arg(subcommand);
418        for arg in args {
419            command.arg(arg.as_ref());
420        }
421        let output = command.output().await?;
422        log::debug!("{:?}: {:?}", command, output);
423        anyhow::ensure!(
424            output.status.success(),
425            "failed to run command {command:?}: {}",
426            String::from_utf8_lossy(&output.stderr)
427        );
428        Ok(String::from_utf8_lossy(&output.stdout).to_string())
429    }
430
431    async fn run_docker_exec(
432        &self,
433        inner_program: &str,
434        working_directory: Option<&str>,
435        env: &HashMap<String, String>,
436        program_args: &[impl AsRef<str>],
437    ) -> Result<String> {
438        let mut args = match working_directory {
439            Some(dir) => vec!["-w".to_string(), dir.to_string()],
440            None => vec![],
441        };
442
443        for (k, v) in env.iter() {
444            args.push("-e".to_string());
445            let env_declaration = format!("{}={}", k, v);
446            args.push(env_declaration);
447        }
448
449        args.push(self.connection_options.container_id.clone());
450        args.push(inner_program.to_string());
451
452        for arg in program_args {
453            args.push(arg.as_ref().to_owned());
454        }
455        self.run_docker_command("exec", args.as_ref()).await
456    }
457
458    async fn download_binary_on_server(
459        &self,
460        url: &str,
461        tmp_path_gz: &RelPath,
462        remote_dir_for_server: &str,
463        delegate: &Arc<dyn RemoteClientDelegate>,
464        cx: &mut AsyncApp,
465    ) -> Result<()> {
466        if let Some(parent) = tmp_path_gz.parent() {
467            self.run_docker_exec(
468                "mkdir",
469                Some(remote_dir_for_server),
470                &Default::default(),
471                &["-p", parent.display(self.path_style()).as_ref()],
472            )
473            .await?;
474        }
475
476        delegate.set_status(Some("Downloading remote development server on host"), cx);
477
478        match self
479            .run_docker_exec(
480                "curl",
481                Some(remote_dir_for_server),
482                &Default::default(),
483                &[
484                    "-f",
485                    "-L",
486                    url,
487                    "-o",
488                    &tmp_path_gz.display(self.path_style()),
489                ],
490            )
491            .await
492        {
493            Ok(_) => {}
494            Err(e) => {
495                if self
496                    .run_docker_exec("which", None, &Default::default(), &["curl"])
497                    .await
498                    .is_ok()
499                {
500                    return Err(e);
501                }
502
503                log::info!("curl is not available, trying wget");
504                match self
505                    .run_docker_exec(
506                        "wget",
507                        Some(remote_dir_for_server),
508                        &Default::default(),
509                        &[url, "-O", &tmp_path_gz.display(self.path_style())],
510                    )
511                    .await
512                {
513                    Ok(_) => {}
514                    Err(e) => {
515                        if self
516                            .run_docker_exec("which", None, &Default::default(), &["wget"])
517                            .await
518                            .is_ok()
519                        {
520                            return Err(e);
521                        } else {
522                            anyhow::bail!("Neither curl nor wget is available");
523                        }
524                    }
525                }
526            }
527        }
528        Ok(())
529    }
530
531    fn kill_inner(&self) -> Result<()> {
532        if let Some(pid) = self.proxy_process.lock().take() {
533            if let Ok(_) = util::command::new_smol_command("kill")
534                .arg(pid.to_string())
535                .spawn()
536            {
537                Ok(())
538            } else {
539                Err(anyhow::anyhow!("Failed to kill process"))
540            }
541        } else {
542            Ok(())
543        }
544    }
545}
546
547#[async_trait(?Send)]
548impl RemoteConnection for DockerExecConnection {
549    fn has_wsl_interop(&self) -> bool {
550        false
551    }
552    fn start_proxy(
553        &self,
554        unique_identifier: String,
555        reconnect: bool,
556        incoming_tx: UnboundedSender<Envelope>,
557        outgoing_rx: UnboundedReceiver<Envelope>,
558        connection_activity_tx: Sender<()>,
559        delegate: Arc<dyn RemoteClientDelegate>,
560        cx: &mut AsyncApp,
561    ) -> Task<Result<i32>> {
562        // We'll try connecting anew every time we open a devcontainer, so proactively try to kill any old connections.
563        if !self.has_been_killed() {
564            if let Err(e) = self.kill_inner() {
565                return Task::ready(Err(e));
566            };
567        }
568
569        delegate.set_status(Some("Starting proxy"), cx);
570
571        let Some(remote_binary_relpath) = self.remote_binary_relpath.clone() else {
572            return Task::ready(Err(anyhow!("Remote binary path not set")));
573        };
574
575        let mut docker_args = vec!["exec".to_string()];
576        for env_var in ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"] {
577            if let Some(value) = std::env::var(env_var).ok() {
578                docker_args.push("-e".to_string());
579                docker_args.push(format!("{}='{}'", env_var, value));
580            }
581        }
582
583        docker_args.extend([
584            "-w".to_string(),
585            self.remote_dir_for_server.clone(),
586            "-i".to_string(),
587            self.connection_options.container_id.to_string(),
588        ]);
589
590        let val = remote_binary_relpath
591            .display(self.path_style())
592            .into_owned();
593        docker_args.push(val);
594        docker_args.push("proxy".to_string());
595        docker_args.push("--identifier".to_string());
596        docker_args.push(unique_identifier);
597        if reconnect {
598            docker_args.push("--reconnect".to_string());
599        }
600        let mut command = util::command::new_smol_command(self.docker_cli());
601        command
602            .kill_on_drop(true)
603            .stdin(Stdio::piped())
604            .stdout(Stdio::piped())
605            .stderr(Stdio::piped())
606            .args(docker_args);
607
608        let Ok(child) = command.spawn() else {
609            return Task::ready(Err(anyhow::anyhow!(
610                "Failed to start remote server process"
611            )));
612        };
613
614        let mut proxy_process = self.proxy_process.lock();
615        *proxy_process = Some(child.id());
616
617        cx.spawn(async move |cx| {
618            super::handle_rpc_messages_over_child_process_stdio(
619                child,
620                incoming_tx,
621                outgoing_rx,
622                connection_activity_tx,
623                cx,
624            )
625            .await
626            .and_then(|status| {
627                if status != 0 {
628                    anyhow::bail!("Remote server exited with status {status}");
629                }
630                Ok(0)
631            })
632        })
633    }
634
635    fn upload_directory(
636        &self,
637        src_path: PathBuf,
638        dest_path: RemotePathBuf,
639        cx: &App,
640    ) -> Task<Result<()>> {
641        let dest_path_str = dest_path.to_string();
642        let src_path_display = src_path.display().to_string();
643
644        let mut command = util::command::new_smol_command(self.docker_cli());
645        command.arg("cp");
646        command.arg("-a"); // Archive mode is required to assign the file ownership to the default docker exec user
647        command.arg(src_path_display);
648        command.arg(format!(
649            "{}:{}",
650            self.connection_options.container_id, dest_path_str
651        ));
652
653        cx.background_spawn(async move {
654            let output = command.output().await?;
655
656            if output.status.success() {
657                Ok(())
658            } else {
659                Err(anyhow::anyhow!("Failed to upload directory"))
660            }
661        })
662    }
663
664    async fn kill(&self) -> Result<()> {
665        self.kill_inner()
666    }
667
668    fn has_been_killed(&self) -> bool {
669        self.proxy_process.lock().is_none()
670    }
671
672    fn build_command(
673        &self,
674        program: Option<String>,
675        args: &[String],
676        env: &HashMap<String, String>,
677        working_dir: Option<String>,
678        _port_forward: Option<(u16, String, u16)>,
679        interactive: Interactive,
680    ) -> Result<CommandTemplate> {
681        let mut parsed_working_dir = None;
682
683        let path_style = self.path_style();
684
685        if let Some(working_dir) = working_dir {
686            let working_dir = RemotePathBuf::new(working_dir, path_style).to_string();
687
688            const TILDE_PREFIX: &'static str = "~/";
689            if working_dir.starts_with(TILDE_PREFIX) {
690                let working_dir = working_dir.trim_start_matches("~").trim_start_matches("/");
691                parsed_working_dir = Some(format!("$HOME/{working_dir}"));
692            } else {
693                parsed_working_dir = Some(working_dir);
694            }
695        }
696
697        let mut inner_program = Vec::new();
698
699        if let Some(program) = program {
700            inner_program.push(program);
701            for arg in args {
702                inner_program.push(arg.clone());
703            }
704        } else {
705            inner_program.push(self.shell());
706            inner_program.push("-l".to_string());
707        };
708
709        let mut docker_args = vec!["exec".to_string()];
710
711        if let Some(parsed_working_dir) = parsed_working_dir {
712            docker_args.push("-w".to_string());
713            docker_args.push(parsed_working_dir);
714        }
715
716        for (k, v) in env.iter() {
717            docker_args.push("-e".to_string());
718            docker_args.push(format!("{}={}", k, v));
719        }
720
721        match interactive {
722            Interactive::Yes => docker_args.push("-it".to_string()),
723            Interactive::No => docker_args.push("-i".to_string()),
724        }
725        docker_args.push(self.connection_options.container_id.to_string());
726
727        docker_args.append(&mut inner_program);
728
729        Ok(CommandTemplate {
730            program: self.docker_cli().to_string(),
731            args: docker_args,
732            // Docker-exec pipes in environment via the "-e" argument
733            env: Default::default(),
734        })
735    }
736
737    fn build_forward_ports_command(
738        &self,
739        _forwards: Vec<(u16, String, u16)>,
740    ) -> Result<CommandTemplate> {
741        Err(anyhow::anyhow!("Not currently supported for docker_exec"))
742    }
743
744    fn connection_options(&self) -> RemoteConnectionOptions {
745        RemoteConnectionOptions::Docker(self.connection_options.clone())
746    }
747
748    fn path_style(&self) -> PathStyle {
749        self.path_style.unwrap_or(PathStyle::Posix)
750    }
751
752    fn shell(&self) -> String {
753        self.shell.clone()
754    }
755
756    fn default_system_shell(&self) -> String {
757        String::from("/bin/sh")
758    }
759}