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