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