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