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