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