1use std::io::ErrorKind;
10use std::net::SocketAddr;
11use std::path::Path;
12use std::path::PathBuf;
13use std::process::Stdio;
14#[cfg(feature = "packaged_rsync")]
15use std::sync::LazyLock;
16use std::time::Duration;
17
18use anyhow::Context;
19use anyhow::Result;
20use anyhow::bail;
21use anyhow::ensure;
22use async_trait::async_trait;
23use futures::FutureExt;
24use futures::StreamExt;
25use futures::TryFutureExt;
26use futures::TryStreamExt;
27use futures::try_join;
28use hyperactor as reference;
29use hyperactor::Actor;
30use hyperactor::Endpoint as _;
31use hyperactor::Handler;
32use hyperactor::context;
33use hyperactor_mesh::ActorMesh;
34use hyperactor_mesh::connect::Connect;
35use hyperactor_mesh::connect::accept;
36use nix::sys::signal;
37use nix::sys::signal::Signal;
38use nix::unistd::Pid;
39use serde::Deserialize;
40use serde::Serialize;
41use tempfile::TempDir;
42#[cfg(feature = "packaged_rsync")]
43use tempfile::TempPath;
44use tokio::fs;
45use tokio::net::TcpListener;
46use tokio::net::TcpStream;
47use tokio::process::Child;
48use tokio::process::Command;
49#[cfg(feature = "packaged_rsync")]
50use tokio::sync::OnceCell;
51use tracing::warn;
52use typeuri::Named;
53
54use crate::code_sync::WorkspaceLocation;
55
56#[cfg(feature = "packaged_rsync")]
57static RSYNC_BIN_PATH: LazyLock<OnceCell<TempPath>> = LazyLock::new(OnceCell::new);
58
59async fn get_rsync_bin_path() -> Result<&'static Path> {
60 #[cfg(feature = "packaged_rsync")]
61 {
62 use std::io::Write;
63 use std::os::unix::fs::PermissionsExt;
64 Ok(RSYNC_BIN_PATH
65 .get_or_try_init(|| async {
66 tokio::task::spawn_blocking(|| {
67 let mut tmp = tempfile::NamedTempFile::with_prefix("rsync.")?;
68 let rsync_bin = include_bytes!("rsync.bin");
69 tmp.write_all(rsync_bin)?;
70 let bin_path = tmp.into_temp_path();
71 std::fs::set_permissions(&bin_path, std::fs::Permissions::from_mode(0o755))?;
72 anyhow::Ok(bin_path)
73 })
74 .await?
75 })
76 .await?)
77 }
78 #[cfg(not(feature = "packaged_rsync"))]
79 {
80 Ok(Path::new("rsync"))
81 }
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct Change {
87 pub change_type: ChangeType,
89 pub path: PathBuf,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub enum ChangeMessage {
95 Deleting,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100pub enum ChangeAction {
101 Received,
103 LocalChange,
105 NotTransferred,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub enum ChangeType {
111 Message(ChangeMessage),
112 Action(ChangeAction, FileType),
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub enum FileType {
118 File,
120 Directory,
122 Symlink,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Named)]
128pub struct RsyncResult {
129 pub changes: Vec<Change>,
131}
132wirevalue::register_type!(RsyncResult);
133
134impl RsyncResult {
135 pub fn empty() -> Self {
137 Self {
138 changes: Vec::new(),
139 }
140 }
141
142 fn parse_from_output(stdout: &str) -> Result<Self> {
145 let mut changes = Vec::new();
146
147 for line in stdout.lines() {
154 let line = line.trim();
155 let (raw_changes, path) = line.split_at(11);
156 let raw_changes = raw_changes.trim();
157 let path = &path[1..]; let mut iter = raw_changes.chars();
160 let change_type = match iter.next().context("missing change type")? {
161 '*' => ChangeType::Message(match iter.as_str() {
162 "deleting" => ChangeMessage::Deleting,
163 _ => bail!("unexpected change message: {}", raw_changes),
164 }),
165 c => {
166 let atype = match c {
167 '.' => ChangeAction::NotTransferred,
168 '>' => ChangeAction::Received,
169 'c' => ChangeAction::LocalChange,
170 _ => bail!("unexpected change type: {}", raw_changes),
171 };
172 let file_type = match iter.next().context("missing file type")? {
173 'f' => FileType::File,
174 'd' => FileType::Directory,
175 'L' => FileType::Symlink,
176 _ => bail!("unexpected file type: {}", raw_changes),
177 };
178 ChangeType::Action(atype, file_type)
179 }
180 };
181
182 changes.push(Change {
183 change_type,
184 path: PathBuf::from(path),
185 });
186 }
187
188 Ok(Self { changes })
189 }
190}
191
192pub async fn do_rsync(addr: &SocketAddr, workspace: &Path) -> Result<RsyncResult> {
193 fs::create_dir_all(workspace).await?;
196
197 let rsync_bin_path = get_rsync_bin_path().await?;
198 let output = Command::new(rsync_bin_path)
199 .arg("--archive")
200 .arg("--delete")
201 .arg("--itemize-changes")
203 .arg("--delete-after")
207 .arg("--delay-updates")
208 .arg("--exclude=.rsync-tmp.*")
209 .arg(format!("--partial-dir=.rsync-tmp.{}", addr.port()))
210 .arg(format!("rsync://{}/workspace", addr))
211 .arg(format!("{}/", workspace.display()))
212 .stdout(Stdio::piped())
213 .stderr(Stdio::piped())
214 .output()
215 .await
216 .map_err(|e| {
217 if e.kind() == std::io::ErrorKind::NotFound {
218 anyhow::anyhow!(
219 "rsync binary: '{}' does not exist. Please ensure 'rsync' is installed and available in PATH.",
220 rsync_bin_path.display()
221 )
222 } else {
223 anyhow::anyhow!("failed to execute rsync: {}", e)
224 }
225 })?;
226
227 output
228 .status
229 .exit_ok()
230 .with_context(|| format!("rsync failed: {}", String::from_utf8_lossy(&output.stderr)))?;
231
232 RsyncResult::parse_from_output(&String::from_utf8(output.stdout)?)
233}
234
235#[derive(Debug)]
236pub struct RsyncDaemon {
237 child: Child,
238 state: TempDir,
239 addr: SocketAddr,
240}
241
242impl RsyncDaemon {
243 pub async fn spawn(listener: TcpListener, workspace: &Path) -> Result<Self> {
244 let state = TempDir::with_prefix("rsyncd.")?;
245
246 let content = format!(
250 r#"\
251[workspace]
252 path = {workspace}
253 use chroot = no
254 list = no
255 read only = true
256 write only = false
257 uid = {uid}
258 hosts allow = localhost ip6-localhost
259"#,
260 workspace = workspace.display(),
261 uid = nix::unistd::getuid().as_raw(),
262 );
263 let config = state.path().join("rsync.config");
264 fs::write(&config, content).await?;
265
266 let addr = listener.local_addr()?;
273 std::mem::drop(listener);
274
275 let mut child = Command::new(get_rsync_bin_path().await?)
277 .arg("--daemon")
278 .arg("--no-detach")
279 .arg(format!("--address={}", addr.ip()))
280 .arg(format!("--port={}", addr.port()))
281 .arg(format!("--config={}", config.display()))
282 .arg(format!("--log-file={}/log", state.path().display()))
283 .kill_on_drop(true)
284 .spawn()?;
285
286 tokio::select! {
290 res = child.wait() => bail!("unexpected early exit: {:?}", res),
291 res = async {
292 loop {
293 match TcpStream::connect(addr).await {
294 Err(err) if err.kind() == ErrorKind::ConnectionRefused => {
295 tokio::time::sleep(Duration::from_millis(1)).await
296 }
297 Err(err) => return Err(err.into()),
298 Ok(_) => break,
299 }
300 }
301 anyhow::Ok(())
302 } => res?,
303 }
304
305 Ok(Self { child, state, addr })
306 }
307
308 pub fn addr(&self) -> &SocketAddr {
309 &self.addr
310 }
311
312 pub async fn shutdown(mut self) -> Result<String> {
313 let logs = fs::read_to_string(self.state.path().join("log")).await;
314 let id = self.child.id().context("missing pid")?;
315 let pid = Pid::from_raw(id as i32);
316 signal::kill(pid, Signal::SIGINT)?;
317 let status = self.child.wait().await?;
318 ensure!(status.code() == Some(20));
320 Ok(logs?)
321 }
322}
323
324#[derive(Debug, Clone, Named, Serialize, Deserialize)]
325pub struct RsyncMessage {
326 pub connect: reference::PortRef<Connect>,
328 pub result: reference::PortRef<Result<RsyncResult, String>>,
330 pub workspace: WorkspaceLocation,
332}
333wirevalue::register_type!(RsyncMessage);
334
335#[derive(Debug, Default)]
336#[hyperactor::export(handlers = [RsyncMessage])]
337#[hyperactor::spawnable]
338pub struct RsyncActor {
339 }
341
342impl Actor for RsyncActor {}
343
344#[async_trait]
345impl Handler<RsyncMessage> for RsyncActor {
346 async fn handle(
347 &mut self,
348 cx: &hyperactor::Context<Self>,
349 RsyncMessage {
350 workspace,
351 connect,
352 result,
353 }: RsyncMessage,
354 ) -> Result<(), anyhow::Error> {
355 let res = async {
356 let workspace = workspace
357 .resolve()
358 .context("resolving workspace location")?;
359 let (connect_msg, completer) = Connect::allocate(cx.self_addr().clone(), cx);
360 connect.post(cx, connect_msg);
361
362 let ipv6_lo: SocketAddr = "[::1]:0".parse()?;
364 let ipv4_lo: SocketAddr = "127.0.0.1:0".parse()?;
365 let addrs: [SocketAddr; 2] = [ipv6_lo, ipv4_lo];
366
367 let (listener, mut stream) = try_join!(
368 TcpListener::bind(&addrs[..]).err_into(),
369 completer.complete(),
370 )?;
371 let addr = listener.local_addr()?;
372 let (rsync_result, _) = try_join!(do_rsync(&addr, &workspace), async move {
373 let (mut local, _) = listener.accept().await?;
374 tokio::io::copy_bidirectional(&mut stream, &mut local).await?;
375 anyhow::Ok(())
376 },)?;
377 anyhow::Ok(rsync_result)
378 }
379 .await;
380 result.post(cx, res.map_err(|e| format!("{:#?}", e)));
381 Ok(())
382 }
383}
384
385pub async fn rsync_mesh<C: context::Actor + Copy + Unpin>(
386 cx: C,
387 actor_mesh: &ActorMesh<RsyncActor>,
388 local_workspace: PathBuf,
389 remote_workspace: WorkspaceLocation,
390) -> Result<Vec<RsyncResult>> {
391 use ndslice::View;
392
393 let daemon = RsyncDaemon::spawn(TcpListener::bind(("::1", 0)).await?, &local_workspace).await?;
395 let daemon_addr = daemon.addr();
396
397 let (rsync_conns_tx, rsync_conns_rx) = cx.mailbox().open_port::<Connect>();
398 let num_actors = actor_mesh.region().num_ranks();
399
400 let res = try_join!(
401 rsync_conns_rx
402 .take(num_actors)
403 .err_into::<anyhow::Error>()
404 .try_for_each_concurrent(None, |connect| async move {
405 let (mut local, mut stream) = try_join!(
406 TcpStream::connect(*daemon_addr).err_into(),
407 accept(cx, cx.instance().self_addr().clone(), connect),
408 )?;
409 tokio::io::copy_bidirectional(&mut local, &mut stream).await?;
410 anyhow::Ok(())
411 })
412 .boxed(),
413 async move {
414 let (result_tx, result_rx) = cx.mailbox().open_port::<Result<RsyncResult, String>>();
415 actor_mesh.cast(
416 &cx,
417 RsyncMessage {
418 connect: rsync_conns_tx.bind(),
419 result: result_tx.bind(),
420 workspace: remote_workspace,
421 },
422 )?;
423 let res: Vec<RsyncResult> = result_rx
424 .take(num_actors)
425 .map(|res| res?.map_err(anyhow::Error::msg))
426 .try_collect()
427 .await?;
428 anyhow::Ok(res)
429 },
430 );
431
432 let logs = daemon.shutdown().await;
434
435 match res {
437 Ok(((), results)) => {
438 let _ = logs?;
439 Ok(results)
440 }
441 Err(err) => match logs {
442 Ok(logs) => Err(err).with_context(|| format!("rsync server logs: {}", logs)),
443 Err(shutdown_err) => {
444 warn!("failed to read logs from rsync daemon: {:?}", shutdown_err);
445 Err(err)
446 }
447 },
448 }
449}
450
451#[cfg(test)]
452mod tests {
453 use anyhow::Result;
454 use anyhow::anyhow;
455 use hyperactor_mesh::ActorMesh;
456 use hyperactor_mesh::context;
457 use hyperactor_mesh::test_utils;
458 use tempfile::TempDir;
459 use tokio::fs;
460 use tokio::net::TcpListener;
461
462 use super::*;
463
464 #[tokio::test]
465 #[cfg_attr(not(fbcode_build), ignore)]
467 async fn test_simple() -> Result<()> {
468 let input = TempDir::new()?;
469 fs::write(input.path().join("foo.txt"), "hello world").await?;
470
471 let output = TempDir::new()?;
472
473 let server = TcpListener::bind(("::", 0)).await?;
474 let daemon = RsyncDaemon::spawn(server, output.path()).await?;
475 do_rsync(daemon.addr(), input.path()).await?;
476 daemon.shutdown().await?;
477
478 assert!(!dir_diff::is_different(&input, &output).map_err(|e| anyhow!("{:?}", e))?);
479
480 Ok(())
481 }
482
483 #[tokio::test]
484 #[cfg_attr(not(fbcode_build), ignore)]
486 async fn test_rsync_actor_and_mesh() -> Result<()> {
487 let source_workspace = TempDir::new()?;
489 fs::write(source_workspace.path().join("test1.txt"), "content1").await?;
490 fs::write(source_workspace.path().join("test2.txt"), "content2").await?;
491 fs::create_dir(source_workspace.path().join("subdir")).await?;
492 fs::write(source_workspace.path().join("subdir/test3.txt"), "content3").await?;
493
494 let target_workspace = TempDir::new()?;
496 fs::create_dir(target_workspace.path().join("subdir5")).await?;
497 fs::write(target_workspace.path().join("foo.txt"), "something").await?;
498
499 let cx = context().await;
501 let instance = cx.actor_instance;
502 let mut host_mesh = test_utils::local_host_mesh(1).await;
503 let proc_mesh = host_mesh
504 .spawn(instance, "rsync_test", ndslice::Extent::unity(), None, None)
505 .await
506 .unwrap();
507 let actor_mesh: ActorMesh<RsyncActor> =
509 proc_mesh.spawn(instance, "rsync_test", &()).await?;
510
511 let results = rsync_mesh(
513 instance,
514 &actor_mesh,
515 source_workspace.path().to_path_buf(),
516 WorkspaceLocation::Constant(target_workspace.path().to_path_buf()),
517 )
518 .await?;
519
520 assert_eq!(results.len(), 1); let rsync_result = &results[0];
524
525 println!("Rsync result: {:#?}", rsync_result);
529
530 assert!(
532 !dir_diff::is_different(&source_workspace, &target_workspace)
533 .map_err(|e| anyhow!("{:?}", e))?
534 );
535
536 let _ = host_mesh.shutdown(instance).await;
537 Ok(())
538 }
539
540 #[tokio::test]
541 async fn test_rsync_result_parsing() -> Result<()> {
542 let stdout = r#">f+++++++++ test1.txt
544>f+++++++++ test2.txt
545cd+++++++++ subdir/
546>f+++++++++ subdir/test3.txt
547*deleting old_file.txt
548"#;
549
550 let result = RsyncResult::parse_from_output(stdout)?;
551
552 let expected_changes = vec![
554 Change {
555 change_type: ChangeType::Action(ChangeAction::Received, FileType::File),
556 path: PathBuf::from("test1.txt"),
557 },
558 Change {
559 change_type: ChangeType::Action(ChangeAction::Received, FileType::File),
560 path: PathBuf::from("test2.txt"),
561 },
562 Change {
563 change_type: ChangeType::Action(ChangeAction::LocalChange, FileType::Directory),
564 path: PathBuf::from("subdir/"),
565 },
566 Change {
567 change_type: ChangeType::Action(ChangeAction::Received, FileType::File),
568 path: PathBuf::from("subdir/test3.txt"),
569 },
570 Change {
571 change_type: ChangeType::Message(ChangeMessage::Deleting),
572 path: PathBuf::from("old_file.txt"),
573 },
574 ];
575
576 assert_eq!(result.changes.len(), expected_changes.len());
578
579 for (actual, expected) in result.changes.iter().zip(expected_changes.iter()) {
581 assert_eq!(
582 actual, expected,
583 "Change mismatch: actual={:?}, expected={:?}",
584 actual, expected
585 );
586 }
587
588 assert_eq!(result.changes, expected_changes);
590
591 Ok(())
592 }
593}