Skip to main content

monarch_hyperactor/code_sync/
rsync.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9use 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/// Represents a single file change from rsync
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct Change {
87    /// The type of change that occurred
88    pub change_type: ChangeType,
89    /// The path of the file that changed
90    pub path: PathBuf,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub enum ChangeMessage {
95    /// Path was deleted
96    Deleting,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100pub enum ChangeAction {
101    /// File was received.
102    Received,
103    // Path was changed/created locally.
104    LocalChange,
105    NotTransferred,
106}
107
108/// The type of change that occurred to a file
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub enum ChangeType {
111    Message(ChangeMessage),
112    Action(ChangeAction, FileType),
113}
114
115/// The type of file that changed
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub enum FileType {
118    /// Regular file
119    File,
120    /// Directory
121    Directory,
122    /// Symbolic link
123    Symlink,
124}
125
126/// Represents the result of an rsync operation with details about what was transferred
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Named)]
128pub struct RsyncResult {
129    /// All changes that occurred during the rsync operation
130    pub changes: Vec<Change>,
131}
132wirevalue::register_type!(RsyncResult);
133
134impl RsyncResult {
135    /// Create an empty rsync result
136    pub fn empty() -> Self {
137        Self {
138            changes: Vec::new(),
139        }
140    }
141
142    /// Parse rsync output to extract file transfer information
143    /// Since create_dir_all ensures the workspace exists, we can assume all stdout lines are changes
144    fn parse_from_output(stdout: &str) -> Result<Self> {
145        let mut changes = Vec::new();
146
147        // Parse stdout for file operations (when using --itemize-changes)
148        // All lines in stdout represent changes since the workspace directory exists
149
150        // rsync itemize format: YXcstpoguax path
151        // Y = update type (>, c, h, ., etc.)
152        // X = file type (f=file, d=directory, L=symlink, etc.)
153        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..]; // remove leading space
158
159            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    // Make sure the target workspace exists, mainly to avoid the "created director ..."
194    // line in rsync output.
195    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        // Show detailed changes for each file
202        .arg("--itemize-changes")
203        // By setting these flags, we make `rsync` immune to multiple invocations
204        // targeting the same dir, which can happen if we don't take care to only
205        // allow one worker on a given host to do the `rsync`.
206        .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        // Write rsync config file
247        // TODO(agallagher): We can setup a secrets file to provide some measure of
248        // security and prevent stray rsync calls from hitting the server.
249        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        // Find free port.  This is potentially racy, as some process could
267        // potentially bind to this port in between now and when `rsync` starts up
268        // below.  But I'm not sure a better way to do this, as rsync doesn't appear
269        // to support `rsync --sockopts=SO_PORTREUSE` (to share this port we've
270        // reserved) or `--port=0` (to pick a free port -- it'll just always use
271        // 873).
272        let addr = listener.local_addr()?;
273        std::mem::drop(listener);
274
275        // Spawn the rsync daemon.
276        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        // Wait until the rsync daemon is ready to connect via polling it (I tried polling
287        // the log file to wait for the "listening" log line, but that gets prevented *before*
288        // it actually starts the listening loop).
289        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        // rsync exists with 20 when sent SIGINT.
319        ensure!(status.code() == Some(20));
320        Ok(logs?)
321    }
322}
323
324#[derive(Debug, Clone, Named, Serialize, Deserialize)]
325pub struct RsyncMessage {
326    /// The connect message to create a duplex bytestream with the client.
327    pub connect: reference::PortRef<Connect>,
328    /// A port to send back the rsync result or any errors.
329    pub result: reference::PortRef<Result<RsyncResult, String>>,
330    /// The location of the workspace to sync.
331    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    //workspace: WorkspaceLocation,
340}
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            // some machines (e.g. github CI) do not have ipv6, so try ipv6 then fallback to ipv4
363            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    // Spawn a rsync daemon to accept incoming connections from actors.
394    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    // Kill rsync server and attempt to grab the logs.
433    let logs = daemon.shutdown().await;
434
435    // Return results, attaching rsync daemon logs on error.
436    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    // TODO: OSS: Cannot assign requested address (os error 99)
466    #[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    // TODO: OSS: Cannot assign requested address (os error 99)
485    #[cfg_attr(not(fbcode_build), ignore)]
486    async fn test_rsync_actor_and_mesh() -> Result<()> {
487        // Create source workspace with test files
488        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        // Create target workspace for the actors
495        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        // Set up actor mesh with 2 RsyncActors
500        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        // Spawn actor mesh with RsyncActors
508        let actor_mesh: ActorMesh<RsyncActor> =
509            proc_mesh.spawn(instance, "rsync_test", &()).await?;
510
511        // Test rsync_mesh function - this coordinates rsync operations across the mesh
512        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        // Verify we got results back
521        assert_eq!(results.len(), 1); // We have 1 actor in the mesh
522
523        let rsync_result = &results[0];
524
525        // Verify that files were transferred (should be at least the files we created)
526        // Note: The exact files detected may vary based on rsync's itemization,
527        // but we should have some indication of transfer activity
528        println!("Rsync result: {:#?}", rsync_result);
529
530        // Verify we copied correctly.
531        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        // Test the parsing logic with mock rsync output
543        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        // Define the expected changes
553        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        // Verify we have all expected changes
577        assert_eq!(result.changes.len(), expected_changes.len());
578
579        // Compare each change
580        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        // Verify the entire result matches
589        assert_eq!(result.changes, expected_changes);
590
591        Ok(())
592    }
593}