diff --git a/.gitignore b/.gitignore index 2dfb2af46..f8c1e80d9 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ distro/result # cachix package list cachix.list + +# temporary configuration file for NixOS tests +distro/etc_nixos/_config_for_test.nix diff --git a/distro/aster_nixos_installer/default.nix b/distro/aster_nixos_installer/default.nix index 907f49f92..baf013930 100644 --- a/distro/aster_nixos_installer/default.nix +++ b/distro/aster_nixos_installer/default.nix @@ -1,6 +1,6 @@ { disable-systemd ? "false", stage-2-hook ? "/bin/sh -l", log-level ? "error" -, console ? "hvc0", test-command ? "", extra-substituters ? "" -, extra-trusted-public-keys ? "", pkgs ? import { } }: +, console ? "hvc0", extra-substituters ? "", extra-trusted-public-keys ? "" +, config-file-name ? "configuration.nix", pkgs ? import { } }: let aster-kernel = builtins.path { name = "aster-kernel-osdk-bin"; @@ -16,7 +16,6 @@ let aster-stage-2-hook = stage-2-hook; aster-log-level = log-level; aster-console = console; - aster-test-command = test-command; aster-substituters = extra-substituters; aster-trusted-public-keys = extra-trusted-public-keys; }; @@ -38,7 +37,7 @@ in pkgs.stdenv.mkDerivation { mkdir -p $out/{bin,etc_nixos} cp ${install_aster_nixos} $out/bin/install_aster_nixos.sh cp -L ${aster_configuration} $out/etc_nixos/aster_configuration.nix - cp -L ${etc-nixos}/configuration.nix $out/etc_nixos/configuration.nix + cp -L ${etc-nixos}/${config-file-name} $out/etc_nixos/configuration.nix cp -r ${etc-nixos}/modules $out/etc_nixos/modules cp -r ${etc-nixos}/overlays $out/etc_nixos/overlays ln -s ${aster-kernel} $out/kernel diff --git a/distro/iso_image/default.nix b/distro/iso_image/default.nix index 2eab310d1..3eb4ac191 100644 --- a/distro/iso_image/default.nix +++ b/distro/iso_image/default.nix @@ -1,8 +1,9 @@ -{ pkgs ? import { }, autoInstall ? false, test-command ? "" -, extra-substituters ? "", extra-trusted-public-keys ? "", version ? "", ... }: +{ pkgs ? import { }, autoInstall ? false, extra-substituters ? "" +, config-file-name ? "configuration.nix", extra-trusted-public-keys ? "" +, version ? "", ... }: let installer = pkgs.callPackage ../aster_nixos_installer { - inherit test-command extra-substituters extra-trusted-public-keys; + inherit extra-substituters extra-trusted-public-keys config-file-name; }; configuration = { imports = [ diff --git a/test/README.md b/test/README.md new file mode 100644 index 000000000..610952594 --- /dev/null +++ b/test/README.md @@ -0,0 +1,20 @@ +# Test Suites + +This directory contains the testing infrastructure for Asterinas, organized into two complementary testing approaches. + +## Test Types + +### Initramfs-Based Tests ([`initramfs/`](initramfs/)) + +Tests running in a minimal initramfs environment. Best for: +- System call validation +- Core functionality testing +- Performance benchmarks + +See [`initramfs/README.md`](initramfs/README.md) for details. + +### NixOS-Based Tests ([`nixos/`](nixos/)) + +Tests running in NixOS environments. + +See [`nixos/README.md`](nixos/README.md) for details. \ No newline at end of file diff --git a/test/nixos/Cargo.lock b/test/nixos/Cargo.lock new file mode 100644 index 000000000..4e1a92452 --- /dev/null +++ b/test/nixos/Cargo.lock @@ -0,0 +1,332 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "comma" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55b672471b4e9f9e95499ea597ff64941a309b2cdbffcc46f2cc5e2d971fd335" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "inventory" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "libc" +version = "0.2.179" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nixos-test-framework" +version = "0.1.0" +dependencies = [ + "inventory", + "nixos-test-macro", + "rexpect", + "strip-ansi-escapes", +] + +[[package]] +name = "nixos-test-macro" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "proc-macro2" +version = "1.0.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rexpect" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c1bcd4ac488e9d2d726d147031cceff5cff6425011ff1914049739770fa4726" +dependencies = [ + "comma", + "nix", + "regex", + "tempfile", + "thiserror", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "test-hello" +version = "0.1.0" +dependencies = [ + "nixos-test-framework", +] + +[[package]] +name = "test-nix" +version = "0.1.0" +dependencies = [ + "nixos-test-framework", +] + +[[package]] +name = "test-podman" +version = "0.1.0" +dependencies = [ + "nixos-test-framework", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" diff --git a/test/nixos/Cargo.toml b/test/nixos/Cargo.toml new file mode 100644 index 000000000..eab9c87aa --- /dev/null +++ b/test/nixos/Cargo.toml @@ -0,0 +1,19 @@ +[workspace] +resolver = "2" +members = [ + "common/framework", "common/test_macro", + "tests/*", +] + +[workspace.package] +version = "0.1.0" +repository = "https://github.com/asterinas/asterinas" +license = "MPL-2.0" +edition = "2024" + +[workspace.dependencies] +inventory = "0.3" +rexpect = "0.6" +strip-ansi-escapes = "0.2" +nixos-test-framework = { path = "common/framework" } +nixos-test-macro = { path = "common/test_macro" } diff --git a/test/nixos/Makefile b/test/nixos/Makefile new file mode 100644 index 000000000..5f7f1d800 --- /dev/null +++ b/test/nixos/Makefile @@ -0,0 +1,97 @@ +# SPDX-License-Identifier: MPL-2.0 + +NIXOS_TEST_SUITE ?= +NIXOS_TEST_CASE ?= +NIXOS_TEST_TIMEOUT ?= 5min + +# Generated configuration file name +TEST_CONFIG_FILE_NAME := _configuration_for_test.nix + +# Paths +MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) +ASTERINAS_ROOT := $(MAKEFILE_DIR)/../.. +NIXOS_CONFIG_DIR := $(ASTERINAS_ROOT)/distro/etc_nixos +BASE_CONFIG := $(NIXOS_CONFIG_DIR)/configuration.nix +TEST_CONFIG := $(NIXOS_CONFIG_DIR)/$(TEST_CONFIG_FILE_NAME) +MERGE_SCRIPT := $(ASTERINAS_ROOT)/test/nixos/common/merge_nixos_config.sh + +# NixOS build and run scripts +BUILD_ISO_SCRIPT := $(ASTERINAS_ROOT)/tools/nixos/build_iso.sh +BUILD_NIXOS_SCRIPT := $(ASTERINAS_ROOT)/tools/nixos/build_nixos.sh +RUN_SCRIPT := $(ASTERINAS_ROOT)/tools/nixos/run.sh + +# Test directory structure +TEST_DIR := tests/$(NIXOS_TEST_SUITE) +EXTRA_CONFIG := $(TEST_DIR)/extra_config.nix + +.PHONY: check-test-name +check-test-name: + @if [ -z "$(NIXOS_TEST_SUITE)" ]; then \ + echo "Error: NIXOS_TEST_SUITE is not set"; \ + echo "Usage: make [target] NIXOS_TEST_SUITE="; \ + exit 1; \ + fi + @if [ ! -d "$(TEST_DIR)" ]; then \ + echo "Error: Test directory '$(TEST_DIR)' does not exist"; \ + exit 1; \ + fi + +.PHONY: prepare +prepare: check-test-name + @echo "==> Preparing configuration for test '$(NIXOS_TEST_SUITE)'..." + @if [ -f "$(EXTRA_CONFIG)" ]; then \ + echo " Found extra config: $(EXTRA_CONFIG)"; \ + echo " Merging configurations..."; \ + bash "$(MERGE_SCRIPT)" "$(BASE_CONFIG)" "$(EXTRA_CONFIG)" "$(TEST_CONFIG)"; \ + if [ $$? -ne 0 ]; then \ + echo "Error: Configuration merge failed"; \ + rm "$(TEST_CONFIG)"; \ + exit 1; \ + fi; \ + else \ + echo " No extra config found, using base configuration"; \ + cp "$(BASE_CONFIG)" "$(TEST_CONFIG)"; \ + fi + +.PHONY: iso +iso: prepare + @echo "==> Building ISO installer for test '$(NIXOS_TEST_NAME)'..." + @echo " Building ISO installer..."; + @bash "$(BUILD_ISO_SCRIPT)" "$(TEST_CONFIG_FILE_NAME)"; + @if [ $$? -ne 0 ]; then \ + echo "Error: ISO build failed"; \ + rm "$(TEST_CONFIG)"; \ + exit 1; \ + fi + @rm "$(TEST_CONFIG)"; + +.PHONY: nixos +nixos: prepare + @echo "==> Building NixOS image for test '$(NIXOS_TEST_NAME)'..." + @bash "$(BUILD_NIXOS_SCRIPT)" "$(TEST_CONFIG_FILE_NAME)"; + @if [ $$? -ne 0 ]; then \ + echo "Error: NixOS build failed"; \ + rm "$(TEST_CONFIG)"; \ + exit 1; \ + fi + @rm "$(TEST_CONFIG)"; + +.PHONY: run_nixos +run_nixos: check-test-name + @cd "$(TEST_DIR)" && \ + TEST_CASE_ARG=""; \ + if [ -n "$(NIXOS_TEST_CASE)" ]; then \ + TEST_CASE_ARG="--test $(NIXOS_TEST_CASE)"; \ + fi; \ + QEMU_CMD="bash $(RUN_SCRIPT) nixos"; \ + cargo run -- --qemu-cmd "$$QEMU_CMD" $$TEST_CASE_ARG || \ + exit 1 + +.PHONY: check +check: + @cargo clippy -- -D warnings + +.PHONY: format +format: + @cargo fmt + @nixfmt . \ No newline at end of file diff --git a/test/nixos/README.md b/test/nixos/README.md new file mode 100644 index 000000000..82c4e22f6 --- /dev/null +++ b/test/nixos/README.md @@ -0,0 +1,137 @@ +# NixOS-Based Test Suites + +This directory contains NixOS-based tests and a framework for writing and running them. The framework executes tests by interacting with a live instance of the operating system in a virtual environment. Thanks to this interactive design, the framework can test virtually any behavior that a real user could trigger through a terminal. It also offers a simple, imperative API, making it easy to write and maintain these interactive test scenarios. + +## Directory Structure + +``` +test/nixos/ +├── common/ +│ ├── template/ # Template for creating new tests +│ └── ... # Core implementation of the framework +├── tests/ +│ ├── podman/ # A real test crate +│ │ ├── Cargo.toml +│ │ ├── extra_config.nix # (Optional) Additional NixOS configuration +│ │ └── src/ +│ │ └── main.rs +│ └── ... # Other tests +└── Makefile +``` + +## Creating a New Test + +### Step 1: Copy the Template + +```bash +cd test/nixos +cp -r common/template tests/my-test +``` + +### Step 2: Update `Cargo.toml` + +Replace `` with your test name: + +### Step 3: Implement Your Tests + +Edit `src/main.rs`: + +```rust +// SPDX-License-Identifier: MPL-2.0 + +use nixos_test_framework::*; +use nixos_test_macro::nixos_test; + +// This macro generates the main function that runs all registered tests +nixos_test_main!(); + +// Register a test case using the #[nixos_test] attribute +#[nixos_test] +fn basic_command_test(nixos_shell: &mut Session) -> Result<(), Error> { + nixos_shell.run_cmd("echo 'Hello, World!'")?; + nixos_shell.run_cmd_and_expect("cat /etc/os-release", "NixOS")?; + Ok(()) +} + +// You can define multiple test cases in the same file +#[nixos_test] +fn file_operations_test(nixos_shell: &mut Session) -> Result<(), Error> { + nixos_shell.run_cmd("touch /tmp/test.txt")?; + nixos_shell.run_cmd_and_expect("ls /tmp", "test.txt")?; + Ok(()) +} +``` + +The `Session` type provides APIs for interacting with the VM. See the [Session API documentation](common/framework/src/session.rs) for details. + +### Step 4: (Optional) Configure NixOS + +If your test requires additional packages or system configuration, edit `extra_config.nix`: + +```nix +{ config, lib, pkgs, ... }: + +{ + environment.systemPackages = with pkgs; [ + # Add required packages here + vim + git + ]; + + # Configure system services + virtualisation.podman.enable = true; +} +``` +This content of this file will be merged with the [default configuration file](../../distro/etc_nixos/configuration.nix) to generate the final configuration file for the testing Asterinas NixOS system. + +## Running Tests + +The following commands should be run under the project root. + +### Build Test Image + +```bash +# Build NixOS image for a test suite +make nixos NIXOS_TEST_SUITE=my-test + +# Or build using ISO installer workflow +make iso NIXOS_TEST_SUITE=my-test +make run_iso +``` + +### Run Tests + +```bash +# Run all tests in the suite +make run_nixos NIXOS_TEST_SUITE=my-test + +# Run a specific test case +make run_nixos NIXOS_TEST_SUITE=my-test NIXOS_TEST_CASE=basic_command_test + +# Customize timeout with units (default: 5min) +make run_nixos NIXOS_TEST_SUITE=my-test NIXOS_TEST_TIMEOUT=10min # 10 minutes +make run_nixos NIXOS_TEST_SUITE=my-test NIXOS_TEST_TIMEOUT=600s # 600 seconds +make run_nixos NIXOS_TEST_SUITE=my-test NIXOS_TEST_TIMEOUT=600000ms # 600000 milliseconds +``` + +### Complete Workflow Examples + +```bash +# Quick test +make nixos NIXOS_TEST_SUITE=my-test && make run_nixos NIXOS_TEST_SUITE=my-test + +# Test with ISO installer +make iso NIXOS_TEST_SUITE=my-test && make run_iso && make run_nixos NIXOS_TEST_SUITE=my-test + +# Run specific test with custom timeout (10 minutes) +make nixos NIXOS_TEST_SUITE=podman +make run_nixos NIXOS_TEST_SUITE=podman NIXOS_TEST_CASE=container_basic_test NIXOS_TEST_TIMEOUT=10min +``` + +## Environment Variables + +- **`NIXOS_TEST_SUITE`**: Name of the test suite to run (required for test mode) +- **`NIXOS_TEST_CASE`**: Specific test case to run (optional, runs all if not specified) +- **`NIXOS_TEST_TIMEOUT`**: Timeout for command execution with unit suffix (optional, default: 5min) + - Supported formats: `ms` (milliseconds), `s` (seconds), `min` (minutes) + - Examples: `300000ms`, `300s`, `5min` \ No newline at end of file diff --git a/test/nixos/common/framework/Cargo.toml b/test/nixos/common/framework/Cargo.toml new file mode 100644 index 000000000..da8645ae2 --- /dev/null +++ b/test/nixos/common/framework/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "nixos-test-framework" +version.workspace = true +repository.workspace = true +license.workspace = true +edition.workspace = true + +[dependencies] +inventory.workspace = true +nixos-test-macro.workspace = true +rexpect.workspace = true +strip-ansi-escapes.workspace = true \ No newline at end of file diff --git a/test/nixos/common/framework/src/lib.rs b/test/nixos/common/framework/src/lib.rs new file mode 100644 index 000000000..f5f601f0b --- /dev/null +++ b/test/nixos/common/framework/src/lib.rs @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: MPL-2.0 + +//! An imperative testing framework for NixOS-based tests. +//! +//! # Core Concepts +//! +//! ## Test Registration +//! +//! Use the `#[nixos_test]` attribute to register test cases. The framework +//! automatically discovers and runs all registered tests. +//! +//! ## Session Interaction +//! +//! Tests are implemented by interacting with a [`Session`] object. The [`Session`] type +//! provides methods for executing commands and verifying output. It supports nested execution +//! contexts (containers, SSH, etc.) with automatic cleanup. +//! +//! See the [template crate](https://github.com/asterinas/asterinas/tree/main/test/nixos/common/template) +//! for a usage example. +//! +//! See the [project README](https://github.com/asterinas/asterinas/tree/main/test/nixos) +//! for complete documentation on creating and running test suites. + +use std::env; + +pub use inventory; +pub use nixos_test_macro::nixos_test; +pub use rexpect::error::Error; +pub use session::{Session, SessionDesc}; + +mod session; + +/// A test case definition. +pub struct TestCase { + pub name: &'static str, + pub test_fn: fn(&mut Session) -> Result<(), Error>, +} + +inventory::collect!(TestCase); + +/// Generates the main function that runs all test cases. +#[macro_export] +macro_rules! nixos_test_main { + () => { + fn main() -> Result<(), Box> { + $crate::__nixos_test_main() + } + }; +} + +#[doc(hidden)] +pub fn __nixos_test_main() -> Result<(), Box> { + let args: Vec = env::args().collect(); + + // Check for --help flag + if args.contains(&"--help".to_string()) || args.contains(&"-h".to_string()) { + print_help(); + return Ok(()); + } + + // Parse --qemu-cmd argument + let qemu_cmd = parse_arg(&args, "--qemu-cmd").ok_or("Missing --qemu-cmd argument")?; + + // Parse optional --test argument + let test_filter = parse_arg(&args, "--test"); + + // Parse timeout from NIXOS_TEST_TIMEOUT environment variable + let timeout_ms = env::var("NIXOS_TEST_TIMEOUT") + .ok() + .map(|v| parse_timeout(&v)) + .transpose()? + .unwrap_or(300_000); // Default: 5 minutes + + let all_test_cases: Vec<&TestCase> = inventory::iter::().collect(); + + if all_test_cases.is_empty() { + return Err("No test cases found".into()); + } + + // Filter test cases if --test is specified + let test_cases: Vec<&TestCase> = if let Some(ref filter) = test_filter { + let filtered: Vec<&TestCase> = all_test_cases + .into_iter() + .filter(|tc| tc.name == filter) + .collect(); + + if filtered.is_empty() { + return Err(format!("Test case '{}' not found", filter).into()); + } + + filtered + } else { + all_test_cases + }; + + if let Some(ref filter) = test_filter { + println!("=== Running single test case: {} ===", filter); + } else { + println!("=== Found {} test case(s) ===", test_cases.len()); + for tc in &test_cases { + println!(" - test_{}", tc.name); + } + println!(); + } + + let mut session = rexpect::spawn(&qemu_cmd, Some(timeout_ms))?; + + println!("--> Waiting for login prompt..."); + let init_prompt = "root@asterinas:"; + session.exp_string(init_prompt)?; + + let desc = SessionDesc::new() + .expect_prompt(init_prompt) + .cmd_to_enter("") + .cmd_to_exit("poweroff"); + let mut session = Session::new(desc, session); + + let mut passed = 0; + let mut failed = 0; + let mut failed_tests = Vec::new(); + + for test_case in test_cases { + println!("=== Running test case: {} ===", test_case.name); + + match session.run(test_case.test_fn) { + Ok(_) => { + println!("✓ Test case 'test_{}' passed\n", test_case.name); + passed += 1; + } + Err(_) => { + println!("✗ Test case 'test_{}' failed\n", test_case.name); + failed += 1; + failed_tests.push(test_case.name); + } + } + } + + println!("=== Test Summary ==="); + println!("Passed: {}", passed); + println!("Failed: {}", failed); + + let res = if !failed_tests.is_empty() { + println!("\nFailed tests:"); + for name in failed_tests { + println!(" - test_{}", name); + } + + Err("Some tests failed") + } else { + Ok(()) + }; + + let shutdown_res = session.shutdown(); + + res?; + shutdown_res?; + + Ok(()) +} + +/// Parses timeout string with units into milliseconds. +/// +/// Supports formats: +/// - `ms` - milliseconds +/// - `s` - seconds +/// - `min` - minutes +/// +/// # Examples +/// +/// ```rust +/// parse_timeout("300000ms") // Ok(300000) +/// parse_timeout("300s") // Ok(300000) +/// parse_timeout("5min") // Ok(300000) +/// ``` +fn parse_timeout(timeout_str: &str) -> Result> { + let timeout_str = timeout_str.trim(); + + if let Some(ms) = timeout_str.strip_suffix("ms") { + return Ok(ms.trim().parse()?); + } + + if let Some(s) = timeout_str.strip_suffix('s') { + let seconds: u64 = s.trim().parse()?; + return Ok(seconds * 1000); + } + + if let Some(m) = timeout_str.strip_suffix("min") { + let minutes: u64 = m.trim().parse()?; + return Ok(minutes * 60000); + } + + Err(format!( + "Invalid timeout format '{}'. Use: ms, s, or m", + timeout_str + ) + .into()) +} + +/// Parse command line argument in the form --flag +fn parse_arg(args: &[String], flag: &str) -> Option { + for i in 0..args.len() { + if args[i] == flag { + return args.get(i + 1).cloned(); + } + } + None +} + +fn print_help() { + println!( + "\ +NixOS-Based Test Framework + +USAGE: + --qemu-cmd [OPTIONS] + +REQUIRED ARGUMENTS: + --qemu-cmd Command to launch QEMU with the test environment + +OPTIONS: + --test Run only the specified test case + -h, --help Print this help message + +ENVIRONMENT VARIABLES: + NIXOS_TEST_TIMEOUT Timeout for command execution + Supports: ms, s, min + Examples: 300000ms, 300s, 5min + (default: 5min = 300000ms) + +" + ); +} diff --git a/test/nixos/common/framework/src/session.rs b/test/nixos/common/framework/src/session.rs new file mode 100644 index 000000000..04f4a4448 --- /dev/null +++ b/test/nixos/common/framework/src/session.rs @@ -0,0 +1,285 @@ +// SPDX-License-Identifier: MPL-2.0 + +use rexpect::session::PtySession; + +use super::Error; + +/// Describes a runtime session configuration. +/// +/// A session descriptor defines how to interact with a particular execution context, +/// such as a shell environment, container, or remote session. It specifies the prompt +/// pattern (by `expect_prompt`) and the commands needed to enter (by `cmd_to_enter`) +/// and exit (by `cmd_to_exit`) the context. +/// +/// # Example +/// +/// The session descriptor uses a fluent builder API: +/// +/// ```rust +/// use nixos_test_framework::SessionDesc; +/// +/// let desc = SessionDesc::new() +/// .expect_prompt("/ #") +/// .cmd_to_enter("podman run -it alpine") +/// .cmd_to_exit("exit"); +/// ``` +pub struct SessionDesc { + prompt: &'static str, + enter_command: &'static str, + exit_cmd: &'static str, +} + +impl SessionDesc { + /// Creates a new session descriptor with empty fields. + /// + /// Use the builder methods to configure the session before use. + pub fn new() -> Self { + Self { + prompt: "", + enter_command: "", + exit_cmd: "", + } + } + + /// Sets the expected prompt pattern for this session. + pub fn expect_prompt(mut self, prompt: &'static str) -> Self { + self.prompt = prompt; + self + } + + /// Sets the command used to enter this session. + pub fn cmd_to_enter(mut self, enter_command: &'static str) -> Self { + self.enter_command = enter_command; + self + } + + /// Sets the command used to exit this session. + pub fn cmd_to_exit(mut self, exit_cmd: &'static str) -> Self { + self.exit_cmd = exit_cmd; + self + } +} + +impl Default for SessionDesc { + fn default() -> Self { + Self::new() + } +} + +/// An interactive session for running commands in a test environment. +/// +/// `Session` provides a high-level interface for interacting with the test environment. +/// It manages execution contexts and handles nested environments automatically. +/// +/// It uses a [`SessionDesc`] to track the current prompt and the correct command +/// to exit the current context. +pub struct Session { + desc: SessionDesc, + pty_session: PtySession, +} + +impl Session { + /// Creates a new session with the given PTY session and descriptor. + pub(super) fn new(desc: SessionDesc, pty_session: PtySession) -> Self { + Self { desc, pty_session } + } + + fn output_error(error: &Error) { + match error { + Error::EOF { + expected, + got, + exit_code, + } => { + println!("=== EOF Error Details ==="); + println!("Expected: {}", expected); + println!( + "Got: {}", + String::from_utf8_lossy(&strip_ansi_escapes::strip(got)) + ); + println!("Exit code: {:?}", exit_code); + println!("========================"); + } + Error::Timeout { + expected, + got, + timeout, + } => { + println!("=== Timeout Error Details ==="); + println!("Expected: {}", expected); + println!( + "Got: {}", + String::from_utf8_lossy(&strip_ansi_escapes::strip(got)) + ); + println!("Timeout: {:?}", timeout); + println!("============================"); + } + _ => {} + } + } + + /// Executes a command in the current session. + /// + /// This method runs the specified command and waits for it to complete. + /// The command is considered complete when the session prompt reappears. + /// + /// Returns an error if the command times out or the session terminates unexpectedly. + /// + /// # Example + /// + /// ```rust + /// use nixos_test_framework::*; + /// + /// fn example(nixos_shell: &mut Session) -> Result<(), Error> { + /// // Execute simple commands + /// nixos_shell.run_cmd("ls -la")?; + /// nixos_shell.run_cmd("cd /tmp")?; + /// nixos_shell.run_cmd("mkdir test_dir")?; + /// + /// Ok(()) + /// } + /// ``` + pub fn run_cmd(&mut self, command: &str) -> Result<(), Error> { + println!("--> Running: {}", command); + self.pty_session.send_line(command)?; + // Read and consume the echoed command line + self.pty_session.exp_string(command).unwrap(); + + if let Err(e) = self.pty_session.exp_string(self.desc.prompt) { + Self::output_error(&e); + return Err(e); + } + + Ok(()) + } + + /// Executes a command and verifies its output contains expected text. + /// + /// This method runs the command and checks that the specified string appears + /// in the output. This is useful for validating command results. + /// + /// Returns an error if: + /// - The expected string is not found in the output + /// - The command times out + /// - The session terminates unexpectedly + /// + /// # Example + /// + /// ```rust + /// use nixos_test_framework::*; + /// + /// fn example(nixos_shell: &mut Session) -> Result<(), Error> { + /// // Verify output contains expected string + /// nixos_shell.run_cmd_and_expect("echo 'Hello, World!'", "Hello")?; + /// + /// // Check if a file exists + /// nixos_shell.run_cmd_and_expect("ls /etc/hostname", "/etc/hostname")?; + /// + /// // Verify system information + /// nixos_shell.run_cmd_and_expect("cat /etc/os-release", "NixOS")?; + /// + /// Ok(()) + /// } + /// ``` + pub fn run_cmd_and_expect(&mut self, command: &str, expected: &str) -> Result<(), Error> { + println!("--> Running: {} (expecting: {})", command, expected); + self.pty_session.send_line(command)?; + // Read and consume the echoed command line + self.pty_session.exp_string(command).unwrap(); + + match self.pty_session.exp_string(self.desc.prompt) { + Ok(unread) => { + let cleaned_unread = + String::from_utf8_lossy(&strip_ansi_escapes::strip(&unread)).to_string(); + if !cleaned_unread.contains(expected) { + println!("=== Unexpected Output ==="); + println!("Expected: {}", expected); + println!("Output before prompt:\n{}", cleaned_unread); + println!("========================="); + return Err(Error::EOF { + expected: expected.to_string(), + got: cleaned_unread, + exit_code: None, + }); + } + } + Err(e) => { + Self::output_error(&e); + return Err(e); + } + } + + Ok(()) + } + + pub(super) fn run(&mut self, test_ops: F) -> Result<(), Error> + where + F: FnOnce(&mut Session) -> Result<(), Error>, + { + (test_ops)(self) + } + + /// Enters a nested session, runs operations, and automatically exits. + /// + /// This method is used to work with nested environments like containers, SSH sessions, + /// or any interactive shell. + /// + /// Returns an error if entering, running operations, or exiting fails. + /// + /// # Example + /// + /// ```rust + /// use nixos_test_framework::*; + /// + /// fn container_test(nixos_shell: &mut Session) -> Result<(), Error> { + /// // Define the container session + /// let container_session_desc = SessionDesc::new() + /// .expect_prompt("/ #") + /// .cmd_to_enter("podman run -it docker.io/library/alpine") + /// .cmd_to_exit("exit"); + /// + /// // Enter container, run tests, and automatically exit + /// nixos_shell.enter_session_and_run(container_session_desc, |alpine_shell| { + /// alpine_shell.run_cmd_and_expect( + /// "cat /etc/os-release", + /// "Alpine" + /// )?; + /// Ok(()) + /// })?; + /// + /// // Back in the host - container has been exited + /// nixos_shell.run_cmd("echo 'Back on host'")?; + /// Ok(()) + /// } + /// ``` + pub fn enter_session_and_run(&mut self, desc: SessionDesc, test_ops: F) -> Result<(), Error> + where + F: FnOnce(&mut Session) -> Result<(), Error>, + { + let old_desc = std::mem::replace(&mut self.desc, desc); + + if let Err(e) = self.run_cmd(self.desc.enter_command) { + self.desc = old_desc; + return Err(e); + } + + let res = self.run(test_ops); + + let exit_cmd = self.desc.exit_cmd; + self.desc = old_desc; + let exit_res = self.run_cmd(exit_cmd); + + res?; + exit_res?; + + Ok(()) + } + + pub(super) fn shutdown(&mut self) -> Result<(), Error> { + self.pty_session.send_line(self.desc.exit_cmd)?; + + self.pty_session.process.wait()?; + + Ok(()) + } +} diff --git a/test/nixos/common/merge_nixos_config.sh b/test/nixos/common/merge_nixos_config.sh new file mode 100755 index 000000000..8c4343760 --- /dev/null +++ b/test/nixos/common/merge_nixos_config.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash + +# SPDX-License-Identifier: MPL-2.0 + +# Usage: merge_nixos_config.sh +# +# This script takes two NixOS configuration files, and , +# as inputs and produces a new NixOS configuration file whose content is the +# combination of the two inputs. If the same key is set by both +# and , then the value provided by takes precedence. +# +# A NixOS configuration file, usually named `configuration.nix`, is written in +# the following form: +# +# { config, lib, pkgs, ... }: { +# key1 = value1; +# key2 = value2; +# } + +set -e + +# Check for the correct number of arguments +if [ "$#" -ne 3 ]; then + echo "Usage: $0 " + exit 1 +fi + +BASE_FILE="$1" +EXTRA_FILE="$2" +MERGED_FILE="$3" + +# Check if input files exist +if [ ! -f "$BASE_FILE" ]; then + echo "Error: Base file not found at '$BASE_FILE'" + exit 1 +fi + +if [ ! -f "$EXTRA_FILE" ]; then + echo "Error: Extra file not found at '$EXTRA_FILE'" + exit 1 +fi + +BASE_CONTENT=$(cat "$BASE_FILE") +EXTRA_CONTENT=$(cat "$EXTRA_FILE") + +# Create the merged configuration file by embedding the file contents directly. +cat > "$MERGED_FILE" < on Asterinas NixOS. +//! +//! # Document maintenance +//! +//! An application's test suite and its "Verified Usage" section in Asterinas Book +//! should always be kept in sync. +//! So whenever you modify the test suite, +//! review the documentation and see if should be updated accordingly. + +use nixos_test_framework::*; + +nixos_test_main!(); + +#[nixos_test] +fn hello_world(nixos_shell: &mut Session) -> Result<(), Error> { + nixos_shell.run_cmd("echo 'Hello, World!' > out.txt")?; + nixos_shell.run_cmd_and_expect("ls out.txt", "out.txt")?; + + Ok(()) +} \ No newline at end of file diff --git a/test/nixos/common/test_macro/Cargo.toml b/test/nixos/common/test_macro/Cargo.toml new file mode 100644 index 000000000..b2d522a5a --- /dev/null +++ b/test/nixos/common/test_macro/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "nixos-test-macro" +version.workspace = true +repository.workspace = true +license.workspace = true +edition.workspace = true + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1.0" +quote = "1.0" +syn = { version = "2.0", features = ["full"] } diff --git a/test/nixos/common/test_macro/src/lib.rs b/test/nixos/common/test_macro/src/lib.rs new file mode 100644 index 000000000..2cbc33916 --- /dev/null +++ b/test/nixos/common/test_macro/src/lib.rs @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 + +//! A procedural macro to register NixOS test cases. +//! +//! This crate should work together with `nixos_test_framework` crate. The +//! registered test cases will be collected and run by the test framework. + +use proc_macro::TokenStream; +use quote::quote; +use syn::{ItemFn, parse_macro_input}; + +/// Registers a function as a NixOS test case. +/// +/// # Example +/// ```rust +/// #[nixos_test] +/// fn my_test() { +/// // test code here +/// } +/// ``` +#[proc_macro_attribute] +pub fn nixos_test(_attr: TokenStream, item: TokenStream) -> TokenStream { + let input = parse_macro_input!(item as ItemFn); + let fn_name = &input.sig.ident; + let fn_name_str = fn_name.to_string(); + + let expanded = quote! { + #input + + ::nixos_test_framework::inventory::submit! { + ::nixos_test_framework::TestCase { + name: #fn_name_str, + test_fn: #fn_name, + } + } + }; + + TokenStream::from(expanded) +} diff --git a/tools/nixos/build_iso.sh b/tools/nixos/build_iso.sh index 6d49e7ba5..7db8e002e 100755 --- a/tools/nixos/build_iso.sh +++ b/tools/nixos/build_iso.sh @@ -9,12 +9,14 @@ ASTERINAS_DIR=$(realpath ${SCRIPT_DIR}/../..) DISTRO_DIR=$(realpath ${ASTERINAS_DIR}/distro) TARGET_DIR=${ASTERINAS_DIR}/target/nixos VERSION=$(cat ${ASTERINAS_DIR}/VERSION) +# Accept config file name as parameter, default to "configuration.nix" +CONFIG_FILE_NAME=${1:-"configuration.nix"} mkdir -p ${TARGET_DIR} nix-build ${DISTRO_DIR}/iso_image \ --arg autoInstall ${AUTO_INSTALL} \ - --argstr test-command "${NIXOS_TEST_COMMAND}" \ + --argstr config-file-name "${CONFIG_FILE_NAME}" \ --argstr extra-substituters "${RELEASE_SUBSTITUTER} ${DEV_SUBSTITUTER}" \ --argstr extra-trusted-public-keys "${RELEASE_TRUSTED_PUBLIC_KEY} ${DEV_TRUSTED_PUBLIC_KEY}" \ --argstr version ${VERSION} \ diff --git a/tools/nixos/build_nixos.sh b/tools/nixos/build_nixos.sh index 4ceb12f4b..66c22eb6f 100755 --- a/tools/nixos/build_nixos.sh +++ b/tools/nixos/build_nixos.sh @@ -8,7 +8,9 @@ SCRIPT_DIR=$(cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd) ASTERINAS_DIR=$(realpath ${SCRIPT_DIR}/../..) ASTER_IMAGE_PATH=${ASTERINAS_DIR}/target/nixos/asterinas.img DISTRO_DIR=$(realpath ${ASTERINAS_DIR}/distro) -CONFIG_PATH=${DISTRO_DIR}/etc_nixos/configuration.nix +# Accept config file name as parameter, default to "configuration.nix" +CONFIG_FILE_NAME=${1:-"configuration.nix"} +CONFIG_PATH=${DISTRO_DIR}/etc_nixos/${CONFIG_FILE_NAME} pushd $DISTRO_DIR nix-build aster_nixos_installer/default.nix \ @@ -16,7 +18,6 @@ nix-build aster_nixos_installer/default.nix \ --argstr stage-2-hook "${NIXOS_STAGE_2_INIT}" \ --argstr log-level "${LOG_LEVEL}" \ --argstr console "${CONSOLE}" \ - --argstr test-command "${NIXOS_TEST_COMMAND}" \ --argstr extra-substituters "${RELEASE_SUBSTITUTER} ${DEV_SUBSTITUTER}" \ --argstr extra-trusted-public-keys "${RELEASE_TRUSTED_PUBLIC_KEY} ${DEV_TRUSTED_PUBLIC_KEY}" popd diff --git a/tools/nixos/run.sh b/tools/nixos/run.sh index 1e8fd8bca..0d8c72659 100755 --- a/tools/nixos/run.sh +++ b/tools/nixos/run.sh @@ -21,6 +21,9 @@ MODE=$1 SCRIPT_DIR=$(dirname "$0") ASTERINAS_DIR=$(realpath "${SCRIPT_DIR}/../..") +# Change to Asterinas root directory to ensure all scripts run from the correct location. +cd "${ASTERINAS_DIR}" + # Base QEMU arguments BASE_QEMU_ARGS="qemu-system-x86_64 \ -bios /root/ovmf/release/OVMF.fd \