//! Deployment and run targets against the in-process SSH / SFTP server. use ide_core::remote_settings::{DeploymentMapping, DeploymentServer, DeploymentSettings, RunTarget, SshAuth}; use ide_core::{CancellationToken, IdeEvent, SharedSink}; use ide_remote::{Deployment, Excludes, Mappings, RemoteRuns, RemoteStart, SyncAction, SyncStatus, SyncStep}; use ide_run::{OutputChunk, RunConfig}; use ide_ssh::{RemoteFs, Session, SshFiles, SshTarget}; use parking_lot::Mutex; use std::sync::Arc; struct Env { _tmp: tempfile::TempDir, project: std::path::PathBuf, server_root: std::path::PathBuf, session: Arc, } async fn env() -> Env { let tmp = tempfile::tempdir().unwrap(); let project = tmp.path().join("project"); let server_root = tmp.path().join("server"); std::fs::create_dir_all(project.join("src")).unwrap(); std::fs::create_dir_all(project.join(".git")).unwrap(); std::fs::create_dir_all(&server_root).unwrap(); std::fs::write(project.join("src/main.py"), "print('hi')\n").unwrap(); std::fs::write(project.join("README.md"), "# demo\n").unwrap(); std::fs::write(project.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap(); let port = ide_ssh::testing::sshd(server_root.clone(), Arc::new(|cmd| (format!("ran: {cmd}"), 0))).await; let files = SshFiles { known_hosts: tmp.path().join("known_hosts"), config: None }; let target = SshTarget { host: "127.0.0.1".into(), port: Some(port), user: "dev".into(), auth: SshAuth::Password, key_file: String::new(), }; let session = match Session::connect(&target, Some("secret"), &files).await { Err(ide_ssh::ConnectError::UnknownHostKey(k)) => { k.trust(&files.known_hosts).unwrap(); Session::connect(&target, Some("secret"), &files).await.ok().unwrap() } other => other.ok().unwrap(), }; Env { _tmp: tmp, project, server_root, session: Arc::new(session) } } fn server() -> DeploymentServer { DeploymentServer { id: "s".into(), name: "staging".into(), root_path: "/srv".into(), mappings: vec![DeploymentMapping { local: String::new(), deployment: "app".into(), web: String::new() }], ..Default::default() } } fn no_progress() -> impl FnMut(&str, usize, usize) + Send { |_, _, _| {} } #[tokio::test] async fn client_imports_preserve_conflicts_and_report_failure_over_sftp_and_local() { let env = env().await; for sftp in [false, true] { let fs = if sftp { RemoteFs::new(env.session.sftp().await.unwrap()) } else { RemoteFs::Local }; let base = if sftp { "/imports".to_owned() } else { env.server_root.join("local").to_string_lossy().into_owned() }; fs.mkdir_all(&base).await.unwrap(); let to = ide_ssh::sftp::join(&base, "copy"); let cancel = CancellationToken::new(); std::fs::create_dir_all(env.project.join("empty")).unwrap(); ide_remote::import::import_local(&fs, &env.project, &to, &cancel).await.unwrap(); assert_eq!(fs.read(&format!("{to}/src/main.py")).await.unwrap(), b"print('hi')\n"); assert!(fs.stat(&format!("{to}/empty")).await.unwrap().unwrap().is_dir); let err = ide_remote::import::import_local(&fs, &env.project, &to, &cancel).await.unwrap_err(); assert_eq!(err.kind, ide_core::error::ErrorKind::AlreadyExists); let file = format!("{to}/README.md"); assert!(fs.upload_new(&env.project.join("src/main.py"), &file, &cancel).await.is_err()); assert_eq!(fs.read(&file).await.unwrap(), b"# demo\n"); let missing = ide_remote::import::import_local(&fs, &env.project.join("missing"), &format!("{base}/missing"), &cancel) .await .unwrap_err(); assert_eq!(missing.kind, ide_core::error::ErrorKind::NotFound); cancel.cancel(); let err = ide_remote::import::import_local(&fs, &env.project, &format!("{base}/cancelled"), &cancel) .await .unwrap_err(); assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled); assert!(fs.stat(&format!("{base}/cancelled")).await.unwrap().is_none()); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let source = env.project.join("links-tree"); std::fs::create_dir_all(&source).unwrap(); std::fs::write(source.join("run.sh"), "#!/bin/sh\necho imported\n").unwrap(); std::fs::set_permissions(source.join("run.sh"), std::fs::Permissions::from_mode(0o751)).unwrap(); std::os::unix::fs::symlink("run.sh", source.join("link")).unwrap(); std::os::unix::fs::symlink("missing", source.join("dangling")).unwrap(); std::os::unix::fs::symlink(".", source.join("cycle")).unwrap(); let socket = std::os::unix::net::UnixListener::bind(source.join("socket")).unwrap(); let dest = format!("{base}/links"); let skipped = ide_remote::import::import_local(&fs, &source, &dest, &CancellationToken::new()).await.unwrap(); assert_eq!(skipped, vec![format!("{}: skipped special file", source.join("socket").display())]); let on_disk = if sftp { env.server_root.join("imports/links") } else { std::path::PathBuf::from(&dest) }; for (name, target) in [("link", "run.sh"), ("dangling", "missing"), ("cycle", ".")] { assert_eq!(std::fs::read_link(on_disk.join(name)).unwrap(), std::path::PathBuf::from(target)); } assert_eq!(std::fs::metadata(on_disk.join("run.sh")).unwrap().permissions().mode() & 0o777, 0o751); assert_eq!(fs.read(&format!("{dest}/run.sh")).await.unwrap(), b"#!/bin/sh\necho imported\n"); assert!(!on_disk.join("socket").exists()); let skipped = ide_remote::import::import_local( &fs, &source.join("socket"), &format!("{base}/socket"), &CancellationToken::new(), ) .await .unwrap(); assert_eq!(skipped.len(), 1); assert!(fs.stat(&format!("{base}/socket")).await.unwrap().is_none()); drop(socket); std::fs::remove_dir_all(source).unwrap(); } fs.close().await; } } #[tokio::test] async fn uploads_compares_syncs_and_downloads() { let env = env().await; let fs = RemoteFs::new(env.session.sftp().await.unwrap()); let server = server(); let settings = DeploymentSettings::default(); let map = Mappings::new(&server, &env.project, "/srv"); let exclude = Excludes::new(&settings, &server, "/srv"); let d = Deployment { fs: &fs, map: &map, exclude: &exclude, preserve_time: true, cancel: CancellationToken::new() }; let report = d.upload(std::slice::from_ref(&env.project), &mut no_progress()).await; assert_eq!((report.files, report.errors.len()), (2, 0), "{report:?}"); assert_eq!(std::fs::read_to_string(env.server_root.join("srv/app/src/main.py")).unwrap(), "print('hi')\n"); assert!(!env.server_root.join("srv/app/.git").exists(), ".git is excluded by name"); let same = d.compare(&env.project).await.unwrap(); assert!(same.iter().all(|e| e.status == SyncStatus::Same), "{same:?}"); // A new local file, a file only on the server, a remote change. std::fs::write(env.project.join("src/new.py"), "x = 1\n").unwrap(); std::fs::write(env.server_root.join("srv/app/remote.txt"), "server side\n").unwrap(); std::fs::write(env.server_root.join("srv/app/README.md"), "# changed on the server, longer\n").unwrap(); let file = std::fs::File::options().write(true).open(env.server_root.join("srv/app/README.md")).unwrap(); file.set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(60)).unwrap(); let entries = d.compare(&env.project).await.unwrap(); let status = |p: &str| entries.iter().find(|e| e.path == p).map(|e| e.status); assert_eq!(status("src/new.py"), Some(SyncStatus::LocalOnly)); assert_eq!(status("remote.txt"), Some(SyncStatus::RemoteOnly)); assert_eq!(status("README.md"), Some(SyncStatus::RemoteNewer)); assert_eq!(status("src/main.py"), Some(SyncStatus::Same)); let steps = vec![ SyncStep { path: "src/new.py".into(), action: SyncAction::Upload }, SyncStep { path: "README.md".into(), action: SyncAction::Download }, SyncStep { path: "remote.txt".into(), action: SyncAction::DeleteRemote }, ]; let report = d.apply(&steps, &mut no_progress()).await; assert_eq!((report.files, report.errors.len()), (3, 0), "{report:?}"); assert!(env.server_root.join("srv/app/src/new.py").is_file()); assert!(!env.server_root.join("srv/app/remote.txt").exists()); assert_eq!(std::fs::read_to_string(env.project.join("README.md")).unwrap(), "# changed on the server, longer\n"); // Download a folder, then delete it remotely. std::fs::remove_dir_all(env.project.join("src")).unwrap(); let report = d.download(&[env.project.join("src")], &mut no_progress()).await; assert_eq!((report.files, report.errors.len()), (2, 0), "{report:?}"); assert!(env.project.join("src/main.py").is_file()); let report = d.delete_remote(&[env.project.join("src")]).await; assert_eq!(report.files, 1); assert!(!env.server_root.join("srv/app/src").exists()); // Sync before a run uploads only what changed. let report = d.push_changes(&env.project, &mut no_progress()).await.unwrap(); assert_eq!(report.files, 2, "{report:?}"); assert_eq!(d.push_changes(&env.project, &mut no_progress()).await.unwrap().files, 0); } struct Sink(Mutex>); impl ide_core::EventSink for Sink { fn emit(&self, event: IdeEvent) { self.0.lock().push(event); } } #[tokio::test] async fn runs_a_configuration_on_a_target() { let env = env().await; let runs = RemoteRuns::new(); let target = RunTarget { id: "t".into(), name: "box".into(), python: "/usr/bin/python3".into(), ..Default::default() }; let config = RunConfig { id: "main".into(), name: "main".into(), kind: "python".into(), command: "python3".into(), args: vec![env.project.join("src/main.py").to_string_lossy().into_owned()], cwd: None, env: Default::default(), detected: false, is_test: false, debug_adapter: None, allow_parallel: false, extras: Default::default(), }; let output: Arc> = Arc::default(); let out = output.clone(); let sink = Arc::new(Sink(Mutex::default())); let start = RemoteStart { session: env.session.clone(), target, root: env.project.clone(), remote_root: "/home/dev/demo".into(), sync: None, }; let info = runs.start( start, &config, sink.clone() as SharedSink, Box::new(move |chunk| match chunk { OutputChunk::Stdout { text } | OutputChunk::Stderr { text } | OutputChunk::System { text } => { out.lock().push_str(&text) } }), ); assert!(runs.contains(info.id)); for _ in 0..200 { if !sink.0.lock().is_empty() { break; } tokio::time::sleep(std::time::Duration::from_millis(10)).await; } let events = sink.0.lock().clone(); assert!(matches!(events.as_slice(), [IdeEvent::RunExited { code: Some(0), .. }]), "{events:?}"); let text = output.lock().clone(); assert!(text.contains("ran: cd /home/dev/demo && exec /usr/bin/python3 /home/dev/demo/src/main.py"), "{text}"); assert!(text.contains("Process finished with exit code 0"), "{text}"); assert!(!runs.list()[0].running); }