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, RemotePlatform,
28 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 "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" => "macos",
128 "Linux" => "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 "aarch64"
140 } else if arch.starts_with("x86") {
141 "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![
586 "exec".to_string(),
587 "-w".to_string(),
588 self.remote_dir_for_server.clone(),
589 "-i".to_string(),
590 self.connection_options.container_id.to_string(),
591 ];
592 for env_var in ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"] {
593 if let Some(value) = std::env::var(env_var).ok() {
594 docker_args.push("-e".to_string());
595 docker_args.push(format!("{}='{}'", env_var, value));
596 }
597 }
598 let val = remote_binary_relpath
599 .display(self.path_style())
600 .into_owned();
601 docker_args.push(val);
602 docker_args.push("proxy".to_string());
603 docker_args.push("--identifier".to_string());
604 docker_args.push(unique_identifier);
605 if reconnect {
606 docker_args.push("--reconnect".to_string());
607 }
608 let mut command = util::command::new_smol_command("docker");
609 command
610 .kill_on_drop(true)
611 .stdin(Stdio::piped())
612 .stdout(Stdio::piped())
613 .stderr(Stdio::piped())
614 .args(docker_args);
615
616 let Ok(child) = command.spawn() else {
617 return Task::ready(Err(anyhow::anyhow!(
618 "Failed to start remote server process"
619 )));
620 };
621
622 let mut proxy_process = self.proxy_process.lock();
623 *proxy_process = Some(child.id());
624
625 super::handle_rpc_messages_over_child_process_stdio(
626 child,
627 incoming_tx,
628 outgoing_rx,
629 connection_activity_tx,
630 cx,
631 )
632 }
633
634 fn upload_directory(
635 &self,
636 src_path: PathBuf,
637 dest_path: RemotePathBuf,
638 cx: &App,
639 ) -> Task<Result<()>> {
640 let dest_path_str = dest_path.to_string();
641 let src_path_display = src_path.display().to_string();
642
643 let mut command = util::command::new_smol_command("docker");
644 command.arg("cp");
645 command.arg("-a"); // Archive mode is required to assign the file ownership to the default docker exec user
646 command.arg(src_path_display);
647 command.arg(format!(
648 "{}:{}",
649 self.connection_options.container_id, dest_path_str
650 ));
651
652 cx.background_spawn(async move {
653 let output = command.output().await?;
654
655 if output.status.success() {
656 Ok(())
657 } else {
658 Err(anyhow::anyhow!("Failed to upload directory"))
659 }
660 })
661 }
662
663 async fn kill(&self) -> Result<()> {
664 self.kill_inner()
665 }
666
667 fn has_been_killed(&self) -> bool {
668 self.proxy_process.lock().is_none()
669 }
670
671 fn build_command(
672 &self,
673 program: Option<String>,
674 args: &[String],
675 env: &HashMap<String, String>,
676 working_dir: Option<String>,
677 _port_forward: Option<(u16, String, u16)>,
678 ) -> Result<CommandTemplate> {
679 let mut parsed_working_dir = None;
680
681 let path_style = self.path_style();
682
683 if let Some(working_dir) = working_dir {
684 let working_dir = RemotePathBuf::new(working_dir, path_style).to_string();
685
686 const TILDE_PREFIX: &'static str = "~/";
687 if working_dir.starts_with(TILDE_PREFIX) {
688 let working_dir = working_dir.trim_start_matches("~").trim_start_matches("/");
689 parsed_working_dir = Some(format!("$HOME/{working_dir}"));
690 } else {
691 parsed_working_dir = Some(working_dir);
692 }
693 }
694
695 let mut inner_program = Vec::new();
696
697 if let Some(program) = program {
698 inner_program.push(program);
699 for arg in args {
700 inner_program.push(arg.clone());
701 }
702 } else {
703 inner_program.push(self.shell());
704 inner_program.push("-l".to_string());
705 };
706
707 let mut docker_args = vec!["exec".to_string()];
708
709 if let Some(parsed_working_dir) = parsed_working_dir {
710 docker_args.push("-w".to_string());
711 docker_args.push(parsed_working_dir);
712 }
713
714 for (k, v) in env.iter() {
715 docker_args.push("-e".to_string());
716 docker_args.push(format!("{}={}", k, v));
717 }
718
719 docker_args.push("-it".to_string());
720 docker_args.push(self.connection_options.container_id.to_string());
721
722 docker_args.append(&mut inner_program);
723
724 Ok(CommandTemplate {
725 program: "docker".to_string(),
726 args: docker_args,
727 // Docker-exec pipes in environment via the "-e" argument
728 env: Default::default(),
729 })
730 }
731
732 fn build_forward_ports_command(
733 &self,
734 _forwards: Vec<(u16, String, u16)>,
735 ) -> Result<CommandTemplate> {
736 Err(anyhow::anyhow!("Not currently supported for docker_exec"))
737 }
738
739 fn connection_options(&self) -> RemoteConnectionOptions {
740 RemoteConnectionOptions::Docker(self.connection_options.clone())
741 }
742
743 fn path_style(&self) -> PathStyle {
744 self.path_style.unwrap_or(PathStyle::Posix)
745 }
746
747 fn shell(&self) -> String {
748 match &self.shell {
749 Some(shell) => shell.clone(),
750 None => self.default_system_shell(),
751 }
752 }
753
754 fn default_system_shell(&self) -> String {
755 String::from("/bin/sh")
756 }
757}