diff --git a/test/test-manager/docs/config.md b/test/test-manager/docs/config.md index f86315241d61..8c4db7fce45c 100644 --- a/test/test-manager/docs/config.md +++ b/test/test-manager/docs/config.md @@ -57,7 +57,7 @@ A configuration containing one Debian 12 VM and one Windows 11 VM "image_path": "$VM_IMAGES/windows11.qcow2", "os_type": "windows", "package_type": null, - "architecture": null, + "architecture": "x64", "provisioner": "noop", "ssh_user": null, "ssh_password": null, diff --git a/test/test-manager/src/config.rs b/test/test-manager/src/config.rs index 7379bd5ec625..95a13c48a62a 100644 --- a/test/test-manager/src/config.rs +++ b/test/test-manager/src/config.rs @@ -154,8 +154,8 @@ pub struct VmConfig { pub package_type: Option, /// CPU architecture - #[arg(long, required_if_eq("os_type", "linux"))] - pub architecture: Option, + #[arg(long)] + pub architecture: Architecture, /// Tool to use for provisioning #[arg(long, default_value = "noop")] @@ -203,8 +203,8 @@ impl VmConfig { pub fn get_default_runner_dir(&self) -> PathBuf { let target_dir = self.get_target_dir(); let subdir = match self.architecture { - None | Some(Architecture::X64) => self.get_x64_runner_subdir(), - Some(Architecture::Aarch64) => self.get_aarch64_runner_subdir(), + Architecture::X64 => self.get_x64_runner_subdir(), + Architecture::Aarch64 => self.get_aarch64_runner_subdir(), }; target_dir.join(subdir) @@ -282,10 +282,10 @@ pub enum Architecture { } impl Architecture { - pub fn get_identifiers(&self) -> &[&'static str] { + pub fn get_identifiers(self) -> Vec<&'static str> { match self { - Architecture::X64 => &["x86_64", "amd64"], - Architecture::Aarch64 => &["arm64", "aarch64"], + Architecture::X64 => vec!["x86_64", "amd64"], + Architecture::Aarch64 => vec!["arm64", "aarch64"], } } } diff --git a/test/test-manager/src/main.rs b/test/test-manager/src/main.rs index a56f1f2ea877..973a1aec06f2 100644 --- a/test/test-manager/src/main.rs +++ b/test/test-manager/src/main.rs @@ -13,6 +13,7 @@ use std::{net::SocketAddr, path::PathBuf}; use anyhow::{Context, Result}; use clap::{builder::PossibleValuesParser, Parser}; +use package::TargetInfo; use tests::{config::TEST_CONFIG, get_filtered_tests}; use vm::provision; @@ -270,22 +271,10 @@ async fn main() -> Result<()> { log::debug!("Mullvad host: {mullvad_host}"); let vm_config = vm::get_vm_config(&config, &vm).context("Cannot get VM config")?; - - let summary_logger = match test_report { - Some(path) => Some( - summary::SummaryLogger::new( - &vm, - test_rpc::meta::Os::from(vm_config.os_type), - &path, - ) - .await - .context("Failed to create summary logger")?, - ), - None => None, - }; + let runner_target = TargetInfo::try_from(vm_config)?; let manifest = package::get_app_manifest( - vm_config, + runner_target, app_package, app_package_to_upgrade_from, gui_package, @@ -337,6 +326,19 @@ async fn main() -> Result<()> { let skip_wait = vm_config.provisioner != config::Provisioner::Noop; + let summary_logger = match test_report { + Some(path) => Some( + summary::SummaryLogger::new( + &vm, + test_rpc::meta::Os::from(vm_config.os_type), + &path, + ) + .await + .context("Failed to create summary logger")?, + ), + None => None, + }; + let result = run_tests::run(&*instance, tests, skip_wait, !verbose, summary_logger) .await .context("Tests failed"); diff --git a/test/test-manager/src/package.rs b/test/test-manager/src/package.rs index 47915563926b..d95a5b117dea 100644 --- a/test/test-manager/src/package.rs +++ b/test/test-manager/src/package.rs @@ -1,5 +1,5 @@ use crate::config::{Architecture, OsType, PackageType, VmConfig}; -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use itertools::Itertools; use regex::Regex; use std::{ @@ -14,24 +14,38 @@ pub struct Manifest { pub gui_package_path: Option, } +/// Basic metadata about the test runner target platform such as OS, architecture and package +/// manager. +#[derive(Debug, Clone, Copy)] +pub enum TargetInfo { + Windows { + arch: Architecture, + }, + Macos { + arch: Architecture, + }, + Linux { + arch: Architecture, + package_type: PackageType, + }, +} + /// Obtain app packages and their filenames /// If it's a path, use the path. /// If it corresponds to a file in packages/, use that package. /// TODO: If it's a git tag or rev, download it. pub fn get_app_manifest( - config: &VmConfig, + runner_target: TargetInfo, app_package: String, app_package_to_upgrade_from: Option, gui_package: Option, package_dir: Option, ) -> Result { - let package_type = (config.os_type, config.package_type, config.architecture); - - let app_package_path = find_app(&app_package, false, package_type, package_dir.as_ref())?; + let app_package_path = find_app(&app_package, false, runner_target, package_dir.as_ref())?; log::info!("App package: {}", app_package_path.display()); let app_package_to_upgrade_from_path = app_package_to_upgrade_from - .map(|app| find_app(&app, false, package_type, package_dir.as_ref())) + .map(|app| find_app(&app, false, runner_target, package_dir.as_ref())) .transpose()?; log::info!("App package to upgrade from: {app_package_to_upgrade_from_path:?}"); @@ -52,7 +66,7 @@ pub fn get_app_manifest( None => &app_version, }, true, - package_type, + runner_target, Some(&ui_e2e_package_dir), ); @@ -86,7 +100,7 @@ pub fn get_version_from_path(app_package_path: &Path) -> Result, Option), + runner_target: TargetInfo, package_dir: Option<&PathBuf>, ) -> Result { // If it's a path, use that path @@ -112,7 +126,7 @@ fn find_app( e2e_bin || path .extension() - .map(|m_ext| m_ext.eq_ignore_ascii_case(get_ext(package_type))) + .map(|m_ext| m_ext.eq_ignore_ascii_case(runner_target.get_ext())) .unwrap_or(false) }) // Filter out irrelevant platforms .map(|path| { @@ -120,10 +134,10 @@ fn find_app( (path, u8_path) }) .filter(|(_path, u8_path)| !(e2e_bin ^ u8_path.contains("app-e2e-tests"))) // Skip non-UI-e2e binaries or vice versa - .filter(|(_path, u8_path)| !e2e_bin || u8_path.contains(get_os_name(package_type))) // Filter out irrelevant platforms + .filter(|(_path, u8_path)| !e2e_bin || u8_path.contains(runner_target.get_os_name())) // Filter out irrelevant platforms .filter(|(_path, u8_path)| { - let linux = e2e_bin || package_type.0 == OsType::Linux; - let matching_ident = package_type.2.map(|arch| arch.get_identifiers().iter().any(|id| u8_path.contains(id))).unwrap_or(true); + let linux = e2e_bin || runner_target.is_linux(); + let matching_ident = runner_target.get_identifiers().any(|id| u8_path.contains(id)); // Skip for non-Linux, because there's only one package !linux || matching_ident }) // Skip file if it doesn't match the architecture @@ -143,22 +157,61 @@ fn find_app( }) } -fn get_ext(package_type: (OsType, Option, Option)) -> &'static str { - match package_type.0 { - OsType::Windows => "exe", - OsType::Macos => "pkg", - OsType::Linux => match package_type.1.expect("must specify package type") { - PackageType::Deb => "deb", - PackageType::Rpm => "rpm", - }, +impl TargetInfo { + const fn is_linux(self) -> bool { + matches!(self, TargetInfo::Linux { .. }) + } + + const fn get_ext(self) -> &'static str { + match self { + TargetInfo::Windows { .. } => "exe", + TargetInfo::Macos { .. } => "pkg", + TargetInfo::Linux { package_type, .. } => match package_type { + PackageType::Deb => "deb", + PackageType::Rpm => "rpm", + }, + } + } + + const fn get_os_name(self) -> &'static str { + match self { + TargetInfo::Windows { .. } => "windows", + TargetInfo::Macos { .. } => "apple", + TargetInfo::Linux { .. } => "linux", + } + } + + fn get_identifiers(self) -> impl Iterator { + match self { + TargetInfo::Windows { arch } + | TargetInfo::Macos { arch } + | TargetInfo::Linux { arch, .. } => arch.get_identifiers().into_iter(), + } } } -fn get_os_name(package_type: (OsType, Option, Option)) -> &'static str { - match package_type.0 { - OsType::Windows => "windows", - OsType::Macos => "apple", - OsType::Linux => "linux", +impl TryFrom<&VmConfig> for TargetInfo { + type Error = anyhow::Error; + + fn try_from(config: &VmConfig) -> std::result::Result { + let target_info = match config.os_type { + OsType::Windows => TargetInfo::Windows { + arch: config.architecture, + }, + OsType::Macos => TargetInfo::Macos { + arch: config.architecture, + }, + OsType::Linux => { + let Some(package_type) = config.package_type else { + bail!("Linux VM configuration did not specify any package type (Deb|Rpm)!"); + }; + TargetInfo::Linux { + arch: config.architecture, + package_type, + } + } + }; + Ok(target_info) } } diff --git a/test/test-manager/src/vm/provision.rs b/test/test-manager/src/vm/provision.rs index 359cf16c33d8..1ca962cbfb2a 100644 --- a/test/test-manager/src/vm/provision.rs +++ b/test/test-manager/src/vm/provision.rs @@ -4,7 +4,7 @@ use crate::{ tests::config::BOOTSTRAP_SCRIPT, }; use anyhow::{bail, Context, Result}; -use ssh2::Session; +use ssh2::{File, Session}; use std::{ io::{self, Read}, net::{IpAddr, SocketAddr, TcpStream}, @@ -59,42 +59,48 @@ async fn provision_ssh( let user = user.to_owned(); let password = password.to_owned(); - let remote_dir = match os_type { - OsType::Windows => r"C:\testing", - OsType::Macos | OsType::Linux => r"/opt/testing", - }; - let local_runner_dir = local_runner_dir.to_owned(); let local_app_manifest = local_app_manifest.to_owned(); - tokio::task::spawn_blocking(move || { + let remote_dir = tokio::task::spawn_blocking(move || { blocking_ssh( user, password, guest_ip, + os_type, &local_runner_dir, local_app_manifest, - remote_dir, ) }) .await .context("Failed to join SSH task")??; - Ok(remote_dir.to_string()) + Ok(remote_dir) } +/// Returns the remote runner directory fn blocking_ssh( user: String, password: String, guest_ip: IpAddr, + os_type: OsType, local_runner_dir: &Path, local_app_manifest: package::Manifest, - remote_dir: &str, -) -> Result<()> { - // Directory that receives the payload. Any directory that the SSH user has access to. - const REMOTE_TEMP_DIR: &str = "/tmp/"; +) -> Result { + let remote_dir = match os_type { + // FIXME: There is a problem with the `ssh2` crate (both with scp and sftp) that + // we can not create new directories, so instead we have to rely on pre-existing + // directories if we want to create / upload files to the Windows guest. As a + // workaround, use `C:` as a temporary directory. + OsType::Windows => "c:", + OsType::Macos | OsType::Linux => "/opt/testing", + }; - let temp_dir = Path::new(REMOTE_TEMP_DIR); + // Directory that receives the payload. Any directory that the SSH user has access to. + let remote_temp_dir = match os_type { + OsType::Windows => r"c:\temp", + OsType::Macos | OsType::Linux => r"/tmp/", + }; let stream = TcpStream::connect(SocketAddr::new(guest_ip, 22)).context("TCP connect failed")?; @@ -106,73 +112,84 @@ fn blocking_ssh( .userauth_password(&user, &password) .context("SSH auth failed")?; + let temp_dir = Path::new(remote_temp_dir); // Transfer a test runner let source = local_runner_dir.join("test-runner"); - ssh_send_file(&session, &source, temp_dir) + ssh_send_file_with_opts(&session, &source, temp_dir, FileOpts { executable: true }) .with_context(|| format!("Failed to send '{source:?}' to remote"))?; // Transfer connection-checker let source = local_runner_dir.join("connection-checker"); - ssh_send_file(&session, &source, temp_dir) + ssh_send_file_with_opts(&session, &source, temp_dir, FileOpts { executable: true }) .with_context(|| format!("Failed to send '{source:?}' to remote"))?; // Transfer app packages let source = &local_app_manifest.app_package_path; - ssh_send_file(&session, source, temp_dir) + ssh_send_file_with_opts(&session, source, temp_dir, FileOpts { executable: true }) .with_context(|| format!("Failed to send '{source:?}' to remote"))?; if let Some(source) = &local_app_manifest.app_package_to_upgrade_from_path { - ssh_send_file(&session, source, temp_dir) + ssh_send_file_with_opts(&session, source, temp_dir, FileOpts { executable: true }) .with_context(|| format!("Failed to send '{source:?}' to remote"))?; } else { log::warn!("No previous app package to upgrade from to send to remote") } if let Some(source) = &local_app_manifest.gui_package_path { - ssh_send_file(&session, source, temp_dir) + ssh_send_file_with_opts(&session, source, temp_dir, FileOpts { executable: true }) .with_context(|| format!("Failed to send '{source:?}' to remote"))?; } else { log::warn!("No UI e2e test to send to remote") } // Transfer setup script - // TODO: Move this name to a constant somewhere? - let bootstrap_script_dest = temp_dir.join("ssh-setup.sh"); - ssh_write(&session, &bootstrap_script_dest, BOOTSTRAP_SCRIPT) + if matches!(os_type, OsType::Linux | OsType::Macos) { + // TODO: Move this name to a constant somewhere? + let bootstrap_script_dest = temp_dir.join("ssh-setup.sh"); + ssh_write_with_opts( + &session, + &bootstrap_script_dest, + BOOTSTRAP_SCRIPT, + FileOpts { executable: true }, + ) .context("failed to send bootstrap script to remote")?; - // Run setup script - let app_package_path = local_app_manifest - .app_package_path - .file_name() - .unwrap() - .to_string_lossy(); - let app_package_to_upgrade_from_path = local_app_manifest - .app_package_to_upgrade_from_path - .map(|path| path.file_name().unwrap().to_string_lossy().into_owned()) - .unwrap_or_default(); - let gui_package_path = local_app_manifest - .gui_package_path - .map(|path| path.file_name().unwrap().to_string_lossy().into_owned()) - .unwrap_or_default(); - - // Run the setup script in the test runner - let cmd = format!( - r#"sudo {} {remote_dir} "{app_package_path}" "{app_package_to_upgrade_from_path}" "{gui_package_path}" "{UNPRIVILEGED_USER}""#, - bootstrap_script_dest.display(), - ); - log::debug!("Running setup script on remote, cmd: {cmd}"); - ssh_exec(&session, &cmd) - .map(drop) - .context("Failed to run setup script") + // Run setup script + let app_package_path = local_app_manifest + .app_package_path + .file_name() + .unwrap() + .to_string_lossy(); + let app_package_to_upgrade_from_path = local_app_manifest + .app_package_to_upgrade_from_path + .map(|path| path.file_name().unwrap().to_string_lossy().into_owned()) + .unwrap_or_default(); + let gui_package_path = local_app_manifest + .gui_package_path + .map(|path| path.file_name().unwrap().to_string_lossy().into_owned()) + .unwrap_or_default(); + + // Run the setup script in the test runner + let cmd = format!( + r#"sudo {} {remote_dir} "{app_package_path}" "{app_package_to_upgrade_from_path}" "{gui_package_path}" "{UNPRIVILEGED_USER}""#, + bootstrap_script_dest.display(), + ); + log::debug!("Running setup script on remote, cmd: {cmd}"); + ssh_exec(&session, &cmd) + .map(drop) + .context("Failed to run setup script")?; + } + + Ok(remote_dir.to_string()) } -/// Copy a `source` file to `dest_dir` in the test runner. +/// Copy a `source` file to `dest_dir` in the test runner with opts. /// -/// Returns the aboslute path in the test runner where the file is stored. -fn ssh_send_file + Copy>( +/// Returns the absolute path in the test runner where the file is stored. +fn ssh_send_file_with_opts + Copy>( session: &Session, source: P, dest_dir: &Path, + opts: FileOpts, ) -> Result { let dest = dest_dir.join( source @@ -190,27 +207,46 @@ fn ssh_send_file + Copy>( let source = std::fs::read(source) .with_context(|| format!("Failed to open file at {}", source.as_ref().display()))?; - ssh_write(session, &dest, &source[..])?; + ssh_write_with_opts(session, &dest, &source[..], opts)?; Ok(dest) } -/// Analogues to [`std::fs::write`], but over ssh! -fn ssh_write, C: AsRef<[u8]>>(session: &Session, dest: P, source: C) -> Result<()> { - let bytes = source.as_ref(); +/// Create a new file with opts at location `dest` and write the content of `source` into it. +/// Returns a handle to the newly created file. +fn ssh_write_with_opts>( + session: &Session, + dest: P, + mut source: impl Read, + opts: FileOpts, +) -> Result { + let sftp = session.sftp()?; + let mut remote_file = sftp.create(dest.as_ref())?; - let source = &mut &bytes[..]; - let source_len = u64::try_from(bytes.len()).context("File too large, did not fit in a u64")?; + io::copy(&mut source, &mut remote_file).context("failed to write file")?; - let mut remote_file = session.scp_send(dest.as_ref(), 0o744, source_len, None)?; + if opts.executable { + make_executable(&mut remote_file)?; + }; - io::copy(source, &mut remote_file).context("failed to write file")?; + Ok(remote_file) +} - remote_file.send_eof()?; - remote_file.wait_eof()?; - remote_file.close()?; - remote_file.wait_close()?; +/// Extra options that may be necessary to configure for files written to the test runner VM. +/// Used in conjunction with the `ssh_*_with_opts` functions. +#[derive(Clone, Copy, Debug, Default)] +struct FileOpts { + /// If file should be executable. + executable: bool, +} +fn make_executable(file: &mut File) -> Result<()> { + // Make sure that the script is executable! + let mut file_stat = file.stat()?; + // 0x111 is the executable bit for Owner/Group/Public + let perm = file_stat.perm.map(|perm| perm | 0x111).unwrap_or(0x111); + file_stat.perm = Some(perm); + file.setstat(file_stat)?; Ok(()) }