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