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