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