diff --git a/flake.nix b/flake.nix index 86963146..7271f23d 100644 --- a/flake.nix +++ b/flake.nix @@ -36,13 +36,13 @@ ./nix/hooks.nix # pre-commit hooks ./nix/utils.nix # utility functions ./nix/shells.nix - ./nix/tests.nix ./wire/cli ./wire/key_agent ./doc ./tests/nix ./runtime ./bench/runner.nix + ./tests/tests.nix ]; systems = import systems; diff --git a/nix/shells.nix b/nix/shells.nix index 752cccf0..08b819b9 100644 --- a/nix/shells.nix +++ b/nix/shells.nix @@ -5,6 +5,7 @@ lib, craneLib, pkgs, + cargo-testing-exports, ... }: let @@ -32,6 +33,7 @@ cfg.installationScript '' export WIRE_TEST_DIR=$(realpath ./tests/rust) + ${cargo-testing-exports} '' ]; }; diff --git a/nix/tests.nix b/nix/tests.nix deleted file mode 100644 index 86c6d30e..00000000 --- a/nix/tests.nix +++ /dev/null @@ -1,38 +0,0 @@ -{ - perSystem = - { - craneLib, - pkgs, - commonArgs, - ... - }: - let - tests = craneLib.buildPackage ( - { - cargoArtifacts = craneLib.buildDepsOnly commonArgs; - doCheck = false; - - doNotPostBuildInstallCargoBinaries = true; - - buildPhase = '' - cargo test --no-run - ''; - - installPhaseCommand = '' - mkdir -p $out - cp $(ls target/debug/deps/{wire,lib,key_agent}-* | grep -v "\.d") $out - ''; - } - // commonArgs - ); - in - { - packages.cargo-tests = pkgs.writeShellScriptBin "run-tests" '' - set -e - for item in "${tests}"/*; do - echo "running $item" - "$item" - done - ''; - }; -} diff --git a/tests/tests.nix b/tests/tests.nix new file mode 100644 index 00000000..1dd7b87d --- /dev/null +++ b/tests/tests.nix @@ -0,0 +1,126 @@ +{ inputs, ... }: +{ + perSystem = + { + craneLib, + pkgs, + lib, + commonArgs, + system, + cargo-testing-vms, + cargo-testing-exports, + self', + ... + }: + let + evalConfig = import (pkgs.path + "/nixos/lib/eval-config.nix"); + tests = craneLib.buildPackage ( + { + cargoArtifacts = craneLib.buildDepsOnly commonArgs; + doCheck = false; + + doNotPostBuildInstallCargoBinaries = true; + + buildPhase = '' + cargo test --no-run + ''; + + installPhaseCommand = '' + mkdir -p $out + cp $(ls target/debug/deps/{wire,lib,key_agent}-* | grep -v "\.d") $out + ''; + } + // commonArgs + ); + + snakeOil = import "${pkgs.path}/nixos/tests/ssh-keys.nix" pkgs; + in + { + packages.cargo-tests = pkgs.writeShellScriptBin "run-tests" '' + set -e + + ${cargo-testing-exports} + + for item in "${tests}"/*; do + echo "running $item" + "$item" + done + ''; + + _module.args = { + cargo-testing-exports = '' + export WIRE_TEST_VM="${cargo-testing-vms}" + export WIRE_PUSHABLE_PATH="${self'.packages.agent}" + export WIRE_SSH_KEY="${snakeOil.snakeOilEd25519PrivateKey}" + ''; + + cargo-testing-vms = + let + mkVM = + index: + evalConfig { + inherit system; + modules = lib.singleton { + imports = [ "${inputs.nixpkgs}/nixos/modules/virtualisation/qemu-vm.nix" ]; + + networking.hostName = "cargo-vm-${builtins.toString index}"; + + boot = { + loader = { + systemd-boot.enable = true; + efi.canTouchEfiVariables = true; + timeout = 0; + }; + + kernelParams = [ "console=ttyS0" ]; + }; + + services = { + openssh = { + enable = true; + settings = { + PermitRootLogin = "without-password"; + }; + }; + + getty.autologinUser = "root"; + }; + + virtualisation = { + graphics = false; + + diskSize = 5024; + diskImage = null; + + # testing for pushing is hard without this + # useBootLoader = true; + useNixStoreImage = true; + writableStore = true; + + forwardPorts = [ + { + from = "host"; + host.port = 2000 + index; + guest.port = 22; + } + ]; + }; + + users.users.root.openssh.authorizedKeys.keys = [ snakeOil.snakeOilEd25519PublicKey ]; + + users.users.root.initialPassword = "root"; + + system.stateVersion = "23.11"; + }; + }; + in + pkgs.linkFarm "vm-forest" ( + builtins.map (index: { + path = (mkVM index).config.system.build.vm; + name = builtins.toString index; + # Updated with every new test that uses a VM + }) (lib.range 0 1) + ); + }; + }; +} diff --git a/wire/lib/src/commands/common.rs b/wire/lib/src/commands/common.rs index dbed9a33..d3c9429c 100644 --- a/wire/lib/src/commands/common.rs +++ b/wire/lib/src/commands/common.rs @@ -160,3 +160,61 @@ pub async fn evaluate_hive_attribute( Either::Left((_, stdout)) | Either::Right((_, stdout)) => stdout, }) } + +#[cfg(test)] +mod tests { + use std::{assert_matches::assert_matches, collections::HashMap, env}; + + use crate::{ + SubCommandModifiers, + commands::{ + CommandArguments, WireCommandChip, common::push, + noninteractive::non_interactive_command_with_env, + }, + errors::CommandError, + hive::node::{Context, Name, Node, Push}, + test_support::test_with_vm, + }; + + #[tokio::test] + async fn push_to_vm() { + let vm = test_with_vm(); + let mut node = Node::from_target(vm.target.clone()); + let name = Name("test".into()); + let mut context = Context::create_test_context( + crate::hive::HiveLocation::Flake { uri: "in-test".to_string(), prefetch: crate::hive::FlakePrefetch { hash: "AAAAA".into(), store_path: "unknown".into() } }, + &name, + &mut node, + ); + context.modifiers = SubCommandModifiers { + ssh_accept_host: crate::StrictHostKeyChecking::No, + ..Default::default() + }; + + let push_path = env::var("WIRE_PUSHABLE_PATH").unwrap(); + let to_push = Push::Path(&push_path); + + let child = non_interactive_command_with_env( + &CommandArguments::new(format!("stat {push_path}"), context.modifiers) + .on_target(Some(&context.node.target)), + HashMap::new(), + ) + .unwrap(); + + assert_matches!( + child.wait_till_success().await, + Err(CommandError::CommandFailed { command_ran, logs, code, reason }) if logs.contains("No such file or directory") + ); + + push(&context, to_push).await.unwrap(); + + let child = non_interactive_command_with_env( + &CommandArguments::new(format!("stat {push_path}"), context.modifiers) + .on_target(Some(&node.target)), + HashMap::new(), + ) + .unwrap(); + + child.wait_till_success().await.unwrap(); + } +} diff --git a/wire/lib/src/hive/node.rs b/wire/lib/src/hive/node.rs index bb0fd143..f05465c4 100644 --- a/wire/lib/src/hive/node.rs +++ b/wire/lib/src/hive/node.rs @@ -2,6 +2,7 @@ // Copyright 2024-2025 wire Contributors #![allow(clippy::missing_errors_doc)] +use std::env; use enum_dispatch::enum_dispatch; use gethostname::gethostname; use serde::{Deserialize, Serialize}; @@ -35,7 +36,7 @@ pub struct Name(pub Arc); pub struct Target { pub hosts: Vec>, pub user: Arc, - pub port: u32, + pub port: u16, #[serde(skip)] current_host: usize, @@ -65,6 +66,12 @@ impl Target { "-p".to_string(), self.port.to_string(), ]; + + if cfg!(test) { + let snake_oil_path = env::var("WIRE_SSH_KEY").unwrap(); + vector.extend(["-i".to_string(), snake_oil_path]); + } + let mut options = vec![ format!( "StrictHostKeyChecking={}", @@ -84,8 +91,38 @@ impl Target { Ok(vector) } -} + pub fn get_preferred_host(&self) -> Result<&Arc, HiveLibError> { + self.hosts + .get(self.current_host) + .ok_or(HiveLibError::NetworkError(NetworkError::HostsExhausted)) + } + + pub const fn host_failed(&mut self) { + self.current_host += 1; + } + + #[cfg(test)] + #[must_use] + pub fn new(host: Arc, user: Arc, port: u16) -> Target { + Target { + hosts: vec![host], + user, + port, + current_host: 0, + } + } + + #[cfg(test)] + #[must_use] + pub fn from_host(host: &str) -> Self { + Target { + hosts: vec![host.into()], + ..Default::default() + } + } +} + #[cfg(test)] impl Default for Target { fn default() -> Self { @@ -100,7 +137,7 @@ impl Default for Target { #[cfg(test)] impl<'a> Context<'a> { - fn create_test_context( + pub(crate) fn create_test_context( hive_location: HiveLocation, name: &'a Name, node: &'a mut Node, @@ -121,27 +158,6 @@ impl<'a> Context<'a> { } } -impl Target { - pub fn get_preferred_host(&self) -> Result<&Arc, HiveLibError> { - self.hosts - .get(self.current_host) - .ok_or(HiveLibError::NetworkError(NetworkError::HostsExhausted)) - } - - pub const fn host_failed(&mut self) { - self.current_host += 1; - } - - #[cfg(test)] - #[must_use] - pub fn from_host(host: &str) -> Self { - Target { - hosts: vec![host.into()], - ..Default::default() - } - } -} - impl Display for Target { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let hosts = itertools::Itertools::join( @@ -209,6 +225,15 @@ impl Node { } /// Tests the connection to a node + #[cfg(test)] + #[must_use] + pub fn from_target(target: Target) -> Self { + Node { + target, + ..Default::default() + } + } + pub async fn ping(&self, modifiers: SubCommandModifiers) -> Result<(), HiveLibError> { let host = self.target.get_preferred_host()?; @@ -488,9 +513,11 @@ mod tests { use super::*; use crate::{ + errors::CommandError, function_name, get_test_path, hive::{Hive, get_hive_location}, location, + test_support::test_with_vm, }; use std::{assert_matches::assert_matches, path::PathBuf}; use std::{collections::HashMap, env}; @@ -729,6 +756,7 @@ mod tests { "/tmp/{}", rand::distr::SampleString::sample_string(&Alphabetic, &mut rand::rng(), 10) ); + let snake_oil_path = env::var("WIRE_SSH_KEY").unwrap(); std::fs::create_dir(&tmp).unwrap(); @@ -739,6 +767,8 @@ mod tests { target.user.to_string(), "-p".to_string(), target.port.to_string(), + "-i".to_string(), + snake_oil_path.clone(), "-o".to_string(), "StrictHostKeyChecking=accept-new".to_string(), "-o".to_string(), @@ -767,6 +797,8 @@ mod tests { target.user.to_string(), "-p".to_string(), target.port.to_string(), + "-i".to_string(), + snake_oil_path.clone(), "-o".to_string(), "StrictHostKeyChecking=accept-new".to_string(), "-o".to_string(), @@ -785,6 +817,8 @@ mod tests { target.user.to_string(), "-p".to_string(), target.port.to_string(), + "-i".to_string(), + snake_oil_path.clone(), "-o".to_string(), "StrictHostKeyChecking=accept-new".to_string(), "-o".to_string(), @@ -827,4 +861,39 @@ mod tests { assert_matches!(status, Err(HiveLibError::Sigint)); } + + /// unfortunately, there is no way to verify that a ping actually occured + /// besides the function returning OK. + #[tokio::test] + async fn ping_vm() { + let vm = test_with_vm(); + let node = Node::from_target(vm.target.clone()); + let modifiers = SubCommandModifiers { + ssh_accept_host: StrictHostKeyChecking::No, + ..Default::default() + }; + + let ping = node.ping(modifiers).await; + + assert_matches!(ping, Ok(())); + } + + #[tokio::test] + async fn ping_non_existent_node() { + let node = Node::from_host("non-existent-node"); + let hostname = node.target.get_preferred_host().unwrap().to_string(); + + let ping = node.ping(SubCommandModifiers::default()).await; + + assert_matches!( + ping, + Err(HiveLibError::NetworkError(NetworkError::HostUnreachable { host, source })) if host == hostname && + matches!( + &source, + CommandError::CommandFailed { command_ran, logs, code, reason } + if command_ran.contains("store ping --store ssh://root@non-existent-node") && + logs.contains("cannot connect to 'root@non-existent-node'") + ) + ); + } } diff --git a/wire/lib/src/test_support.rs b/wire/lib/src/test_support.rs index bf696417..81c285a3 100644 --- a/wire/lib/src/test_support.rs +++ b/wire/lib/src/test_support.rs @@ -2,14 +2,21 @@ // Copyright 2024-2025 wire Contributors use std::{ + env, fs::{self, create_dir}, io, - path::Path, - process::Command, + net::TcpStream, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + sync::{LazyLock, atomic::AtomicU16}, + thread, + time::{Duration, Instant}, }; use tempdir::TempDir; +use crate::hive::node::Target; + pub fn make_flake_sandbox(path: &Path) -> Result { let tmp_dir = TempDir::new("wire-test")?; @@ -65,3 +72,54 @@ pub fn make_flake_sandbox(path: &Path) -> Result { Ok(tmp_dir) } + +pub(crate) struct CargoTestVirtualMachine { + pub(crate) target: Target, + child: Child, +} + +// corresponds to `tests/tests.nix`, that file needs to be updated +// to support new tests being added +static TEST_COUNTER: LazyLock = LazyLock::new(|| AtomicU16::new(0)); +const VM_START_WAIT: std::time::Duration = Duration::from_secs(10); +const VM_PORT_BASE: u16 = 2000; + +fn wait_for_port(port: u16) { + let start = Instant::now(); + + while start.elapsed() < VM_START_WAIT { + match TcpStream::connect(("localhost", port)) { + Ok(_) => return, + Err(_) => { + thread::sleep(Duration::from_millis(100)); + } + } + } + + panic!("Test vm failed to open port {port}"); +} + +// corresponds to `tests/tests.nix`, do not change values without updating that :) +pub fn test_with_vm() -> CargoTestVirtualMachine { + let mut vms_path = PathBuf::from(env::var("WIRE_TEST_VM").unwrap()); + let index = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + vms_path.push(format!("{index}/bin/run-cargo-vm-{index}-vm")); + + let child = Command::new(vms_path.clone()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + + wait_for_port(VM_PORT_BASE + index); + + let target = Target::new("localhost".into(), "root".into(), VM_PORT_BASE + index); + + CargoTestVirtualMachine { target, child } +} + +impl Drop for CargoTestVirtualMachine { + fn drop(&mut self) { + let _ = self.child.kill(); + } +}