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