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 = 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 let shell_kind = ShellKind::Posix;
305 let orig_tmp_path = tmp_path.display(self.path_style());
306 let server_mode = format!("{:o}", server_mode);
307 let server_mode = shell_kind
308 .try_quote(&server_mode)
309 .context("shell quoting")?;
310 let dst_path = dst_path.display(self.path_style());
311 let dst_path = shell_kind.try_quote(&dst_path).context("shell quoting")?;
312 let script = if let Some(tmp_path) = orig_tmp_path.strip_suffix(".gz") {
313 let orig_tmp_path = shell_kind
314 .try_quote(&orig_tmp_path)
315 .context("shell quoting")?;
316 let tmp_path = shell_kind.try_quote(&tmp_path).context("shell quoting")?;
317 format!(
318 "gunzip -f {orig_tmp_path} && chmod {server_mode} {tmp_path} && mv {tmp_path} {dst_path}",
319 )
320 } else {
321 let orig_tmp_path = shell_kind
322 .try_quote(&orig_tmp_path)
323 .context("shell quoting")?;
324 format!("chmod {server_mode} {orig_tmp_path} && mv {orig_tmp_path} {dst_path}",)
325 };
326 let args = shell_kind.args_for_shell(false, script.to_string());
327 self.run_docker_exec(
328 "sh",
329 Some(&remote_dir_for_server),
330 &Default::default(),
331 &args,
332 )
333 .await
334 .log_err();
335 Ok(())
336 }
337
338 async fn upload_local_server_binary(
339 &self,
340 src_path: &Path,
341 tmp_path_gz: &RelPath,
342 remote_dir_for_server: &str,
343 delegate: &Arc<dyn RemoteClientDelegate>,
344 cx: &mut AsyncApp,
345 ) -> Result<()> {
346 if let Some(parent) = tmp_path_gz.parent() {
347 self.run_docker_exec(
348 "mkdir",
349 Some(remote_dir_for_server),
350 &Default::default(),
351 &["-p", parent.display(self.path_style()).as_ref()],
352 )
353 .await?;
354 }
355
356 let src_stat = smol::fs::metadata(&src_path).await?;
357 let size = src_stat.len();
358
359 let t0 = Instant::now();
360 delegate.set_status(Some("Uploading remote development server"), cx);
361 log::info!(
362 "uploading remote development server to {:?} ({}kb)",
363 tmp_path_gz,
364 size / 1024
365 );
366 self.upload_file(src_path, tmp_path_gz, remote_dir_for_server)
367 .await
368 .context("failed to upload server binary")?;
369 log::info!("uploaded remote development server in {:?}", t0.elapsed());
370 Ok(())
371 }
372
373 async fn upload_file(
374 &self,
375 src_path: &Path,
376 dest_path: &RelPath,
377 remote_dir_for_server: &str,
378 ) -> Result<()> {
379 log::debug!("uploading file {:?} to {:?}", src_path, dest_path);
380
381 let src_path_display = src_path.display().to_string();
382 let dest_path_str = dest_path.display(self.path_style());
383
384 let mut command = util::command::new_smol_command(self.docker_cli());
385 command.arg("cp");
386 command.arg("-a");
387 command.arg(&src_path_display);
388 command.arg(format!(
389 "{}:{}/{}",
390 &self.connection_options.container_id, remote_dir_for_server, dest_path_str
391 ));
392
393 let output = command.output().await?;
394
395 if output.status.success() {
396 return Ok(());
397 }
398
399 let stderr = String::from_utf8_lossy(&output.stderr);
400 log::debug!(
401 "failed to upload file via docker cp {src_path_display} -> {dest_path_str}: {stderr}",
402 );
403 anyhow::bail!(
404 "failed to upload file via docker cp {} -> {}: {}",
405 src_path_display,
406 dest_path_str,
407 stderr,
408 );
409 }
410
411 async fn run_docker_command(
412 &self,
413 subcommand: &str,
414 args: &[impl AsRef<str>],
415 ) -> Result<String> {
416 let mut command = util::command::new_smol_command(self.docker_cli());
417 command.arg(subcommand);
418 for arg in args {
419 command.arg(arg.as_ref());
420 }
421 let output = command.output().await?;
422 log::debug!("{:?}: {:?}", command, output);
423 anyhow::ensure!(
424 output.status.success(),
425 "failed to run command {command:?}: {}",
426 String::from_utf8_lossy(&output.stderr)
427 );
428 Ok(String::from_utf8_lossy(&output.stdout).to_string())
429 }
430
431 async fn run_docker_exec(
432 &self,
433 inner_program: &str,
434 working_directory: Option<&str>,
435 env: &HashMap<String, String>,
436 program_args: &[impl AsRef<str>],
437 ) -> Result<String> {
438 let mut args = match working_directory {
439 Some(dir) => vec!["-w".to_string(), dir.to_string()],
440 None => vec![],
441 };
442
443 for (k, v) in env.iter() {
444 args.push("-e".to_string());
445 let env_declaration = format!("{}={}", k, v);
446 args.push(env_declaration);
447 }
448
449 args.push(self.connection_options.container_id.clone());
450 args.push(inner_program.to_string());
451
452 for arg in program_args {
453 args.push(arg.as_ref().to_owned());
454 }
455 self.run_docker_command("exec", args.as_ref()).await
456 }
457
458 async fn download_binary_on_server(
459 &self,
460 url: &str,
461 tmp_path_gz: &RelPath,
462 remote_dir_for_server: &str,
463 delegate: &Arc<dyn RemoteClientDelegate>,
464 cx: &mut AsyncApp,
465 ) -> Result<()> {
466 if let Some(parent) = tmp_path_gz.parent() {
467 self.run_docker_exec(
468 "mkdir",
469 Some(remote_dir_for_server),
470 &Default::default(),
471 &["-p", parent.display(self.path_style()).as_ref()],
472 )
473 .await?;
474 }
475
476 delegate.set_status(Some("Downloading remote development server on host"), cx);
477
478 match self
479 .run_docker_exec(
480 "curl",
481 Some(remote_dir_for_server),
482 &Default::default(),
483 &[
484 "-f",
485 "-L",
486 url,
487 "-o",
488 &tmp_path_gz.display(self.path_style()),
489 ],
490 )
491 .await
492 {
493 Ok(_) => {}
494 Err(e) => {
495 if self
496 .run_docker_exec("which", None, &Default::default(), &["curl"])
497 .await
498 .is_ok()
499 {
500 return Err(e);
501 }
502
503 log::info!("curl is not available, trying wget");
504 match self
505 .run_docker_exec(
506 "wget",
507 Some(remote_dir_for_server),
508 &Default::default(),
509 &[url, "-O", &tmp_path_gz.display(self.path_style())],
510 )
511 .await
512 {
513 Ok(_) => {}
514 Err(e) => {
515 if self
516 .run_docker_exec("which", None, &Default::default(), &["wget"])
517 .await
518 .is_ok()
519 {
520 return Err(e);
521 } else {
522 anyhow::bail!("Neither curl nor wget is available");
523 }
524 }
525 }
526 }
527 }
528 Ok(())
529 }
530
531 fn kill_inner(&self) -> Result<()> {
532 if let Some(pid) = self.proxy_process.lock().take() {
533 if let Ok(_) = util::command::new_smol_command("kill")
534 .arg(pid.to_string())
535 .spawn()
536 {
537 Ok(())
538 } else {
539 Err(anyhow::anyhow!("Failed to kill process"))
540 }
541 } else {
542 Ok(())
543 }
544 }
545}
546
547#[async_trait(?Send)]
548impl RemoteConnection for DockerExecConnection {
549 fn has_wsl_interop(&self) -> bool {
550 false
551 }
552 fn start_proxy(
553 &self,
554 unique_identifier: String,
555 reconnect: bool,
556 incoming_tx: UnboundedSender<Envelope>,
557 outgoing_rx: UnboundedReceiver<Envelope>,
558 connection_activity_tx: Sender<()>,
559 delegate: Arc<dyn RemoteClientDelegate>,
560 cx: &mut AsyncApp,
561 ) -> Task<Result<i32>> {
562 // We'll try connecting anew every time we open a devcontainer, so proactively try to kill any old connections.
563 if !self.has_been_killed() {
564 if let Err(e) = self.kill_inner() {
565 return Task::ready(Err(e));
566 };
567 }
568
569 delegate.set_status(Some("Starting proxy"), cx);
570
571 let Some(remote_binary_relpath) = self.remote_binary_relpath.clone() else {
572 return Task::ready(Err(anyhow!("Remote binary path not set")));
573 };
574
575 let mut docker_args = vec!["exec".to_string()];
576 for env_var in ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"] {
577 if let Some(value) = std::env::var(env_var).ok() {
578 docker_args.push("-e".to_string());
579 docker_args.push(format!("{}='{}'", env_var, value));
580 }
581 }
582
583 docker_args.extend([
584 "-w".to_string(),
585 self.remote_dir_for_server.clone(),
586 "-i".to_string(),
587 self.connection_options.container_id.to_string(),
588 ]);
589
590 let val = remote_binary_relpath
591 .display(self.path_style())
592 .into_owned();
593 docker_args.push(val);
594 docker_args.push("proxy".to_string());
595 docker_args.push("--identifier".to_string());
596 docker_args.push(unique_identifier);
597 if reconnect {
598 docker_args.push("--reconnect".to_string());
599 }
600 let mut command = util::command::new_smol_command(self.docker_cli());
601 command
602 .kill_on_drop(true)
603 .stdin(Stdio::piped())
604 .stdout(Stdio::piped())
605 .stderr(Stdio::piped())
606 .args(docker_args);
607
608 let Ok(child) = command.spawn() else {
609 return Task::ready(Err(anyhow::anyhow!(
610 "Failed to start remote server process"
611 )));
612 };
613
614 let mut proxy_process = self.proxy_process.lock();
615 *proxy_process = Some(child.id());
616
617 cx.spawn(async move |cx| {
618 super::handle_rpc_messages_over_child_process_stdio(
619 child,
620 incoming_tx,
621 outgoing_rx,
622 connection_activity_tx,
623 cx,
624 )
625 .await
626 .and_then(|status| {
627 if status != 0 {
628 anyhow::bail!("Remote server exited with status {status}");
629 }
630 Ok(0)
631 })
632 })
633 }
634
635 fn upload_directory(
636 &self,
637 src_path: PathBuf,
638 dest_path: RemotePathBuf,
639 cx: &App,
640 ) -> Task<Result<()>> {
641 let dest_path_str = dest_path.to_string();
642 let src_path_display = src_path.display().to_string();
643
644 let mut command = util::command::new_smol_command(self.docker_cli());
645 command.arg("cp");
646 command.arg("-a"); // Archive mode is required to assign the file ownership to the default docker exec user
647 command.arg(src_path_display);
648 command.arg(format!(
649 "{}:{}",
650 self.connection_options.container_id, dest_path_str
651 ));
652
653 cx.background_spawn(async move {
654 let output = command.output().await?;
655
656 if output.status.success() {
657 Ok(())
658 } else {
659 Err(anyhow::anyhow!("Failed to upload directory"))
660 }
661 })
662 }
663
664 async fn kill(&self) -> Result<()> {
665 self.kill_inner()
666 }
667
668 fn has_been_killed(&self) -> bool {
669 self.proxy_process.lock().is_none()
670 }
671
672 fn build_command(
673 &self,
674 program: Option<String>,
675 args: &[String],
676 env: &HashMap<String, String>,
677 working_dir: Option<String>,
678 _port_forward: Option<(u16, String, u16)>,
679 interactive: Interactive,
680 ) -> Result<CommandTemplate> {
681 let mut parsed_working_dir = None;
682
683 let path_style = self.path_style();
684
685 if let Some(working_dir) = working_dir {
686 let working_dir = RemotePathBuf::new(working_dir, path_style).to_string();
687
688 const TILDE_PREFIX: &'static str = "~/";
689 if working_dir.starts_with(TILDE_PREFIX) {
690 let working_dir = working_dir.trim_start_matches("~").trim_start_matches("/");
691 parsed_working_dir = Some(format!("$HOME/{working_dir}"));
692 } else {
693 parsed_working_dir = Some(working_dir);
694 }
695 }
696
697 let mut inner_program = Vec::new();
698
699 if let Some(program) = program {
700 inner_program.push(program);
701 for arg in args {
702 inner_program.push(arg.clone());
703 }
704 } else {
705 inner_program.push(self.shell());
706 inner_program.push("-l".to_string());
707 };
708
709 let mut docker_args = vec!["exec".to_string()];
710
711 if let Some(parsed_working_dir) = parsed_working_dir {
712 docker_args.push("-w".to_string());
713 docker_args.push(parsed_working_dir);
714 }
715
716 for (k, v) in env.iter() {
717 docker_args.push("-e".to_string());
718 docker_args.push(format!("{}={}", k, v));
719 }
720
721 match interactive {
722 Interactive::Yes => docker_args.push("-it".to_string()),
723 Interactive::No => docker_args.push("-i".to_string()),
724 }
725 docker_args.push(self.connection_options.container_id.to_string());
726
727 docker_args.append(&mut inner_program);
728
729 Ok(CommandTemplate {
730 program: self.docker_cli().to_string(),
731 args: docker_args,
732 // Docker-exec pipes in environment via the "-e" argument
733 env: Default::default(),
734 })
735 }
736
737 fn build_forward_ports_command(
738 &self,
739 _forwards: Vec<(u16, String, u16)>,
740 ) -> Result<CommandTemplate> {
741 Err(anyhow::anyhow!("Not currently supported for docker_exec"))
742 }
743
744 fn connection_options(&self) -> RemoteConnectionOptions {
745 RemoteConnectionOptions::Docker(self.connection_options.clone())
746 }
747
748 fn path_style(&self) -> PathStyle {
749 self.path_style.unwrap_or(PathStyle::Posix)
750 }
751
752 fn shell(&self) -> String {
753 self.shell.clone()
754 }
755
756 fn default_system_shell(&self) -> String {
757 String::from("/bin/sh")
758 }
759}