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