From 76dbd753fefd40140656885add42d21412e15d31 Mon Sep 17 00:00:00 2001 From: marshmallow Date: Fri, 24 Oct 2025 21:03:17 +1100 Subject: [PATCH 1/3] work on per-cargo-test virtual machine --- flake.nix | 2 +- nix/shells.nix | 2 + nix/tests.nix | 38 ---------------- tests/tests.nix | 112 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 39 deletions(-) delete mode 100644 nix/tests.nix create mode 100644 tests/tests.nix 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..2fb3f8bc 100644 --- a/nix/shells.nix +++ b/nix/shells.nix @@ -5,6 +5,7 @@ lib, craneLib, pkgs, + cargo-testing-vms, ... }: let @@ -32,6 +33,7 @@ cfg.installationScript '' export WIRE_TEST_DIR=$(realpath ./tests/rust) + export WIRE_TEST_VM="${cargo-testing-vms}" '' ]; }; 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..a386c7b9 --- /dev/null +++ b/tests/tests.nix @@ -0,0 +1,112 @@ +{ inputs, ... }: +{ + perSystem = + { + craneLib, + pkgs, + lib, + commonArgs, + system, + cargo-testing-vms, + ... + }: + 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 + ); + in + { + packages.cargo-tests = pkgs.writeShellScriptBin "run-tests" '' + set -e + + export WIRE_TEST_VM="${cargo-testing-vms}" + + for item in "${tests}"/*; do + echo "running $item" + "$item" + done + ''; + + _module.args = { + 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; + + forwardPorts = [ + { + from = "host"; + host.port = 2000 + index; + guest.port = 22; + } + ]; + }; + + users.users.root.openssh.authorizedKeys.keys = [ + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPSvOZoSGVEpR6eTDK9OJ31MWQPF2s8oLc8J7MBh6nez marsh@maple" + ]; + + 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; + }) (lib.range 0 1) + ); + }; + }; +} From a86b659f466a3c929102982af82c81c27bd772b9 Mon Sep 17 00:00:00 2001 From: marshmallow Date: Sat, 25 Oct 2025 18:22:34 +1100 Subject: [PATCH 2/3] add test for node.ping --- nix/shells.nix | 4 +- tests/tests.nix | 22 +++++-- wire/lib/src/commands/common.rs | 58 ++++++++++++++++ wire/lib/src/hive/node.rs | 113 +++++++++++++++++++++++++------- wire/lib/src/test_support.rs | 62 +++++++++++++++++- 5 files changed, 227 insertions(+), 32 deletions(-) diff --git a/nix/shells.nix b/nix/shells.nix index 2fb3f8bc..08b819b9 100644 --- a/nix/shells.nix +++ b/nix/shells.nix @@ -5,7 +5,7 @@ lib, craneLib, pkgs, - cargo-testing-vms, + cargo-testing-exports, ... }: let @@ -33,7 +33,7 @@ cfg.installationScript '' export WIRE_TEST_DIR=$(realpath ./tests/rust) - export WIRE_TEST_VM="${cargo-testing-vms}" + ${cargo-testing-exports} '' ]; }; diff --git a/tests/tests.nix b/tests/tests.nix index a386c7b9..1dd7b87d 100644 --- a/tests/tests.nix +++ b/tests/tests.nix @@ -8,6 +8,8 @@ commonArgs, system, cargo-testing-vms, + cargo-testing-exports, + self', ... }: let @@ -30,12 +32,14 @@ } // commonArgs ); + + snakeOil = import "${pkgs.path}/nixos/tests/ssh-keys.nix" pkgs; in { packages.cargo-tests = pkgs.writeShellScriptBin "run-tests" '' set -e - export WIRE_TEST_VM="${cargo-testing-vms}" + ${cargo-testing-exports} for item in "${tests}"/*; do echo "running $item" @@ -44,6 +48,12 @@ ''; _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 = @@ -82,6 +92,11 @@ diskSize = 5024; diskImage = null; + # testing for pushing is hard without this + # useBootLoader = true; + useNixStoreImage = true; + writableStore = true; + forwardPorts = [ { from = "host"; @@ -91,9 +106,7 @@ ]; }; - users.users.root.openssh.authorizedKeys.keys = [ - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPSvOZoSGVEpR6eTDK9OJ31MWQPF2s8oLc8J7MBh6nez marsh@maple" - ]; + users.users.root.openssh.authorizedKeys.keys = [ snakeOil.snakeOilEd25519PublicKey ]; users.users.root.initialPassword = "root"; @@ -105,6 +118,7 @@ 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..0fab1f72 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("in-test".to_string()), + &name, + &mut node, + ); + context.modifiers = SubCommandModifiers { + ssh_accept_host: true, + ..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..7b9702f9 100644 --- a/wire/lib/src/hive/node.rs +++ b/wire/lib/src/hive/node.rs @@ -35,7 +35,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 +65,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 +90,36 @@ 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 fn host_failed(&mut self) { + self.current_host += 1; + } + + #[cfg(test)] + pub fn new(host: Arc, user: Arc, port: u16) -> Target { + Target { + hosts: vec![host], + user, + port, + current_host: 0, + } + } + + #[cfg(test)] + 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 +134,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 +155,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 +222,14 @@ impl Node { } /// Tests the connection to a node + #[cfg(test)] + 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 +509,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 +752,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 +763,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 +793,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 +813,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 +857,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: true, + ..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(); + } +} From dbb50eefee89db88172cf1d6dbf36a9473deaae0 Mon Sep 17 00:00:00 2001 From: marshmallow Date: Wed, 10 Dec 2025 11:41:00 +1100 Subject: [PATCH 3/3] small tweaks to fix rebase --- wire/lib/src/commands/common.rs | 4 ++-- wire/lib/src/hive/node.rs | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/wire/lib/src/commands/common.rs b/wire/lib/src/commands/common.rs index 0fab1f72..d3c9429c 100644 --- a/wire/lib/src/commands/common.rs +++ b/wire/lib/src/commands/common.rs @@ -182,12 +182,12 @@ mod tests { 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("in-test".to_string()), + 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: true, + ssh_accept_host: crate::StrictHostKeyChecking::No, ..Default::default() }; diff --git a/wire/lib/src/hive/node.rs b/wire/lib/src/hive/node.rs index 7b9702f9..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}; @@ -97,11 +98,12 @@ impl Target { .ok_or(HiveLibError::NetworkError(NetworkError::HostsExhausted)) } - pub fn host_failed(&mut self) { + 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], @@ -112,6 +114,7 @@ impl Target { } #[cfg(test)] + #[must_use] pub fn from_host(host: &str) -> Self { Target { hosts: vec![host.into()], @@ -223,6 +226,7 @@ impl Node { /// Tests the connection to a node #[cfg(test)] + #[must_use] pub fn from_target(target: Target) -> Self { Node { target, @@ -865,7 +869,7 @@ mod tests { let vm = test_with_vm(); let node = Node::from_target(vm.target.clone()); let modifiers = SubCommandModifiers { - ssh_accept_host: true, + ssh_accept_host: StrictHostKeyChecking::No, ..Default::default() };