From d061561acbea7f993a6dbbffa1c2f4766c53d1b3 Mon Sep 17 00:00:00 2001 From: Xinhong Hu Date: Mon, 3 Aug 2026 10:11:22 +0800 Subject: [PATCH 01/40] =?UTF-8?q?feat(net):=20=E5=90=AF=E7=94=A8=20ArceOS?= =?UTF-8?q?=20=E7=BD=91=E7=BB=9C=E6=A0=88=E3=80=81SMP=3D2=20=E4=B8=8E=20vC?= =?UTF-8?q?PU=20=E6=A0=B8=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为网络管理控制面铺路。启用 ax-std `net` feature 触发网络子系统初始化,defconfig SMP 1→2(全局默认值,目前仅在 QEMU smoke 上验证:aarch64、x86_64 vmx/svm、 loongarch64);aarch64/riscv64/x86_64 VM 配置把 vCPU 显式绑到 Core 1(FDT 路径 phys_cpu_ids 生效,set_phys_cpu_sets 启动时重算),管理面留在 Core 0。main 的 start_default_vms/shell 任务拆分在此之前已存在,本提交仅在 main 补充核隔离说明 注释与 `shell task on CPU{}` 诊断日志,并让 vCPU 运行日志带 CPU id 便于核隔离 断言。另在 Cargo.toml 引入 axum/tokio/serde_json 可选依赖与 http-test/http-axum feature,为后续只读/控制 HTTP API PR 预留基础设施(本期未启用)。 Changes: - os/axvisor/Cargo.toml: ax-std 启用 net feature;新增 axum/tokio/serde_json 可选依赖 + http-test/http-axum feature - os/axvisor/configs/defconfig.toml: smp 1→2(全局默认,仅 QEMU smoke 验证) - os/axvisor/configs/vms/qemu/{aarch64,riscv64,x86_64}/arceos-smp1.toml 等: vCPU 绑 Core 1,注释英文化 - os/axvisor/src/main.rs: 核隔离说明注释 + `shell task on CPU{}` 诊断日志 - virtualization/axvm/src/runtime/vcpus.rs: vCPU 运行日志带 CPU id - test-suit/axvisor/normal/qemu/smoke/qemu-x86_64-vmx.toml: -smp 2 + virtio-net + 网络/核隔离断言 - test-suit/axvisor/normal/qemu/smoke/qemu-{x86_64-svm,loongarch64}.toml: -smp 2 - docs/docs/architecture/axvisor.md: 同步退出日志字符串 --- os/axvisor/Cargo.toml | 16 ++++++++++++++++ os/axvisor/configs/defconfig.toml | 2 +- .../configs/vms/qemu/aarch64/arceos-smp1.toml | 6 ++++-- .../configs/vms/qemu/aarch64/linux-smp1.toml | 6 ++++-- .../configs/vms/qemu/riscv64/arceos-smp1.toml | 6 ++++-- .../configs/vms/qemu/x86_64/arceos-smp1.toml | 4 ++-- os/axvisor/src/main.rs | 7 +++++++ .../normal/qemu/smoke/qemu-loongarch64.toml | 2 +- .../normal/qemu/smoke/qemu-x86_64-svm.toml | 2 +- .../normal/qemu/smoke/qemu-x86_64-vmx.toml | 15 ++++++++++++--- virtualization/axvm/src/runtime/vcpus.rs | 7 ++++++- 11 files changed, 58 insertions(+), 15 deletions(-) diff --git a/os/axvisor/Cargo.toml b/os/axvisor/Cargo.toml index f4d96ae5f7..42e12ab2d9 100644 --- a/os/axvisor/Cargo.toml +++ b/os/axvisor/Cargo.toml @@ -47,6 +47,11 @@ stack-protector = ["ax-std/stack-protector"] backtrace = ["ax-std/backtrace", "dep:axbacktrace"] test-backtrace-panic = ["backtrace"] test-panic-no-backtrace = ["dep:axbacktrace"] +# axum-based management HTTP server (see doc/plan/axum-implementation.md). +# Off by default; enabled together with `http-axum`. Replaces the hand-rolled +# pilot, which is intentionally not carried forward. +http-test = [] +http-axum = ["dep:axum", "dep:tokio", "dep:serde_json"] [dependencies] shlex.workspace = true @@ -57,6 +62,16 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(axtest)', 'cfg(feature, va [target.'cfg(any(not(any(windows, unix)), target_env = "musl"))'.dependencies] anyhow.workspace = true log = "0.4" +axum = { version = "0.8", optional = true } +serde_json = { version = "1", optional = true } +tokio = { version = "1", optional = true, features = [ + "rt", + "macros", + "net", + "time", + "sync", + "io-util", +] } # System dependent modules provided by ArceOS. ax-std = { workspace = true, default-features = false, features = [ @@ -71,6 +86,7 @@ ax-std = { workspace = true, default-features = false, features = [ "hv", "tls", "std-compat", + "net", ] } # System dependent modules provided by ArceOS-Hypervisor (bare-metal only) # ax-runtime = { version = "=0.3.0-preview.1", features = ["alloc", "irq", "paging", "smp", "multitask"] } diff --git a/os/axvisor/configs/defconfig.toml b/os/axvisor/configs/defconfig.toml index 7b34df8cc2..3e4fd44198 100644 --- a/os/axvisor/configs/defconfig.toml +++ b/os/axvisor/configs/defconfig.toml @@ -8,4 +8,4 @@ task-stack-size = 0x40000 # uint ticks-per-sec = 1000 # uint # Number of CPUs -smp = 1 # uint +smp = 2 # uint diff --git a/os/axvisor/configs/vms/qemu/aarch64/arceos-smp1.toml b/os/axvisor/configs/vms/qemu/aarch64/arceos-smp1.toml index 3fa5d81b08..c020575f43 100644 --- a/os/axvisor/configs/vms/qemu/aarch64/arceos-smp1.toml +++ b/os/axvisor/configs/vms/qemu/aarch64/arceos-smp1.toml @@ -9,8 +9,10 @@ name = "arceos-qemu" guest_type = "passthrough" # The number of virtual CPUs. cpu_num = 1 -# Guest vm physical cpu ids. -phys_cpu_ids = [0] +# Guest vm physical cpu ids (required on FDT paths and the only effective knob: +# logical index 1 -> mask 0b10 = Core 1; set_phys_cpu_sets recomputes and +# overwrites phys_cpu_sets at boot, so phys_cpu_sets is not set here). +phys_cpu_ids = [1] # # Vm kernel configs diff --git a/os/axvisor/configs/vms/qemu/aarch64/linux-smp1.toml b/os/axvisor/configs/vms/qemu/aarch64/linux-smp1.toml index 9dac2d1fd0..5be43feb7e 100644 --- a/os/axvisor/configs/vms/qemu/aarch64/linux-smp1.toml +++ b/os/axvisor/configs/vms/qemu/aarch64/linux-smp1.toml @@ -9,8 +9,10 @@ name = "linux-qemu" guest_type = "passthrough" # The number of virtual CPUs. cpu_num = 1 -# Guest vm physical cpu sets. -phys_cpu_ids = [0] +# Guest vm physical cpu ids (required on FDT paths and the only effective knob: +# logical index 1 -> mask 0b10 = Core 1; set_phys_cpu_sets recomputes and +# overwrites phys_cpu_sets at boot, so phys_cpu_sets is not set here). +phys_cpu_ids = [1] # # Vm kernel configs diff --git a/os/axvisor/configs/vms/qemu/riscv64/arceos-smp1.toml b/os/axvisor/configs/vms/qemu/riscv64/arceos-smp1.toml index 55702df809..d41d7793f8 100644 --- a/os/axvisor/configs/vms/qemu/riscv64/arceos-smp1.toml +++ b/os/axvisor/configs/vms/qemu/riscv64/arceos-smp1.toml @@ -9,8 +9,10 @@ name = "arceos-qemu" guest_type = "passthrough" # The number of virtual CPUs. cpu_num = 1 -# Guest vm physical cpu ids. -phys_cpu_ids = [0] +# Guest vm physical cpu ids (required on FDT paths and the only effective knob: +# logical index 1 -> mask 0b10 = Core 1; set_phys_cpu_sets recomputes and +# overwrites phys_cpu_sets at boot, so phys_cpu_sets is not set here). +phys_cpu_ids = [1] # # Vm kernel configs diff --git a/os/axvisor/configs/vms/qemu/x86_64/arceos-smp1.toml b/os/axvisor/configs/vms/qemu/x86_64/arceos-smp1.toml index 15dc58c044..53f762881a 100644 --- a/os/axvisor/configs/vms/qemu/x86_64/arceos-smp1.toml +++ b/os/axvisor/configs/vms/qemu/x86_64/arceos-smp1.toml @@ -9,8 +9,8 @@ name = "arceos-qemu" guest_type = "passthrough" # The number of virtual CPUs. cpu_num = 1 -# Guest vm physical cpu sets. -phys_cpu_sets = [1] +# Guest vm physical cpu sets (CPU mask bitmap: Core 1 = 0b10 = 2; `1` is Core 0). +phys_cpu_sets = [2] # # Vm kernel configs diff --git a/os/axvisor/src/main.rs b/os/axvisor/src/main.rs index e5a1288e88..766b50290a 100644 --- a/os/axvisor/src/main.rs +++ b/os/axvisor/src/main.rs @@ -56,6 +56,9 @@ fn init_panic_hook() { /// 2. Check and enable hardware virtualization on every CPU. /// 3. Build and start configured guest VMs. /// 4. Run the VM completion waiter and management console concurrently. +/// +/// The vCPU tasks are pinned to the secondary CPUs via `phys_cpu_ids` in the +/// VM configs, while the management console stays on the primary CPU. fn main() { #[cfg(any(feature = "backtrace", feature = "test-panic-no-backtrace"))] init_panic_hook(); @@ -88,5 +91,9 @@ fn main() { info!("[OK] Default guest initialized"); + // The management console runs on the primary CPU (Core 0) while the vCPU + // tasks are pinned to Core 1 via `phys_cpu_ids`, so it stays responsive + // regardless of guest behavior. + info!("shell task on CPU{}", axvm::host::cpu::current_id()); shell::console_init(); } diff --git a/test-suit/axvisor/normal/qemu/smoke/qemu-loongarch64.toml b/test-suit/axvisor/normal/qemu/smoke/qemu-loongarch64.toml index 04dde534e0..6fefdd8c55 100644 --- a/test-suit/axvisor/normal/qemu/smoke/qemu-loongarch64.toml +++ b/test-suit/axvisor/normal/qemu/smoke/qemu-loongarch64.toml @@ -6,7 +6,7 @@ args = [ "-m", "2G", "-smp", - "1", + "2", "-nographic", "-serial", "mon:stdio", diff --git a/test-suit/axvisor/normal/qemu/smoke/qemu-x86_64-svm.toml b/test-suit/axvisor/normal/qemu/smoke/qemu-x86_64-svm.toml index 4259fb19c8..c7e435602b 100644 --- a/test-suit/axvisor/normal/qemu/smoke/qemu-x86_64-svm.toml +++ b/test-suit/axvisor/normal/qemu/smoke/qemu-x86_64-svm.toml @@ -11,7 +11,7 @@ args = [ "-machine", "q35,smbus=off,usb=off,graphics=off", "-smp", - "1", + "2", "-accel", "kvm", "-device", diff --git a/test-suit/axvisor/normal/qemu/smoke/qemu-x86_64-vmx.toml b/test-suit/axvisor/normal/qemu/smoke/qemu-x86_64-vmx.toml index b6f77729b6..0b87ddb895 100644 --- a/test-suit/axvisor/normal/qemu/smoke/qemu-x86_64-vmx.toml +++ b/test-suit/axvisor/normal/qemu/smoke/qemu-x86_64-vmx.toml @@ -11,7 +11,7 @@ args = [ "-machine", "q35,smbus=off,usb=off,graphics=off", "-smp", - "1", + "2", "-accel", "kvm", "-device", @@ -20,8 +20,12 @@ args = [ "id=disk0,if=none,format=raw,file=${workspace}/tmp/axbuild/rootfs/rootfs-x86_64-alpine.img", "-m", "512M", - "-net", - "none", + # Host-side NIC for the ArceOS network subsystem on the hypervisor itself + # (invisible to the guest). Verified by the `use NIC 0:` success regex. + "-netdev", + "user,id=net0", + "-device", + "virtio-net-pci,netdev=net0,addr=04.0", "-vga", "none", ] @@ -36,6 +40,11 @@ fail_regex = [ success_regex = [ "(?m)^AXVISOR_NVME_RW_PAYLOAD\\s*$", "(?m)^AXVISOR_NVME_ROOTFS_RW_PASSED\\s*$", + # Host-side network subsystem init (enabled via the `net` feature on ax-std). + "Initialize network subsystem", + "use NIC 0:", + # Management console runs on the primary CPU while vCPUs are pinned to Core 1. + "shell task on CPU0", ] shell_prefix = "axvisor:/$" shell_init_cmd = """ diff --git a/virtualization/axvm/src/runtime/vcpus.rs b/virtualization/axvm/src/runtime/vcpus.rs index 4699fe639f..3a71b14baa 100644 --- a/virtualization/axvm/src/runtime/vcpus.rs +++ b/virtualization/axvm/src/runtime/vcpus.rs @@ -475,7 +475,12 @@ fn vcpu_run() { mark_vcpu_running(&vm); } - info!("VM[{}] VCpu[{}] running...", vm.id(), vcpu.id()); + info!( + "VM[{}] VCpu[{}] running on CPU{}...", + vm.id(), + vcpu.id(), + ax_hal::percpu::this_cpu_id() + ); loop { if vcpu_id == 0 { From 0ae812d1a0bfc9558279a014fb55b4853f4da219 Mon Sep 17 00:00:00 2001 From: Xinhong Hu Date: Wed, 5 Aug 2026 20:25:35 +0800 Subject: [PATCH 02/40] =?UTF-8?q?feat(axvisor):=20=E6=96=B0=E5=A2=9E=20axu?= =?UTF-8?q?m=20=E5=8F=AA=E8=AF=BB=20HTTP=20=E7=AE=A1=E7=90=86=20API?= =?UTF-8?q?=EF=BC=88GET=20/api/vms=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR B 将管理面 HTTP 从手写 pilot 替换为 axum 正式实现。tokio current_thread runtime 仅启用 IO driver(enable_io()),首次验证 axum in-hypervisor 真能运行: eventfd/epoll syscall 已就绪,无需 timerfd,PR D 条件不触发。路由 GET /api/vms 与 GET /api/vms/{id} 镜像 pilot API,JSON 改用 serde_json 构造。 http-test 内建自测用 tower oneshot 直接驱动 router(不经 TCP),确定性断言 200/404。HTTP server 在 VMM 启动前 bind(对齐 pilot fix 307f30251:cooperative FIFO 下 spawn 顺序即就绪队列顺序,先启动管理面才能控制默认 VM)。 Changes: - os/axvisor/src/http/: 新增 mod.rs(serve 入口)、axum.rs(Router + enable_io runtime + oneshot 自测)、vm.rs(serde_json handler);移除手写 pilot - os/axvisor/src/main.rs: 追加 mod http,http::serve 独立线程在 launch_default_vms 之前 spawn,管理面先于 guest 启动 - os/axvisor/Cargo.toml: http-test 隐含 http-axum 并引入 tower 依赖 - test-suit/axvisor/normal/qemu-http-axum-readonly/: aarch64 + x86_64 双架构 运行验证,断言 GET /api/vms -> 200 与 /api/vms/999 -> 404;x86_64 构建 通过 [env] 禁用 httparse SIMD,规避裸机目标上的 rustc-LLVM 代码生成错误 --- Cargo.lock | 1 + os/axvisor/Cargo.toml | 3 +- os/axvisor/src/http/axum.rs | 89 +++++++++++++++++++ os/axvisor/src/http/mod.rs | 24 +++++ os/axvisor/src/http/vm.rs | 75 ++++++++++++++++ os/axvisor/src/main.rs | 21 ++++- .../build-aarch64-unknown-none-softfloat.toml | 10 +++ .../build-x86_64-unknown-none-vmx.toml | 17 ++++ .../http-axum-readonly/qemu-aarch64.toml | 29 ++++++ .../http-axum-readonly/qemu-x86_64-vmx.toml | 31 +++++++ 10 files changed, 297 insertions(+), 3 deletions(-) create mode 100644 os/axvisor/src/http/axum.rs create mode 100644 os/axvisor/src/http/mod.rs create mode 100644 os/axvisor/src/http/vm.rs create mode 100644 test-suit/axvisor/normal/qemu-http-axum-readonly/build-aarch64-unknown-none-softfloat.toml create mode 100644 test-suit/axvisor/normal/qemu-http-axum-readonly/build-x86_64-unknown-none-vmx.toml create mode 100644 test-suit/axvisor/normal/qemu-http-axum-readonly/http-axum-readonly/qemu-aarch64.toml create mode 100644 test-suit/axvisor/normal/qemu-http-axum-readonly/http-axum-readonly/qemu-x86_64-vmx.toml diff --git a/Cargo.lock b/Cargo.lock index 6dd53fac11..8a6b853a85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1552,6 +1552,7 @@ dependencies = [ "syn 3.0.3", "tokio", "toml 1.1.4+spec-1.1.0", + "tower", ] [[package]] diff --git a/os/axvisor/Cargo.toml b/os/axvisor/Cargo.toml index 42e12ab2d9..608bfe0453 100644 --- a/os/axvisor/Cargo.toml +++ b/os/axvisor/Cargo.toml @@ -50,7 +50,7 @@ test-panic-no-backtrace = ["dep:axbacktrace"] # axum-based management HTTP server (see doc/plan/axum-implementation.md). # Off by default; enabled together with `http-axum`. Replaces the hand-rolled # pilot, which is intentionally not carried forward. -http-test = [] +http-test = ["http-axum", "dep:tower"] http-axum = ["dep:axum", "dep:tokio", "dep:serde_json"] [dependencies] @@ -64,6 +64,7 @@ anyhow.workspace = true log = "0.4" axum = { version = "0.8", optional = true } serde_json = { version = "1", optional = true } +tower = { version = "0.5", optional = true } tokio = { version = "1", optional = true, features = [ "rt", "macros", diff --git a/os/axvisor/src/http/axum.rs b/os/axvisor/src/http/axum.rs new file mode 100644 index 0000000000..1580b66cfb --- /dev/null +++ b/os/axvisor/src/http/axum.rs @@ -0,0 +1,89 @@ +//! axum-based management HTTP server (`http-axum` feature). +//! +//! Runs an axum `Router` on a tokio current-thread runtime and serves the +//! management API. Routes and JSON fields mirror the hand-rolled pilot's API, +//! but dispatch and JSON construction are delegated to axum + serde_json. +//! +//! ```text +//! GET /api/vms → 200, JSON array (summary form) +//! GET /api/vms/{id} → 200, JSON detail (with vcpu_states) | 404 +//! ``` +//! +//! PR B is the first in-hypervisor axum runtime validation. Two things are +//! deliberately exercised: +//! +//! 1. The tokio reactor initializes with `enable_io()` only (no time driver), +//! so no `timerfd` syscall is required. If axum serve turns out to need the +//! time driver, PR D (timerfd syscall) becomes mandatory. +//! 2. `tower::ServiceExt::oneshot` calls the router without TCP, so the +//! `http-test` self-test is deterministic and free of task-scheduling timing. + +use axum::{Router, routing::get}; + +use crate::http::vm; + +/// Assemble the management routes. +pub fn router() -> Router { + Router::new() + .route("/api/vms", get(vm::list_vms)) + .route("/api/vms/{id}", get(vm::vm_detail)) +} + +/// Blocking serve: build a tokio current-thread runtime and hand it to axum. +/// +/// `main` spawns this on its own task via `std::thread::spawn(|| http::serve())`; +/// the runtime is built here. Only the IO driver is enabled — the epoll +/// reactor suffices for `axum::serve`; a time driver would need `timerfd`. +pub fn serve() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_io() + .build() + .expect("failed to build tokio runtime"); + rt.block_on(async { + #[cfg(feature = "http-test")] + self_test().await; + + let listener = tokio::net::TcpListener::bind("0.0.0.0:8080") + .await + .expect("failed to bind management HTTP server"); + info!("management HTTP server (axum) listening on 0.0.0.0:8080"); + axum::serve(listener, router()).await.expect("server error"); + }); +} + +/// `http-test` built-in self-test: drive the router with +/// `tower::ServiceExt::oneshot` (no TCP loopback) and print the actual status +/// codes for QEMU smoke-test regex assertion. Asserts the same contract as the +/// pilot: `GET /api/vms -> 200` and `GET /api/vms/999 -> 404` (no specific VM +/// id is bound). +#[cfg(feature = "http-test")] +async fn self_test() { + use axum::body::Body; + use axum::http::Request; + use tower::ServiceExt; + + let router = router(); + + let list = router + .clone() + .oneshot( + Request::builder() + .uri("/api/vms") + .body(Body::empty()) + .expect("failed to build request"), + ) + .await + .expect("GET /api/vms failed"); + info!("HTTP self-test: GET /api/vms -> {}", list.status()); + + let detail = router + .oneshot( + Request::builder() + .uri("/api/vms/999") + .body(Body::empty()) + .expect("failed to build request"), + ) + .await + .expect("GET /api/vms/999 failed"); + info!("HTTP self-test: GET /api/vms/999 -> {}", detail.status()); +} diff --git a/os/axvisor/src/http/mod.rs b/os/axvisor/src/http/mod.rs new file mode 100644 index 0000000000..020a7dc0f4 --- /dev/null +++ b/os/axvisor/src/http/mod.rs @@ -0,0 +1,24 @@ +//! Management HTTP control plane. +//! +//! Served by an axum `Router` running on a tokio current-thread runtime +//! (see [`axum`]). The read-only routes live in [`vm`]; control routes +//! (start/stop) are added by the next PR. JSON is built with `serde_json`. +//! +//! This whole module is only compiled under the `http-axum` feature, which is +//! off by default. The hand-rolled HTTP/1.0 pilot was intentionally not +//! carried forward. + +#[cfg(feature = "http-axum")] +pub mod axum; +#[cfg(feature = "http-axum")] +pub mod vm; + +/// Blocking entry point for the management HTTP server. +/// +/// Spawned on its own task (see `crate::main`); builds the tokio runtime and +/// serves until the hypervisor shuts down. Under `http-test` the built-in +/// self-test runs first and prints deterministic handler results. +#[cfg(feature = "http-axum")] +pub fn serve() { + axum::serve(); +} diff --git a/os/axvisor/src/http/vm.rs b/os/axvisor/src/http/vm.rs new file mode 100644 index 0000000000..26930e2ef7 --- /dev/null +++ b/os/axvisor/src/http/vm.rs @@ -0,0 +1,75 @@ +//! Read-only VM status axum handlers. +//! +//! JSON is built with `serde_json::json!()` (no hand-written escaping). These +//! handlers are shared by the TCP serving path in [`super::axum`] and the +//! `http-test` built-in self-test, so the self-test exercises exactly the same +//! logic the network path dispatches to. + +use axum::{Json, extract::Path, http::StatusCode}; +use axvm::{AxVMRef, VmVcpuState}; +use serde_json::{Value, json}; + +use crate::manager::AxvmManager; + +/// `GET /api/vms` — list all known VMs (summary form). +pub async fn list_vms() -> Json> { + let items: Vec = AxvmManager::vm_list().iter().map(vm_json_summary).collect(); + Json(items) +} + +/// `GET /api/vms/{id}` — detail for one VM, or 404 if unknown. +pub async fn vm_detail(Path(id_str): Path) -> Result, StatusCode> { + let Ok(id) = id_str.parse::() else { + return Err(StatusCode::NOT_FOUND); + }; + match AxvmManager::vm_by_id(id) { + Some(vm) => Ok(Json(vm_json(&vm, true))), + None => Err(StatusCode::NOT_FOUND), + } +} + +fn vm_json_summary(vm: &AxVMRef) -> Value { + vm_json(vm, false) +} + +fn vm_json(vm: &AxVMRef, with_vcpus: bool) -> Value { + let memory_mb = vm + .memory_regions() + .iter() + .fold(0usize, |acc, region| acc.saturating_add(region.size())) + / (1024 * 1024); + let mut json = json!({ + "id": vm.id(), + "name": vm.name(), + "status": vm.status().as_str(), + "cpu_num": vm.vcpu_num(), + "memory_mb": memory_mb, + }); + if with_vcpus { + let vcpus: Vec = vm + .vcpu_snapshots() + .iter() + .map(|vcpu| { + json!({ + "id": vcpu.id, + "state": vcpu_state_str(vcpu.state), + "phys_cpu_set": vcpu.phys_cpu_set, + }) + }) + .collect(); + json["vcpu_states"] = json!(vcpus); + } + json +} + +fn vcpu_state_str(state: VmVcpuState) -> &'static str { + match state { + VmVcpuState::Invalid => "invalid", + VmVcpuState::Created => "created", + VmVcpuState::Free => "free", + VmVcpuState::Ready => "ready", + VmVcpuState::Running => "running", + VmVcpuState::Blocked => "blocked", + VmVcpuState::Starting => "starting", + } +} diff --git a/os/axvisor/src/main.rs b/os/axvisor/src/main.rs index 766b50290a..7fa35f0360 100644 --- a/os/axvisor/src/main.rs +++ b/os/axvisor/src/main.rs @@ -32,6 +32,8 @@ use ax_std as _; mod banner; mod config; mod guest_console; +#[cfg(feature = "http-axum")] +mod http; mod manager; mod shell; mod virtio_net; @@ -54,8 +56,9 @@ fn init_panic_hook() { /// /// 1. Print the startup banner. /// 2. Check and enable hardware virtualization on every CPU. -/// 3. Build and start configured guest VMs. -/// 4. Run the VM completion waiter and management console concurrently. +/// 3. Build the default guest VMs. +/// 4. Spawn the management plane first — the HTTP server so the API is live +/// before any guest boots — then the VM lifecycle waiter and the shell. /// /// The vCPU tasks are pinned to the secondary CPUs via `phys_cpu_ids` in the /// VM configs, while the management console stays on the primary CPU. @@ -78,6 +81,19 @@ fn main() { .unwrap_or_else(|error| panic!("failed to initialize AxVM manager: {error:#}")); manager.init_default_vms(); + + // The management HTTP server accepts connections in a loop and needs its + // own task so neither the shell nor the VMM blocks it. It is spawned + // first: under cooperative FIFO scheduling the spawn order is the + // ready-queue order, so the server binds here before `launch_default_vms` + // queues the vCPU tasks. `http-test` runs its self-test inside `http::serve` + // before any socket work. + #[cfg(feature = "http-axum")] + std::thread::Builder::new() + .name("axvisor-http".into()) + .spawn(http::serve) + .unwrap_or_else(|error| panic!("failed to start management HTTP server: {error}")); + let default_vms = manager::AxvmManager::vm_list(); guest_console::configure_host_console_reader(&default_vms) .unwrap_or_else(|error| panic!("failed to configure host console input: {error:#}")); @@ -95,5 +111,6 @@ fn main() { // tasks are pinned to Core 1 via `phys_cpu_ids`, so it stays responsive // regardless of guest behavior. info!("shell task on CPU{}", axvm::host::cpu::current_id()); + shell::console_init(); } diff --git a/test-suit/axvisor/normal/qemu-http-axum-readonly/build-aarch64-unknown-none-softfloat.toml b/test-suit/axvisor/normal/qemu-http-axum-readonly/build-aarch64-unknown-none-softfloat.toml new file mode 100644 index 0000000000..1ceda8556d --- /dev/null +++ b/test-suit/axvisor/normal/qemu-http-axum-readonly/build-aarch64-unknown-none-softfloat.toml @@ -0,0 +1,10 @@ +# PR B: axum read-only HTTP API runtime verification. +# http-test implies http-axum (tokio + axum + serde_json). fs/nvme is not +# enabled: QEMU has no disk and fs would panic at boot; this test only +# verifies the management HTTP server runs. +features = [ + "http-test", +] +log = "Info" +target = "aarch64-unknown-none-softfloat" +vm_configs = [] diff --git a/test-suit/axvisor/normal/qemu-http-axum-readonly/build-x86_64-unknown-none-vmx.toml b/test-suit/axvisor/normal/qemu-http-axum-readonly/build-x86_64-unknown-none-vmx.toml new file mode 100644 index 0000000000..fe678b17a5 --- /dev/null +++ b/test-suit/axvisor/normal/qemu-http-axum-readonly/build-x86_64-unknown-none-vmx.toml @@ -0,0 +1,17 @@ +# PR B: axum read-only HTTP API runtime verification (x86_64). +# http-test implies http-axum (tokio + axum + serde_json). fs/nvme is not +# enabled: this test only verifies the management HTTP server runs. +features = [ + "http-test", +] +log = "Info" +target = "x86_64-unknown-none" +vm_configs = [] + +# httparse 1.10 (a hyper HTTP/1 parsing dependency) trips a rustc-LLVM codegen +# error at opt-level=3 on this bare-metal target where SSE is disabled: its +# runtime SSE42 fallback (#[target_feature(enable = "sse42")]) cannot be +# lowered. Disable SIMD via the official escape hatch (x86_64 only; the aarch64 +# NEON path compiles fine and does not need this). +[env] +CARGO_CFG_HTTPARSE_DISABLE_SIMD = "1" diff --git a/test-suit/axvisor/normal/qemu-http-axum-readonly/http-axum-readonly/qemu-aarch64.toml b/test-suit/axvisor/normal/qemu-http-axum-readonly/http-axum-readonly/qemu-aarch64.toml new file mode 100644 index 0000000000..20679ae8d5 --- /dev/null +++ b/test-suit/axvisor/normal/qemu-http-axum-readonly/http-axum-readonly/qemu-aarch64.toml @@ -0,0 +1,29 @@ +args = [ + "-nographic", + "-cpu", + "cortex-a72", + "-machine", + "virt,virtualization=on,gic-version=3", + "-smp", + "2", + "-m", + "512M", +] +# PR B: first in-hypervisor axum runtime verification. +# - tokio current_thread runtime is initialized with enable_io() only (needs +# only epoll, no timerfd syscall). +# - the tower::ServiceExt::oneshot self-test avoids TCP and prints +# deterministic status codes. +# - if enable_io() is insufficient (time driver / timerfd required), a panic +# hits fail_regex. +fail_regex = [ + "(?i)\\bpanic(?:ked)?\\b", + "(?i)kernel panic", +] +success_regex = [ + "HTTP self-test: GET /api/vms -> 200", + "HTTP self-test: GET /api/vms/999 -> 404", +] +timeout = 120 +to_bin = true +uefi = false diff --git a/test-suit/axvisor/normal/qemu-http-axum-readonly/http-axum-readonly/qemu-x86_64-vmx.toml b/test-suit/axvisor/normal/qemu-http-axum-readonly/http-axum-readonly/qemu-x86_64-vmx.toml new file mode 100644 index 0000000000..c63135a9aa --- /dev/null +++ b/test-suit/axvisor/normal/qemu-http-axum-readonly/http-axum-readonly/qemu-x86_64-vmx.toml @@ -0,0 +1,31 @@ +args = [ + "-no-user-config", + "-display", + "none", + "-serial", + "stdio", + "-monitor", + "none", + "-cpu", + "host,-la57,+vmx-ept,+vmx-unrestricted-guest,+vmx-flexpriority", + "-machine", + "q35,smbus=off,usb=off,graphics=off", + "-smp", + "2", + "-accel", + "kvm", + "-m", + "512M", + "-vga", + "none", +] +timeout = 600 +fail_regex = [ + "(?i)\\bpanic(?:ked)?\\b", +] +success_regex = [ + "HTTP self-test: GET /api/vms -> 200", + "HTTP self-test: GET /api/vms/999 -> 404", +] +to_bin = true +uefi = true From ab1ca7cb6cb23651dcb68aa6f8219212e7e5c301 Mon Sep 17 00:00:00 2001 From: Xinhong Hu Date: Thu, 6 Aug 2026 01:34:04 +0800 Subject: [PATCH 03/40] fix(axvisor): explicit tower util feature and rename http::axum to server Address review items on the axum management plane. Changes: - os/axvisor/Cargo.toml: declare tower `util` explicitly. `ServiceExt::oneshot` is gated behind tower's `util` feature, which default features do not include; it only compiled via axum's transitive `util` enablement - os/axvisor/src/http/mod.rs: rename the `http::axum` submodule to `http::server` to avoid shadowing the external axum crate (mod.rs `axum::serve()` previously resolved to the submodule while server.rs `axum::serve()` resolved to the crate); drop the redundant `#[cfg(feature = "http-axum")]` on module items, already gated by `mod http` - os/axvisor/src/http/axum.rs -> os/axvisor/src/http/server.rs: file rename - os/axvisor/src/http/vm.rs: update doc reference `[super::axum]` -> `[super::server]` - os/axvisor/src/main.rs: correct the spawn-order comment. `ax_std::thread::spawn` only enqueues the task; the main task keeps running until it yields, so the HTTP server's bind is not guaranteed to precede `launch_default_vms` --- os/axvisor/Cargo.toml | 2 +- os/axvisor/src/http/mod.rs | 11 ++++------- os/axvisor/src/http/{axum.rs => server.rs} | 0 os/axvisor/src/http/vm.rs | 2 +- os/axvisor/src/main.rs | 11 ++++++----- 5 files changed, 12 insertions(+), 14 deletions(-) rename os/axvisor/src/http/{axum.rs => server.rs} (100%) diff --git a/os/axvisor/Cargo.toml b/os/axvisor/Cargo.toml index 608bfe0453..53ce5a87ec 100644 --- a/os/axvisor/Cargo.toml +++ b/os/axvisor/Cargo.toml @@ -64,7 +64,7 @@ anyhow.workspace = true log = "0.4" axum = { version = "0.8", optional = true } serde_json = { version = "1", optional = true } -tower = { version = "0.5", optional = true } +tower = { version = "0.5", optional = true, features = ["util"] } tokio = { version = "1", optional = true, features = [ "rt", "macros", diff --git a/os/axvisor/src/http/mod.rs b/os/axvisor/src/http/mod.rs index 020a7dc0f4..2652ca2ed7 100644 --- a/os/axvisor/src/http/mod.rs +++ b/os/axvisor/src/http/mod.rs @@ -1,16 +1,14 @@ //! Management HTTP control plane. //! //! Served by an axum `Router` running on a tokio current-thread runtime -//! (see [`axum`]). The read-only routes live in [`vm`]; control routes -//! (start/stop) are added by the next PR. JSON is built with `serde_json`. +//! (see [`server`]). The VM list/detail and lifecycle routes live in [`vm`]. +//! JSON is built with `serde_json`. //! //! This whole module is only compiled under the `http-axum` feature, which is //! off by default. The hand-rolled HTTP/1.0 pilot was intentionally not //! carried forward. -#[cfg(feature = "http-axum")] -pub mod axum; -#[cfg(feature = "http-axum")] +pub mod server; pub mod vm; /// Blocking entry point for the management HTTP server. @@ -18,7 +16,6 @@ pub mod vm; /// Spawned on its own task (see `crate::main`); builds the tokio runtime and /// serves until the hypervisor shuts down. Under `http-test` the built-in /// self-test runs first and prints deterministic handler results. -#[cfg(feature = "http-axum")] pub fn serve() { - axum::serve(); + server::serve(); } diff --git a/os/axvisor/src/http/axum.rs b/os/axvisor/src/http/server.rs similarity index 100% rename from os/axvisor/src/http/axum.rs rename to os/axvisor/src/http/server.rs diff --git a/os/axvisor/src/http/vm.rs b/os/axvisor/src/http/vm.rs index 26930e2ef7..3688aa9ab5 100644 --- a/os/axvisor/src/http/vm.rs +++ b/os/axvisor/src/http/vm.rs @@ -1,7 +1,7 @@ //! Read-only VM status axum handlers. //! //! JSON is built with `serde_json::json!()` (no hand-written escaping). These -//! handlers are shared by the TCP serving path in [`super::axum`] and the +//! handlers are shared by the TCP serving path in [`super::server`] and the //! `http-test` built-in self-test, so the self-test exercises exactly the same //! logic the network path dispatches to. diff --git a/os/axvisor/src/main.rs b/os/axvisor/src/main.rs index 7fa35f0360..109e8251b2 100644 --- a/os/axvisor/src/main.rs +++ b/os/axvisor/src/main.rs @@ -83,11 +83,12 @@ fn main() { manager.init_default_vms(); // The management HTTP server accepts connections in a loop and needs its - // own task so neither the shell nor the VMM blocks it. It is spawned - // first: under cooperative FIFO scheduling the spawn order is the - // ready-queue order, so the server binds here before `launch_default_vms` - // queues the vCPU tasks. `http-test` runs its self-test inside `http::serve` - // before any socket work. + // own task so neither the shell nor the VMM blocks it. It is spawned first + // so the management API is ready as early as possible. `ax_std::thread::spawn` + // only enqueues the task — the main task keeps running until it yields or + // blocks — so the server's bind does not necessarily happen before + // `launch_default_vms` queues the vCPU tasks; the ordering is best-effort. + // `http-test` runs its self-test inside `http::serve` before any socket work. #[cfg(feature = "http-axum")] std::thread::Builder::new() .name("axvisor-http".into()) From 36c84dc330e4a2c824d3e6cc044791330f4249a2 Mon Sep 17 00:00:00 2001 From: Xinhong Hu Date: Thu, 6 Aug 2026 16:49:39 +0800 Subject: [PATCH 04/40] test(axvm): add deterministic vCPU affinity mapping tests The management control-plane smoke tests isolate the guest vCPU onto physical Core 1 so the HTTP task stays on Core 0. This mapping previously had no regression coverage; a reviewer asked for a deterministic mapping test at the axvm config/scheduling layer. Changes: - Add default_vcpu_affinities tests covering phys_cpu_ids=[1] (aarch64/riscv64 arceos-smp1.toml), phys_cpu_sets=[2] (x86_64 arceos-smp1.toml), and the vcpu-id fallback in architecture/ops.rs. - Add a PhysCpuList::get_vcpu_affinities_pcpu_ids test in config.rs exercising the public config path with the same three cases. --- virtualization/axvm/src/architecture/ops.rs | 24 +++++++++++++++++++++ virtualization/axvm/src/config.rs | 15 +++++++++++++ 2 files changed, 39 insertions(+) diff --git a/virtualization/axvm/src/architecture/ops.rs b/virtualization/axvm/src/architecture/ops.rs index 51451adc1b..091e70f6a2 100644 --- a/virtualization/axvm/src/architecture/ops.rs +++ b/virtualization/axvm/src/architecture/ops.rs @@ -483,4 +483,28 @@ mod tests { ); assert!(dispatcher.drain(0).is_empty()); } + + #[test] + fn default_vcpu_affinities_pins_single_cpu_to_isolated_core() { + // aarch64/riscv64 arceos-smp1.toml: cpu_num=1, phys_cpu_ids=[1] → pin to physical Core 1. + assert_eq!( + default_vcpu_affinities(1, Some(&[1]), None), + vec![(0, None, 1)] + ); + // x86_64 arceos-smp1.toml: phys_cpu_sets=[2] → affinity mask 0b10 (Core 1). + assert_eq!( + default_vcpu_affinities(1, None, Some(&[2])), + vec![(0, Some(2), 0)] + ); + // Default: no pinning, vcpu id equals physical id. + assert_eq!(default_vcpu_affinities(1, None, None), vec![(0, None, 0)]); + } + + #[test] + fn default_vcpu_affinities_multi_cpu_falls_back_to_vcpu_id() { + assert_eq!( + default_vcpu_affinities(2, None, None), + vec![(0, None, 0), (1, None, 1)] + ); + } } diff --git a/virtualization/axvm/src/config.rs b/virtualization/axvm/src/config.rs index 063409c0b7..d2eda04eb7 100644 --- a/virtualization/axvm/src/config.rs +++ b/virtualization/axvm/src/config.rs @@ -594,4 +594,19 @@ mod tests { Err(crate::AxVmError::InvalidConfig { .. }) )); } + + #[test] + fn phys_cpu_list_pins_single_vcpu_to_isolated_core() { + // aarch64/riscv64 arceos-smp1.toml: cpu_num=1, phys_cpu_ids=[1] → physical Core 1. + let list = PhysCpuList::new(1, Some(vec![1]), None); + assert_eq!(list.get_vcpu_affinities_pcpu_ids(), vec![(0, None, 1)]); + + // x86_64 arceos-smp1.toml: phys_cpu_sets=[2] → affinity mask 0b10 (Core 1). + let list = PhysCpuList::new(1, None, Some(vec![2])); + assert_eq!(list.get_vcpu_affinities_pcpu_ids(), vec![(0, Some(2), 0)]); + + // Default: no pinning, vcpu id equals physical id. + let list = PhysCpuList::new(1, None, None); + assert_eq!(list.get_vcpu_affinities_pcpu_ids(), vec![(0, None, 0)]); + } } From c044e2aaf82f90006401b2da4ccfb0e121022294 Mon Sep 17 00:00:00 2001 From: Xinhong Hu Date: Thu, 6 Aug 2026 16:55:16 +0800 Subject: [PATCH 05/40] docs(axvisor): drop stale plan-doc reference from http-axum feature comment The plan doc doc/plan/axum-implementation.md lands with the docs PR in the stack, so pr1's Cargo.toml comment must not point at a file absent from the tree it merges with. Point at the implementation module instead. Changes: - os/axvisor/Cargo.toml: http-axum comment now references src/http/. --- os/axvisor/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/os/axvisor/Cargo.toml b/os/axvisor/Cargo.toml index 53ce5a87ec..e899ae06f1 100644 --- a/os/axvisor/Cargo.toml +++ b/os/axvisor/Cargo.toml @@ -47,7 +47,7 @@ stack-protector = ["ax-std/stack-protector"] backtrace = ["ax-std/backtrace", "dep:axbacktrace"] test-backtrace-panic = ["backtrace"] test-panic-no-backtrace = ["dep:axbacktrace"] -# axum-based management HTTP server (see doc/plan/axum-implementation.md). +# axum-based management HTTP server (see src/http/ for the Router and self-test). # Off by default; enabled together with `http-axum`. Replaces the hand-rolled # pilot, which is intentionally not carried forward. http-test = ["http-axum", "dep:tower"] From 57b8bcd5a15cd5c318fdc21bc8149576c5e15fd9 Mon Sep 17 00:00:00 2001 From: Xinhong Hu Date: Wed, 5 Aug 2026 21:35:51 +0800 Subject: [PATCH 06/40] =?UTF-8?q?feat(axvisor):=20VM=20start/stop=20?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=20API=EF=BC=88axum=20=E5=BC=82=E6=AD=A5=20st?= =?UTF-8?q?op=20+=20lifecycle=20=E8=87=AA=E6=B5=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 管理面需要能通过 HTTP 控制 VM 生命周期,而不只是只读查询。此前 PR B 只提供 GET 只读路由;本 PR 新增 POST /api/vms/{id}/start|stop, 并引入 no-auto-start feature——默认 VM 创建后停留在 Ready,由管理 面按需启动/停止,从而让控制 API 可被确定性自测。stop 是请求语义: vCPU 异步退出,因此自测先轮询 running_vcpu_count 确认 vCPU 已进入 guest 再发 stop,避免 vCPU 任务错过 Running 窗口而被永久挂起。 Changes: - os/axvisor: 新增 no-auto-start feature,main.rs 门控 auto-start 路径 - os/axvisor: http/vm.rs 新增 vm_start/vm_stop handler 与 AxVmError→HTTP 状态码映射 - os/axvisor: http/axum.rs 新增 POST 路由与 start/409/stop/stopped/404 lifecycle 自测 - axvm: 新增 AxVM::running_vcpu_count() 公开方法 - test-suit: 新增 qemu-http-axum-control aarch64 用例 --- os/axvisor/Cargo.toml | 15 +- os/axvisor/src/guest_console/mod.rs | 8 + os/axvisor/src/guest_console/mux/mod.rs | 7 + os/axvisor/src/http/mod.rs | 4 +- os/axvisor/src/http/server.rs | 182 +++++++++++++++--- os/axvisor/src/http/vm.rs | 74 ++++++- os/axvisor/src/main.rs | 8 + os/axvisor/src/manager.rs | 14 ++ .../build-aarch64-unknown-none-softfloat.toml | 13 ++ .../http-axum-control/qemu-aarch64.toml | 29 +++ virtualization/axvm/src/vm/mod.rs | 35 +++- 11 files changed, 347 insertions(+), 42 deletions(-) create mode 100644 test-suit/axvisor/normal/qemu-http-axum-control/build-aarch64-unknown-none-softfloat.toml create mode 100644 test-suit/axvisor/normal/qemu-http-axum-control/http-axum-control/qemu-aarch64.toml diff --git a/os/axvisor/Cargo.toml b/os/axvisor/Cargo.toml index e899ae06f1..2b40f0371e 100644 --- a/os/axvisor/Cargo.toml +++ b/os/axvisor/Cargo.toml @@ -52,6 +52,9 @@ test-panic-no-backtrace = ["dep:axbacktrace"] # pilot, which is intentionally not carried forward. http-test = ["http-axum", "dep:tower"] http-axum = ["dep:axum", "dep:tokio", "dep:serde_json"] +# Do not auto-boot the default VMs at startup; the HTTP control plane starts +# and stops them on demand (VMs are created and stay in `Ready`). +no-auto-start = [] [dependencies] shlex.workspace = true @@ -65,14 +68,10 @@ log = "0.4" axum = { version = "0.8", optional = true } serde_json = { version = "1", optional = true } tower = { version = "0.5", optional = true, features = ["util"] } -tokio = { version = "1", optional = true, features = [ - "rt", - "macros", - "net", - "time", - "sync", - "io-util", -] } +# The runtime only enables the IO driver (`enable_io()`), so no `time` driver +# and thus no `timerfd` syscall is needed. `rt` + `net` cover the manually +# built current-thread runtime and `TcpListener`. +tokio = { version = "1", optional = true, features = ["rt", "net"] } # System dependent modules provided by ArceOS. ax-std = { workspace = true, default-features = false, features = [ diff --git a/os/axvisor/src/guest_console/mod.rs b/os/axvisor/src/guest_console/mod.rs index d7158dbff0..420b92ed38 100644 --- a/os/axvisor/src/guest_console/mod.rs +++ b/os/axvisor/src/guest_console/mod.rs @@ -4,6 +4,14 @@ mod host; mod mux; pub(crate) use host::{configure_host_console_reader, read_host_byte, wait_for_host_input}; +#[cfg_attr( + feature = "no-auto-start", + expect( + unused_imports, + reason = "only the auto-start boot path attaches the console to a default running VM" + ) +)] +pub(crate) use mux::attach_default; pub(crate) use mux::{ ConsoleInputEvent, activate, attach, attach_default, attached_vm, mark_running, mark_stopped, reconcile_vm_states, remove, route_host_byte, serial_backend_factory, diff --git a/os/axvisor/src/guest_console/mux/mod.rs b/os/axvisor/src/guest_console/mux/mod.rs index 82ea2d977a..0897efc4df 100644 --- a/os/axvisor/src/guest_console/mux/mod.rs +++ b/os/axvisor/src/guest_console/mux/mod.rs @@ -472,6 +472,13 @@ pub fn route_host_byte(byte: u8) -> ConsoleInputEvent { } /// Attach the lowest-ID member of the default running VM set. +#[cfg_attr( + feature = "no-auto-start", + expect( + dead_code, + reason = "only the auto-start boot path attaches the console to a default running VM" + ) +)] pub fn attach_default(running: impl IntoIterator) -> Option { GUEST_CONSOLE_MUX.attach_default(running) } diff --git a/os/axvisor/src/http/mod.rs b/os/axvisor/src/http/mod.rs index 2652ca2ed7..a37c856b86 100644 --- a/os/axvisor/src/http/mod.rs +++ b/os/axvisor/src/http/mod.rs @@ -1,8 +1,8 @@ //! Management HTTP control plane. //! //! Served by an axum `Router` running on a tokio current-thread runtime -//! (see [`server`]). The VM list/detail and lifecycle routes live in [`vm`]. -//! JSON is built with `serde_json`. +//! (see [`server`]). The VM list/detail and start/stop lifecycle routes live +//! in [`vm`]. JSON is built with `serde_json`. //! //! This whole module is only compiled under the `http-axum` feature, which is //! off by default. The hand-rolled HTTP/1.0 pilot was intentionally not diff --git a/os/axvisor/src/http/server.rs b/os/axvisor/src/http/server.rs index 1580b66cfb..e7a7db216c 100644 --- a/os/axvisor/src/http/server.rs +++ b/os/axvisor/src/http/server.rs @@ -5,20 +5,22 @@ //! but dispatch and JSON construction are delegated to axum + serde_json. //! //! ```text -//! GET /api/vms → 200, JSON array (summary form) -//! GET /api/vms/{id} → 200, JSON detail (with vcpu_states) | 404 +//! GET /api/vms → 200, JSON array (summary form) +//! GET /api/vms/{id} → 200, JSON detail (with vcpu_states) | 404 +//! POST /api/vms/{id}/start → 200 {"ok":true,"status":...} | 404 | 409 | 503 +//! POST /api/vms/{id}/stop → 200 {"ok":true,"status":...} | 404 | 409 | 503 //! ``` //! -//! PR B is the first in-hypervisor axum runtime validation. Two things are -//! deliberately exercised: +//! The tokio reactor is initialized with `enable_io()` only (no time driver), +//! which needs only epoll, so no `timerfd` syscall is required. //! -//! 1. The tokio reactor initializes with `enable_io()` only (no time driver), -//! so no `timerfd` syscall is required. If axum serve turns out to need the -//! time driver, PR D (timerfd syscall) becomes mandatory. -//! 2. `tower::ServiceExt::oneshot` calls the router without TCP, so the -//! `http-test` self-test is deterministic and free of task-scheduling timing. +//! `http-test` drives the router with `tower::ServiceExt::oneshot` (no TCP +//! loopback), so the assertions are deterministic and free of task-scheduling +//! timing. Under `no-auto-start` the default VMs stay in `Ready` and the +//! self-test additionally drives one VM through a full +//! start/409/stop/stopped/404 lifecycle. -use axum::{Router, routing::get}; +use axum::{Router, routing::get, routing::post}; use crate::http::vm; @@ -27,6 +29,8 @@ pub fn router() -> Router { Router::new() .route("/api/vms", get(vm::list_vms)) .route("/api/vms/{id}", get(vm::vm_detail)) + .route("/api/vms/{id}/start", post(vm::vm_start)) + .route("/api/vms/{id}/stop", post(vm::vm_stop)) } /// Blocking serve: build a tokio current-thread runtime and hand it to axum. @@ -53,37 +57,157 @@ pub fn serve() { /// `http-test` built-in self-test: drive the router with /// `tower::ServiceExt::oneshot` (no TCP loopback) and print the actual status -/// codes for QEMU smoke-test regex assertion. Asserts the same contract as the -/// pilot: `GET /api/vms -> 200` and `GET /api/vms/999 -> 404` (no specific VM -/// id is bound). +/// codes for QEMU smoke-test regex assertion. Asserts `GET /api/vms -> 200` and +/// `GET /api/vms/999 -> 404` (no specific VM id is bound). #[cfg(feature = "http-test")] async fn self_test() { + let router = router(); + + let list = send_status(&router, "GET", "/api/vms").await; + info!("HTTP self-test: GET /api/vms -> {}", list); + + let detail = send_status(&router, "GET", "/api/vms/999").await; + info!("HTTP self-test: GET /api/vms/999 -> {}", detail); + + // With `no-auto-start` the default VMs are created but left in `Ready`, so + // the control API can be exercised over a full start/stop cycle. + #[cfg(feature = "no-auto-start")] + if let Some(id) = lifecycle_test::first_vm_id() { + lifecycle_test::self_test_lifecycle(router, id).await; + } +} + +/// Send a single request to the router and return its status code. +#[cfg(feature = "http-test")] +async fn send_status(router: &Router, method: &str, uri: &str) -> axum::http::StatusCode { use axum::body::Body; use axum::http::Request; use tower::ServiceExt; - let router = router(); - - let list = router + router .clone() .oneshot( Request::builder() - .uri("/api/vms") + .method(method) + .uri(uri) .body(Body::empty()) .expect("failed to build request"), ) .await - .expect("GET /api/vms failed"); - info!("HTTP self-test: GET /api/vms -> {}", list.status()); + .expect("request failed") + .status() +} - let detail = router - .oneshot( - Request::builder() - .uri("/api/vms/999") - .body(Body::empty()) - .expect("failed to build request"), - ) - .await - .expect("GET /api/vms/999 failed"); - info!("HTTP self-test: GET /api/vms/999 -> {}", detail.status()); +/// The control-lifecycle self-test, only built when both `http-test` and +/// `no-auto-start` are enabled (the default VMs stay in `Ready`). +/// +/// All polling here parks the task on a timer alarm (`std::thread::sleep` → +/// ArceOS `run_queue::sleep_until`), which reschedules and lets other Core-0 +/// tasks (shell, VM manager) run while waiting — it does not busy-wait. +#[cfg(all(feature = "http-test", feature = "no-auto-start"))] +mod lifecycle_test { + use ax_std::time::{Duration, Instant}; + use axum::Router; + + use super::send_status; + + /// The id of the first registered VM, if any. + pub(super) fn first_vm_id() -> Option { + crate::manager::AxvmManager::vm_list() + .first() + .map(|vm| vm.id()) + } + + /// Drive one VM through `start -> 409 -> stop -> stopped` and the 404 path, + /// verifying each expected outcome internally and printing a single + /// deterministic PASSED/FAILED sentinel for the QEMU regex matcher. + /// + /// `stop` is a request: the `Stopped` state only arrives once the vCPU + /// (running on another CPU) observes the request and exits, so the self-test + /// polls with explicit sleeps instead of blocking. `start` flips the VM status + /// to `Running` synchronously while the vCPU task is still being queued on its + /// target CPU, so before issuing a stop the self-test must wait until the vCPU + /// task has actually entered the guest (`running_vcpu_count`); otherwise a stop + /// issued in that window would strand the vCPU task waiting forever for a + /// `Running` state it already missed. + /// + /// Note: a `start` *after* stop is deliberately not asserted here. Restarting a + /// stopped VM spawns a fresh vCPU task that the scheduler never runs on its + /// pinned CPU once that CPU has idled (no IPI wake source in the current + /// build), leaving the VM stuck in `Running`. See the network-management + /// design doc for the tracked limitation. + pub(super) async fn self_test_lifecycle(router: Router, id: usize) { + let mut passed = true; + + let start = send_status(&router, "POST", &format!("/api/vms/{id}/start")).await; + info!("HTTP self-test: POST /api/vms/{id}/start -> {}", start); + passed &= start == axum::http::StatusCode::OK; + passed &= poll_vcpu_running(id); + + // A `Ready`/`Stopped`/`Running`-incompatible transition is a 409. + let invalid = send_status(&router, "POST", &format!("/api/vms/{id}/start")).await; + info!("HTTP self-test: POST start on running VM -> {}", invalid); + passed &= invalid == axum::http::StatusCode::CONFLICT; + + let stop = send_status(&router, "POST", &format!("/api/vms/{id}/stop")).await; + info!("HTTP self-test: POST /api/vms/{id}/stop -> {}", stop); + passed &= stop == axum::http::StatusCode::OK; + passed &= poll_status(id, "stopped"); + + let bad = send_status(&router, "POST", "/api/vms/999/start").await; + info!("HTTP self-test: POST /api/vms/999/start -> {}", bad); + passed &= bad == axum::http::StatusCode::NOT_FOUND; + + if passed { + info!("HTTP self-test: control lifecycle PASSED"); + } else { + error!("HTTP self-test: control lifecycle FAILED"); + } + } + + /// Wait until a vCPU of the VM has actually entered the guest run loop. + /// + /// `start_vm()` returns as soon as the VM status is `Running`; the vCPU task is + /// spawned on the calling CPU and migrated to its pinned CPU asynchronously. A + /// stop issued before that migration completes is observed by a vCPU task still + /// waiting for the `Running` state and never becomes effective. Polling + /// `running_vcpu_count` closes that window deterministically. Returns whether + /// the vCPU entered within the poll bound, for the self-test's pass/fail + /// accounting. + pub(super) fn poll_vcpu_running(id: usize) -> bool { + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(5) { + let entered = crate::manager::AxvmManager::vm_by_id(id) + .map(|vm| vm.running_vcpu_count() > 0) + .unwrap_or(false); + if entered { + info!("HTTP self-test: VM[{id}] vCPU entered guest"); + return true; + } + std::thread::sleep(Duration::from_millis(1)); + } + warn!("HTTP self-test: VM[{id}] vCPU did not enter guest within the poll bound"); + false + } + + /// Poll the VM status until it reports `want`, sleeping between checks so other + /// primary-CPU tasks are not starved. A wall-clock deadline is used rather than + /// an iteration count because the guest boot + stop completion latency is + /// timing-dependent (~100 ms in QEMU). Returns whether the status was reached, + /// for the self-test's internal pass/fail accounting. + pub(super) fn poll_status(id: usize, want: &str) -> bool { + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(5) { + let status = crate::manager::AxvmManager::vm_by_id(id) + .map(|vm| vm.status().as_str().to_owned()) + .unwrap_or_default(); + if status == want { + info!("HTTP self-test: VM[{id}] reached status '{want}'"); + return true; + } + std::thread::sleep(Duration::from_millis(1)); + } + warn!("HTTP self-test: VM[{id}] did not reach '{want}' within the poll bound"); + false + } } diff --git a/os/axvisor/src/http/vm.rs b/os/axvisor/src/http/vm.rs index 3688aa9ab5..4ec61e0a05 100644 --- a/os/axvisor/src/http/vm.rs +++ b/os/axvisor/src/http/vm.rs @@ -1,4 +1,4 @@ -//! Read-only VM status axum handlers. +//! VM status and lifecycle axum handlers. //! //! JSON is built with `serde_json::json!()` (no hand-written escaping). These //! handlers are shared by the TCP serving path in [`super::server`] and the @@ -6,7 +6,7 @@ //! logic the network path dispatches to. use axum::{Json, extract::Path, http::StatusCode}; -use axvm::{AxVMRef, VmVcpuState}; +use axvm::{AxVMRef, AxVmError, VmVcpuState}; use serde_json::{Value, json}; use crate::manager::AxvmManager; @@ -28,6 +28,76 @@ pub async fn vm_detail(Path(id_str): Path) -> Result, Status } } +/// `POST /api/vms/{id}/start` — start a VM. +pub async fn vm_start(Path(id_str): Path) -> Result, StatusCode> { + vm_action(&id_str, VmAction::Start) +} + +/// `POST /api/vms/{id}/stop` — request a VM stop. +/// +/// `stop` has request semantics: it returns as soon as the request is accepted, +/// while the vCPU exits and the VM reaches `Stopped` asynchronously. +pub async fn vm_stop(Path(id_str): Path) -> Result, StatusCode> { + vm_action(&id_str, VmAction::Stop) +} + +/// A lifecycle action on a VM. +enum VmAction { + Start, + Stop, +} + +/// Drive one lifecycle action, mapping host errors to HTTP status codes. +/// +/// Unknown VMs yield 404, invalid lifecycle transitions yield 409, and host +/// resource exhaustion yields 503. +fn vm_action(id_str: &str, action: VmAction) -> Result, StatusCode> { + let Ok(id) = id_str.parse::() else { + return Err(StatusCode::NOT_FOUND); + }; + // No existence pre-check: an unknown VM surfaces as `VmNotFound` from the + // action and maps to 404 below, keeping the check-then-act window closed. + let result = match action { + VmAction::Start => AxvmManager::start_vm(id), + VmAction::Stop => AxvmManager::stop_vm(id), + }; + match result { + Ok(()) => Ok(Json(vm_action_json(id))), + Err(error) => Err(map_axvm_error(error)), + } +} + +/// Report the VM status right after a lifecycle action was accepted. +fn vm_action_json(id: usize) -> Value { + let status = AxvmManager::vm_by_id(id) + .map(|vm| vm.status().as_str()) + .unwrap_or("unknown"); + json!({ "ok": true, "status": status }) +} + +/// Map an AxVM runtime error to an HTTP status code. +fn map_axvm_error(error: anyhow::Error) -> StatusCode { + let cause = error.root_cause(); + match cause.downcast_ref::() { + // A lifecycle transition that the current state does not allow. + Some(AxVmError::InvalidTransition { .. } | AxVmError::InvalidState { .. }) => { + StatusCode::CONFLICT + } + // Host resources (memory, vCPU list, devices, ...) were unavailable. + Some(AxVmError::OutOfMemory { .. } | AxVmError::ResourceUnavailable { .. }) => { + StatusCode::SERVICE_UNAVAILABLE + } + // Unknown VMs surface as `VmNotFound` from the action (there is no + // existence pre-check), mapping to 404. Anything else is a host-side + // fault. + Some(AxVmError::VmNotFound { .. }) => StatusCode::NOT_FOUND, + _ => { + error!("management HTTP action failed: {error:#}"); + StatusCode::INTERNAL_SERVER_ERROR + } + } +} + fn vm_json_summary(vm: &AxVMRef) -> Value { vm_json(vm, false) } diff --git a/os/axvisor/src/main.rs b/os/axvisor/src/main.rs index 109e8251b2..6f2da9c67b 100644 --- a/os/axvisor/src/main.rs +++ b/os/axvisor/src/main.rs @@ -98,14 +98,22 @@ fn main() { let default_vms = manager::AxvmManager::vm_list(); guest_console::configure_host_console_reader(&default_vms) .unwrap_or_else(|error| panic!("failed to configure host console input: {error:#}")); + + // With `no-auto-start` the default VMs are only created (staying in + // `Ready`) and the management plane boots them on demand, so nothing is + // launched or waited on here. + #[cfg(not(feature = "no-auto-start"))] let started_vms = manager.launch_default_vms(); + #[cfg(not(feature = "no-auto-start"))] guest_console::attach_default(started_vms); + #[cfg(not(feature = "no-auto-start"))] std::thread::Builder::new() .name("axvisor-vm-wait".into()) .spawn(manager::AxvmManager::wait_for_default_vms) .unwrap_or_else(|error| panic!("failed to start VM completion waiter: {error}")); + #[cfg(not(feature = "no-auto-start"))] info!("[OK] Default guest initialized"); // The management console runs on the primary CPU (Core 0) while the vCPU diff --git a/os/axvisor/src/manager.rs b/os/axvisor/src/manager.rs index 5c7f5ef6f9..2a9c9ac317 100644 --- a/os/axvisor/src/manager.rs +++ b/os/axvisor/src/manager.rs @@ -37,11 +37,25 @@ impl AxvmManager { } /// Start the default VM set without blocking the management console. + #[cfg_attr( + feature = "no-auto-start", + expect( + dead_code, + reason = "only the auto-start boot path launches the default VMs" + ) + )] pub fn launch_default_vms(&self) -> Vec { self.runtime.launch_default_vms() } /// Wait until every running VM has stopped. + #[cfg_attr( + feature = "no-auto-start", + expect( + dead_code, + reason = "only the auto-start boot path waits for default-VM completion" + ) + )] pub fn wait_for_default_vms() { AxvmRuntime::wait_for_all_vms(); } diff --git a/test-suit/axvisor/normal/qemu-http-axum-control/build-aarch64-unknown-none-softfloat.toml b/test-suit/axvisor/normal/qemu-http-axum-control/build-aarch64-unknown-none-softfloat.toml new file mode 100644 index 0000000000..d051671fc5 --- /dev/null +++ b/test-suit/axvisor/normal/qemu-http-axum-control/build-aarch64-unknown-none-softfloat.toml @@ -0,0 +1,13 @@ +# PR C: axum start/stop control API runtime verification. +# `no-auto-start` keeps the default VMs in `Ready` so the self-test can drive +# one VM through the full lifecycle via the HTTP control API. The guest image is +# embedded at build time (`image_location = "memory"` -> build.rs +# include_bytes!), so no `fs` feature is required; the vmconfig (untracked under +# `os/axvisor/tmp/vmconfigs/`) is generated locally. +features = [ + "http-test", + "no-auto-start", +] +log = "Info" +target = "aarch64-unknown-none-softfloat" +vm_configs = ["os/axvisor/tmp/vmconfigs/arceos-aarch64-http-control.toml"] diff --git a/test-suit/axvisor/normal/qemu-http-axum-control/http-axum-control/qemu-aarch64.toml b/test-suit/axvisor/normal/qemu-http-axum-control/http-axum-control/qemu-aarch64.toml new file mode 100644 index 0000000000..a5f4b7649e --- /dev/null +++ b/test-suit/axvisor/normal/qemu-http-axum-control/http-axum-control/qemu-aarch64.toml @@ -0,0 +1,29 @@ +args = [ + "-nographic", + "-cpu", + "cortex-a72", + "-machine", + "virt,virtualization=on,gic-version=3", + "-smp", + "2", + # `-m 1g` (vs the readonly test's 512M): the control test boots a real guest + # with a 256M MAP_IDENTICAL region, and the hypervisor plus that region must + # both fit in QEMU's total RAM; 512M would be too tight. + "-m", + "1g", +] +timeout = 600 +# The runner's stream matcher stops at the FIRST match (fail checked before +# success), so per-step status lines cannot be asserted independently. The +# self-test verifies every step internally and prints exactly one sentinel: +# PASSED only if the full lifecycle (start -> running -> stop -> stopped -> 404) +# met every expectation. +fail_regex = [ + "(?i)\\bpanic(?:ked)?\\b", + "HTTP self-test: control lifecycle FAILED", +] +success_regex = [ + "HTTP self-test: control lifecycle PASSED", +] +to_bin = true +uefi = false diff --git a/virtualization/axvm/src/vm/mod.rs b/virtualization/axvm/src/vm/mod.rs index adb2587d25..07ab0b8b67 100644 --- a/virtualization/axvm/src/vm/mod.rs +++ b/virtualization/axvm/src/vm/mod.rs @@ -387,8 +387,11 @@ impl VmRuntimeHandle { } pub(crate) fn mark_vcpu_running(&self) { + // Release publishes the "a vCPU has entered the guest" signal to the + // control plane, which observes it with an Acquire load + // (`running_halting_vcpu_count`) before issuing a request-stop. self.running_halting_vcpu_count - .fetch_add(1, Ordering::Relaxed); + .fetch_add(1, Ordering::Release); } pub(crate) fn publish_cpu_on_start_success(&self, ack: &crate::runtime::vcpus::CpuOnStartAck) { @@ -404,6 +407,12 @@ impl VmRuntimeHandle { == Ok(1) } + pub(crate) fn running_halting_vcpu_count(&self) -> usize { + // Acquire pairs with the Release increment in `mark_vcpu_running`: the + // caller observes "a vCPU has entered the guest" before acting on it. + self.running_halting_vcpu_count.load(Ordering::Acquire) + } + pub(crate) fn record_lifecycle_error(&self, error: AxVmError) { let mut recorded = self.lifecycle_error.lock_unpoisoned(); if recorded.is_none() { @@ -940,6 +949,30 @@ impl AxVM { .collect() } + /// Returns the number of vCPUs whose task has entered the guest run loop + /// and not yet finished exiting. + /// + /// A vCPU increments the count once, right before its first guest entry + /// (`vcpu_run`), and decrements it when it stops, so the count covers the + /// whole running + halting window: a non-zero value means at least one vCPU + /// task has been scheduled and is executing the guest. This differs from + /// `start_vm()`, which flips the VM status to `Running` synchronously while + /// the vCPU task may still be queued on another CPU. + /// + /// The count is a publish/observe signal between the vCPU cores and the + /// control plane: `mark_vcpu_running` increments it with `Release`, and this + /// getter loads it with `Acquire`. The control plane polls it to `> 0` + /// before issuing a request-stop, so a stop is only requested after a vCPU + /// has actually entered the guest; otherwise a stop issued before the vCPU + /// is scheduled would strand the vCPU task waiting forever for a `Running` + /// window it already missed. Because the count includes the halting window, + /// it must be read only as a monotone "a vCPU has entered" signal, not as an + /// exact "still running" count. + pub fn running_vcpu_count(&self) -> usize { + self.with_runtime(|runtime| Ok(runtime.running_halting_vcpu_count())) + .unwrap_or(0) + } + /// Returns the root address of the nested page table for the VM. pub fn nested_page_table_root(&self) -> AxVmResult { self.with_resources(|resources| Ok(resources.address_space.page_table_root())) From 40d0115e005c4c6648fe55c764783711702af235 Mon Sep 17 00:00:00 2001 From: Xinhong Hu Date: Thu, 6 Aug 2026 00:02:57 +0800 Subject: [PATCH 07/40] =?UTF-8?q?fix(axvisor):=20=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=20guest=20=E6=94=B9=E7=94=A8=20registry=20?= =?UTF-8?q?=E9=A2=84=E7=BD=AE=E9=95=9C=E5=83=8F=EF=BC=8CCI=20=E5=8F=AF?= =?UTF-8?q?=E5=A4=8D=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 控制测试原本依赖 gitignored 的 os/axvisor/tmp/ 下 vmconfig 与 guest 内核, clean checkout / CI 上 build.rs include_bytes! 找不到内核镜像导致构建失败。 改为复用 timer-stress 的预置机制:vmconfig 入库到 test-suit,kernel_path 指向 cargo xtask image pull qemu-aarch64 --output-dir tmp/axbuild/images 产出的 arceos-qemu,CI 在测试前先执行 pull,无需提交 442KB 二进制。 Changes: - 新增 test-suit/axvisor/normal/qemu-http-axum-control/aarch64-arceos-http-control.toml - build config 的 vm_configs 指向入库的 vmconfig - CI 新增 http-axum-control 测试条目(image pull + test) --- .github/workflows/ci.yml | 11 +++++ .../aarch64-arceos-http-control.toml | 44 +++++++++++++++++++ .../build-aarch64-unknown-none-softfloat.toml | 8 ++-- 3 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 test-suit/axvisor/normal/qemu-http-axum-control/aarch64-arceos-http-control.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ceb925294..a63859400b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -462,6 +462,17 @@ jobs: container_image: base limit_to_owner: "" main_pr_only: false + - name: Test axvisor aarch64 qemu (http control) + use_container: false + runs_on: '["self-hosted","linux","qcs"]' + self_hosted_owner: rcore-os + command: | + cargo xtask image pull qemu-aarch64 --output-dir tmp/axbuild/images + cargo xtask axvisor test qemu --arch aarch64 --test-case http-axum-control + cache_key: "" + container_image: base + limit_to_owner: "" + main_pr_only: false - name: Test axvisor aarch64 qemu (panic modes) use_container: false runs_on: '["self-hosted","linux","qcs"]' diff --git a/test-suit/axvisor/normal/qemu-http-axum-control/aarch64-arceos-http-control.toml b/test-suit/axvisor/normal/qemu-http-axum-control/aarch64-arceos-http-control.toml new file mode 100644 index 0000000000..1f1cdad846 --- /dev/null +++ b/test-suit/axvisor/normal/qemu-http-axum-control/aarch64-arceos-http-control.toml @@ -0,0 +1,44 @@ +# AxVisor control-plane test guest (aarch64). +# +# Booted by the qemu-http-axum-control test via the HTTP start/stop API. The +# kernel is baked into the hypervisor image at build time (`image_location = +# "memory"` -> `build.rs` include_bytes!), so no `fs` feature is required. +# +# The guest kernel comes from the managed `qemu-aarch64` registry image, pulled +# by `cargo xtask image pull qemu-aarch64 --output-dir tmp/axbuild/images` +# (same provisioning step the gicv2/gicv3-timer-stress tests use; CI runs the +# pull before the test). The path is relative to this file: four levels up +# reaches the workspace root, then `tmp/axbuild/images/...`. +# +# `phys_cpu_ids = [1]` pins the vCPU to physical CPU 1, keeping the management +# plane (HTTP server) on CPU 0 as PR1's core isolation requires. +[base] +id = 1 +name = "arceos-qemu" +guest_type = "passthrough" +cpu_num = 1 +phys_cpu_ids = [1] + +[kernel] +entry_point = 0x8020_0000 +image_location = "memory" +kernel_path = "../../../../tmp/axbuild/images/qemu-aarch64/arceos/arceos-qemu" +kernel_load_addr = 0x8020_0000 +dtb_load_addr = 0x8000_0000 + +# Memory regions with format (`base_paddr`, `size`, `flags`, `map_type`). +# map_type: 0 = MAP_ALLOC, 1 = MAP_IDENTICAL, 2 = MAP_RESERVED. +# +# 256M MAP_IDENTICAL: enough for the 442KB ArceOS guest kernel and fits in a +# `-m 1g` QEMU alongside the hypervisor (a 1G region overruns `-m 1g`). For +# identical memory the hypervisor re-plans the kernel load address to wherever +# the region lands (`vm::boot::BootImagePlan`), so the `0x8020_0000` entry/load +# addresses in `[kernel]` are relative guidance only. +memory_regions = [ + [0x8000_0000, 0x1000_0000, 0x7, 1], # System RAM 256M MAP_IDENTICAL +] + +# Physical-device selection. Virtual platform devices are machine-owned. +[devices] +passthrough = [] +disabled = [{ path = "/pcie@10000000" }] diff --git a/test-suit/axvisor/normal/qemu-http-axum-control/build-aarch64-unknown-none-softfloat.toml b/test-suit/axvisor/normal/qemu-http-axum-control/build-aarch64-unknown-none-softfloat.toml index d051671fc5..3d1977bf31 100644 --- a/test-suit/axvisor/normal/qemu-http-axum-control/build-aarch64-unknown-none-softfloat.toml +++ b/test-suit/axvisor/normal/qemu-http-axum-control/build-aarch64-unknown-none-softfloat.toml @@ -2,12 +2,14 @@ # `no-auto-start` keeps the default VMs in `Ready` so the self-test can drive # one VM through the full lifecycle via the HTTP control API. The guest image is # embedded at build time (`image_location = "memory"` -> build.rs -# include_bytes!), so no `fs` feature is required; the vmconfig (untracked under -# `os/axvisor/tmp/vmconfigs/`) is generated locally. +# include_bytes!), so no `fs` feature is required. The vmconfig is committed next +# to this file and references the managed `qemu-aarch64` registry image; CI runs +# `cargo xtask image pull qemu-aarch64 --output-dir tmp/axbuild/images` before +# the test to provision the guest kernel (see the timer-stress jobs). features = [ "http-test", "no-auto-start", ] log = "Info" target = "aarch64-unknown-none-softfloat" -vm_configs = ["os/axvisor/tmp/vmconfigs/arceos-aarch64-http-control.toml"] +vm_configs = ["test-suit/axvisor/normal/qemu-http-axum-control/aarch64-arceos-http-control.toml"] From 5d041719691bbbbd4d08424f32938e54173346af Mon Sep 17 00:00:00 2001 From: Xinhong Hu Date: Thu, 6 Aug 2026 00:12:17 +0800 Subject: [PATCH 08/40] =?UTF-8?q?fix(axvisor):=20start-on-stopped=20?= =?UTF-8?q?=E6=98=BE=E5=BC=8F=20409=EF=BC=8Cstop=20=E5=93=8D=E5=BA=94?= =?UTF-8?q?=E6=A0=87=E6=B3=A8=20async?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit restart-after-stop 因调度限制(停掉的 VM 再 start,新 vCPU task 在已 idle 的 固定核上不被调度)会让 VM 永久卡在 Running,调用方无从感知。改为在 vm_action 的 Start 分支对 Stopped 状态显式返回 409 Conflict,把限制固化为契约而非隐式 挂起;同时 stop 响应增加 "async": true 字段,明确 stop 是异步请求、返回的 status 可能仍是 running/stopping。自测补充 start-after-stop -> 409 断言。 Changes: - os/axvisor/src/http/vm.rs: Start 分支对 Stopped 返回 409;stop 响应加 async 标记 - os/axvisor/src/http/axum.rs: lifecycle 自测断言 start-on-stopped -> 409 --- os/axvisor/src/http/server.rs | 19 ++++++++++++++----- os/axvisor/src/http/vm.rs | 26 ++++++++++++++++++++++---- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/os/axvisor/src/http/server.rs b/os/axvisor/src/http/server.rs index e7a7db216c..46b537c6e4 100644 --- a/os/axvisor/src/http/server.rs +++ b/os/axvisor/src/http/server.rs @@ -131,11 +131,11 @@ mod lifecycle_test { /// issued in that window would strand the vCPU task waiting forever for a /// `Running` state it already missed. /// - /// Note: a `start` *after* stop is deliberately not asserted here. Restarting a - /// stopped VM spawns a fresh vCPU task that the scheduler never runs on its - /// pinned CPU once that CPU has idled (no IPI wake source in the current - /// build), leaving the VM stuck in `Running`. See the network-management - /// design doc for the tracked limitation. + /// Restarting a stopped VM spawns a fresh vCPU task that the scheduler never + /// runs on its pinned CPU once that CPU has idled (no IPI wake source in the + /// current build). The API contract rejects `start` on a `Stopped` VM with + /// 409 (see `vm_action`) instead of letting it hang in `Running`, and the + /// self-test asserts that rejection below. pub(super) async fn self_test_lifecycle(router: Router, id: usize) { let mut passed = true; @@ -154,6 +154,15 @@ mod lifecycle_test { passed &= stop == axum::http::StatusCode::OK; passed &= poll_status(id, "stopped"); + // Restart-after-stop is unsupported (scheduler limitation); the contract + // rejects it with 409 rather than hanging the VM in `Running`. + let restart = send_status(&router, "POST", &format!("/api/vms/{id}/start")).await; + info!( + "HTTP self-test: POST /api/vms/{id}/start on stopped VM -> {}", + restart + ); + passed &= restart == axum::http::StatusCode::CONFLICT; + let bad = send_status(&router, "POST", "/api/vms/999/start").await; info!("HTTP self-test: POST /api/vms/999/start -> {}", bad); passed &= bad == axum::http::StatusCode::NOT_FOUND; diff --git a/os/axvisor/src/http/vm.rs b/os/axvisor/src/http/vm.rs index 4ec61e0a05..4376715216 100644 --- a/os/axvisor/src/http/vm.rs +++ b/os/axvisor/src/http/vm.rs @@ -6,7 +6,7 @@ //! logic the network path dispatches to. use axum::{Json, extract::Path, http::StatusCode}; -use axvm::{AxVMRef, AxVmError, VmVcpuState}; +use axvm::{AxVMRef, AxVmError, VmStatus, VmVcpuState}; use serde_json::{Value, json}; use crate::manager::AxvmManager; @@ -57,22 +57,40 @@ fn vm_action(id_str: &str, action: VmAction) -> Result, StatusCode> }; // No existence pre-check: an unknown VM surfaces as `VmNotFound` from the // action and maps to 404 below, keeping the check-then-act window closed. + // Restart-after-stop is not supported: a fresh vCPU task on an idled pinned + // CPU is never scheduled (no IPI wake source), so `start_vm` would accept + // the start and leave the VM stuck in `Running`. Reject it explicitly so the + // limitation is a contract error rather than an implicit hang. + if matches!(action, VmAction::Start) + && AxvmManager::vm_by_id(id).is_some_and(|vm| vm.status() == VmStatus::Stopped) + { + return Err(StatusCode::CONFLICT); + } let result = match action { VmAction::Start => AxvmManager::start_vm(id), VmAction::Stop => AxvmManager::stop_vm(id), }; match result { - Ok(()) => Ok(Json(vm_action_json(id))), + Ok(()) => Ok(Json(vm_action_json(id, action))), Err(error) => Err(map_axvm_error(error)), } } /// Report the VM status right after a lifecycle action was accepted. -fn vm_action_json(id: usize) -> Value { +/// +/// `stop` is a request: the `Stopped` state arrives only once the vCPU observes +/// the request and exits asynchronously, so the reported status may still be +/// `running`/`stopping`. The `"async": true` marker makes that explicit so +/// callers do not mistake the accepted-request response for a completed stop. +fn vm_action_json(id: usize, action: VmAction) -> Value { let status = AxvmManager::vm_by_id(id) .map(|vm| vm.status().as_str()) .unwrap_or("unknown"); - json!({ "ok": true, "status": status }) + json!({ + "ok": true, + "status": status, + "async": matches!(action, VmAction::Stop), + }) } /// Map an AxVM runtime error to an HTTP status code. From bc0609333be79d4c96b3a574e5009a53cf4e3410 Mon Sep 17 00:00:00 2001 From: Xinhong Hu Date: Wed, 5 Aug 2026 23:40:07 +0800 Subject: [PATCH 09/40] =?UTF-8?q?docs(axvisor):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E7=AE=A1=E7=90=86=20HTTP=20=E6=8E=A7=E5=88=B6=E9=9D=A2=20QEMU?= =?UTF-8?q?=20hostfwd=20+=20curl=20=E6=93=8D=E4=BD=9C=E6=8C=87=E5=8D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit http-test 自测(tower oneshot)不经过真实 TCP,hostfwd + curl 才是管理 API 的真实网络路径。文档沉淀 aarch64/x86_64 从构建、to_bin、QEMU hostfwd 引导到 curl 全流程的完整步骤,并声明该 research/debug 接口的安全边界(EL2 内、 无认证、非生产级),便于后续开发与评审复现。 Changes: - 新增 doc/http-control-plane-quickstart.md:构建/引导/curl 全流程、安全边界、清理、FAQ --- .../doc/http-control-plane-quickstart.md | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 os/axvisor/doc/http-control-plane-quickstart.md diff --git a/os/axvisor/doc/http-control-plane-quickstart.md b/os/axvisor/doc/http-control-plane-quickstart.md new file mode 100644 index 0000000000..4a7cb00c42 --- /dev/null +++ b/os/axvisor/doc/http-control-plane-quickstart.md @@ -0,0 +1,162 @@ +# AxVisor 管理 HTTP 控制面 — QEMU hostfwd + curl 操作指南 + +> 管理 API 的单元/自测(`http-test` feature + tower `oneshot`)不经过真实 TCP; +> 本文档描述用 QEMU hostfwd 端口转发走**真实网络栈**验证的完整流程,并声明该 +> 接口的安全边界。**所有命令在工作区根目录(`tgoskits/`)执行**——构建产物位于 +> 工作区 `target/`,不是 `os/axvisor/target/`。 + +--- + +## 1. 架构 + +``` +host: curl http://localhost:18081/... + │ TCP + ▼ +QEMU user-mode NAT (hostfwd=tcp::18081-:8080) + │ 转发到虚拟机内 8080 + ▼ +AxVisor (hypervisor, EL2) — 管理 HTTP 服务器 (axum + tokio, 0.0.0.0:8080) + │ 同步调用 AxvmManager API + ▼ +vCPU (Core 1+) — guest +``` + +- **HTTP 服务器运行在 AxVisor(host)上,不在 guest 里。** 网络输入直接进入 hypervisor。 +- QEMU user 模式网络在内部做 NAT:host 的 18081 端口 → 虚拟机的 8080 端口。 +- **hostfwd 仅 QEMU user 模式 netdev 可用**(`-netdev user,id=net0,hostfwd=...`), + 不能用 tap/bridge。user 模式性能差,只适合开发/调试。 + +--- + +## 2. aarch64 快速开始(推荐,TCG 无需 KVM) + +控制 API 验证需要 guest 真实 boot,而 `http-test` 内置自测会把 VM stop(Core 1 idle +触发 restart-after-stop 调度限制)。所以手工验证用**手动 config**(`features = +["no-auto-start", "http-axum"]`,**不含 http-test**)构建,默认 VM 保持 `Ready`,由 curl +驱动启停。config 在 `os/axvisor/tmp/configs/http-control-manual.toml`。 + +### 2.1 构建 + +```bash +# 工作区根目录执行 + +# 1. 预置 guest 内核:控制测试 boot 的 arceos-qemu 来自 registry 的 qemu-aarch64 包, +# pull 到 tmp/axbuild/images/(与 CI 的 http-axum-control / timer-stress 步骤一致; +# 该文件在测试/构建时被 build.rs include_bytes! 嵌入 hypervisor 镜像) +cargo xtask image pull qemu-aarch64 --output-dir tmp/axbuild/images + +# 2. 构建(手动 config,无 http-test,默认 VM 保持 Ready) +cargo xtask axvisor build --config os/axvisor/tmp/configs/http-control-manual.toml +``` + +> **to_bin 陷阱:** `build`(无 QEMU case)**不会重新生成 `.bin`**——to_bin 由 QEMU +> case config 决定。QEMU 会引导到旧 test 产物 `.bin`(带 `http-test`)。手工从 ELF +> 重新生成: +> +> ```bash +> llvm-objcopy --strip-all -O binary \ +> target/aarch64-unknown-linux-musl/release/axvisor \ +> target/aarch64-unknown-linux-musl/release/axvisor.bin +> ``` + +### 2.2 引导 QEMU(background)+ hostfwd + +```bash +qemu-system-aarch64 -nographic -cpu cortex-a72 \ + -machine virt,virtualization=on,gic-version=3 -smp 2 -m 1g \ + -kernel target/aarch64-unknown-linux-musl/release/axvisor.bin \ + -netdev user,id=net0,hostfwd=tcp::18081-:8080 \ + -device virtio-net-pci,netdev=net0 +``` + +引导日志关键标记:`Initialize network subsystem...`、`use NIC 0: "virtio-net"`(真实网络 +枚举)、`management HTTP server (axum) listening on 0.0.0.0:8080`(前缀 `0:12` = Core 0 +task 12,核隔离:管理面在 Core 0)、`shell task on CPU0`。vCPU 启动后日志有 +`VM[1] VCpu[0] running on CPU1`(vCPU 在 Core 1)。 + +### 2.3 curl 全流程 + +```bash +# 只读:VM 保持 Ready +curl -s http://localhost:18081/api/vms # 200, JSON 数组(status="ready") +curl -s http://localhost:18081/api/vms/1 # 200, 明细(含 vcpu_states) +curl -s http://localhost:18081/api/vms/999 # 404 + +# 控制:start -> guest 运行 -> guest 退出 -> stopped +curl -s -X POST http://localhost:18081/api/vms/1/start # 200 {"ok":true,"status":"running","async":false} +curl -s http://localhost:18081/api/vms/1 # 200, status="stopped"(guest 退出后) +curl -s -X POST http://localhost:18081/api/vms/999/start # 404 +curl -s -X POST http://localhost:18081/api/vms/1/stop # 200 {"ok":true,"status":"stopping","async":true}(对已停 VM 也 200,幂等) +curl -s -X POST http://localhost:18081/api/vms/1/start # 409(restart-after-stop 不支持,契约拒绝) +``` + +> **stop 是异步请求:** 响应带 `"async": true`,POST stop 返回 200 只表示请求被接受, +> `Stopped` 要等 vCPU 退出,返回的 `status` 可能仍是 `running`/`stopping`。立即 GET +> 可能仍是 `stopping`。需等数秒再 GET,或循环 curl 直到状态稳定。 +> **start-on-stopped 返回 409**:restart-after-stop 是已知调度限制(stopped VM 再 +> start 时新 vCPU task 在其固定核已 idle 后不会被调度),API 契约显式拒绝(409), +> 不会让 VM 挂起在 `running`。 + +--- + +## 3. x86_64 变体(UEFI 引导) + +x86_64 需要 OVMF UEFI 引导,产物含 PE32+ EFI app。步骤(工作区根目录执行): + +```bash +# 1. 由测试 harness 刷新产物(x86_64 case 用 vmx variant) +cargo xtask axvisor test qemu --test-case http-axum-readonly --arch x86_64 + +# 2. 手工引导 + hostfwd +cp /usr/share/OVMF/OVMF_VARS_4M.fd /tmp/axvisor-manual.vars.fd +qemu-system-x86_64 -no-user-config -display none -serial stdio -monitor none \ + -cpu host,-la57,+vmx-ept,+vmx-unrestricted-guest,+vmx-flexpriority \ + -machine q35,smbus=off,usb=off,graphics=off -smp 2 -accel kvm -m 512M -vga none \ + -drive if=pflash,format=raw,unit=0,readonly=on,file=/usr/share/OVMF/OVMF_CODE_4M.fd \ + -drive if=pflash,format=raw,unit=1,file=/tmp/axvisor-manual.vars.fd \ + -drive format=raw,file=fat:rw:target/x86_64-unknown-linux-musl/release/axvisor.esp \ + -netdev user,id=net0,hostfwd=tcp::18080-:8080 \ + -device virtio-net-pci,netdev=net0 + +# 3. 验证 +curl -s -i http://localhost:18080/api/vms # 200 +curl -s -i http://localhost:18080/api/vms/999 # 404 +``` + +--- + +## 4. 清理 + +手动 QEMU 验证后必须 kill 掉残留实例,否则旧实例占住 hostfwd 端口并持续烧 CPU: + +```bash +pkill -f qemu-system +``` + +--- + +## 5. 安全边界声明(research/debug interface) + +> 本节为文档声明,非本期实现目标。 + +- **无认证、无加密:** HTTP 服务器运行在 hypervisor 内(EL2),网络输入直接进入 + hypervisor。`0.0.0.0:8080` 对管理网络全开放。 +- **默认信任管理网络:** 这不是生产级远程管理 API,仅用于开发/调试。 +- **与 IVC/hypercall 的关系(互补):** HTTP 是 **host 管理员**对外入口;IVC + (`IvcChannelFactory`)和 hypercall 是 **guest ↔ hypervisor** 内部通道。二者是不同 + 方向的通信机制,不互相替代。 + +--- + +## 6. 常见问题 + +| 现象 | 原因 / 处理 | +|------|------------| +| curl 超时/拒绝连接 | QEMU 未启动或 `hostfwd` 端口被旧实例占用。`pkill -f qemu-system` 后重启。 | +| 启动日志无 `use NIC 0:` | `net` feature 未启用(检查 build config `features`)。 | +| 无 `management HTTP server` 日志 | `http-axum` feature 缺失——`http::serve()` 整个模块在无该 feature 时不编译。config 必须含 `http-axum`(见 §2.1)。 | +| 启动后无 VM(`GET /api/vms` 空数组) | 构建用 `http-test` 自测产物(VM 被 stop 且自测驱动过生命周期);按 §2.1 重生成 `.bin`。 | +| 启动日志报 `VM[1] VCpu[0] run ... error ... VGIC ... Distributor write ... register requires Dword` | 预置 guest 镜像(`arceos-qemu`)在 GIC 初始化处做 byte 宽 GICD 写,VGIC 模拟拒绝。**与 HTTP 控制面无关**(vCPU 侧 device 错误),VM 仍会经 Fault 路径转为 `stopped`,控制流程不受影响。 | +| 幂等 stop 时日志出现 `Stopping VM[1]: Forced`(前缀 `0:12` = HTTP 任务) | 正常。`stop_vm` 一律用 `StopReason::Forced`(runtime/mod.rs:128);对已 Stopped VM,`request_stop_with` 是幂等 no-op(machine.rs:322-333),返回 200。`Forced` 是 stop 的标准 reason,不是错误。 | +| `POST start` 对 stopped VM 返回 409 | 正常,是契约行为。restart-after-stop 是已知调度限制(stopped VM 再 start 时新 vCPU task 在已 idle 的固定核上不被调度),`vm_action` 显式以 409 拒绝,避免 VM 挂起在 `running`。 | From 9e3da8115bbfc3222e86ff3ee83381c7fefb6a5c Mon Sep 17 00:00:00 2001 From: Xinhong Hu Date: Thu, 6 Aug 2026 01:37:53 +0800 Subject: [PATCH 10/40] =?UTF-8?q?docs(axvisor):=20=E5=AE=8C=E5=96=84=20HTT?= =?UTF-8?q?P=20=E6=8E=A7=E5=88=B6=E9=9D=A2=20quickstart=20=E5=8F=AF?= =?UTF-8?q?=E5=A4=8D=E7=8E=B0=E6=80=A7=E4=B8=8E=20NIC=20=E6=8E=92=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 管理手册引用的 http-control-manual.toml 是本地 gitignore 文件,读者无法重建, 且排障表把缺少 use NIC 0: 日志归因于不存在的 net feature 开关,两条都妨碍复现 与排查。本变更把 config 内容内嵌进文档,并按 net 无条件启用的现状修正排障指引。 Changes: - 内嵌 http-control-manual.toml 完整内容,读者可直接复制重建 - 修正 troubleshooting 表:net 由 ax-std 无条件启用,缺 use NIC 0: 是 QEMU 未提供网卡 --- os/axvisor/doc/http-control-plane-quickstart.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/os/axvisor/doc/http-control-plane-quickstart.md b/os/axvisor/doc/http-control-plane-quickstart.md index 4a7cb00c42..98bfaa3321 100644 --- a/os/axvisor/doc/http-control-plane-quickstart.md +++ b/os/axvisor/doc/http-control-plane-quickstart.md @@ -34,7 +34,15 @@ vCPU (Core 1+) — guest 控制 API 验证需要 guest 真实 boot,而 `http-test` 内置自测会把 VM stop(Core 1 idle 触发 restart-after-stop 调度限制)。所以手工验证用**手动 config**(`features = ["no-auto-start", "http-axum"]`,**不含 http-test**)构建,默认 VM 保持 `Ready`,由 curl -驱动启停。config 在 `os/axvisor/tmp/configs/http-control-manual.toml`。 +驱动启停。config 位于 `os/axvisor/tmp/configs/http-control-manual.toml`(本地 gitignore, +内容如下,可自行重建): + +```toml +features = ["no-auto-start", "http-axum"] +log = "Info" +target = "aarch64-unknown-none-softfloat" +vm_configs = ["test-suit/axvisor/normal/qemu-http-axum-control/aarch64-arceos-http-control.toml"] +``` ### 2.1 构建 @@ -154,7 +162,7 @@ pkill -f qemu-system | 现象 | 原因 / 处理 | |------|------------| | curl 超时/拒绝连接 | QEMU 未启动或 `hostfwd` 端口被旧实例占用。`pkill -f qemu-system` 后重启。 | -| 启动日志无 `use NIC 0:` | `net` feature 未启用(检查 build config `features`)。 | +| 启动日志无 `use NIC 0:` | `net` 能力由 `ax-std` 无条件启用(不通过 axvisor build config 开关),缺该日志说明 QEMU 没给虚拟机提供网卡——检查 `-netdev user` + `-device virtio-net-pci` 是否都在,或 `hostfwd` 端口是否被残留实例占用(`pkill -f qemu-system` 后重启)。 | | 无 `management HTTP server` 日志 | `http-axum` feature 缺失——`http::serve()` 整个模块在无该 feature 时不编译。config 必须含 `http-axum`(见 §2.1)。 | | 启动后无 VM(`GET /api/vms` 空数组) | 构建用 `http-test` 自测产物(VM 被 stop 且自测驱动过生命周期);按 §2.1 重生成 `.bin`。 | | 启动日志报 `VM[1] VCpu[0] run ... error ... VGIC ... Distributor write ... register requires Dword` | 预置 guest 镜像(`arceos-qemu`)在 GIC 初始化处做 byte 宽 GICD 写,VGIC 模拟拒绝。**与 HTTP 控制面无关**(vCPU 侧 device 错误),VM 仍会经 Fault 路径转为 `stopped`,控制流程不受影响。 | From fd8f71942d1a446f039ca856e2d17e4c302aaa98 Mon Sep 17 00:00:00 2001 From: Xinhong Hu Date: Thu, 6 Aug 2026 00:40:41 +0800 Subject: [PATCH 11/40] =?UTF-8?q?feat(axvisor):=20=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=B6=20create/delete=20VM=20=E6=8E=A7=E5=88=B6=20API?= =?UTF-8?q?=EF=BC=88=E5=8F=97=E9=99=90=20create=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 动态创建/删除 VM 需要在运行时验证完整 VM 生命周期(内存/vCPU/device 初始化、 remove 无 task 泄漏、失败回滚),而非仅 HTTP 包装。前置验证发现 guest 镜像只能 构建期内嵌(include_bytes!),运行时按 base.id 严格匹配内嵌镜像,而内嵌镜像与 VM 注册共用同一份 config 列表——新 id 无内嵌镜像、已有 id 已注册。因此 create 仅对「已内嵌镜像且当前未注册」的 id 有效(remove-then-recreate 默认 VM), QEMU 实测全流程 PASSED。 Changes: - http/vm.rs: 新增 POST /api/vms/create(Json body {"toml"})与 DELETE /api/vms/{id} handler,错误映射 400/409/404/500;remove 显式两步 (先 destroy 检查返回值,再 remove_vm 移出注册表),避免 Drop 静默失败 - http/axum.rs: 注册 create/delete 路由(get(vm_detail).delete(vm_delete)); http-dynamic-test feature 下新增 dynamic 自测 (remove→create→ready→重复create 409→remove→404),单一 PASSED/FAILED sentinel - Cargo.toml: 新增 http-dynamic-test feature(= http-test + no-auto-start) - test-suit/axvisor/normal/qemu-http-axum-dynamic/: guest/build/qemu 三份配置, 复用构建期内嵌镜像(image_location=memory),success_regex 匹配 sentinel - ci.yml: 新增 self-hosted qemu http-axum-dynamic job(先 image pull 再 test) - doc/http-control-plane-quickstart.md: create/delete 端点 curl 流程、 内嵌镜像约束说明、新增 FAQ 行 --- .github/workflows/ci.yml | 11 ++ os/axvisor/Cargo.toml | 4 + .../doc/http-control-plane-quickstart.md | 23 ++++ os/axvisor/src/http/server.rs | 104 +++++++++++++++++- os/axvisor/src/http/vm.rs | 54 ++++++++- .../aarch64-arceos-http-dynamic.toml | 49 +++++++++ .../build-aarch64-unknown-none-softfloat.toml | 16 +++ .../http-axum-dynamic/qemu-aarch64.toml | 29 +++++ 8 files changed, 283 insertions(+), 7 deletions(-) create mode 100644 test-suit/axvisor/normal/qemu-http-axum-dynamic/aarch64-arceos-http-dynamic.toml create mode 100644 test-suit/axvisor/normal/qemu-http-axum-dynamic/build-aarch64-unknown-none-softfloat.toml create mode 100644 test-suit/axvisor/normal/qemu-http-axum-dynamic/http-axum-dynamic/qemu-aarch64.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a63859400b..cc9a35be94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -473,6 +473,17 @@ jobs: container_image: base limit_to_owner: "" main_pr_only: false + - name: Test axvisor aarch64 qemu (http dynamic) + use_container: false + runs_on: '["self-hosted","linux","qcs"]' + self_hosted_owner: rcore-os + command: | + cargo xtask image pull qemu-aarch64 --output-dir tmp/axbuild/images + cargo xtask axvisor test qemu --arch aarch64 --test-case http-axum-dynamic + cache_key: "" + container_image: base + limit_to_owner: "" + main_pr_only: false - name: Test axvisor aarch64 qemu (panic modes) use_container: false runs_on: '["self-hosted","linux","qcs"]' diff --git a/os/axvisor/Cargo.toml b/os/axvisor/Cargo.toml index 2b40f0371e..e165873680 100644 --- a/os/axvisor/Cargo.toml +++ b/os/axvisor/Cargo.toml @@ -55,6 +55,10 @@ http-axum = ["dep:axum", "dep:tokio", "dep:serde_json"] # Do not auto-boot the default VMs at startup; the HTTP control plane starts # and stops them on demand (VMs are created and stay in `Ready`). no-auto-start = [] +# Runtime create/delete self-test (`qemu-http-axum-dynamic`). Implies the base +# control self-test and the no-auto-start lifecycle test, then drives one default +# VM through remove -> create -> ready -> 409 -> remove -> 404. +http-dynamic-test = ["http-test", "no-auto-start"] [dependencies] shlex.workspace = true diff --git a/os/axvisor/doc/http-control-plane-quickstart.md b/os/axvisor/doc/http-control-plane-quickstart.md index 98bfaa3321..b832277a23 100644 --- a/os/axvisor/doc/http-control-plane-quickstart.md +++ b/os/axvisor/doc/http-control-plane-quickstart.md @@ -97,8 +97,28 @@ curl -s http://localhost:18081/api/vms/1 # 200, status="stopped" curl -s -X POST http://localhost:18081/api/vms/999/start # 404 curl -s -X POST http://localhost:18081/api/vms/1/stop # 200 {"ok":true,"status":"stopping","async":true}(对已停 VM 也 200,幂等) curl -s -X POST http://localhost:18081/api/vms/1/start # 409(restart-after-stop 不支持,契约拒绝) + +# 动态创建/删除:create -> ready -> 409(重复 id)-> remove -> 404 +curl -s -X DELETE http://localhost:18081/api/vms/1 # 204(先删除默认 VM,释放其 id) +curl -s -X POST http://localhost:18081/api/vms/create \ + -H 'Content-Type: application/json' \ + -d '{"toml": "<完整 TOML 配置,base.id 必须命中已内嵌镜像且未注册>"}' # 200 {"id":1} +curl -s http://localhost:18081/api/vms/1 # 200, status="ready"(重建后) +curl -s -X POST http://localhost:18081/api/vms/create \ + -H 'Content-Type: application/json' \ + -d '{"toml": "<同上>"}' # 409(id 已注册,重复 create 是契约错误) +curl -s -X DELETE http://localhost:18081/api/vms/1 # 204 +curl -s http://localhost:18081/api/vms/1 # 404(已移除) ``` +> **create 的镜像约束:** guest 内核只能在构建期内嵌(`image_location = "memory"` → +> `build.rs` include_bytes!),运行时按 `base.id` 严格匹配内嵌镜像 +> (`memory_images_for_vm`,boot/images/mod.rs:223-238)。因此 create 只能复现**构建期 +> 已内嵌且当前未注册**的 id(即先 DELETE 释放 id,再用同一 TOML 重建);新 id 无内嵌镜像 +> → 500。这是受限的 create/delete:验证运行时 VM 生命周期(内存/vCPU/device 初始化、 +> remove 无 task 泄漏、失败回滚),而非任意镜像的运行时加载。对应自测 +> `test-suit/axvisor/normal/qemu-http-axum-dynamic/`(`--test-case http-axum-dynamic`)。 + > **stop 是异步请求:** 响应带 `"async": true`,POST stop 返回 200 只表示请求被接受, > `Stopped` 要等 vCPU 退出,返回的 `status` 可能仍是 `running`/`stopping`。立即 GET > 可能仍是 `stopping`。需等数秒再 GET,或循环 curl 直到状态稳定。 @@ -168,3 +188,6 @@ pkill -f qemu-system | 启动日志报 `VM[1] VCpu[0] run ... error ... VGIC ... Distributor write ... register requires Dword` | 预置 guest 镜像(`arceos-qemu`)在 GIC 初始化处做 byte 宽 GICD 写,VGIC 模拟拒绝。**与 HTTP 控制面无关**(vCPU 侧 device 错误),VM 仍会经 Fault 路径转为 `stopped`,控制流程不受影响。 | | 幂等 stop 时日志出现 `Stopping VM[1]: Forced`(前缀 `0:12` = HTTP 任务) | 正常。`stop_vm` 一律用 `StopReason::Forced`(runtime/mod.rs:128);对已 Stopped VM,`request_stop_with` 是幂等 no-op(machine.rs:322-333),返回 200。`Forced` 是 stop 的标准 reason,不是错误。 | | `POST start` 对 stopped VM 返回 409 | 正常,是契约行为。restart-after-stop 是已知调度限制(stopped VM 再 start 时新 vCPU task 在已 idle 的固定核上不被调度),`vm_action` 显式以 409 拒绝,避免 VM 挂起在 `running`。 | +| `POST /api/vms/create` 返回 500 | create 的 TOML `base.id` 没有构建期内嵌镜像(新 id)→ 运行时 `memory_images_for_vm` 报 NotFound。create 只能复现已内嵌且未注册的 id(先 DELETE 再 create);TOML 解析失败 → 400,id 已注册 → 409。 | +| `DELETE /api/vms/{id}` 返回 500 | `vm.destroy()` 失败(VM 停在 `Destroying`)。handler 先 destroy 后 remove,destroy 失败时 VM 仍在注册表内,可重试 DELETE。 | +| 动态自测日志出现 `VM[1] vCPU runtime cleanup skipped: InvalidState` | 正常。删除从未 start 的 VM 时 `join_all_vcpu_tasks` 无 vCPU runtime 可 join(`cleanup_vm_vcpus` 的 `warn!` 路径),资源仍正确释放(`resources cleanup completed`)。 | diff --git a/os/axvisor/src/http/server.rs b/os/axvisor/src/http/server.rs index 46b537c6e4..a285a2641b 100644 --- a/os/axvisor/src/http/server.rs +++ b/os/axvisor/src/http/server.rs @@ -5,10 +5,12 @@ //! but dispatch and JSON construction are delegated to axum + serde_json. //! //! ```text -//! GET /api/vms → 200, JSON array (summary form) -//! GET /api/vms/{id} → 200, JSON detail (with vcpu_states) | 404 -//! POST /api/vms/{id}/start → 200 {"ok":true,"status":...} | 404 | 409 | 503 -//! POST /api/vms/{id}/stop → 200 {"ok":true,"status":...} | 404 | 409 | 503 +//! GET /api/vms → 200, JSON array (summary form) +//! GET /api/vms/{id} → 200, JSON detail (with vcpu_states) | 404 +//! POST /api/vms/create → 200 {"id":N} | 400 | 409 | 500 (body {"toml": "..."}) +//! DELETE /api/vms/{id} → 204 | 404 | 500 +//! POST /api/vms/{id}/start → 200 {"ok":true,"status":...} | 404 | 409 | 503 +//! POST /api/vms/{id}/stop → 200 {"ok":true,"status":...} | 404 | 409 | 503 //! ``` //! //! The tokio reactor is initialized with `enable_io()` only (no time driver), @@ -18,7 +20,8 @@ //! loopback), so the assertions are deterministic and free of task-scheduling //! timing. Under `no-auto-start` the default VMs stay in `Ready` and the //! self-test additionally drives one VM through a full -//! start/409/stop/stopped/404 lifecycle. +//! start/409/stop/stopped/404 lifecycle. `http-dynamic-test` then drives the +//! same VM through remove -> create -> ready -> 409 -> remove -> 404. use axum::{Router, routing::get, routing::post}; @@ -28,7 +31,8 @@ use crate::http::vm; pub fn router() -> Router { Router::new() .route("/api/vms", get(vm::list_vms)) - .route("/api/vms/{id}", get(vm::vm_detail)) + .route("/api/vms/{id}", get(vm::vm_detail).delete(vm::vm_delete)) + .route("/api/vms/create", post(vm::vm_create)) .route("/api/vms/{id}/start", post(vm::vm_start)) .route("/api/vms/{id}/stop", post(vm::vm_stop)) } @@ -46,6 +50,8 @@ pub fn serve() { rt.block_on(async { #[cfg(feature = "http-test")] self_test().await; + #[cfg(feature = "http-dynamic-test")] + dynamic_test::self_test_dynamic().await; let listener = tokio::net::TcpListener::bind("0.0.0.0:8080") .await @@ -220,3 +226,89 @@ mod lifecycle_test { false } } + +/// The create/delete self-test, only built under `http-dynamic-test`. +/// +/// Runs after the base control self-test, so the default VM has already been +/// started and stopped by [`super::lifecycle_test::self_test_lifecycle`] and +/// sits in `Stopped`. The test removes that VM, recreates it from its own +/// build-time config (the create body reuses `static_vm_configs().first()`, +/// whose id owns an embedded guest image), checks it is `Ready`, verifies a +/// duplicate create is rejected with 409, then removes it again and confirms a +/// 404. +#[cfg(feature = "http-dynamic-test")] +mod dynamic_test { + use axum::body::Body; + use axum::http::Request; + use serde_json::json; + use tower::ServiceExt; + + use super::{lifecycle_test, send_status}; + + /// Drive one VM through remove -> create -> ready -> 409 -> remove -> 404, + /// printing a single deterministic PASSED/FAILED sentinel. + pub(super) async fn self_test_dynamic() { + let router = super::router(); + let mut passed = true; + + // The create body is the VM's own build-time config: its id owns an + // embedded guest image, which is the only way the runtime boot-image + // resolver (`memory_images_for_vm`) can satisfy the load. + let toml = crate::config::vmcfg::static_vm_configs() + .first() + .copied() + .expect("dynamic self-test requires a static VM config"); + let id = lifecycle_test::first_vm_id().expect("dynamic self-test requires a default VM"); + + // 1. Remove the default VM (registered at boot, left `Stopped` by the + // base control self-test). destroy() + remove_vm() are synchronous. + let removed = send_status(&router, "DELETE", &format!("/api/vms/{id}")).await; + info!("HTTP self-test: DELETE /api/vms/{id} -> {removed}"); + passed &= removed == axum::http::StatusCode::NO_CONTENT; + + // 2. Recreate it from the same TOML. + let created = send_create(&router, toml).await; + info!("HTTP self-test: POST /api/vms/create -> {created}"); + passed &= created == axum::http::StatusCode::OK; + + // 3. The recreated VM is registered and `Ready`. + passed &= lifecycle_test::poll_status(id, "ready"); + + // 4. A duplicate id is a contract error (409), not an opaque 500. + let dup = send_create(&router, toml).await; + info!("HTTP self-test: POST /api/vms/create duplicate -> {dup}"); + passed &= dup == axum::http::StatusCode::CONFLICT; + + // 5. Remove it again, then confirm it is gone. + let removed = send_status(&router, "DELETE", &format!("/api/vms/{id}")).await; + info!("HTTP self-test: DELETE /api/vms/{id} -> {removed}"); + passed &= removed == axum::http::StatusCode::NO_CONTENT; + + let gone = send_status(&router, "GET", &format!("/api/vms/{id}")).await; + info!("HTTP self-test: GET /api/vms/{id} -> {gone}"); + passed &= gone == axum::http::StatusCode::NOT_FOUND; + + if passed { + info!("HTTP self-test: dynamic create/delete PASSED"); + } else { + error!("HTTP self-test: dynamic create/delete FAILED"); + } + } + + /// POST a create request with the given TOML body. + async fn send_create(router: &axum::Router, toml: &str) -> axum::http::StatusCode { + router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/vms/create") + .header("content-type", "application/json") + .body(Body::from(json!({ "toml": toml }).to_string())) + .expect("failed to build request"), + ) + .await + .expect("request failed") + .status() + } +} diff --git a/os/axvisor/src/http/vm.rs b/os/axvisor/src/http/vm.rs index 4376715216..90454bb9d1 100644 --- a/os/axvisor/src/http/vm.rs +++ b/os/axvisor/src/http/vm.rs @@ -1,4 +1,4 @@ -//! VM status and lifecycle axum handlers. +//! VM status, lifecycle, and create/delete axum handlers. //! //! JSON is built with `serde_json::json!()` (no hand-written escaping). These //! handlers are shared by the TCP serving path in [`super::server`] and the @@ -7,6 +7,7 @@ use axum::{Json, extract::Path, http::StatusCode}; use axvm::{AxVMRef, AxVmError, VmStatus, VmVcpuState}; +use axvmconfig::GuestConfig; use serde_json::{Value, json}; use crate::manager::AxvmManager; @@ -28,6 +29,57 @@ pub async fn vm_detail(Path(id_str): Path) -> Result, Status } } +/// `POST /api/vms/create` — create a VM from a TOML config in the JSON body. +/// +/// Body: `{"toml": "<完整 TOML 配置>"}`. The guest kernel must be a build-time +/// embedded image (`image_location = "memory"`) whose id matches the config's +/// `base.id`, and that id must not currently be registered. Because embedded +/// images are matched by id (`memory_images_for_vm`), a config whose id has no +/// embedded image fails with 500 — the runtime can only realize guest images +/// that were baked into the hypervisor at build time. +pub async fn vm_create(Json(payload): Json) -> Result, StatusCode> { + let toml = payload + .get("toml") + .and_then(Value::as_str) + .ok_or(StatusCode::BAD_REQUEST)?; + let config = GuestConfig::from_toml(toml).map_err(|_| StatusCode::BAD_REQUEST)?; + let id = config.base.id; + // Explicit duplicate check: `create_vm_from_toml` fails on a re-registered id + // with a plain anyhow string, so surface the conflict as a contract error + // (409) instead of an opaque 500. + if AxvmManager::vm_by_id(id).is_some() { + return Err(StatusCode::CONFLICT); + } + match AxvmManager::create_vm_from_toml(toml) { + Ok(id) => { + info!("HTTP: VM[{id}] created via control API"); + Ok(Json(json!({ "id": id }))) + } + Err(error) => { + error!("HTTP: create VM[{id}] failed: {error:#}"); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +/// `DELETE /api/vms/{id}` — destroy and unregister a VM. +/// +/// Two explicit steps so a failed destroy stays retryable: `destroy()` first +/// (its result is checked), and the registry is only touched on success. This +/// avoids relying on `Drop`-time destroy, which merely warns on failure after +/// the VM is already unregistered, leaving no handle to retry with. +pub async fn vm_delete(Path(id_str): Path) -> Result { + let Ok(id) = id_str.parse::() else { + return Err(StatusCode::NOT_FOUND); + }; + let vm = AxvmManager::vm_by_id(id).ok_or(StatusCode::NOT_FOUND)?; + vm.destroy() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + AxvmManager::remove_vm(id).ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + info!("HTTP: VM[{id}] removed via control API"); + Ok(StatusCode::NO_CONTENT) +} + /// `POST /api/vms/{id}/start` — start a VM. pub async fn vm_start(Path(id_str): Path) -> Result, StatusCode> { vm_action(&id_str, VmAction::Start) diff --git a/test-suit/axvisor/normal/qemu-http-axum-dynamic/aarch64-arceos-http-dynamic.toml b/test-suit/axvisor/normal/qemu-http-axum-dynamic/aarch64-arceos-http-dynamic.toml new file mode 100644 index 0000000000..e52392f7ef --- /dev/null +++ b/test-suit/axvisor/normal/qemu-http-axum-dynamic/aarch64-arceos-http-dynamic.toml @@ -0,0 +1,49 @@ +# AxVisor dynamic create/delete test guest (aarch64). +# +# Booted by the qemu-http-axum-dynamic test via the HTTP create/delete API. The +# kernel is baked into the hypervisor image at build time (`image_location = +# "memory"` -> `build.rs` include_bytes!), so no `fs` feature is required. +# +# The dynamic self-test removes this VM, then recreates it from the same TOML +# (the create body reuses the build-time config string). The runtime boot-image +# resolver matches the embedded image strictly by `base.id`, so the recreated VM +# must reuse this id and this embedded image. +# +# The guest kernel comes from the managed `qemu-aarch64` registry image, pulled +# by `cargo xtask image pull qemu-aarch64 --output-dir tmp/axbuild/images` +# (same provisioning step the control test uses; CI runs the pull before the +# test). The path is relative to this file: four levels up reaches the workspace +# root, then `tmp/axbuild/images/...`. +# +# `phys_cpu_ids = [1]` pins the vCPU to physical CPU 1, keeping the management +# plane (HTTP server) on CPU 0 as PR1's core isolation requires. +[base] +id = 1 +name = "arceos-qemu" +guest_type = "passthrough" +cpu_num = 1 +phys_cpu_ids = [1] + +[kernel] +entry_point = 0x8020_0000 +image_location = "memory" +kernel_path = "../../../../tmp/axbuild/images/qemu-aarch64/arceos/arceos-qemu" +kernel_load_addr = 0x8020_0000 +dtb_load_addr = 0x8000_0000 + +# Memory regions with format (`base_paddr`, `size`, `flags`, `map_type`). +# map_type: 0 = MAP_ALLOC, 1 = MAP_IDENTICAL, 2 = MAP_RESERVED. +# +# 256M MAP_IDENTICAL: enough for the 442KB ArceOS guest kernel and fits in a +# `-m 1g` QEMU alongside the hypervisor (a 1G region overruns `-m 1g`). For +# identical memory the hypervisor re-plans the kernel load address to wherever +# the region lands (`vm::boot::BootImagePlan`), so the `0x8020_0000` entry/load +# addresses in `[kernel]` are relative guidance only. +memory_regions = [ + [0x8000_0000, 0x1000_0000, 0x7, 1], # System RAM 256M MAP_IDENTICAL +] + +# Physical-device selection. Virtual platform devices are machine-owned. +[devices] +passthrough = [] +disabled = [{ path = "/pcie@10000000" }] diff --git a/test-suit/axvisor/normal/qemu-http-axum-dynamic/build-aarch64-unknown-none-softfloat.toml b/test-suit/axvisor/normal/qemu-http-axum-dynamic/build-aarch64-unknown-none-softfloat.toml new file mode 100644 index 0000000000..713e163f62 --- /dev/null +++ b/test-suit/axvisor/normal/qemu-http-axum-dynamic/build-aarch64-unknown-none-softfloat.toml @@ -0,0 +1,16 @@ +# PR E: runtime create/delete over the axum control API. +# `http-dynamic-test` implies `http-test` + `no-auto-start`: the base control +# self-test runs first (start/stop lifecycle), then the dynamic self-test +# removes the default VM, recreates it from its own build-time config (the id +# owns an embedded guest image), and removes it again. The guest image is +# embedded at build time (`image_location = "memory"` -> build.rs +# include_bytes!), so no `fs` feature is required. The vmconfig is committed +# next to this file and references the managed `qemu-aarch64` registry image; CI +# runs `cargo xtask image pull qemu-aarch64 --output-dir tmp/axbuild/images` +# before the test to provision the guest kernel. +features = [ + "http-dynamic-test", +] +log = "Info" +target = "aarch64-unknown-none-softfloat" +vm_configs = ["test-suit/axvisor/normal/qemu-http-axum-dynamic/aarch64-arceos-http-dynamic.toml"] diff --git a/test-suit/axvisor/normal/qemu-http-axum-dynamic/http-axum-dynamic/qemu-aarch64.toml b/test-suit/axvisor/normal/qemu-http-axum-dynamic/http-axum-dynamic/qemu-aarch64.toml new file mode 100644 index 0000000000..3c8d873140 --- /dev/null +++ b/test-suit/axvisor/normal/qemu-http-axum-dynamic/http-axum-dynamic/qemu-aarch64.toml @@ -0,0 +1,29 @@ +args = [ + "-nographic", + "-cpu", + "cortex-a72", + "-machine", + "virt,virtualization=on,gic-version=3", + "-smp", + "2", + # `-m 1g`: the dynamic test's guest uses a 256M MAP_IDENTICAL region, and the + # hypervisor plus that region must both fit in QEMU's total RAM (same sizing as + # the control test, whose guest config the dynamic guest mirrors). + "-m", + "1g", +] +timeout = 600 +# The runner's stream matcher stops at the FIRST match (fail checked before +# success), so per-step status lines cannot be asserted independently. The +# self-test verifies every step internally and prints exactly one sentinel: +# PASSED only if the full create/delete lifecycle +# (remove -> create -> ready -> 409 -> remove -> 404) met every expectation. +fail_regex = [ + "(?i)\\bpanic(?:ked)?\\b", + "HTTP self-test: dynamic create/delete FAILED", +] +success_regex = [ + "HTTP self-test: dynamic create/delete PASSED", +] +to_bin = true +uefi = false From cb58b929b62b1bd5f41bc4db70eab66c0842769b Mon Sep 17 00:00:00 2001 From: Xinhong Hu Date: Thu, 6 Aug 2026 01:38:40 +0800 Subject: [PATCH 12/40] =?UTF-8?q?fix(axvisor):=20dynamic=20=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=20fail=5Fregex=20=E8=A1=A5=E6=8A=93=20control=20lifec?= =?UTF-8?q?ycle=20FAILED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dynamic 构建同时运行基础 control lifecycle 自测与 create/delete 自测, 两个自测各打印独立 FAILED 哨兵且互不影响结论。流式匹配器在首个匹配处停止 (fail 先于 success 检查),若只匹配 dynamic 的 FAILED,则 control 失败后 dynamic 通过会被误判为整体 PASS。补上 control 哨兵消除假阳性。 Changes: - qemu-http-axum-dynamic fail_regex 加入 "HTTP self-test: control lifecycle FAILED" - 更新注释说明两个自测各自的 FAILED 哨兵都必须被捕获 --- .../http-axum-dynamic/qemu-aarch64.toml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/test-suit/axvisor/normal/qemu-http-axum-dynamic/http-axum-dynamic/qemu-aarch64.toml b/test-suit/axvisor/normal/qemu-http-axum-dynamic/http-axum-dynamic/qemu-aarch64.toml index 3c8d873140..8c63df49e5 100644 --- a/test-suit/axvisor/normal/qemu-http-axum-dynamic/http-axum-dynamic/qemu-aarch64.toml +++ b/test-suit/axvisor/normal/qemu-http-axum-dynamic/http-axum-dynamic/qemu-aarch64.toml @@ -15,11 +15,15 @@ args = [ timeout = 600 # The runner's stream matcher stops at the FIRST match (fail checked before # success), so per-step status lines cannot be asserted independently. The -# self-test verifies every step internally and prints exactly one sentinel: -# PASSED only if the full create/delete lifecycle -# (remove -> create -> ready -> 409 -> remove -> 404) met every expectation. +# binary runs the base control lifecycle self-test first, then the create/delete +# self-test; each prints its own FAILED sentinel and each has an independent +# pass/fail result. The create/delete test only prints PASSED if the full +# remove -> create -> ready -> 409 -> remove -> 404 sequence met every +# expectation, so both FAILED sentinels must be caught or a control-lifecycle +# failure followed by a create/delete pass would falsely report success. fail_regex = [ "(?i)\\bpanic(?:ked)?\\b", + "HTTP self-test: control lifecycle FAILED", "HTTP self-test: dynamic create/delete FAILED", ] success_regex = [ From 7d4fca9e3e938c5c9384003e600182748923bfd4 Mon Sep 17 00:00:00 2001 From: Xinhong Hu Date: Thu, 6 Aug 2026 22:48:51 +0800 Subject: [PATCH 13/40] =?UTF-8?q?fix(axvm):=20vcpu=5Frun=20=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E6=94=B9=E7=94=A8=20host::cpu::current=5Fid=20?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E8=A3=B8=20ax=5Fhal=20=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit b2e1d41a2 在 vcpu_run 日志里直接写 ax_hal::percpu::this_cpu_id(), 但代码库惯例是从不直接以 ax_hal 为 extern crate 访问(host/arceos.rs 走 ax_std::os::arceos::modules 模块树,main.rs 用 axvm::host::cpu::current_id)。 裸 ax_hal 引用在部分构建图里无法解析,导致 CI smoke-svm 构建 axvm (lib) 时报 E0433 cannot find module or crate ax_hal。改用 crate 自身 的 host::cpu::current_id() 抽象,与 main.rs 保持一致。 Changes: - vcpus.rs vcpu_run 日志 CPU 号改调 crate::host::cpu::current_id() --- virtualization/axvm/src/runtime/vcpus.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/virtualization/axvm/src/runtime/vcpus.rs b/virtualization/axvm/src/runtime/vcpus.rs index 3a71b14baa..03738bcb19 100644 --- a/virtualization/axvm/src/runtime/vcpus.rs +++ b/virtualization/axvm/src/runtime/vcpus.rs @@ -479,7 +479,7 @@ fn vcpu_run() { "VM[{}] VCpu[{}] running on CPU{}...", vm.id(), vcpu.id(), - ax_hal::percpu::this_cpu_id() + crate::host::cpu::current_id() ); loop { From a29d45ce0f2501a105e140fb01c4ca1646b61ca8 Mon Sep 17 00:00:00 2001 From: Xinhong Hu Date: Fri, 7 Aug 2026 15:37:02 +0800 Subject: [PATCH 14/40] =?UTF-8?q?fix(axvisor):=20readonly=20HTTP=20?= =?UTF-8?q?=E8=87=AA=E6=B5=8B=E5=8D=95=E5=93=A8=E5=85=B5=20+=20x86=5F64=20?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=E5=8F=AF=E5=8F=91=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QEMU runner 的流式 matcher 遇首个 marker 即停,原两个 success_regex 是 "任一匹配即成功",任一端点回归时另一个日志仍会让用例通过,无法验证本 PR 声明的 200/404 契约。同时 x86_64 的 build/qemu 配置带 `-vmx` variant 后缀,case 名被推导成 `http-axum-readonly-vmx`,`--test-case http-axum-readonly` 在 x86_64 下无法发现该用例。 Changes: - self-test 改为两个状态码都正确才输出单一 `readonly PASSED`,否则输出 `readonly FAILED`(由 fail_regex 捕获);两个架构 QEMU 配置同步匹配哨兵 - x86_64 build/qemu 配置改为非 variant 命名,`--arch x86_64` 可直接发现并 运行 `http-axum-readonly`(保留 Intel KVM `+vmx-*` args 与 SIMD workaround) - 新增 readonly CI job(aarch64 qcs + x86_64 Intel kvm),双架构覆盖落地 CI --- .github/workflows/ci.yml | 18 +++++++++ .../doc/http-control-plane-quickstart.md | 2 +- os/axvisor/src/guest_console/mod.rs | 2 +- os/axvisor/src/http/server.rs | 16 ++++++-- ...mx.toml => build-x86_64-unknown-none.toml} | 0 .../http-axum-readonly/qemu-aarch64.toml | 17 ++++++--- .../http-axum-readonly/qemu-x86_64-vmx.toml | 31 ---------------- .../http-axum-readonly/qemu-x86_64.toml | 37 +++++++++++++++++++ 8 files changed, 81 insertions(+), 42 deletions(-) rename test-suit/axvisor/normal/qemu-http-axum-readonly/{build-x86_64-unknown-none-vmx.toml => build-x86_64-unknown-none.toml} (100%) delete mode 100644 test-suit/axvisor/normal/qemu-http-axum-readonly/http-axum-readonly/qemu-x86_64-vmx.toml create mode 100644 test-suit/axvisor/normal/qemu-http-axum-readonly/http-axum-readonly/qemu-x86_64.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc9a35be94..fb7a3faff0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -484,6 +484,15 @@ jobs: container_image: base limit_to_owner: "" main_pr_only: false + - name: Test axvisor aarch64 qemu (http readonly) + use_container: false + runs_on: '["self-hosted","linux","qcs"]' + self_hosted_owner: rcore-os + command: cargo xtask axvisor test qemu --arch aarch64 --test-case http-axum-readonly + cache_key: "" + container_image: base + limit_to_owner: "" + main_pr_only: false - name: Test axvisor aarch64 qemu (panic modes) use_container: false runs_on: '["self-hosted","linux","qcs"]' @@ -714,6 +723,15 @@ jobs: container_image: "" limit_to_owner: rcore-os main_pr_only: false + - name: Test axvisor self-hosted x86_64 (http readonly) + use_container: false + runs_on: '["self-hosted","linux","intel","kvm"]' + require_kvm: true + command: cargo xtask axvisor test qemu --arch x86_64 --test-case http-axum-readonly + cache_key: "" + container_image: "" + limit_to_owner: rcore-os + main_pr_only: false - name: Test axloader HTTP smoke use_container: false runs_on: '["self-hosted","linux","intel","kvm"]' diff --git a/os/axvisor/doc/http-control-plane-quickstart.md b/os/axvisor/doc/http-control-plane-quickstart.md index b832277a23..fbb6060f3a 100644 --- a/os/axvisor/doc/http-control-plane-quickstart.md +++ b/os/axvisor/doc/http-control-plane-quickstart.md @@ -133,7 +133,7 @@ curl -s http://localhost:18081/api/vms/1 # 404(已移除) x86_64 需要 OVMF UEFI 引导,产物含 PE32+ EFI app。步骤(工作区根目录执行): ```bash -# 1. 由测试 harness 刷新产物(x86_64 case 用 vmx variant) +# 1. 由测试 harness 刷新产物(x86_64 case 用 `-cpu host,+vmx-*`,需 Intel KVM 主机) cargo xtask axvisor test qemu --test-case http-axum-readonly --arch x86_64 # 2. 手工引导 + hostfwd diff --git a/os/axvisor/src/guest_console/mod.rs b/os/axvisor/src/guest_console/mod.rs index 420b92ed38..361749275c 100644 --- a/os/axvisor/src/guest_console/mod.rs +++ b/os/axvisor/src/guest_console/mod.rs @@ -13,6 +13,6 @@ pub(crate) use host::{configure_host_console_reader, read_host_byte, wait_for_ho )] pub(crate) use mux::attach_default; pub(crate) use mux::{ - ConsoleInputEvent, activate, attach, attach_default, attached_vm, mark_running, mark_stopped, + ConsoleInputEvent, activate, attach, attached_vm, mark_running, mark_stopped, reconcile_vm_states, remove, route_host_byte, serial_backend_factory, }; diff --git a/os/axvisor/src/http/server.rs b/os/axvisor/src/http/server.rs index a285a2641b..7e5819aaf3 100644 --- a/os/axvisor/src/http/server.rs +++ b/os/axvisor/src/http/server.rs @@ -62,9 +62,12 @@ pub fn serve() { } /// `http-test` built-in self-test: drive the router with -/// `tower::ServiceExt::oneshot` (no TCP loopback) and print the actual status -/// codes for QEMU smoke-test regex assertion. Asserts `GET /api/vms -> 200` and -/// `GET /api/vms/999 -> 404` (no specific VM id is bound). +/// `tower::ServiceExt::oneshot` (no TCP loopback) and assert the read-only +/// endpoints: `GET /api/vms -> 200` and `GET /api/vms/999 -> 404` (no specific +/// VM id is bound). The per-request status lines are diagnostics only; the QEMU +/// regex matcher stops at the FIRST marker it sees, so two independent success +/// lines could not assert both endpoints. The test therefore prints a single +/// `readonly PASSED`/`readonly FAILED` sentinel that reflects both assertions. #[cfg(feature = "http-test")] async fn self_test() { let router = router(); @@ -75,6 +78,13 @@ async fn self_test() { let detail = send_status(&router, "GET", "/api/vms/999").await; info!("HTTP self-test: GET /api/vms/999 -> {}", detail); + let passed = list == axum::http::StatusCode::OK && detail == axum::http::StatusCode::NOT_FOUND; + if passed { + info!("HTTP self-test: readonly PASSED"); + } else { + error!("HTTP self-test: readonly FAILED"); + } + // With `no-auto-start` the default VMs are created but left in `Ready`, so // the control API can be exercised over a full start/stop cycle. #[cfg(feature = "no-auto-start")] diff --git a/test-suit/axvisor/normal/qemu-http-axum-readonly/build-x86_64-unknown-none-vmx.toml b/test-suit/axvisor/normal/qemu-http-axum-readonly/build-x86_64-unknown-none.toml similarity index 100% rename from test-suit/axvisor/normal/qemu-http-axum-readonly/build-x86_64-unknown-none-vmx.toml rename to test-suit/axvisor/normal/qemu-http-axum-readonly/build-x86_64-unknown-none.toml diff --git a/test-suit/axvisor/normal/qemu-http-axum-readonly/http-axum-readonly/qemu-aarch64.toml b/test-suit/axvisor/normal/qemu-http-axum-readonly/http-axum-readonly/qemu-aarch64.toml index 20679ae8d5..b77f341c65 100644 --- a/test-suit/axvisor/normal/qemu-http-axum-readonly/http-axum-readonly/qemu-aarch64.toml +++ b/test-suit/axvisor/normal/qemu-http-axum-readonly/http-axum-readonly/qemu-aarch64.toml @@ -12,17 +12,22 @@ args = [ # PR B: first in-hypervisor axum runtime verification. # - tokio current_thread runtime is initialized with enable_io() only (needs # only epoll, no timerfd syscall). -# - the tower::ServiceExt::oneshot self-test avoids TCP and prints -# deterministic status codes. -# - if enable_io() is insufficient (time driver / timerfd required), a panic -# hits fail_regex. +# - the tower::ServiceExt::oneshot self-test avoids TCP and prints the two +# read-only status codes, then a single PASSED/FAILED sentinel. +# - the runner's stream matcher stops at the FIRST marker, so the two per-request +# status lines cannot be asserted independently; success requires the final +# `readonly PASSED` sentinel, which the self-test prints only when both +# GET /api/vms -> 200 and GET /api/vms/999 -> 404 hold. +# - any assertion failure prints `readonly FAILED` (caught by fail_regex), and +# if enable_io() is insufficient (time driver / timerfd required) a panic +# also hits fail_regex. fail_regex = [ "(?i)\\bpanic(?:ked)?\\b", "(?i)kernel panic", + "HTTP self-test: readonly FAILED", ] success_regex = [ - "HTTP self-test: GET /api/vms -> 200", - "HTTP self-test: GET /api/vms/999 -> 404", + "HTTP self-test: readonly PASSED", ] timeout = 120 to_bin = true diff --git a/test-suit/axvisor/normal/qemu-http-axum-readonly/http-axum-readonly/qemu-x86_64-vmx.toml b/test-suit/axvisor/normal/qemu-http-axum-readonly/http-axum-readonly/qemu-x86_64-vmx.toml deleted file mode 100644 index c63135a9aa..0000000000 --- a/test-suit/axvisor/normal/qemu-http-axum-readonly/http-axum-readonly/qemu-x86_64-vmx.toml +++ /dev/null @@ -1,31 +0,0 @@ -args = [ - "-no-user-config", - "-display", - "none", - "-serial", - "stdio", - "-monitor", - "none", - "-cpu", - "host,-la57,+vmx-ept,+vmx-unrestricted-guest,+vmx-flexpriority", - "-machine", - "q35,smbus=off,usb=off,graphics=off", - "-smp", - "2", - "-accel", - "kvm", - "-m", - "512M", - "-vga", - "none", -] -timeout = 600 -fail_regex = [ - "(?i)\\bpanic(?:ked)?\\b", -] -success_regex = [ - "HTTP self-test: GET /api/vms -> 200", - "HTTP self-test: GET /api/vms/999 -> 404", -] -to_bin = true -uefi = true diff --git a/test-suit/axvisor/normal/qemu-http-axum-readonly/http-axum-readonly/qemu-x86_64.toml b/test-suit/axvisor/normal/qemu-http-axum-readonly/http-axum-readonly/qemu-x86_64.toml new file mode 100644 index 0000000000..8696ba26be --- /dev/null +++ b/test-suit/axvisor/normal/qemu-http-axum-readonly/http-axum-readonly/qemu-x86_64.toml @@ -0,0 +1,37 @@ +args = [ + "-no-user-config", + "-display", + "none", + "-serial", + "stdio", + "-monitor", + "none", + "-cpu", + "host,-la57,+vmx-ept,+vmx-unrestricted-guest,+vmx-flexpriority", + "-machine", + "q35,smbus=off,usb=off,graphics=off", + "-smp", + "2", + "-accel", + "kvm", + "-m", + "512M", + "-vga", + "none", +] +timeout = 600 +# Same single-sentinel contract as qemu-aarch64.toml: the runner's stream +# matcher stops at the FIRST marker, so success requires the final `readonly +# PASSED` sentinel (printed only when both GET /api/vms -> 200 and +# GET /api/vms/999 -> 404 hold); any assertion failure prints `readonly FAILED`, +# caught by fail_regex. The `+vmx-*` CPU flags require an Intel KVM host; on an +# AMD host, add a qemu-x86_64-svm.toml variant following the `smoke-svm` case. +fail_regex = [ + "(?i)\\bpanic(?:ked)?\\b", + "HTTP self-test: readonly FAILED", +] +success_regex = [ + "HTTP self-test: readonly PASSED", +] +to_bin = true +uefi = true From 5b6e3fc8380189535e1c99b26397f049308b32f7 Mon Sep 17 00:00:00 2001 From: Xinhong Hu Date: Fri, 7 Aug 2026 20:58:16 +0800 Subject: [PATCH 15/40] =?UTF-8?q?feat(axvisor):=20QEMU=20hostfwd=20host?= =?UTF-8?q?=E2=86=92guest=20TCP=20=E9=9B=86=E6=88=90=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=EF=BC=88http-axum-tcp=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #1909 评审阻塞:http-axum-readonly 的 oneshot 自测在 TcpListener::bind 之前 就打印所有成功哨兵,不经过 TcpListener::bind / axum::serve / Tokio IO reactor 或 AxVisor 网络入口,真实 listener 无法绑定也会提前 PASSED。本提交新增带 QEMU hostfwd 的 host→guest TCP 集成测试:宿主机侧 probe 线程通过 QEMU user-mode networking 真实请求访客内管理 API(GET /api/vms→200、GET /api/vms/999→404), 独立校验响应状态后把单一 PASSED/FAILED 裁决 POST 到测试专用 /__probe_result 端点,hypervisor 镜像到串口,复用现有哨兵→kill 机制。oneshot 自测保留作路由 单测。hostfwd 端口在测试时动态选取(bind 127.0.0.1:0)避免 CI 端口冲突。 Changes: - os/axvisor: 新增 http-tcp-test feature(http-axum,无 tower oneshot)与 POST /__probe_result 裁决中继端点 - scripts/axbuild: 新增 host_probe.rs(HostHttpProbeGuard),hostfwd 端口动态 选取,netdev/device 按独立 argv 追加;QemuCaseExtraConfig 增加 host_http_probe - test-suit: 新增 qemu-http-axum-tcp 用例(aarch64 + x86_64,[host_http_probe]) - ci: axvisor 矩阵新增 aarch64 / x86_64 (http tcp) 两个 job --- .github/workflows/ci.yml | 18 + os/axvisor/Cargo.toml | 12 + os/axvisor/src/http/server.rs | 53 ++- os/axvisor/src/http/vm.rs | 18 + scripts/axbuild/src/axvisor/test/qemu.rs | 53 ++- scripts/axbuild/src/test/case/types.rs | 30 ++ scripts/axbuild/src/test/host_probe.rs | 339 ++++++++++++++++++ scripts/axbuild/src/test/mod.rs | 1 + scripts/axbuild/src/test/qemu/mod.rs | 5 +- scripts/axbuild/src/test/qemu/types.rs | 2 + .../build-aarch64-unknown-none-softfloat.toml | 12 + .../build-x86_64-unknown-none.toml | 18 + .../http-axum-tcp/qemu-aarch64.toml | 36 ++ .../http-axum-tcp/qemu-x86_64.toml | 40 +++ 14 files changed, 631 insertions(+), 6 deletions(-) create mode 100644 scripts/axbuild/src/test/host_probe.rs create mode 100644 test-suit/axvisor/normal/qemu-http-axum-tcp/build-aarch64-unknown-none-softfloat.toml create mode 100644 test-suit/axvisor/normal/qemu-http-axum-tcp/build-x86_64-unknown-none.toml create mode 100644 test-suit/axvisor/normal/qemu-http-axum-tcp/http-axum-tcp/qemu-aarch64.toml create mode 100644 test-suit/axvisor/normal/qemu-http-axum-tcp/http-axum-tcp/qemu-x86_64.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb7a3faff0..c0731bb226 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -493,6 +493,15 @@ jobs: container_image: base limit_to_owner: "" main_pr_only: false + - name: Test axvisor aarch64 qemu (http tcp) + use_container: false + runs_on: '["self-hosted","linux","qcs"]' + self_hosted_owner: rcore-os + command: cargo xtask axvisor test qemu --arch aarch64 --test-case http-axum-tcp + cache_key: "" + container_image: base + limit_to_owner: "" + main_pr_only: false - name: Test axvisor aarch64 qemu (panic modes) use_container: false runs_on: '["self-hosted","linux","qcs"]' @@ -732,6 +741,15 @@ jobs: container_image: "" limit_to_owner: rcore-os main_pr_only: false + - name: Test axvisor self-hosted x86_64 (http tcp) + use_container: false + runs_on: '["self-hosted","linux","intel","kvm"]' + require_kvm: true + command: cargo xtask axvisor test qemu --arch x86_64 --test-case http-axum-tcp + cache_key: "" + container_image: "" + limit_to_owner: rcore-os + main_pr_only: false - name: Test axloader HTTP smoke use_container: false runs_on: '["self-hosted","linux","intel","kvm"]' diff --git a/os/axvisor/Cargo.toml b/os/axvisor/Cargo.toml index e165873680..36a5159bae 100644 --- a/os/axvisor/Cargo.toml +++ b/os/axvisor/Cargo.toml @@ -17,6 +17,7 @@ include = [ "build.rs", "configs/**", "xtask/src/**", + "ui/**", "README.md", "LICENSE*", ] @@ -52,6 +53,12 @@ test-panic-no-backtrace = ["dep:axbacktrace"] # pilot, which is intentionally not carried forward. http-test = ["http-axum", "dep:tower"] http-axum = ["dep:axum", "dep:tokio", "dep:serde_json"] +# Real-TCP probe relay (`qemu-http-axum-tcp`): adds the test-only +# `POST /__probe_result` endpoint so a host-side probe can relay its verdict +# into the serial log after checking the management API over QEMU hostfwd. +# Unlike `http-test`, no `tower`/oneshot self-test is built, so the management +# server binds and serves immediately. +http-tcp-test = ["http-axum"] # Do not auto-boot the default VMs at startup; the HTTP control plane starts # and stops them on demand (VMs are created and stay in `Ready`). no-auto-start = [] @@ -59,6 +66,11 @@ no-auto-start = [] # control self-test and the no-auto-start lifecycle test, then drives one default # VM through remove -> create -> ready -> 409 -> remove -> 404. http-dynamic-test = ["http-test", "no-auto-start"] +# Serve the web management dashboard from the filesystem. Embeds the UI assets +# (`ui/`) into the binary at build time and writes them to `/web/` at startup; +# requires the `fs` feature plus a platform block driver (e.g. `ax-driver/nvme`) +# so the rootfs is mounted and writable. +web-ui = ["http-axum", "fs"] [dependencies] shlex.workspace = true diff --git a/os/axvisor/src/http/server.rs b/os/axvisor/src/http/server.rs index 7e5819aaf3..6bf30c5ec1 100644 --- a/os/axvisor/src/http/server.rs +++ b/os/axvisor/src/http/server.rs @@ -13,6 +13,9 @@ //! POST /api/vms/{id}/stop → 200 {"ok":true,"status":...} | 404 | 409 | 503 //! ``` //! +//! Under `web-ui`, the dashboard is also served: `GET /` → the index page and +//! `GET /style.css` / `GET /dashboard.js` → the static assets (see [`web_ui`]). +//! //! The tokio reactor is initialized with `enable_io()` only (no time driver), //! which needs only epoll, so no `timerfd` syscall is required. //! @@ -29,12 +32,24 @@ use crate::http::vm; /// Assemble the management routes. pub fn router() -> Router { - Router::new() + let mut router = Router::new() .route("/api/vms", get(vm::list_vms)) .route("/api/vms/{id}", get(vm::vm_detail).delete(vm::vm_delete)) .route("/api/vms/create", post(vm::vm_create)) .route("/api/vms/{id}/start", post(vm::vm_start)) - .route("/api/vms/{id}/stop", post(vm::vm_stop)) + .route("/api/vms/{id}/stop", post(vm::vm_stop)); + + #[cfg(feature = "web-ui")] + { + router = router.merge(crate::http::web_ui::ui_routes()); + } + + #[cfg(feature = "http-tcp-test")] + { + router = router.route("/__probe_result", post(vm::probe_result)); + } + + router } /// Blocking serve: build a tokio current-thread runtime and hand it to axum. @@ -43,6 +58,11 @@ pub fn router() -> Router { /// the runtime is built here. Only the IO driver is enabled — the epoll /// reactor suffices for `axum::serve`; a time driver would need `timerfd`. pub fn serve() { + // Write the embedded web dashboard to the rootfs before serving; the + // filesystem is mounted by the time `main` spawns this task. + #[cfg(feature = "web-ui")] + crate::http::web_ui::init(); + let rt = tokio::runtime::Builder::new_current_thread() .enable_io() .build() @@ -91,6 +111,35 @@ async fn self_test() { if let Some(id) = lifecycle_test::first_vm_id() { lifecycle_test::self_test_lifecycle(router, id).await; } + + // Verify the web dashboard routes when `web-ui` is enabled. `init()` has + // already written the embedded assets to `/web/` before the runtime was + // built, so the filesystem reads below resolve. + #[cfg(feature = "web-ui")] + { + // `router` here is the local binding from the top of `self_test`; use + // the qualified path to re-invoke the module-level `router()` builder + // (the top binding may already be moved into `lifecycle_test`). + let router = self::router(); + let index = send_status(&router, "GET", "/").await; + info!("HTTP self-test: GET / -> {}", index); + let css = send_status(&router, "GET", "/style.css").await; + info!("HTTP self-test: GET /style.css -> {}", css); + let js = send_status(&router, "GET", "/dashboard.js").await; + info!("HTTP self-test: GET /dashboard.js -> {}", js); + let missing = send_status(&router, "GET", "/nonexistent.js").await; + info!("HTTP self-test: GET /nonexistent.js -> {}", missing); + + let passed = index == axum::http::StatusCode::OK + && css == axum::http::StatusCode::OK + && js == axum::http::StatusCode::OK + && missing == axum::http::StatusCode::NOT_FOUND; + if passed { + info!("HTTP self-test: web-ui PASSED"); + } else { + error!("HTTP self-test: web-ui FAILED"); + } + } } /// Send a single request to the router and return its status code. diff --git a/os/axvisor/src/http/vm.rs b/os/axvisor/src/http/vm.rs index 90454bb9d1..6da40ff13d 100644 --- a/os/axvisor/src/http/vm.rs +++ b/os/axvisor/src/http/vm.rs @@ -93,6 +93,24 @@ pub async fn vm_stop(Path(id_str): Path) -> Result, StatusCo vm_action(&id_str, VmAction::Stop) } +/// `POST /__probe_result` — test-only relay endpoint for the host-side TCP probe +/// (`http-tcp-test` / `qemu-http-axum-tcp`). +/// +/// The host probe checks the management API over a real TCP connection +/// (QEMU hostfwd), independently asserting the response statuses, then POSTs a +/// single `PASSED`/`FAILED` verdict here. This handler only mirrors that verdict +/// into the serial log, where the QEMU runner's stream matcher picks it up and +/// terminates the run. The assertion itself happens host-side, so the sentinel +/// reflects the statuses the probe actually received over the wire. +#[cfg(feature = "http-tcp-test")] +pub async fn probe_result(body: String) -> StatusCode { + match body.trim() { + "PASSED" => info!("HTTP self-test: tcp PASSED"), + _ => error!("HTTP self-test: tcp FAILED"), + } + StatusCode::OK +} + /// A lifecycle action on a VM. enum VmAction { Start, diff --git a/scripts/axbuild/src/axvisor/test/qemu.rs b/scripts/axbuild/src/axvisor/test/qemu.rs index 7862ac230c..c7cf821b79 100644 --- a/scripts/axbuild/src/axvisor/test/qemu.rs +++ b/scripts/axbuild/src/axvisor/test/qemu.rs @@ -21,7 +21,7 @@ use super::{ use crate::{ axvisor::{ArgsTestQemu, Axvisor, build, rootfs}, context::{AxvisorCliArgs, ResolvedAxvisorRequest, SnapshotPersistence}, - test::{case as test_case, qemu as test_qemu}, + test::{case as test_case, host_probe, qemu as test_qemu}, }; const VCPU_RUNTIME_ERROR: &str = r"VM\[\d+\] run VCpu\[\d+\] get error"; @@ -335,9 +335,41 @@ impl Axvisor { asset_config: &test_case::CaseAssetConfig, ) -> anyhow::Result<()> { let prepare_started = Instant::now(); - let (qemu, prepared_assets) = self + let (mut qemu, prepared_assets) = self .load_qemu_case_config(request, case, asset_config) .await?; + + // Optional host->guest TCP probe over QEMU user-mode networking. When + // `[host_http_probe]` is configured, the host acts as a *client* that + // dials a management API inside the guest through a hostfwd port, checks + // the response statuses, and relays a PASSED/FAILED verdict to + // `POST /__probe_result`. The probe must live for the whole run, so its + // guard is spawned here and dropped at scope end (after QEMU exits). + let mut host_probe_guard = None; + if let Some(probe_config) = + test_qemu::load_qemu_case_extra_config(&case.case.case.qemu_config_path)? + .host_http_probe + { + let host_port = pick_free_local_port()?; + // Each QEMU option and its value must be a separate argv element + // (QEMU takes the value of `-netdev`/`-device` from the following + // argument), matching how the `.toml` config stores them. + qemu.args.extend([ + "-netdev".to_string(), + format!( + "user,id=net0,hostfwd=tcp::{host_port}-:{}", + probe_config.guest_port + ), + "-device".to_string(), + "virtio-net-pci,netdev=net0".to_string(), + ]); + host_probe_guard = Some(host_probe::HostHttpProbeGuard::start( + &probe_config, + host_port, + &case.case.case.name, + )?); + } + test_case::run_qemu_with_prepared_case_assets( &mut self.app, cargo, @@ -350,10 +382,25 @@ impl Axvisor { qemu_timing_fields: None, }, ) - .await + .await?; + + // Joins the probe thread now that QEMU has exited. + drop(host_probe_guard); + Ok(()) } } +/// Pick a free loopback port for the QEMU hostfwd listen, then release it so +/// QEMU can bind it. A freshly-assigned ephemeral port avoids stale-port +/// collisions from CI runner reuse (the same ports are never parked on a +/// previous run's leftover QEMU). A small bind-release-bind TOCTOU window +/// exists but is acceptable for a local test harness. +fn pick_free_local_port() -> anyhow::Result { + let listener = std::net::TcpListener::bind(("127.0.0.1", 0)) + .context("failed to pick a free local port for QEMU hostfwd")?; + Ok(listener.local_addr()?.port()) +} + fn axvisor_qemu_test_build_args(arch: &str, config: Option) -> AxvisorCliArgs { AxvisorCliArgs { config, diff --git a/scripts/axbuild/src/test/case/types.rs b/scripts/axbuild/src/test/case/types.rs index 52150848ee..aefeae3ee6 100644 --- a/scripts/axbuild/src/test/case/types.rs +++ b/scripts/axbuild/src/test/case/types.rs @@ -65,6 +65,36 @@ pub(crate) struct HostHttpServerConfig { pub(crate) dir: Option, } +/// Host-side TCP probe configuration (`qemu-http-axum-tcp`). +/// +/// Direction is the reverse of [`HostHttpServerConfig`]: instead of the host +/// serving fixtures to the guest, the host acts as a *client* that probes a +/// management API running *inside* the guest, over QEMU user-mode networking +/// hostfwd (`-netdev user,hostfwd=tcp::-:`). The probe +/// makes real HTTP requests, asserts the response statuses, and relays a single +/// PASSED/FAILED verdict to a guest endpoint (`POST /__probe_result`), which the +/// hypervisor mirrors into the serial log for the QEMU runner's stream matcher. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub(crate) struct HostHttpProbeConfig { + /// Guest-side port the in-guest HTTP server binds to. The harness forwards a + /// freshly picked host port to it via hostfwd, so the two never collide. + #[serde(default = "default_probe_guest_port")] + pub(crate) guest_port: u16, + /// Total seconds the probe may spend retrying the initial TCP connect before + /// giving up (guest boot + network init). Must be less than the QEMU case + /// `timeout` so a broken server fails on the probe, not on the QEMU timeout. + #[serde(default = "default_probe_connect_timeout_secs")] + pub(crate) connect_timeout_secs: u64, +} + +fn default_probe_guest_port() -> u16 { + 8080 +} + +fn default_probe_connect_timeout_secs() -> u64 { + 120 +} + fn default_host_http_bind() -> String { "127.0.0.1".to_string() } diff --git a/scripts/axbuild/src/test/host_probe.rs b/scripts/axbuild/src/test/host_probe.rs new file mode 100644 index 0000000000..b633341a9f --- /dev/null +++ b/scripts/axbuild/src/test/host_probe.rs @@ -0,0 +1,339 @@ +//! Host-side TCP probe for QEMU hostfwd integration tests (`qemu-http-axum-tcp`). +//! +//! The probe is the reverse of [`super::host_http`]: instead of serving host +//! fixtures to the guest, it acts as a *client* that dials a management API +//! running *inside* the guest through QEMU user-mode networking +//! (`-netdev user,hostfwd=tcp::-:`). It makes real HTTP +//! requests, asserts the response statuses, and relays a single PASSED/FAILED +//! verdict to a guest endpoint (`POST /__probe_result`) that the hypervisor +//! mirrors into the serial log. The QEMU runner's stream matcher then picks up +//! the sentinel and terminates the run, exactly as it does for the in-guest +//! self-test sentinels. +//! +//! The probe is currently hardcoded to the read-only contract (the same two +//! endpoints `http-test`'s oneshot self-test covers): `GET /api/vms -> 200` and +//! `GET /api/vms/999 -> 404`. + +use std::{ + io::{Read, Write}, + net::TcpStream, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + mpsc, + }, + thread, + time::{Duration, Instant}, +}; + +use anyhow::bail; + +use crate::test::case::HostHttpProbeConfig; + +/// Per-attempt IO timeout for a single HTTP request/response exchange. +const IO_TIMEOUT: Duration = Duration::from_secs(5); +/// Sleep between readiness retries. +const CONNECT_RETRY_INTERVAL: Duration = Duration::from_millis(100); + +pub(crate) struct HostHttpProbeGuard { + stop: Arc, + thread: Option>, +} + +impl HostHttpProbeGuard { + pub(crate) fn start( + config: &HostHttpProbeConfig, + host_port: u16, + case_name: &str, + ) -> anyhow::Result { + let addr = format!("127.0.0.1:{host_port}"); + let connect_timeout = Duration::from_secs(config.connect_timeout_secs); + let stop = Arc::new(AtomicBool::new(false)); + let thread_stop = stop.clone(); + let case_name = case_name.to_string(); + let (ready_tx, ready_rx) = mpsc::channel(); + + let thread_addr = addr.clone(); + let thread_case_name = case_name.clone(); + let thread = thread::spawn(move || { + let _ = ready_tx.send(()); + run_probe( + &thread_addr, + &thread_case_name, + connect_timeout, + &thread_stop, + ); + }); + + if ready_rx.recv_timeout(Duration::from_secs(1)).is_err() { + stop.store(true, Ordering::Release); + bail!("host http probe for `{case_name}` did not become ready"); + } + + println!(" host http probe: {addr} -> guest:{}", config.guest_port); + Ok(Self { + stop, + thread: Some(thread), + }) + } +} + +impl Drop for HostHttpProbeGuard { + fn drop(&mut self) { + self.stop.store(true, Ordering::Release); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +fn run_probe(addr: &str, case_name: &str, connect_timeout: Duration, stop: &AtomicBool) { + let started = Instant::now(); + + // Wait for the guest HTTP server to accept connections. `GET /api/vms` is + // retried until it yields a parsed status (readiness), the connect timeout + // elapses, or a stop is requested. A parsed but wrong status is still + // "ready"; the assertion below records it as a failure. + let mut passed = true; + let list = poll_status(addr, "/api/vms", started, connect_timeout, stop); + match list { + Some(status) => { + println!(" host http probe: {case_name}: GET /api/vms -> {status} (expect 200)"); + passed &= status == 200; + } + None => { + eprintln!( + " host http probe: {case_name}: guest HTTP server never became reachable within \ + {connect_timeout:?}" + ); + passed = false; + } + } + + // The server is up; single attempt for the 404 path. + if passed { + match request_status(addr, "GET", "/api/vms/999", None) { + Some(status) => { + println!( + " host http probe: {case_name}: GET /api/vms/999 -> {status} (expect 404)" + ); + passed &= status == 404; + } + None => { + eprintln!(" host http probe: {case_name}: GET /api/vms/999 failed"); + passed = false; + } + } + } + + // Relay the verdict. The hypervisor mirrors it into the serial log, where + // the QEMU runner's stream matcher sees the sentinel and ends the run. If + // the relay itself fails (e.g. the server disappeared), no sentinel ever + // appears in serial and the run ends on the QEMU timeout — still a failure. + let verdict = if passed { "PASSED" } else { "FAILED" }; + match request_status(addr, "POST", "/__probe_result", Some(verdict)) { + Some(status) => { + println!(" host http probe: {case_name}: verdict {verdict} relayed (status {status})") + } + None => eprintln!(" host http probe: {case_name}: failed to relay verdict {verdict}"), + } +} + +/// Retry a request until it yields a parsed status, the deadline elapses, or a +/// stop is requested. Used for the first request, which doubles as the +/// readiness probe. +fn poll_status( + addr: &str, + path: &str, + started: Instant, + connect_timeout: Duration, + stop: &AtomicBool, +) -> Option { + loop { + if stop.load(Ordering::Acquire) { + return None; + } + if started.elapsed() >= connect_timeout { + return None; + } + if let Some(status) = request_status(addr, "GET", path, None) { + return Some(status); + } + thread::sleep(CONNECT_RETRY_INTERVAL); + } +} + +/// Send one HTTP/1.1 request over a fresh connection and parse the status code. +/// `body` (when present) is sent as the request body with a JSON content type. +fn request_status(addr: &str, method: &str, path: &str, body: Option<&str>) -> Option { + let Ok(mut stream) = TcpStream::connect(addr) else { + return None; + }; + let _ = stream.set_read_timeout(Some(IO_TIMEOUT)); + let _ = stream.set_write_timeout(Some(IO_TIMEOUT)); + + let mut request = format!("{method} {path} HTTP/1.1\r\nHost: {addr}\r\n"); + if let Some(body) = body { + request.push_str(&format!( + "Content-Type: application/json\r\nContent-Length: {}\r\n", + body.len() + )); + } + request.push_str("Connection: close\r\n\r\n"); + if let Some(body) = body { + request.push_str(body); + } + + if stream.write_all(request.as_bytes()).is_err() { + return None; + } + let mut response = Vec::new(); + if stream.read_to_end(&mut response).is_err() { + return None; + } + parse_status(&response) +} + +/// Extract the numeric HTTP status code from a response. +fn parse_status(response: &[u8]) -> Option { + let head = String::from_utf8_lossy(response); + let status_line = head.lines().next()?; + let mut parts = status_line.split_whitespace(); + let _protocol = parts.next()?; + let status = parts.next()?; + status.parse().ok() +} + +#[cfg(test)] +mod tests { + use std::{ + io::{Read, Write}, + net::TcpListener, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc, + }, + thread, + time::Duration, + }; + + use super::{parse_status, poll_status, request_status}; + + /// Serve canned responses on a background thread: `/api/vms` -> 200, + /// `/api/vms/999` -> 404, anything else -> 200 with the request body echoed + /// in a header (so the relay POST can be observed). + fn start_fake_server() -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake server"); + let port = listener.local_addr().unwrap().port(); + thread::spawn(move || { + for stream in listener.incoming() { + let mut stream = match stream { + Ok(stream) => stream, + Err(_) => break, + }; + let mut request = Vec::new(); + let mut buf = [0u8; 512]; + loop { + match stream.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + request.extend_from_slice(&buf[..n]); + if request.windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + Err(_) => break, + } + } + let head = String::from_utf8_lossy(&request); + let first_line = head.lines().next().unwrap_or(""); + let status = if first_line.contains("/api/vms/999") { + "404 Not Found" + } else { + "200 OK" + }; + let body = + format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + let _ = stream.write_all(body.as_bytes()); + } + }); + port + } + + #[test] + fn parse_status_extracts_numeric_code() { + assert_eq!( + parse_status(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"), + Some(200) + ); + assert_eq!( + parse_status(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"), + Some(404) + ); + assert_eq!(parse_status(b"garbage"), None); + assert_eq!(parse_status(b""), None); + } + + #[test] + fn request_status_returns_expected_codes_from_fake_server() { + let port = start_fake_server(); + let addr = format!("127.0.0.1:{port}"); + assert_eq!(request_status(&addr, "GET", "/api/vms", None), Some(200)); + assert_eq!( + request_status(&addr, "GET", "/api/vms/999", None), + Some(404) + ); + } + + #[test] + fn poll_status_returns_once_server_is_up() { + let port = start_fake_server(); + let addr = format!("127.0.0.1:{port}"); + let stop = AtomicBool::new(false); + let status = poll_status( + &addr, + "/api/vms", + std::time::Instant::now(), + Duration::from_secs(5), + &stop, + ); + assert_eq!(status, Some(200)); + } + + #[test] + fn poll_status_gives_up_on_stop() { + let port = start_fake_server(); + let addr = format!("127.0.0.1:{port}"); + let stop = AtomicBool::new(true); + let status = poll_status( + &addr, + "/api/vms", + std::time::Instant::now(), + Duration::from_secs(10), + &stop, + ); + assert_eq!(status, None); + } + + #[test] + fn request_status_handles_body_relay() { + let port = start_fake_server(); + let addr = format!("127.0.0.1:{port}"); + assert_eq!( + request_status(&addr, "POST", "/__probe_result", Some("PASSED")), + Some(200) + ); + } + + #[test] + fn connection_refused_returns_none() { + // Bind and immediately drop to get a guaranteed-free port. + let port = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + let addr = format!("127.0.0.1:{port}"); + assert_eq!(request_status(&addr, "GET", "/api/vms", None), None); + } +} diff --git a/scripts/axbuild/src/test/mod.rs b/scripts/axbuild/src/test/mod.rs index 0bfa46bb4e..b8bc857497 100644 --- a/scripts/axbuild/src/test/mod.rs +++ b/scripts/axbuild/src/test/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod build; pub(crate) mod case; pub(crate) mod cross; pub(crate) mod host_http; +pub(crate) mod host_probe; pub(crate) mod qemu; pub(crate) mod std; pub(crate) mod suite; diff --git a/scripts/axbuild/src/test/qemu/mod.rs b/scripts/axbuild/src/test/qemu/mod.rs index be4bf7dda1..fe3c7c5c14 100644 --- a/scripts/axbuild/src/test/qemu/mod.rs +++ b/scripts/axbuild/src/test/qemu/mod.rs @@ -10,7 +10,10 @@ use serde::Deserialize; use crate::{ context::validate_supported_target, - test::case::{HostHttpServerConfig, TestQemuCase, TestQemuSubcase, TestQemuSubcaseKind}, + test::case::{ + HostHttpProbeConfig, HostHttpServerConfig, TestQemuCase, TestQemuSubcase, + TestQemuSubcaseKind, + }, }; const TIMEOUT_SCALE_ENV: &str = "AXBUILD_TEST_TIMEOUT_SCALE"; diff --git a/scripts/axbuild/src/test/qemu/types.rs b/scripts/axbuild/src/test/qemu/types.rs index 64a9f0c60b..105f77a383 100644 --- a/scripts/axbuild/src/test/qemu/types.rs +++ b/scripts/axbuild/src/test/qemu/types.rs @@ -89,6 +89,8 @@ pub(crate) struct QemuCaseExtraConfig { #[serde(default)] pub(crate) host_http_server: Option, #[serde(default)] + pub(crate) host_http_probe: Option, + #[serde(default)] pub(crate) snapshot: Option, } diff --git a/test-suit/axvisor/normal/qemu-http-axum-tcp/build-aarch64-unknown-none-softfloat.toml b/test-suit/axvisor/normal/qemu-http-axum-tcp/build-aarch64-unknown-none-softfloat.toml new file mode 100644 index 0000000000..4758e941d0 --- /dev/null +++ b/test-suit/axvisor/normal/qemu-http-axum-tcp/build-aarch64-unknown-none-softfloat.toml @@ -0,0 +1,12 @@ +# PR B: axum management HTTP API host->guest TCP integration verification. +# http-tcp-test implies http-axum (tokio + axum + serde_json) and adds the +# test-only POST /__probe_result relay endpoint. No tower/oneshot self-test is +# built: the TcpListener::bind + axum::serve path runs for real, and the host +# probe (QEMU hostfwd) makes actual HTTP requests to the in-guest API. +# fs/nvme is not enabled: QEMU has no disk and fs would panic at boot. +features = [ + "http-tcp-test", +] +log = "Info" +target = "aarch64-unknown-none-softfloat" +vm_configs = [] diff --git a/test-suit/axvisor/normal/qemu-http-axum-tcp/build-x86_64-unknown-none.toml b/test-suit/axvisor/normal/qemu-http-axum-tcp/build-x86_64-unknown-none.toml new file mode 100644 index 0000000000..efd2fa5f74 --- /dev/null +++ b/test-suit/axvisor/normal/qemu-http-axum-tcp/build-x86_64-unknown-none.toml @@ -0,0 +1,18 @@ +# PR B: axum management HTTP API host->guest TCP integration verification +# (x86_64). http-tcp-test implies http-axum (tokio + axum + serde_json) and +# adds the test-only POST /__probe_result relay endpoint. fs/nvme is not +# enabled: this test only verifies the management HTTP server runs. +features = [ + "http-tcp-test", +] +log = "Info" +target = "x86_64-unknown-none" +vm_configs = [] + +# httparse 1.10 (a hyper HTTP/1 parsing dependency) trips a rustc-LLVM codegen +# error at opt-level=3 on this bare-metal target where SSE is disabled: its +# runtime SSE42 fallback (#[target_feature(enable = "sse42")]) cannot be +# lowered. Disable SIMD via the official escape hatch (x86_64 only; the aarch64 +# NEON path compiles fine and does not need this). +[env] +CARGO_CFG_HTTPARSE_DISABLE_SIMD = "1" diff --git a/test-suit/axvisor/normal/qemu-http-axum-tcp/http-axum-tcp/qemu-aarch64.toml b/test-suit/axvisor/normal/qemu-http-axum-tcp/http-axum-tcp/qemu-aarch64.toml new file mode 100644 index 0000000000..12e6a4a3e3 --- /dev/null +++ b/test-suit/axvisor/normal/qemu-http-axum-tcp/http-axum-tcp/qemu-aarch64.toml @@ -0,0 +1,36 @@ +args = [ + "-nographic", + "-cpu", + "cortex-a72", + "-machine", + "virt,virtualization=on,gic-version=3", + "-smp", + "2", + "-m", + "512M", +] +# PR B: host->guest TCP integration verification over QEMU user-mode +# networking. The driver appends `-netdev user,id=net0,hostfwd=tcp::-:` +# and `-device virtio-net-pci,netdev=net0` when `[host_http_probe]` is present, +# then runs a host-side probe that dials the in-guest management API, checks the +# response statuses (GET /api/vms -> 200, GET /api/vms/999 -> 404), and POSTs a +# single PASSED/FAILED verdict to the test-only /__probe_result endpoint. That +# endpoint relays the verdict into the serial log, and the runner's stream +# matcher stops at the FIRST marker — so success requires the final `tcp PASSED` +# sentinel, which only appears when the probe observed the expected statuses +# over real TCP. The `[host_http_probe]` connect timeout (default 120s) must be +# less than `timeout` so a broken server fails on the probe, not on the QEMU +# timeout. +fail_regex = [ + "(?i)\\bpanic(?:ked)?\\b", + "(?i)kernel panic", + "HTTP self-test: tcp FAILED", +] +success_regex = [ + "HTTP self-test: tcp PASSED", +] +timeout = 180 +to_bin = true +uefi = false + +[host_http_probe] diff --git a/test-suit/axvisor/normal/qemu-http-axum-tcp/http-axum-tcp/qemu-x86_64.toml b/test-suit/axvisor/normal/qemu-http-axum-tcp/http-axum-tcp/qemu-x86_64.toml new file mode 100644 index 0000000000..44f2cb0055 --- /dev/null +++ b/test-suit/axvisor/normal/qemu-http-axum-tcp/http-axum-tcp/qemu-x86_64.toml @@ -0,0 +1,40 @@ +args = [ + "-no-user-config", + "-display", + "none", + "-serial", + "stdio", + "-monitor", + "none", + "-cpu", + "host,-la57,+vmx-ept,+vmx-unrestricted-guest,+vmx-flexpriority", + "-machine", + "q35,smbus=off,usb=off,graphics=off", + "-smp", + "2", + "-accel", + "kvm", + "-m", + "512M", + "-vga", + "none", +] +# Same single-sentinel contract as qemu-aarch64.toml: the driver appends the +# hostfwd netdev + virtio-net-pci device when `[host_http_probe]` is present, +# and a host-side probe drives the in-guest management API over real TCP, +# POSTing its PASSED/FAILED verdict to /__probe_result. Success requires the +# final `tcp PASSED` sentinel. The `+vmx-*` CPU flags require an Intel KVM host; +# on an AMD host, add a qemu-x86_64-svm.toml variant following the `smoke-svm` +# case. +fail_regex = [ + "(?i)\\bpanic(?:ked)?\\b", + "HTTP self-test: tcp FAILED", +] +success_regex = [ + "HTTP self-test: tcp PASSED", +] +timeout = 600 +to_bin = true +uefi = true + +[host_http_probe] From 18dfb51ad2a0f7b6362e6b45be784a50a7f30810 Mon Sep 17 00:00:00 2001 From: Xinhong Hu Date: Fri, 7 Aug 2026 22:34:08 +0800 Subject: [PATCH 16/40] =?UTF-8?q?fix(axvisor):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E8=AF=AF=E5=B8=A6=E5=85=A5=20PR1=20=E7=9A=84=20web-ui=20?= =?UTF-8?q?=E9=AA=A8=E6=9E=B6=E5=B9=B6=E6=B8=85=E7=90=86=20host=5Fprobe=20?= =?UTF-8?q?=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR1(axum 基础设施)不应包含 web-ui 仪表盘——该功能属于后续 PR6。 此前 web-ui 的 Cargo.toml feature、server.rs 引用与 CI job 被提交到本分支, 但实现(src/http/web_ui.rs、mod.rs 声明、build.rs 内嵌代码生成)未一并提交, 导致 E0433(模块 web_ui 无法解析)构建失败。按 reviewer 意见整体移除 web-ui 骨架:删除 feature、include、router merge、serve() init 调用、self-test 段落 与两个 CI job,build.rs/mod.rs 恢复与上游一致。同时清理 host_probe.rs tests 模块未使用的 Ordering/mpsc 导入,消除 clippy 阻塞项。 Changes: - os/axvisor: 移除 web-ui feature(Cargo.toml)与 server.rs 中的相关引用/自测 - scripts/axbuild: host_probe tests 模块移除未使用导入(Ordering/mpsc) --- os/axvisor/Cargo.toml | 6 ---- os/axvisor/src/http/server.rs | 42 -------------------------- scripts/axbuild/src/test/host_probe.rs | 5 +-- 3 files changed, 1 insertion(+), 52 deletions(-) diff --git a/os/axvisor/Cargo.toml b/os/axvisor/Cargo.toml index 36a5159bae..cd607648f4 100644 --- a/os/axvisor/Cargo.toml +++ b/os/axvisor/Cargo.toml @@ -17,7 +17,6 @@ include = [ "build.rs", "configs/**", "xtask/src/**", - "ui/**", "README.md", "LICENSE*", ] @@ -66,11 +65,6 @@ no-auto-start = [] # control self-test and the no-auto-start lifecycle test, then drives one default # VM through remove -> create -> ready -> 409 -> remove -> 404. http-dynamic-test = ["http-test", "no-auto-start"] -# Serve the web management dashboard from the filesystem. Embeds the UI assets -# (`ui/`) into the binary at build time and writes them to `/web/` at startup; -# requires the `fs` feature plus a platform block driver (e.g. `ax-driver/nvme`) -# so the rootfs is mounted and writable. -web-ui = ["http-axum", "fs"] [dependencies] shlex.workspace = true diff --git a/os/axvisor/src/http/server.rs b/os/axvisor/src/http/server.rs index 6bf30c5ec1..ef1d3f79aa 100644 --- a/os/axvisor/src/http/server.rs +++ b/os/axvisor/src/http/server.rs @@ -13,9 +13,6 @@ //! POST /api/vms/{id}/stop → 200 {"ok":true,"status":...} | 404 | 409 | 503 //! ``` //! -//! Under `web-ui`, the dashboard is also served: `GET /` → the index page and -//! `GET /style.css` / `GET /dashboard.js` → the static assets (see [`web_ui`]). -//! //! The tokio reactor is initialized with `enable_io()` only (no time driver), //! which needs only epoll, so no `timerfd` syscall is required. //! @@ -39,11 +36,6 @@ pub fn router() -> Router { .route("/api/vms/{id}/start", post(vm::vm_start)) .route("/api/vms/{id}/stop", post(vm::vm_stop)); - #[cfg(feature = "web-ui")] - { - router = router.merge(crate::http::web_ui::ui_routes()); - } - #[cfg(feature = "http-tcp-test")] { router = router.route("/__probe_result", post(vm::probe_result)); @@ -58,11 +50,6 @@ pub fn router() -> Router { /// the runtime is built here. Only the IO driver is enabled — the epoll /// reactor suffices for `axum::serve`; a time driver would need `timerfd`. pub fn serve() { - // Write the embedded web dashboard to the rootfs before serving; the - // filesystem is mounted by the time `main` spawns this task. - #[cfg(feature = "web-ui")] - crate::http::web_ui::init(); - let rt = tokio::runtime::Builder::new_current_thread() .enable_io() .build() @@ -111,35 +98,6 @@ async fn self_test() { if let Some(id) = lifecycle_test::first_vm_id() { lifecycle_test::self_test_lifecycle(router, id).await; } - - // Verify the web dashboard routes when `web-ui` is enabled. `init()` has - // already written the embedded assets to `/web/` before the runtime was - // built, so the filesystem reads below resolve. - #[cfg(feature = "web-ui")] - { - // `router` here is the local binding from the top of `self_test`; use - // the qualified path to re-invoke the module-level `router()` builder - // (the top binding may already be moved into `lifecycle_test`). - let router = self::router(); - let index = send_status(&router, "GET", "/").await; - info!("HTTP self-test: GET / -> {}", index); - let css = send_status(&router, "GET", "/style.css").await; - info!("HTTP self-test: GET /style.css -> {}", css); - let js = send_status(&router, "GET", "/dashboard.js").await; - info!("HTTP self-test: GET /dashboard.js -> {}", js); - let missing = send_status(&router, "GET", "/nonexistent.js").await; - info!("HTTP self-test: GET /nonexistent.js -> {}", missing); - - let passed = index == axum::http::StatusCode::OK - && css == axum::http::StatusCode::OK - && js == axum::http::StatusCode::OK - && missing == axum::http::StatusCode::NOT_FOUND; - if passed { - info!("HTTP self-test: web-ui PASSED"); - } else { - error!("HTTP self-test: web-ui FAILED"); - } - } } /// Send a single request to the router and return its status code. diff --git a/scripts/axbuild/src/test/host_probe.rs b/scripts/axbuild/src/test/host_probe.rs index b633341a9f..c0102e5f22 100644 --- a/scripts/axbuild/src/test/host_probe.rs +++ b/scripts/axbuild/src/test/host_probe.rs @@ -209,10 +209,7 @@ mod tests { use std::{ io::{Read, Write}, net::TcpListener, - sync::{ - atomic::{AtomicBool, Ordering}, - mpsc, - }, + sync::atomic::AtomicBool, thread, time::Duration, }; From e71f62b1f0b1d8a645be09ce26aed80c6e08c513 Mon Sep 17 00:00:00 2001 From: Xinhong Hu Date: Sat, 8 Aug 2026 10:32:56 +0800 Subject: [PATCH 17/40] =?UTF-8?q?feat(axvisor):=20=E7=AE=A1=E7=90=86=20HTT?= =?UTF-8?q?P=20=E6=8E=A7=E5=88=B6=E9=9D=A2=E8=AE=A4=E8=AF=81=20+=20?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=20loopback=20=E7=BB=91=E5=AE=9A=20+=20?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=20TCP=20=E6=8B=92=E7=BB=9D=E8=AE=BF=E9=97=AE?= =?UTF-8?q?=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 安全加固(PR 阻塞项):写路由(create/delete/start/stop、__probe_result)要求 Authorization: Bearer ,token 经构建期 [env] AXVM_HTTP_TOKEN 注入 (option_env!);未设置时默认拒绝(一律 401)。服务器默认绑定 127.0.0.1:8080, hostfwd 测试构建显式 opt-in [env] AXVM_HTTP_BIND="0.0.0.0:8080"。 验证:aarch64/x86_64 http-axum-tcp 均 PASS(401/200/404 断言全部命中); aarch64 readonly/control/dynamic 回归通过;host_probe 单测 8/8;axvisor clippy (musl+build-std)无新增警告;fmt 干净。 Changes: - os/axvisor/src/http/auth.rs:ApiToken FromRequestParts 提取器(默认拒绝) - http/{vm,server}.rs:五个写 handler 挂载 ApiToken;bind_addr() 可配绑定 - host_probe:request_status 支持 token;新增"无 token 写请求 -> 401"拒绝访问断言 - control/dynamic/tcp 构建 config 烘焙 token;readonly/tcp 显式绑定所有接口 - quickstart 安全边界章节改写,curl 写命令带 Bearer token --- .../doc/http-control-plane-quickstart.md | 59 ++++-- os/axvisor/src/http/auth.rs | 63 +++++++ os/axvisor/src/http/mod.rs | 5 + os/axvisor/src/http/server.rs | 50 ++++-- os/axvisor/src/http/vm.rs | 23 ++- scripts/axbuild/src/test/case/types.rs | 6 + scripts/axbuild/src/test/host_probe.rs | 169 +++++++++++++++--- .../build-aarch64-unknown-none-softfloat.toml | 5 + .../build-aarch64-unknown-none-softfloat.toml | 6 + .../build-aarch64-unknown-none-softfloat.toml | 7 + .../build-x86_64-unknown-none.toml | 6 + .../build-aarch64-unknown-none-softfloat.toml | 9 + .../build-x86_64-unknown-none.toml | 6 + .../http-axum-tcp/qemu-aarch64.toml | 19 +- .../http-axum-tcp/qemu-x86_64.toml | 11 +- 15 files changed, 373 insertions(+), 71 deletions(-) create mode 100644 os/axvisor/src/http/auth.rs diff --git a/os/axvisor/doc/http-control-plane-quickstart.md b/os/axvisor/doc/http-control-plane-quickstart.md index fbb6060f3a..2be0a7b4d3 100644 --- a/os/axvisor/doc/http-control-plane-quickstart.md +++ b/os/axvisor/doc/http-control-plane-quickstart.md @@ -16,13 +16,15 @@ host: curl http://localhost:18081/... QEMU user-mode NAT (hostfwd=tcp::18081-:8080) │ 转发到虚拟机内 8080 ▼ -AxVisor (hypervisor, EL2) — 管理 HTTP 服务器 (axum + tokio, 0.0.0.0:8080) +AxVisor (hypervisor, EL2) — 管理 HTTP 服务器 (axum + tokio, 可配绑定) │ 同步调用 AxvmManager API ▼ vCPU (Core 1+) — guest ``` - **HTTP 服务器运行在 AxVisor(host)上,不在 guest 里。** 网络输入直接进入 hypervisor。 +- **监听地址可配置(默认 loopback):** 服务器默认绑 `127.0.0.1:8080`;本文档的 hostfwd + 流程需要监听所有接口,故构建 config 显式设置 `[env] AXVM_HTTP_BIND = "0.0.0.0:8080"`。 - QEMU user 模式网络在内部做 NAT:host 的 18081 端口 → 虚拟机的 8080 端口。 - **hostfwd 仅 QEMU user 模式 netdev 可用**(`-netdev user,id=net0,hostfwd=...`), 不能用 tap/bridge。user 模式性能差,只适合开发/调试。 @@ -42,6 +44,11 @@ features = ["no-auto-start", "http-axum"] log = "Info" target = "aarch64-unknown-none-softfloat" vm_configs = ["test-suit/axvisor/normal/qemu-http-axum-control/aarch64-arceos-http-control.toml"] + +# hostfwd 需要服务器监听所有接口;写路由用构建期 token 保护(见 §5) +[env] +AXVM_HTTP_TOKEN = "axvisor-http-test-token" +AXVM_HTTP_BIND = "0.0.0.0:8080" ``` ### 2.1 构建 @@ -86,28 +93,34 @@ task 12,核隔离:管理面在 Core 0)、`shell task on CPU0`。vCPU 启 ### 2.3 curl 全流程 ```bash -# 只读:VM 保持 Ready +# 所有写命令(POST/DELETE)都需要 Bearer token(与 config 的 AXVM_HTTP_TOKEN 一致)。 +TOKEN='Authorization: Bearer axvisor-http-test-token' + +# 只读(开放,无需 token):VM 保持 Ready curl -s http://localhost:18081/api/vms # 200, JSON 数组(status="ready") curl -s http://localhost:18081/api/vms/1 # 200, 明细(含 vcpu_states) curl -s http://localhost:18081/api/vms/999 # 404 +# 无 token 的写请求被拒绝(401):认证边界回归 +curl -s -i -X POST http://localhost:18081/api/vms/1/start # 401 Unauthorized + # 控制:start -> guest 运行 -> guest 退出 -> stopped -curl -s -X POST http://localhost:18081/api/vms/1/start # 200 {"ok":true,"status":"running","async":false} +curl -s -X POST -H "$TOKEN" http://localhost:18081/api/vms/1/start # 200 {"ok":true,"status":"running","async":false} curl -s http://localhost:18081/api/vms/1 # 200, status="stopped"(guest 退出后) -curl -s -X POST http://localhost:18081/api/vms/999/start # 404 -curl -s -X POST http://localhost:18081/api/vms/1/stop # 200 {"ok":true,"status":"stopping","async":true}(对已停 VM 也 200,幂等) -curl -s -X POST http://localhost:18081/api/vms/1/start # 409(restart-after-stop 不支持,契约拒绝) +curl -s -X POST -H "$TOKEN" http://localhost:18081/api/vms/999/start # 404 +curl -s -X POST -H "$TOKEN" http://localhost:18081/api/vms/1/stop # 200 {"ok":true,"status":"stopping","async":true}(对已停 VM 也 200,幂等) +curl -s -X POST -H "$TOKEN" http://localhost:18081/api/vms/1/start # 409(restart-after-stop 不支持,契约拒绝) # 动态创建/删除:create -> ready -> 409(重复 id)-> remove -> 404 -curl -s -X DELETE http://localhost:18081/api/vms/1 # 204(先删除默认 VM,释放其 id) +curl -s -X DELETE -H "$TOKEN" http://localhost:18081/api/vms/1 # 204(先删除默认 VM,释放其 id) curl -s -X POST http://localhost:18081/api/vms/create \ - -H 'Content-Type: application/json' \ + -H "$TOKEN" -H 'Content-Type: application/json' \ -d '{"toml": "<完整 TOML 配置,base.id 必须命中已内嵌镜像且未注册>"}' # 200 {"id":1} curl -s http://localhost:18081/api/vms/1 # 200, status="ready"(重建后) curl -s -X POST http://localhost:18081/api/vms/create \ - -H 'Content-Type: application/json' \ + -H "$TOKEN" -H 'Content-Type: application/json' \ -d '{"toml": "<同上>"}' # 409(id 已注册,重复 create 是契约错误) -curl -s -X DELETE http://localhost:18081/api/vms/1 # 204 +curl -s -X DELETE -H "$TOKEN" http://localhost:18081/api/vms/1 # 204 curl -s http://localhost:18081/api/vms/1 # 404(已移除) ``` @@ -147,9 +160,10 @@ qemu-system-x86_64 -no-user-config -display none -serial stdio -monitor none \ -netdev user,id=net0,hostfwd=tcp::18080-:8080 \ -device virtio-net-pci,netdev=net0 -# 3. 验证 +# 3. 验证(只读端点开放;写端点需要 Bearer token) curl -s -i http://localhost:18080/api/vms # 200 curl -s -i http://localhost:18080/api/vms/999 # 404 +curl -s -i -X POST http://localhost:18080/api/vms/1/start # 401(readonly 产物未烘焙 token,写默认拒绝) ``` --- @@ -164,13 +178,22 @@ pkill -f qemu-system --- -## 5. 安全边界声明(research/debug interface) - -> 本节为文档声明,非本期实现目标。 - -- **无认证、无加密:** HTTP 服务器运行在 hypervisor 内(EL2),网络输入直接进入 - hypervisor。`0.0.0.0:8080` 对管理网络全开放。 -- **默认信任管理网络:** 这不是生产级远程管理 API,仅用于开发/调试。 +## 5. 安全边界 + +控制面采用 **认证 + 受限监听** 双层防护: + +- **写接口强制认证(Bearer token):** `create`/`delete`/`start`/`stop` 以及测试专用 + `POST /__probe_result` 都要求 `Authorization: Bearer ` 头,token 在构建期经 + `[env] AXVM_HTTP_TOKEN` 注入(`option_env!` 读取,与 `shell/command/base.rs` 读 + `AX_ARCH` 的机制一致)。**默认拒绝**:构建时未设置该变量则所有写路由一律 `401`, + 没有"回退为允许写入 + 警告"的路径 —— 忘记配置 token 的构建会在测试中直接失败。 +- **受限监听(默认 loopback):** 服务器默认绑定 `127.0.0.1:8080`,管理网络不可达。 + 需要 hostfwd/对外暴露的构建必须显式设置 `[env] AXVM_HTTP_BIND = "0.0.0.0:8080"` 才 + 会监听所有接口;即便如此,写接口仍受 token 保护。 +- **读接口开放:** `GET` 列表/明细是只读的,用于管理面板/调试仪表盘,不做状态变更。 + 默认 loopback 绑定下管理网络无法访问;对外暴露时仅能枚举 VM 清单(无变更能力)。 +- **仍无加密:** HTTP 明文。在 bare-metal hypervisor 内启用 TLS 超出本期范围;如需暴露 + 到不受信网络,请确保 token 强度、在可信网络段内使用,并考虑前置带 TLS 的反向代理。 - **与 IVC/hypercall 的关系(互补):** HTTP 是 **host 管理员**对外入口;IVC (`IvcChannelFactory`)和 hypercall 是 **guest ↔ hypervisor** 内部通道。二者是不同 方向的通信机制,不互相替代。 diff --git a/os/axvisor/src/http/auth.rs b/os/axvisor/src/http/auth.rs new file mode 100644 index 0000000000..7cf5e5002d --- /dev/null +++ b/os/axvisor/src/http/auth.rs @@ -0,0 +1,63 @@ +//! Bearer-token access control for the management HTTP control plane. +//! +//! Mutating routes (`create`/`delete`/`start`/`stop`, plus the test-only +//! `POST /__probe_result` relay) require an `Authorization: Bearer ` +//! header matching the build-time token. The token is baked into the image at +//! build time from the `[env] AXVM_HTTP_TOKEN` build-config variable — the same +//! `option_env!` mechanism `crate::shell::command::base` uses for `AX_ARCH`. +//! +//! The control plane is **deny-by-default**: if `AXVM_HTTP_TOKEN` is unset, +//! every protected route returns `401` and cannot be used. There is no +//! "fall back to allowing writes without a token" path — a build that forgets +//! the token fails its tests instead of silently exposing EL2 state changes. +//! Read-only routes (`GET`) are intentionally left open; they expose no state +//! mutation, and the default loopback bind (see [`crate::http::server`]) keeps +//! them off the management network unless an operator explicitly opts in. + +use axum::{ + extract::FromRequestParts, + http::{ + StatusCode, + header::{AUTHORIZATION, HeaderValue}, + }, +}; + +/// A request that carries a matching `Authorization: Bearer ` header. +/// +/// Attach as the first extractor on a mutating handler. Rejects the request +/// with `401 Unauthorized` when no token was baked into the image +/// (`AXVM_HTTP_TOKEN` unset) or the header is missing / does not match. +pub struct ApiToken; + +impl ApiToken { + /// Whether the given header value carries the required bearer token. + fn header_matches(value: &HeaderValue) -> bool { + let Some(token) = option_env!("AXVM_HTTP_TOKEN") else { + return false; + }; + value.to_str().ok().is_some_and(|value| { + value + .strip_prefix("Bearer ") + .is_some_and(|rest| rest == token) + }) + } +} + +impl FromRequestParts for ApiToken { + type Rejection = StatusCode; + + async fn from_request_parts( + parts: &mut axum::http::request::Parts, + _state: &S, + ) -> Result { + let authorized = parts + .headers + .get(AUTHORIZATION) + .is_some_and(Self::header_matches); + if authorized { + Ok(ApiToken) + } else { + Err(StatusCode::UNAUTHORIZED) + } + } +} diff --git a/os/axvisor/src/http/mod.rs b/os/axvisor/src/http/mod.rs index a37c856b86..7b55772e6e 100644 --- a/os/axvisor/src/http/mod.rs +++ b/os/axvisor/src/http/mod.rs @@ -4,10 +4,15 @@ //! (see [`server`]). The VM list/detail and start/stop lifecycle routes live //! in [`vm`]. JSON is built with `serde_json`. //! +//! Security boundary: mutating routes require a build-time bearer token +//! ([`auth`]); the server binds `127.0.0.1:8080` by default and only binds +//! wider when `[env] AXVM_HTTP_BIND` opts in. See the per-module docs. +//! //! This whole module is only compiled under the `http-axum` feature, which is //! off by default. The hand-rolled HTTP/1.0 pilot was intentionally not //! carried forward. +pub mod auth; pub mod server; pub mod vm; diff --git a/os/axvisor/src/http/server.rs b/os/axvisor/src/http/server.rs index ef1d3f79aa..9d37dbc389 100644 --- a/os/axvisor/src/http/server.rs +++ b/os/axvisor/src/http/server.rs @@ -13,6 +13,11 @@ //! POST /api/vms/{id}/stop → 200 {"ok":true,"status":...} | 404 | 409 | 503 //! ``` //! +//! Mutating routes (`create`/`delete`/`start`/`stop`, plus the test-only +//! `POST /__probe_result`) require `Authorization: Bearer ` with the +//! build-time `[env] AXVM_HTTP_TOKEN`; see [`crate::http::auth`]. GET routes +//! are open. The listener binds [`bind_addr`], loopback by default. +//! //! The tokio reactor is initialized with `enable_io()` only (no time driver), //! which needs only epoll, so no `timerfd` syscall is required. //! @@ -44,6 +49,17 @@ pub fn router() -> Router { router } +/// Bind address for the management HTTP server. +/// +/// Defaults to loopback (`127.0.0.1:8080`) so a stock `http-axum` build is not +/// reachable from the management network. Test/dev flows that need QEMU +/// hostfwd to reach the in-guest listener must opt in to all interfaces by +/// setting `[env] AXVM_HTTP_BIND = "0.0.0.0:8080"` in their build config; the +/// mutating routes still require the bearer token regardless of the bind. +fn bind_addr() -> &'static str { + option_env!("AXVM_HTTP_BIND").unwrap_or("127.0.0.1:8080") +} + /// Blocking serve: build a tokio current-thread runtime and hand it to axum. /// /// `main` spawns this on its own task via `std::thread::spawn(|| http::serve())`; @@ -60,10 +76,11 @@ pub fn serve() { #[cfg(feature = "http-dynamic-test")] dynamic_test::self_test_dynamic().await; - let listener = tokio::net::TcpListener::bind("0.0.0.0:8080") + let bind = bind_addr(); + let listener = tokio::net::TcpListener::bind(bind) .await .expect("failed to bind management HTTP server"); - info!("management HTTP server (axum) listening on 0.0.0.0:8080"); + info!("management HTTP server (axum) listening on {bind}"); axum::serve(listener, router()).await.expect("server error"); }); } @@ -101,18 +118,25 @@ async fn self_test() { } /// Send a single request to the router and return its status code. +/// +/// When the image was built with `[env] AXVM_HTTP_TOKEN`, the request carries +/// the matching `Authorization: Bearer ` header so write-path +/// self-tests pass the control-plane gate. Builds without a token send no +/// header (their read-only self-tests hit the open GET routes). #[cfg(feature = "http-test")] async fn send_status(router: &Router, method: &str, uri: &str) -> axum::http::StatusCode { use axum::body::Body; use axum::http::Request; use tower::ServiceExt; + let mut builder = Request::builder().method(method).uri(uri); + if let Some(token) = option_env!("AXVM_HTTP_TOKEN") { + builder = builder.header("authorization", format!("Bearer {token}")); + } router .clone() .oneshot( - Request::builder() - .method(method) - .uri(uri) + builder .body(Body::empty()) .expect("failed to build request"), ) @@ -312,15 +336,21 @@ mod dynamic_test { } } - /// POST a create request with the given TOML body. + /// POST a create request with the given TOML body. The request carries the + /// build-time bearer token so the write-path gate admits it (the dynamic + /// build config sets `[env] AXVM_HTTP_TOKEN`). async fn send_create(router: &axum::Router, toml: &str) -> axum::http::StatusCode { + let mut builder = Request::builder() + .method("POST") + .uri("/api/vms/create") + .header("content-type", "application/json"); + if let Some(token) = option_env!("AXVM_HTTP_TOKEN") { + builder = builder.header("authorization", format!("Bearer {token}")); + } router .clone() .oneshot( - Request::builder() - .method("POST") - .uri("/api/vms/create") - .header("content-type", "application/json") + builder .body(Body::from(json!({ "toml": toml }).to_string())) .expect("failed to build request"), ) diff --git a/os/axvisor/src/http/vm.rs b/os/axvisor/src/http/vm.rs index 6da40ff13d..cf3444878b 100644 --- a/os/axvisor/src/http/vm.rs +++ b/os/axvisor/src/http/vm.rs @@ -10,6 +10,7 @@ use axvm::{AxVMRef, AxVmError, VmStatus, VmVcpuState}; use axvmconfig::GuestConfig; use serde_json::{Value, json}; +use crate::http::auth::ApiToken; use crate::manager::AxvmManager; /// `GET /api/vms` — list all known VMs (summary form). @@ -37,7 +38,10 @@ pub async fn vm_detail(Path(id_str): Path) -> Result, Status /// images are matched by id (`memory_images_for_vm`), a config whose id has no /// embedded image fails with 500 — the runtime can only realize guest images /// that were baked into the hypervisor at build time. -pub async fn vm_create(Json(payload): Json) -> Result, StatusCode> { +pub async fn vm_create( + _token: ApiToken, + Json(payload): Json, +) -> Result, StatusCode> { let toml = payload .get("toml") .and_then(Value::as_str) @@ -68,7 +72,10 @@ pub async fn vm_create(Json(payload): Json) -> Result, Status /// (its result is checked), and the registry is only touched on success. This /// avoids relying on `Drop`-time destroy, which merely warns on failure after /// the VM is already unregistered, leaving no handle to retry with. -pub async fn vm_delete(Path(id_str): Path) -> Result { +pub async fn vm_delete( + _token: ApiToken, + Path(id_str): Path, +) -> Result { let Ok(id) = id_str.parse::() else { return Err(StatusCode::NOT_FOUND); }; @@ -81,7 +88,10 @@ pub async fn vm_delete(Path(id_str): Path) -> Result) -> Result, StatusCode> { +pub async fn vm_start( + _token: ApiToken, + Path(id_str): Path, +) -> Result, StatusCode> { vm_action(&id_str, VmAction::Start) } @@ -89,7 +99,10 @@ pub async fn vm_start(Path(id_str): Path) -> Result, StatusC /// /// `stop` has request semantics: it returns as soon as the request is accepted, /// while the vCPU exits and the VM reaches `Stopped` asynchronously. -pub async fn vm_stop(Path(id_str): Path) -> Result, StatusCode> { +pub async fn vm_stop( + _token: ApiToken, + Path(id_str): Path, +) -> Result, StatusCode> { vm_action(&id_str, VmAction::Stop) } @@ -103,7 +116,7 @@ pub async fn vm_stop(Path(id_str): Path) -> Result, StatusCo /// terminates the run. The assertion itself happens host-side, so the sentinel /// reflects the statuses the probe actually received over the wire. #[cfg(feature = "http-tcp-test")] -pub async fn probe_result(body: String) -> StatusCode { +pub async fn probe_result(_token: ApiToken, body: String) -> StatusCode { match body.trim() { "PASSED" => info!("HTTP self-test: tcp PASSED"), _ => error!("HTTP self-test: tcp FAILED"), diff --git a/scripts/axbuild/src/test/case/types.rs b/scripts/axbuild/src/test/case/types.rs index aefeae3ee6..92b30e4a97 100644 --- a/scripts/axbuild/src/test/case/types.rs +++ b/scripts/axbuild/src/test/case/types.rs @@ -85,6 +85,12 @@ pub(crate) struct HostHttpProbeConfig { /// `timeout` so a broken server fails on the probe, not on the QEMU timeout. #[serde(default = "default_probe_connect_timeout_secs")] pub(crate) connect_timeout_secs: u64, + /// Bearer token the probe must send on authenticated requests, matching the + /// guest build's `[env] AXVM_HTTP_TOKEN`. The probe also asserts that an + /// *unauthenticated* write request is rejected with 401 (the access-denied + /// regression the management-control-plane security review requires). + #[serde(default)] + pub(crate) token: Option, } fn default_probe_guest_port() -> u16 { diff --git a/scripts/axbuild/src/test/host_probe.rs b/scripts/axbuild/src/test/host_probe.rs index c0102e5f22..8af085c8e1 100644 --- a/scripts/axbuild/src/test/host_probe.rs +++ b/scripts/axbuild/src/test/host_probe.rs @@ -10,9 +10,14 @@ //! the sentinel and terminates the run, exactly as it does for the in-guest //! self-test sentinels. //! -//! The probe is currently hardcoded to the read-only contract (the same two -//! endpoints `http-test`'s oneshot self-test covers): `GET /api/vms -> 200` and -//! `GET /api/vms/999 -> 404`. +//! The probe asserts the management control plane's security boundary over real +//! TCP: an unauthenticated write request (`POST /api/vms/999/start` with no +//! `Authorization` header) must be rejected with 401 — the access-denied +//! regression the security review requires — then verifies the authenticated +//! contract (`GET /api/vms -> 200`, `GET /api/vms/999 -> 404`, and an +//! authenticated write to an unknown VM -> 404). All authenticated requests +//! carry the `token` from the case config, which must match the guest build's +//! `[env] AXVM_HTTP_TOKEN`. use std::{ io::{Read, Write}, @@ -55,12 +60,14 @@ impl HostHttpProbeGuard { let thread_addr = addr.clone(); let thread_case_name = case_name.clone(); + let token = config.token.clone(); let thread = thread::spawn(move || { let _ = ready_tx.send(()); run_probe( &thread_addr, &thread_case_name, connect_timeout, + token.as_deref(), &thread_stop, ); }); @@ -87,13 +94,20 @@ impl Drop for HostHttpProbeGuard { } } -fn run_probe(addr: &str, case_name: &str, connect_timeout: Duration, stop: &AtomicBool) { +fn run_probe( + addr: &str, + case_name: &str, + connect_timeout: Duration, + token: Option<&str>, + stop: &AtomicBool, +) { let started = Instant::now(); - // Wait for the guest HTTP server to accept connections. `GET /api/vms` is - // retried until it yields a parsed status (readiness), the connect timeout - // elapses, or a stop is requested. A parsed but wrong status is still - // "ready"; the assertion below records it as a failure. + // Wait for the guest HTTP server to accept connections. `GET /api/vms` is a + // read-only route, so this readiness probe needs no token; it is retried + // until it yields a parsed status, the connect timeout elapses, or a stop + // is requested. A parsed but wrong status is still "ready"; the assertion + // below records it as a failure. let mut passed = true; let list = poll_status(addr, "/api/vms", started, connect_timeout, stop); match list { @@ -110,9 +124,28 @@ fn run_probe(addr: &str, case_name: &str, connect_timeout: Duration, stop: &Atom } } - // The server is up; single attempt for the 404 path. + // Access-denied regression (security review): an unauthenticated write to a + // mutating route must be rejected with 401. The auth gate runs before any + // VM lookup, so this holds regardless of whether VM 999 exists. + if passed { + match request_status(addr, "POST", "/api/vms/999/start", None, None) { + Some(status) => { + println!( + " host http probe: {case_name}: POST /api/vms/999/start (no token) -> \ + {status} (expect 401)" + ); + passed &= status == 401; + } + None => { + eprintln!(" host http probe: {case_name}: unauthenticated write request failed"); + passed = false; + } + } + } + + // The server is up; single attempt for the authenticated 404 path. if passed { - match request_status(addr, "GET", "/api/vms/999", None) { + match request_status(addr, "GET", "/api/vms/999", None, token) { Some(status) => { println!( " host http probe: {case_name}: GET /api/vms/999 -> {status} (expect 404)" @@ -126,12 +159,32 @@ fn run_probe(addr: &str, case_name: &str, connect_timeout: Duration, stop: &Atom } } - // Relay the verdict. The hypervisor mirrors it into the serial log, where - // the QEMU runner's stream matcher sees the sentinel and ends the run. If - // the relay itself fails (e.g. the server disappeared), no sentinel ever - // appears in serial and the run ends on the QEMU timeout — still a failure. + // Authenticated write: with the token the gate admits the request, and an + // unknown VM still yields the contract's 404. Proves writes are reachable + // with valid credentials rather than silently open or always denied. + if passed { + match request_status(addr, "POST", "/api/vms/999/start", None, token) { + Some(status) => { + println!( + " host http probe: {case_name}: POST /api/vms/999/start (with token) -> \ + {status} (expect 404)" + ); + passed &= status == 404; + } + None => { + eprintln!(" host http probe: {case_name}: authenticated write request failed"); + passed = false; + } + } + } + + // Relay the verdict (authenticated: `/__probe_result` is a protected route). + // The hypervisor mirrors it into the serial log, where the QEMU runner's + // stream matcher sees the sentinel and ends the run. If the relay itself + // fails (e.g. the server disappeared), no sentinel ever appears in serial + // and the run ends on the QEMU timeout — still a failure. let verdict = if passed { "PASSED" } else { "FAILED" }; - match request_status(addr, "POST", "/__probe_result", Some(verdict)) { + match request_status(addr, "POST", "/__probe_result", Some(verdict), token) { Some(status) => { println!(" host http probe: {case_name}: verdict {verdict} relayed (status {status})") } @@ -141,7 +194,8 @@ fn run_probe(addr: &str, case_name: &str, connect_timeout: Duration, stop: &Atom /// Retry a request until it yields a parsed status, the deadline elapses, or a /// stop is requested. Used for the first request, which doubles as the -/// readiness probe. +/// readiness probe. The readiness route (`GET /api/vms`) is open, so no token +/// is sent. fn poll_status( addr: &str, path: &str, @@ -156,7 +210,7 @@ fn poll_status( if started.elapsed() >= connect_timeout { return None; } - if let Some(status) = request_status(addr, "GET", path, None) { + if let Some(status) = request_status(addr, "GET", path, None, None) { return Some(status); } thread::sleep(CONNECT_RETRY_INTERVAL); @@ -165,7 +219,15 @@ fn poll_status( /// Send one HTTP/1.1 request over a fresh connection and parse the status code. /// `body` (when present) is sent as the request body with a JSON content type. -fn request_status(addr: &str, method: &str, path: &str, body: Option<&str>) -> Option { +/// `token` (when present) adds an `Authorization: Bearer ` header for +/// protected (mutating) routes. +fn request_status( + addr: &str, + method: &str, + path: &str, + body: Option<&str>, + token: Option<&str>, +) -> Option { let Ok(mut stream) = TcpStream::connect(addr) else { return None; }; @@ -173,6 +235,9 @@ fn request_status(addr: &str, method: &str, path: &str, body: Option<&str>) -> O let _ = stream.set_write_timeout(Some(IO_TIMEOUT)); let mut request = format!("{method} {path} HTTP/1.1\r\nHost: {addr}\r\n"); + if let Some(token) = token { + request.push_str(&format!("Authorization: Bearer {token}\r\n")); + } if let Some(body) = body { request.push_str(&format!( "Content-Type: application/json\r\nContent-Length: {}\r\n", @@ -216,9 +281,18 @@ mod tests { use super::{parse_status, poll_status, request_status}; - /// Serve canned responses on a background thread: `/api/vms` -> 200, - /// `/api/vms/999` -> 404, anything else -> 200 with the request body echoed - /// in a header (so the relay POST can be observed). + /// Bearer token the fake server accepts, mirroring the guest build's + /// `[env] AXVM_HTTP_TOKEN` for the control-plane auth gate. + const TEST_TOKEN: &str = "test-token"; + + /// Serve canned responses on a background thread, emulating the guest's + /// control-plane auth boundary: + /// - `GET` routes are open: `/api/vms` -> 200, `/api/vms/999` -> 404. + /// - Any unauthenticated write (`POST` with no matching + /// `Authorization: Bearer ` header, including the `/__probe_result` + /// relay) -> 401. + /// - An authenticated write -> routed normally (404 for the unknown VM, + /// 200 for the `/__probe_result` relay). fn start_fake_server() -> u16 { let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake server"); let port = listener.local_addr().unwrap().port(); @@ -244,7 +318,13 @@ mod tests { } let head = String::from_utf8_lossy(&request); let first_line = head.lines().next().unwrap_or(""); - let status = if first_line.contains("/api/vms/999") { + let authorized = head + .lines() + .any(|line| line == format!("Authorization: Bearer {TEST_TOKEN}")); + let is_write = first_line.starts_with("POST "); + let status = if is_write && !authorized { + "401 Unauthorized" + } else if first_line.contains("/api/vms/999") { "404 Not Found" } else { "200 OK" @@ -275,9 +355,34 @@ mod tests { fn request_status_returns_expected_codes_from_fake_server() { let port = start_fake_server(); let addr = format!("127.0.0.1:{port}"); - assert_eq!(request_status(&addr, "GET", "/api/vms", None), Some(200)); assert_eq!( - request_status(&addr, "GET", "/api/vms/999", None), + request_status(&addr, "GET", "/api/vms", None, None), + Some(200) + ); + assert_eq!( + request_status(&addr, "GET", "/api/vms/999", None, None), + Some(404) + ); + } + + #[test] + fn unauthenticated_write_is_denied_with_401() { + let port = start_fake_server(); + let addr = format!("127.0.0.1:{port}"); + assert_eq!( + request_status(&addr, "POST", "/api/vms/999/start", None, None), + Some(401) + ); + } + + #[test] + fn authenticated_write_reaches_the_route() { + let port = start_fake_server(); + let addr = format!("127.0.0.1:{port}"); + // With the token the gate admits the write; the unknown VM still yields + // the contract's 404. + assert_eq!( + request_status(&addr, "POST", "/api/vms/999/start", None, Some(TEST_TOKEN)), Some(404) ); } @@ -316,10 +421,22 @@ mod tests { fn request_status_handles_body_relay() { let port = start_fake_server(); let addr = format!("127.0.0.1:{port}"); + // The relay endpoint is a protected route: authenticated -> 200, + // unauthenticated -> 401. assert_eq!( - request_status(&addr, "POST", "/__probe_result", Some("PASSED")), + request_status( + &addr, + "POST", + "/__probe_result", + Some("PASSED"), + Some(TEST_TOKEN) + ), Some(200) ); + assert_eq!( + request_status(&addr, "POST", "/__probe_result", Some("FAILED"), None), + Some(401) + ); } #[test] @@ -331,6 +448,6 @@ mod tests { .unwrap() .port(); let addr = format!("127.0.0.1:{port}"); - assert_eq!(request_status(&addr, "GET", "/api/vms", None), None); + assert_eq!(request_status(&addr, "GET", "/api/vms", None, None), None); } } diff --git a/test-suit/axvisor/normal/qemu-http-axum-control/build-aarch64-unknown-none-softfloat.toml b/test-suit/axvisor/normal/qemu-http-axum-control/build-aarch64-unknown-none-softfloat.toml index 3d1977bf31..9819a1cb88 100644 --- a/test-suit/axvisor/normal/qemu-http-axum-control/build-aarch64-unknown-none-softfloat.toml +++ b/test-suit/axvisor/normal/qemu-http-axum-control/build-aarch64-unknown-none-softfloat.toml @@ -13,3 +13,8 @@ features = [ log = "Info" target = "aarch64-unknown-none-softfloat" vm_configs = ["test-suit/axvisor/normal/qemu-http-axum-control/aarch64-arceos-http-control.toml"] + +# The lifecycle self-test drives start/stop through the router; the mutating +# routes require the build-time bearer token, so bake one here. +[env] +AXVM_HTTP_TOKEN = "axvisor-http-test-token" diff --git a/test-suit/axvisor/normal/qemu-http-axum-dynamic/build-aarch64-unknown-none-softfloat.toml b/test-suit/axvisor/normal/qemu-http-axum-dynamic/build-aarch64-unknown-none-softfloat.toml index 713e163f62..9a9790061e 100644 --- a/test-suit/axvisor/normal/qemu-http-axum-dynamic/build-aarch64-unknown-none-softfloat.toml +++ b/test-suit/axvisor/normal/qemu-http-axum-dynamic/build-aarch64-unknown-none-softfloat.toml @@ -14,3 +14,9 @@ features = [ log = "Info" target = "aarch64-unknown-none-softfloat" vm_configs = ["test-suit/axvisor/normal/qemu-http-axum-dynamic/aarch64-arceos-http-dynamic.toml"] + +# The lifecycle + create/delete self-tests drive write routes through the +# router; the mutating routes require the build-time bearer token, so bake one +# here. +[env] +AXVM_HTTP_TOKEN = "axvisor-http-test-token" diff --git a/test-suit/axvisor/normal/qemu-http-axum-readonly/build-aarch64-unknown-none-softfloat.toml b/test-suit/axvisor/normal/qemu-http-axum-readonly/build-aarch64-unknown-none-softfloat.toml index 1ceda8556d..e062487ad7 100644 --- a/test-suit/axvisor/normal/qemu-http-axum-readonly/build-aarch64-unknown-none-softfloat.toml +++ b/test-suit/axvisor/normal/qemu-http-axum-readonly/build-aarch64-unknown-none-softfloat.toml @@ -8,3 +8,10 @@ features = [ log = "Info" target = "aarch64-unknown-none-softfloat" vm_configs = [] + +# This test artifact is also used by the quickstart's manual hostfwd + curl +# flow, which needs the in-guest listener to accept connections on the guest +# NIC IP, so opt in to all interfaces. The self-test runs in-process before the +# bind, so this does not affect the assertions. +[env] +AXVM_HTTP_BIND = "0.0.0.0:8080" diff --git a/test-suit/axvisor/normal/qemu-http-axum-readonly/build-x86_64-unknown-none.toml b/test-suit/axvisor/normal/qemu-http-axum-readonly/build-x86_64-unknown-none.toml index fe678b17a5..61ac2ca269 100644 --- a/test-suit/axvisor/normal/qemu-http-axum-readonly/build-x86_64-unknown-none.toml +++ b/test-suit/axvisor/normal/qemu-http-axum-readonly/build-x86_64-unknown-none.toml @@ -13,5 +13,11 @@ vm_configs = [] # runtime SSE42 fallback (#[target_feature(enable = "sse42")]) cannot be # lowered. Disable SIMD via the official escape hatch (x86_64 only; the aarch64 # NEON path compiles fine and does not need this). +# +# This test artifact is also used by the quickstart's manual hostfwd + curl +# flow, which needs the in-guest listener to accept connections on the guest +# NIC IP, so opt in to all interfaces. The self-test runs in-process before the +# bind, so this does not affect the assertions. [env] CARGO_CFG_HTTPARSE_DISABLE_SIMD = "1" +AXVM_HTTP_BIND = "0.0.0.0:8080" diff --git a/test-suit/axvisor/normal/qemu-http-axum-tcp/build-aarch64-unknown-none-softfloat.toml b/test-suit/axvisor/normal/qemu-http-axum-tcp/build-aarch64-unknown-none-softfloat.toml index 4758e941d0..83df65fdf0 100644 --- a/test-suit/axvisor/normal/qemu-http-axum-tcp/build-aarch64-unknown-none-softfloat.toml +++ b/test-suit/axvisor/normal/qemu-http-axum-tcp/build-aarch64-unknown-none-softfloat.toml @@ -10,3 +10,12 @@ features = [ log = "Info" target = "aarch64-unknown-none-softfloat" vm_configs = [] + +# Control-plane auth + bind. The mutating routes require +# `Authorization: Bearer `; the host probe sends this same token +# (`[host_http_probe] token`) and also asserts an unauthenticated write is +# rejected with 401. The server binds loopback by default; QEMU hostfwd +# forwards to the guest NIC IP, so this test opts in to all interfaces. +[env] +AXVM_HTTP_TOKEN = "axvisor-http-test-token" +AXVM_HTTP_BIND = "0.0.0.0:8080" diff --git a/test-suit/axvisor/normal/qemu-http-axum-tcp/build-x86_64-unknown-none.toml b/test-suit/axvisor/normal/qemu-http-axum-tcp/build-x86_64-unknown-none.toml index efd2fa5f74..3ad21514b9 100644 --- a/test-suit/axvisor/normal/qemu-http-axum-tcp/build-x86_64-unknown-none.toml +++ b/test-suit/axvisor/normal/qemu-http-axum-tcp/build-x86_64-unknown-none.toml @@ -14,5 +14,11 @@ vm_configs = [] # runtime SSE42 fallback (#[target_feature(enable = "sse42")]) cannot be # lowered. Disable SIMD via the official escape hatch (x86_64 only; the aarch64 # NEON path compiles fine and does not need this). +# +# Control-plane auth + bind: the mutating routes require the bearer token below +# (the host probe sends the same token and asserts an unauthenticated write is +# rejected with 401), and hostfwd reachability requires binding all interfaces. [env] CARGO_CFG_HTTPARSE_DISABLE_SIMD = "1" +AXVM_HTTP_TOKEN = "axvisor-http-test-token" +AXVM_HTTP_BIND = "0.0.0.0:8080" diff --git a/test-suit/axvisor/normal/qemu-http-axum-tcp/http-axum-tcp/qemu-aarch64.toml b/test-suit/axvisor/normal/qemu-http-axum-tcp/http-axum-tcp/qemu-aarch64.toml index 12e6a4a3e3..0dddc79417 100644 --- a/test-suit/axvisor/normal/qemu-http-axum-tcp/http-axum-tcp/qemu-aarch64.toml +++ b/test-suit/axvisor/normal/qemu-http-axum-tcp/http-axum-tcp/qemu-aarch64.toml @@ -12,15 +12,17 @@ args = [ # PR B: host->guest TCP integration verification over QEMU user-mode # networking. The driver appends `-netdev user,id=net0,hostfwd=tcp::-:` # and `-device virtio-net-pci,netdev=net0` when `[host_http_probe]` is present, -# then runs a host-side probe that dials the in-guest management API, checks the -# response statuses (GET /api/vms -> 200, GET /api/vms/999 -> 404), and POSTs a -# single PASSED/FAILED verdict to the test-only /__probe_result endpoint. That -# endpoint relays the verdict into the serial log, and the runner's stream +# then runs a host-side probe that dials the in-guest management API over real +# TCP. The probe asserts the control-plane security boundary (an unauthenticated +# write -> 401) and the authenticated contract (GET /api/vms -> 200, +# GET /api/vms/999 -> 404, authenticated write to an unknown VM -> 404), then +# POSTs a single PASSED/FAILED verdict to the test-only /__probe_result endpoint. +# That endpoint relays the verdict into the serial log, and the runner's stream # matcher stops at the FIRST marker — so success requires the final `tcp PASSED` -# sentinel, which only appears when the probe observed the expected statuses -# over real TCP. The `[host_http_probe]` connect timeout (default 120s) must be -# less than `timeout` so a broken server fails on the probe, not on the QEMU -# timeout. +# sentinel, which only appears when the probe observed every expected status +# over real TCP. `token` must match the guest build's `[env] AXVM_HTTP_TOKEN`. +# The `[host_http_probe]` connect timeout (default 120s) must be less than +# `timeout` so a broken server fails on the probe, not on the QEMU timeout. fail_regex = [ "(?i)\\bpanic(?:ked)?\\b", "(?i)kernel panic", @@ -34,3 +36,4 @@ to_bin = true uefi = false [host_http_probe] +token = "axvisor-http-test-token" diff --git a/test-suit/axvisor/normal/qemu-http-axum-tcp/http-axum-tcp/qemu-x86_64.toml b/test-suit/axvisor/normal/qemu-http-axum-tcp/http-axum-tcp/qemu-x86_64.toml index 44f2cb0055..2dba6731a5 100644 --- a/test-suit/axvisor/normal/qemu-http-axum-tcp/http-axum-tcp/qemu-x86_64.toml +++ b/test-suit/axvisor/normal/qemu-http-axum-tcp/http-axum-tcp/qemu-x86_64.toml @@ -22,10 +22,12 @@ args = [ # Same single-sentinel contract as qemu-aarch64.toml: the driver appends the # hostfwd netdev + virtio-net-pci device when `[host_http_probe]` is present, # and a host-side probe drives the in-guest management API over real TCP, -# POSTing its PASSED/FAILED verdict to /__probe_result. Success requires the -# final `tcp PASSED` sentinel. The `+vmx-*` CPU flags require an Intel KVM host; -# on an AMD host, add a qemu-x86_64-svm.toml variant following the `smoke-svm` -# case. +# asserting the auth boundary (unauthenticated write -> 401) and the +# authenticated contract, then POSTing its PASSED/FAILED verdict to +# /__probe_result. Success requires the final `tcp PASSED` sentinel. `token` +# must match the guest build's `[env] AXVM_HTTP_TOKEN`. The `+vmx-*` CPU flags +# require an Intel KVM host; on an AMD host, add a qemu-x86_64-svm.toml variant +# following the `smoke-svm` case. fail_regex = [ "(?i)\\bpanic(?:ked)?\\b", "HTTP self-test: tcp FAILED", @@ -38,3 +40,4 @@ to_bin = true uefi = true [host_http_probe] +token = "axvisor-http-test-token" From b357c43c57c4522457c448fff93958ad5f76d5c6 Mon Sep 17 00:00:00 2001 From: Xinhong Hu Date: Mon, 10 Aug 2026 23:56:53 +0800 Subject: [PATCH 18/40] =?UTF-8?q?refactor(axvisor):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E5=86=85=E6=A0=B8=E5=86=85=20self-test=EF=BC=8C=E6=94=B9?= =?UTF-8?q?=E7=94=A8=20host=20probe=20+=20fs=20Linux=20guest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTTP 管理面测试原先把断言逻辑经 Cargo feature 编译进内核(http-test / http-dynamic-test / http-tcp-test),并靠 /__probe_result 回传端点把 verdict 中继到串口,导致测试与生产构建耦合、漏 feature 会让行为不一致。 改为纯 host 侧探针后,测试经 QEMU hostfwd 发真实 TCP 请求断言,行为由 [env] 环境变量控制,不再改动 Cargo feature;客户机统一走 fs 加载的默认 linux-smp1.toml,删除测试专用 VM 配置副本。QEMU 经 QMP quit 结束, quit 被忽略时经 SO_PEERCRED 取 PID 后 SIGKILL 兜底,runner 以探针 verdict 为结果。 Changes: - 删除 http-test / http-dynamic-test / http-tcp-test feature 与 tower 依赖,移除 /__probe_result 回传端点与内核内 self_test - host probe 扩展 HostHttpProbeScenario(ReadOnly / Lifecycle / Dynamic),QMP quit 被忽略时 SIGKILL 兜底,runner 以探针 verdict 为结果 - readonly/control/dynamic/tcp 测试统一改用 fs 加载的默认 linux-smp1.toml,删除 control/dynamic 测试专用 VM 配置副本 --- Cargo.lock | 1 - os/axvisor/Cargo.toml | 18 +- os/axvisor/src/http/auth.rs | 6 +- os/axvisor/src/http/mod.rs | 3 +- os/axvisor/src/http/server.rs | 306 +----- os/axvisor/src/http/vm.rs | 22 +- os/axvisor/src/main.rs | 1 - scripts/axbuild/src/axvisor/test/qemu.rs | 79 +- scripts/axbuild/src/test/case/types.rs | 39 +- scripts/axbuild/src/test/host_probe.rs | 943 ++++++++++++++---- .../aarch64-arceos-http-control.toml | 44 - .../build-aarch64-unknown-none-softfloat.toml | 26 +- .../http-axum-control/qemu-aarch64.toml | 32 +- .../aarch64-arceos-http-dynamic.toml | 49 - .../build-aarch64-unknown-none-softfloat.toml | 31 +- .../http-axum-dynamic/qemu-aarch64.toml | 44 +- .../build-aarch64-unknown-none-softfloat.toml | 17 +- .../build-x86_64-unknown-none.toml | 14 +- .../http-axum-readonly/qemu-aarch64.toml | 30 +- .../http-axum-readonly/qemu-x86_64.toml | 22 +- .../build-aarch64-unknown-none-softfloat.toml | 9 +- .../build-x86_64-unknown-none.toml | 8 +- .../http-axum-tcp/qemu-aarch64.toml | 29 +- .../http-axum-tcp/qemu-x86_64.toml | 21 +- 24 files changed, 1043 insertions(+), 751 deletions(-) delete mode 100644 test-suit/axvisor/normal/qemu-http-axum-control/aarch64-arceos-http-control.toml delete mode 100644 test-suit/axvisor/normal/qemu-http-axum-dynamic/aarch64-arceos-http-dynamic.toml diff --git a/Cargo.lock b/Cargo.lock index 8a6b853a85..6dd53fac11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1552,7 +1552,6 @@ dependencies = [ "syn 3.0.3", "tokio", "toml 1.1.4+spec-1.1.0", - "tower", ] [[package]] diff --git a/os/axvisor/Cargo.toml b/os/axvisor/Cargo.toml index cd607648f4..b6955339b3 100644 --- a/os/axvisor/Cargo.toml +++ b/os/axvisor/Cargo.toml @@ -47,24 +47,13 @@ stack-protector = ["ax-std/stack-protector"] backtrace = ["ax-std/backtrace", "dep:axbacktrace"] test-backtrace-panic = ["backtrace"] test-panic-no-backtrace = ["dep:axbacktrace"] -# axum-based management HTTP server (see src/http/ for the Router and self-test). -# Off by default; enabled together with `http-axum`. Replaces the hand-rolled -# pilot, which is intentionally not carried forward. -http-test = ["http-axum", "dep:tower"] +# axum-based management HTTP server (see src/http/ for the Router). +# Off by default. Replaces the hand-rolled pilot, which is intentionally not +# carried forward. http-axum = ["dep:axum", "dep:tokio", "dep:serde_json"] -# Real-TCP probe relay (`qemu-http-axum-tcp`): adds the test-only -# `POST /__probe_result` endpoint so a host-side probe can relay its verdict -# into the serial log after checking the management API over QEMU hostfwd. -# Unlike `http-test`, no `tower`/oneshot self-test is built, so the management -# server binds and serves immediately. -http-tcp-test = ["http-axum"] # Do not auto-boot the default VMs at startup; the HTTP control plane starts # and stops them on demand (VMs are created and stay in `Ready`). no-auto-start = [] -# Runtime create/delete self-test (`qemu-http-axum-dynamic`). Implies the base -# control self-test and the no-auto-start lifecycle test, then drives one default -# VM through remove -> create -> ready -> 409 -> remove -> 404. -http-dynamic-test = ["http-test", "no-auto-start"] [dependencies] shlex.workspace = true @@ -77,7 +66,6 @@ anyhow.workspace = true log = "0.4" axum = { version = "0.8", optional = true } serde_json = { version = "1", optional = true } -tower = { version = "0.5", optional = true, features = ["util"] } # The runtime only enables the IO driver (`enable_io()`), so no `time` driver # and thus no `timerfd` syscall is needed. `rt` + `net` cover the manually # built current-thread runtime and `TcpListener`. diff --git a/os/axvisor/src/http/auth.rs b/os/axvisor/src/http/auth.rs index 7cf5e5002d..097669836c 100644 --- a/os/axvisor/src/http/auth.rs +++ b/os/axvisor/src/http/auth.rs @@ -1,8 +1,8 @@ //! Bearer-token access control for the management HTTP control plane. //! -//! Mutating routes (`create`/`delete`/`start`/`stop`, plus the test-only -//! `POST /__probe_result` relay) require an `Authorization: Bearer ` -//! header matching the build-time token. The token is baked into the image at +//! Mutating routes (`create`/`delete`/`start`/`stop`) require an +//! `Authorization: Bearer ` header matching the build-time token. The +//! token is baked into the image at //! build time from the `[env] AXVM_HTTP_TOKEN` build-config variable — the same //! `option_env!` mechanism `crate::shell::command::base` uses for `AX_ARCH`. //! diff --git a/os/axvisor/src/http/mod.rs b/os/axvisor/src/http/mod.rs index 7b55772e6e..9534e9c8ac 100644 --- a/os/axvisor/src/http/mod.rs +++ b/os/axvisor/src/http/mod.rs @@ -19,8 +19,7 @@ pub mod vm; /// Blocking entry point for the management HTTP server. /// /// Spawned on its own task (see `crate::main`); builds the tokio runtime and -/// serves until the hypervisor shuts down. Under `http-test` the built-in -/// self-test runs first and prints deterministic handler results. +/// serves until the hypervisor shuts down. pub fn serve() { server::serve(); } diff --git a/os/axvisor/src/http/server.rs b/os/axvisor/src/http/server.rs index 9d37dbc389..b1703cf318 100644 --- a/os/axvisor/src/http/server.rs +++ b/os/axvisor/src/http/server.rs @@ -13,20 +13,13 @@ //! POST /api/vms/{id}/stop → 200 {"ok":true,"status":...} | 404 | 409 | 503 //! ``` //! -//! Mutating routes (`create`/`delete`/`start`/`stop`, plus the test-only -//! `POST /__probe_result`) require `Authorization: Bearer ` with the -//! build-time `[env] AXVM_HTTP_TOKEN`; see [`crate::http::auth`]. GET routes -//! are open. The listener binds [`bind_addr`], loopback by default. +//! Mutating routes (`create`/`delete`/`start`/`stop`) require +//! `Authorization: Bearer ` with the build-time `[env] AXVM_HTTP_TOKEN`; +//! see [`crate::http::auth`]. GET routes are open. The listener binds +//! [`bind_addr`], loopback by default. //! //! The tokio reactor is initialized with `enable_io()` only (no time driver), //! which needs only epoll, so no `timerfd` syscall is required. -//! -//! `http-test` drives the router with `tower::ServiceExt::oneshot` (no TCP -//! loopback), so the assertions are deterministic and free of task-scheduling -//! timing. Under `no-auto-start` the default VMs stay in `Ready` and the -//! self-test additionally drives one VM through a full -//! start/409/stop/stopped/404 lifecycle. `http-dynamic-test` then drives the -//! same VM through remove -> create -> ready -> 409 -> remove -> 404. use axum::{Router, routing::get, routing::post}; @@ -34,19 +27,12 @@ use crate::http::vm; /// Assemble the management routes. pub fn router() -> Router { - let mut router = Router::new() + Router::new() .route("/api/vms", get(vm::list_vms)) .route("/api/vms/{id}", get(vm::vm_detail).delete(vm::vm_delete)) .route("/api/vms/create", post(vm::vm_create)) .route("/api/vms/{id}/start", post(vm::vm_start)) - .route("/api/vms/{id}/stop", post(vm::vm_stop)); - - #[cfg(feature = "http-tcp-test")] - { - router = router.route("/__probe_result", post(vm::probe_result)); - } - - router + .route("/api/vms/{id}/stop", post(vm::vm_stop)) } /// Bind address for the management HTTP server. @@ -71,11 +57,6 @@ pub fn serve() { .build() .expect("failed to build tokio runtime"); rt.block_on(async { - #[cfg(feature = "http-test")] - self_test().await; - #[cfg(feature = "http-dynamic-test")] - dynamic_test::self_test_dynamic().await; - let bind = bind_addr(); let listener = tokio::net::TcpListener::bind(bind) .await @@ -84,278 +65,3 @@ pub fn serve() { axum::serve(listener, router()).await.expect("server error"); }); } - -/// `http-test` built-in self-test: drive the router with -/// `tower::ServiceExt::oneshot` (no TCP loopback) and assert the read-only -/// endpoints: `GET /api/vms -> 200` and `GET /api/vms/999 -> 404` (no specific -/// VM id is bound). The per-request status lines are diagnostics only; the QEMU -/// regex matcher stops at the FIRST marker it sees, so two independent success -/// lines could not assert both endpoints. The test therefore prints a single -/// `readonly PASSED`/`readonly FAILED` sentinel that reflects both assertions. -#[cfg(feature = "http-test")] -async fn self_test() { - let router = router(); - - let list = send_status(&router, "GET", "/api/vms").await; - info!("HTTP self-test: GET /api/vms -> {}", list); - - let detail = send_status(&router, "GET", "/api/vms/999").await; - info!("HTTP self-test: GET /api/vms/999 -> {}", detail); - - let passed = list == axum::http::StatusCode::OK && detail == axum::http::StatusCode::NOT_FOUND; - if passed { - info!("HTTP self-test: readonly PASSED"); - } else { - error!("HTTP self-test: readonly FAILED"); - } - - // With `no-auto-start` the default VMs are created but left in `Ready`, so - // the control API can be exercised over a full start/stop cycle. - #[cfg(feature = "no-auto-start")] - if let Some(id) = lifecycle_test::first_vm_id() { - lifecycle_test::self_test_lifecycle(router, id).await; - } -} - -/// Send a single request to the router and return its status code. -/// -/// When the image was built with `[env] AXVM_HTTP_TOKEN`, the request carries -/// the matching `Authorization: Bearer ` header so write-path -/// self-tests pass the control-plane gate. Builds without a token send no -/// header (their read-only self-tests hit the open GET routes). -#[cfg(feature = "http-test")] -async fn send_status(router: &Router, method: &str, uri: &str) -> axum::http::StatusCode { - use axum::body::Body; - use axum::http::Request; - use tower::ServiceExt; - - let mut builder = Request::builder().method(method).uri(uri); - if let Some(token) = option_env!("AXVM_HTTP_TOKEN") { - builder = builder.header("authorization", format!("Bearer {token}")); - } - router - .clone() - .oneshot( - builder - .body(Body::empty()) - .expect("failed to build request"), - ) - .await - .expect("request failed") - .status() -} - -/// The control-lifecycle self-test, only built when both `http-test` and -/// `no-auto-start` are enabled (the default VMs stay in `Ready`). -/// -/// All polling here parks the task on a timer alarm (`std::thread::sleep` → -/// ArceOS `run_queue::sleep_until`), which reschedules and lets other Core-0 -/// tasks (shell, VM manager) run while waiting — it does not busy-wait. -#[cfg(all(feature = "http-test", feature = "no-auto-start"))] -mod lifecycle_test { - use ax_std::time::{Duration, Instant}; - use axum::Router; - - use super::send_status; - - /// The id of the first registered VM, if any. - pub(super) fn first_vm_id() -> Option { - crate::manager::AxvmManager::vm_list() - .first() - .map(|vm| vm.id()) - } - - /// Drive one VM through `start -> 409 -> stop -> stopped` and the 404 path, - /// verifying each expected outcome internally and printing a single - /// deterministic PASSED/FAILED sentinel for the QEMU regex matcher. - /// - /// `stop` is a request: the `Stopped` state only arrives once the vCPU - /// (running on another CPU) observes the request and exits, so the self-test - /// polls with explicit sleeps instead of blocking. `start` flips the VM status - /// to `Running` synchronously while the vCPU task is still being queued on its - /// target CPU, so before issuing a stop the self-test must wait until the vCPU - /// task has actually entered the guest (`running_vcpu_count`); otherwise a stop - /// issued in that window would strand the vCPU task waiting forever for a - /// `Running` state it already missed. - /// - /// Restarting a stopped VM spawns a fresh vCPU task that the scheduler never - /// runs on its pinned CPU once that CPU has idled (no IPI wake source in the - /// current build). The API contract rejects `start` on a `Stopped` VM with - /// 409 (see `vm_action`) instead of letting it hang in `Running`, and the - /// self-test asserts that rejection below. - pub(super) async fn self_test_lifecycle(router: Router, id: usize) { - let mut passed = true; - - let start = send_status(&router, "POST", &format!("/api/vms/{id}/start")).await; - info!("HTTP self-test: POST /api/vms/{id}/start -> {}", start); - passed &= start == axum::http::StatusCode::OK; - passed &= poll_vcpu_running(id); - - // A `Ready`/`Stopped`/`Running`-incompatible transition is a 409. - let invalid = send_status(&router, "POST", &format!("/api/vms/{id}/start")).await; - info!("HTTP self-test: POST start on running VM -> {}", invalid); - passed &= invalid == axum::http::StatusCode::CONFLICT; - - let stop = send_status(&router, "POST", &format!("/api/vms/{id}/stop")).await; - info!("HTTP self-test: POST /api/vms/{id}/stop -> {}", stop); - passed &= stop == axum::http::StatusCode::OK; - passed &= poll_status(id, "stopped"); - - // Restart-after-stop is unsupported (scheduler limitation); the contract - // rejects it with 409 rather than hanging the VM in `Running`. - let restart = send_status(&router, "POST", &format!("/api/vms/{id}/start")).await; - info!( - "HTTP self-test: POST /api/vms/{id}/start on stopped VM -> {}", - restart - ); - passed &= restart == axum::http::StatusCode::CONFLICT; - - let bad = send_status(&router, "POST", "/api/vms/999/start").await; - info!("HTTP self-test: POST /api/vms/999/start -> {}", bad); - passed &= bad == axum::http::StatusCode::NOT_FOUND; - - if passed { - info!("HTTP self-test: control lifecycle PASSED"); - } else { - error!("HTTP self-test: control lifecycle FAILED"); - } - } - - /// Wait until a vCPU of the VM has actually entered the guest run loop. - /// - /// `start_vm()` returns as soon as the VM status is `Running`; the vCPU task is - /// spawned on the calling CPU and migrated to its pinned CPU asynchronously. A - /// stop issued before that migration completes is observed by a vCPU task still - /// waiting for the `Running` state and never becomes effective. Polling - /// `running_vcpu_count` closes that window deterministically. Returns whether - /// the vCPU entered within the poll bound, for the self-test's pass/fail - /// accounting. - pub(super) fn poll_vcpu_running(id: usize) -> bool { - let start = Instant::now(); - while start.elapsed() < Duration::from_secs(5) { - let entered = crate::manager::AxvmManager::vm_by_id(id) - .map(|vm| vm.running_vcpu_count() > 0) - .unwrap_or(false); - if entered { - info!("HTTP self-test: VM[{id}] vCPU entered guest"); - return true; - } - std::thread::sleep(Duration::from_millis(1)); - } - warn!("HTTP self-test: VM[{id}] vCPU did not enter guest within the poll bound"); - false - } - - /// Poll the VM status until it reports `want`, sleeping between checks so other - /// primary-CPU tasks are not starved. A wall-clock deadline is used rather than - /// an iteration count because the guest boot + stop completion latency is - /// timing-dependent (~100 ms in QEMU). Returns whether the status was reached, - /// for the self-test's internal pass/fail accounting. - pub(super) fn poll_status(id: usize, want: &str) -> bool { - let start = Instant::now(); - while start.elapsed() < Duration::from_secs(5) { - let status = crate::manager::AxvmManager::vm_by_id(id) - .map(|vm| vm.status().as_str().to_owned()) - .unwrap_or_default(); - if status == want { - info!("HTTP self-test: VM[{id}] reached status '{want}'"); - return true; - } - std::thread::sleep(Duration::from_millis(1)); - } - warn!("HTTP self-test: VM[{id}] did not reach '{want}' within the poll bound"); - false - } -} - -/// The create/delete self-test, only built under `http-dynamic-test`. -/// -/// Runs after the base control self-test, so the default VM has already been -/// started and stopped by [`super::lifecycle_test::self_test_lifecycle`] and -/// sits in `Stopped`. The test removes that VM, recreates it from its own -/// build-time config (the create body reuses `static_vm_configs().first()`, -/// whose id owns an embedded guest image), checks it is `Ready`, verifies a -/// duplicate create is rejected with 409, then removes it again and confirms a -/// 404. -#[cfg(feature = "http-dynamic-test")] -mod dynamic_test { - use axum::body::Body; - use axum::http::Request; - use serde_json::json; - use tower::ServiceExt; - - use super::{lifecycle_test, send_status}; - - /// Drive one VM through remove -> create -> ready -> 409 -> remove -> 404, - /// printing a single deterministic PASSED/FAILED sentinel. - pub(super) async fn self_test_dynamic() { - let router = super::router(); - let mut passed = true; - - // The create body is the VM's own build-time config: its id owns an - // embedded guest image, which is the only way the runtime boot-image - // resolver (`memory_images_for_vm`) can satisfy the load. - let toml = crate::config::vmcfg::static_vm_configs() - .first() - .copied() - .expect("dynamic self-test requires a static VM config"); - let id = lifecycle_test::first_vm_id().expect("dynamic self-test requires a default VM"); - - // 1. Remove the default VM (registered at boot, left `Stopped` by the - // base control self-test). destroy() + remove_vm() are synchronous. - let removed = send_status(&router, "DELETE", &format!("/api/vms/{id}")).await; - info!("HTTP self-test: DELETE /api/vms/{id} -> {removed}"); - passed &= removed == axum::http::StatusCode::NO_CONTENT; - - // 2. Recreate it from the same TOML. - let created = send_create(&router, toml).await; - info!("HTTP self-test: POST /api/vms/create -> {created}"); - passed &= created == axum::http::StatusCode::OK; - - // 3. The recreated VM is registered and `Ready`. - passed &= lifecycle_test::poll_status(id, "ready"); - - // 4. A duplicate id is a contract error (409), not an opaque 500. - let dup = send_create(&router, toml).await; - info!("HTTP self-test: POST /api/vms/create duplicate -> {dup}"); - passed &= dup == axum::http::StatusCode::CONFLICT; - - // 5. Remove it again, then confirm it is gone. - let removed = send_status(&router, "DELETE", &format!("/api/vms/{id}")).await; - info!("HTTP self-test: DELETE /api/vms/{id} -> {removed}"); - passed &= removed == axum::http::StatusCode::NO_CONTENT; - - let gone = send_status(&router, "GET", &format!("/api/vms/{id}")).await; - info!("HTTP self-test: GET /api/vms/{id} -> {gone}"); - passed &= gone == axum::http::StatusCode::NOT_FOUND; - - if passed { - info!("HTTP self-test: dynamic create/delete PASSED"); - } else { - error!("HTTP self-test: dynamic create/delete FAILED"); - } - } - - /// POST a create request with the given TOML body. The request carries the - /// build-time bearer token so the write-path gate admits it (the dynamic - /// build config sets `[env] AXVM_HTTP_TOKEN`). - async fn send_create(router: &axum::Router, toml: &str) -> axum::http::StatusCode { - let mut builder = Request::builder() - .method("POST") - .uri("/api/vms/create") - .header("content-type", "application/json"); - if let Some(token) = option_env!("AXVM_HTTP_TOKEN") { - builder = builder.header("authorization", format!("Bearer {token}")); - } - router - .clone() - .oneshot( - builder - .body(Body::from(json!({ "toml": toml }).to_string())) - .expect("failed to build request"), - ) - .await - .expect("request failed") - .status() - } -} diff --git a/os/axvisor/src/http/vm.rs b/os/axvisor/src/http/vm.rs index cf3444878b..fcdcac4617 100644 --- a/os/axvisor/src/http/vm.rs +++ b/os/axvisor/src/http/vm.rs @@ -1,9 +1,7 @@ //! VM status, lifecycle, and create/delete axum handlers. //! //! JSON is built with `serde_json::json!()` (no hand-written escaping). These -//! handlers are shared by the TCP serving path in [`super::server`] and the -//! `http-test` built-in self-test, so the self-test exercises exactly the same -//! logic the network path dispatches to. +//! handlers are dispatched by the TCP serving path in [`super::server`]. use axum::{Json, extract::Path, http::StatusCode}; use axvm::{AxVMRef, AxVmError, VmStatus, VmVcpuState}; @@ -106,24 +104,6 @@ pub async fn vm_stop( vm_action(&id_str, VmAction::Stop) } -/// `POST /__probe_result` — test-only relay endpoint for the host-side TCP probe -/// (`http-tcp-test` / `qemu-http-axum-tcp`). -/// -/// The host probe checks the management API over a real TCP connection -/// (QEMU hostfwd), independently asserting the response statuses, then POSTs a -/// single `PASSED`/`FAILED` verdict here. This handler only mirrors that verdict -/// into the serial log, where the QEMU runner's stream matcher picks it up and -/// terminates the run. The assertion itself happens host-side, so the sentinel -/// reflects the statuses the probe actually received over the wire. -#[cfg(feature = "http-tcp-test")] -pub async fn probe_result(_token: ApiToken, body: String) -> StatusCode { - match body.trim() { - "PASSED" => info!("HTTP self-test: tcp PASSED"), - _ => error!("HTTP self-test: tcp FAILED"), - } - StatusCode::OK -} - /// A lifecycle action on a VM. enum VmAction { Start, diff --git a/os/axvisor/src/main.rs b/os/axvisor/src/main.rs index 6f2da9c67b..ef4ed8f8ef 100644 --- a/os/axvisor/src/main.rs +++ b/os/axvisor/src/main.rs @@ -88,7 +88,6 @@ fn main() { // only enqueues the task — the main task keeps running until it yields or // blocks — so the server's bind does not necessarily happen before // `launch_default_vms` queues the vCPU tasks; the ordering is best-effort. - // `http-test` runs its self-test inside `http::serve` before any socket work. #[cfg(feature = "http-axum")] std::thread::Builder::new() .name("axvisor-http".into()) diff --git a/scripts/axbuild/src/axvisor/test/qemu.rs b/scripts/axbuild/src/axvisor/test/qemu.rs index c7cf821b79..7a1adfe69e 100644 --- a/scripts/axbuild/src/axvisor/test/qemu.rs +++ b/scripts/axbuild/src/axvisor/test/qemu.rs @@ -341,16 +341,44 @@ impl Axvisor { // Optional host->guest TCP probe over QEMU user-mode networking. When // `[host_http_probe]` is configured, the host acts as a *client* that - // dials a management API inside the guest through a hostfwd port, checks - // the response statuses, and relays a PASSED/FAILED verdict to - // `POST /__probe_result`. The probe must live for the whole run, so its - // guard is spawned here and dropped at scope end (after QEMU exits). + // dials a management API inside the guest through a hostfwd port and + // asserts the responses entirely host-side. The probe must live for the + // whole run, so its guard is spawned here and dropped at scope end + // (after QEMU exits). + // + // The probe also drives QEMU termination: after it stores its verdict it + // connects to a QMP monitor socket and sends `quit`, so the run ends on + // the probe result instead of the serial-timeout path. That makes the + // probe verdict the authoritative test result (no `/__probe_result` + // relay inside the guest). let mut host_probe_guard = None; - if let Some(probe_config) = + if let Some(mut probe_config) = test_qemu::load_qemu_case_extra_config(&case.case.case.qemu_config_path)? .host_http_probe { + // A relative `config_toml` in the dynamic scenario is resolved against + // the workspace root (axbuild may be invoked from any directory), so + // the probe reads the same file regardless of the caller's CWD. + if let test_case::HostHttpProbeScenario::Dynamic { + ref mut config_toml, + } = probe_config.scenario + { + let path = PathBuf::from(&*config_toml); + if !path.is_absolute() { + *config_toml = self + .app + .workspace_root() + .join(path) + .to_string_lossy() + .into(); + } + } let host_port = pick_free_local_port()?; + let qmp_socket = std::env::temp_dir().join(format!( + "axvisor-qmp-{}-{}.sock", + case.case.case.name, + std::process::id() + )); // Each QEMU option and its value must be a separate argv element // (QEMU takes the value of `-netdev`/`-device` from the following // argument), matching how the `.toml` config stores them. @@ -362,15 +390,23 @@ impl Axvisor { ), "-device".to_string(), "virtio-net-pci,netdev=net0".to_string(), + "-qmp".to_string(), + format!("unix:{},server=on,wait=off", qmp_socket.to_string_lossy()), ]); host_probe_guard = Some(host_probe::HostHttpProbeGuard::start( &probe_config, host_port, &case.case.case.name, + Some(qmp_socket), )?); } - test_case::run_qemu_with_prepared_case_assets( + // QEMU's exit code is not the verdict for probe cases: the probe quits + // QEMU (cleanly, or force-kills it if `quit` is ignored) whether it + // passed or failed, so the stored probe result decides. For non-probe + // cases the serial-success path in + // `run_qemu_with_prepared_case_assets` still applies unchanged. + let qemu_result = test_case::run_qemu_with_prepared_case_assets( &mut self.app, cargo, qemu, @@ -382,11 +418,38 @@ impl Axvisor { qemu_timing_fields: None, }, ) - .await?; + .await; // Joins the probe thread now that QEMU has exited. + let probe_configured = host_probe_guard.is_some(); + let probe_result = host_probe_guard + .as_ref() + .and_then(|guard| guard.take_result()); + let killed_by_probe = host_probe_guard + .as_ref() + .map(|guard| guard.killed_by_probe()) + .unwrap_or(false); drop(host_probe_guard); - Ok(()) + + match (qemu_result, probe_configured, probe_result, killed_by_probe) { + // The probe force-killed QEMU (QMP `quit` was ignored): the stored + // probe verdict is authoritative, even though QEMU exited non-zero. + (_, true, Some(verdict), true) => verdict, + // A real QEMU failure (boot failure, guest crash, serial sentinel) + // always wins regardless of probe configuration. + (Err(err), ..) => Err(err), + // Non-probe case: QEMU exit is the verdict. + (Ok(()), false, _, _) => Ok(()), + // Probe case: the stored probe verdict decides. + (Ok(()), true, Some(Ok(())), _) => Ok(()), + (Ok(()), true, Some(Err(err)), _) => Err(err), + (Ok(()), true, None, _) => { + anyhow::bail!( + "host http probe for `{}` produced no verdict", + case.case.case.name + ) + } + } } } diff --git a/scripts/axbuild/src/test/case/types.rs b/scripts/axbuild/src/test/case/types.rs index 92b30e4a97..64b4eacf4f 100644 --- a/scripts/axbuild/src/test/case/types.rs +++ b/scripts/axbuild/src/test/case/types.rs @@ -65,15 +65,16 @@ pub(crate) struct HostHttpServerConfig { pub(crate) dir: Option, } -/// Host-side TCP probe configuration (`qemu-http-axum-tcp`). +/// Host-side TCP probe configuration. /// /// Direction is the reverse of [`HostHttpServerConfig`]: instead of the host /// serving fixtures to the guest, the host acts as a *client* that probes a /// management API running *inside* the guest, over QEMU user-mode networking /// hostfwd (`-netdev user,hostfwd=tcp::-:`). The probe -/// makes real HTTP requests, asserts the response statuses, and relays a single -/// PASSED/FAILED verdict to a guest endpoint (`POST /__probe_result`), which the -/// hypervisor mirrors into the serial log for the QEMU runner's stream matcher. +/// makes real HTTP requests and asserts the responses entirely host-side — there +/// is no guest-side test relay endpoint. When the probe finishes (pass or fail) +/// it quits QEMU over its QMP monitor socket, and the runner reads the stored +/// verdict from the probe guard as the test result. #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] pub(crate) struct HostHttpProbeConfig { /// Guest-side port the in-guest HTTP server binds to. The harness forwards a @@ -91,6 +92,36 @@ pub(crate) struct HostHttpProbeConfig { /// regression the management-control-plane security review requires). #[serde(default)] pub(crate) token: Option, + /// Which management-plane behavior the probe exercises. + #[serde(default)] + pub(crate) scenario: HostHttpProbeScenario, +} + +/// Probe scenario: the set of management API behaviors the host probe drives +/// and asserts. The probe is host-side by design; nothing in the guest knows a +/// test is running. +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)] +pub(crate) enum HostHttpProbeScenario { + /// Read-only routes: `GET /api/vms`, the 404 on an unknown VM, and the 401 + /// access-denied check on an unauthenticated write. No VM is started or + /// created, so it works with any axvisor build (even `vm_configs = []`). + #[default] + ReadOnly, + /// Lifecycle: `POST /api/vms/{id}/start`, poll until `running`, then + /// `POST /api/vms/{id}/stop` and poll until `stopped`. The target VM is a + /// default/static VM that must exist in `Ready` (`no-auto-start`). + Lifecycle { + /// VM id to drive through start -> running -> stop -> stopped. + vm_id: u64, + }, + /// Dynamic create/delete: read a VM config TOML from the host, `POST + /// /api/vms/create`, poll until the VM is `Ready`, then `DELETE` it and poll + /// until it is gone. The config may reference any guest image available to + /// the runtime (an fs-backed kernel on the rootfs, or an embedded image). + Dynamic { + /// Host path of the VM config TOML to send in the create body. + config_toml: String, + }, } fn default_probe_guest_port() -> u16 { diff --git a/scripts/axbuild/src/test/host_probe.rs b/scripts/axbuild/src/test/host_probe.rs index 8af085c8e1..372f8ad2ee 100644 --- a/scripts/axbuild/src/test/host_probe.rs +++ b/scripts/axbuild/src/test/host_probe.rs @@ -1,29 +1,40 @@ -//! Host-side TCP probe for QEMU hostfwd integration tests (`qemu-http-axum-tcp`). +//! Host-side TCP probe for QEMU hostfwd integration tests. //! //! The probe is the reverse of [`super::host_http`]: instead of serving host //! fixtures to the guest, it acts as a *client* that dials a management API //! running *inside* the guest through QEMU user-mode networking //! (`-netdev user,hostfwd=tcp::-:`). It makes real HTTP -//! requests, asserts the response statuses, and relays a single PASSED/FAILED -//! verdict to a guest endpoint (`POST /__probe_result`) that the hypervisor -//! mirrors into the serial log. The QEMU runner's stream matcher then picks up -//! the sentinel and terminates the run, exactly as it does for the in-guest -//! self-test sentinels. +//! requests and asserts the responses entirely host-side — there is no +//! guest-side relay endpoint, so nothing in the hypervisor knows a test is +//! running. //! -//! The probe asserts the management control plane's security boundary over real -//! TCP: an unauthenticated write request (`POST /api/vms/999/start` with no -//! `Authorization` header) must be rejected with 401 — the access-denied -//! regression the security review requires — then verifies the authenticated -//! contract (`GET /api/vms -> 200`, `GET /api/vms/999 -> 404`, and an -//! authenticated write to an unknown VM -> 404). All authenticated requests -//! carry the `token` from the case config, which must match the guest build's -//! `[env] AXVM_HTTP_TOKEN`. +//! When the probe finishes — pass or fail — it quits QEMU over the QMP monitor +//! socket the runner added (`-qmp unix:...,server=on,wait=off`), so the QEMU +//! process exits cleanly and the runner reads the stored verdict from the guard +//! as the test result. The case `timeout` remains the backstop if the probe or +//! its QMP quit fails. +//! +//! Scenarios (see [`HostHttpProbeScenario`]): +//! - `ReadOnly`: the security-boundary + read contract. An unauthenticated +//! write (`POST /api/vms/999/start` with no `Authorization` header) must be +//! rejected with 401; then the authenticated contract (`GET /api/vms -> 200`, +//! `GET /api/vms/999 -> 404`, authenticated write to an unknown VM -> 404). +//! - `Lifecycle { vm_id }`: drive one registered VM through +//! start -> running -> stop -> stopped via the mutating routes. +//! - `Dynamic { config_toml }`: create a VM from a host-side TOML config, poll +//! it `Ready`, verify a duplicate create conflicts (409), then delete it and +//! poll until it is gone. +//! +//! All authenticated requests carry the `token` from the case config, which must +//! match the guest build's `[env] AXVM_HTTP_TOKEN`. use std::{ + fs, io::{Read, Write}, net::TcpStream, + path::{Path, PathBuf}, sync::{ - Arc, + Arc, Mutex, atomic::{AtomicBool, Ordering}, mpsc, }, @@ -31,45 +42,94 @@ use std::{ time::{Duration, Instant}, }; -use anyhow::bail; +use anyhow::{Context, bail, ensure}; +use serde_json::Value; -use crate::test::case::HostHttpProbeConfig; +use crate::test::case::{HostHttpProbeConfig, HostHttpProbeScenario}; -/// Per-attempt IO timeout for a single HTTP request/response exchange. -const IO_TIMEOUT: Duration = Duration::from_secs(5); +/// Per-attempt IO timeout for a single HTTP request/response exchange. The +/// dynamic `create` handler reads the guest kernel from the rootfs inside the +/// request (fs loading), which is the slowest single exchange; 30s covers it +/// without making a stuck server hold the probe for too long. +const IO_TIMEOUT: Duration = Duration::from_secs(30); /// Sleep between readiness retries. const CONNECT_RETRY_INTERVAL: Duration = Duration::from_millis(100); +/// How long to keep retrying the QMP connect before giving up on quitting QEMU. +const QMP_CONNECT_RETRY_INTERVAL: Duration = Duration::from_millis(100); +const QMP_CONNECT_RETRIES: usize = 10; +/// How long to wait after QMP `quit` for QEMU to exit on its own before +/// force-killing it. QEMU can ignore `quit` when its main loop is stuck in a +/// busy poll (observed under the axbuild runner), so the probe must not wait +/// forever for a clean exit. +#[cfg(target_os = "linux")] +const QMP_QUIT_GRACE: Duration = Duration::from_secs(3); +/// Interval for polling whether QEMU is still alive after `quit`. +#[cfg(target_os = "linux")] +const QMP_ALIVE_POLL_INTERVAL: Duration = Duration::from_millis(100); pub(crate) struct HostHttpProbeGuard { stop: Arc, + result: Arc>>>, + /// Set when the probe had to SIGKILL QEMU because it ignored QMP `quit`. + /// The runner uses this to prefer the stored probe verdict over QEMU's + /// non-zero exit status. + killed_by_probe: Arc, thread: Option>, } impl HostHttpProbeGuard { + /// Spawn the probe thread and return a guard that owns its lifecycle. + /// + /// `qmp_socket` is the path QEMU binds from its `-qmp unix:...` argument; + /// the probe connects to it after its assertions finish to quit QEMU. When + /// `None`, the probe only stores its verdict and relies on the case timeout + /// to end the run. pub(crate) fn start( config: &HostHttpProbeConfig, host_port: u16, case_name: &str, + qmp_socket: Option, ) -> anyhow::Result { let addr = format!("127.0.0.1:{host_port}"); let connect_timeout = Duration::from_secs(config.connect_timeout_secs); let stop = Arc::new(AtomicBool::new(false)); let thread_stop = stop.clone(); + let result = Arc::new(Mutex::new(None)); + let thread_result = result.clone(); + let killed_by_probe = Arc::new(AtomicBool::new(false)); + let thread_killed = killed_by_probe.clone(); let case_name = case_name.to_string(); let (ready_tx, ready_rx) = mpsc::channel(); let thread_addr = addr.clone(); let thread_case_name = case_name.clone(); let token = config.token.clone(); + let scenario = config.scenario.clone(); let thread = thread::spawn(move || { let _ = ready_tx.send(()); - run_probe( + let verdict = run_probe( &thread_addr, &thread_case_name, connect_timeout, token.as_deref(), + &scenario, &thread_stop, ); + *thread_result.lock().unwrap() = Some(verdict); + // Quit QEMU so the run ends on the probe verdict instead of the + // serial-timeout path. `request_qmp_quit` force-kills QEMU when it + // ignores `quit` (a hang observed under the runner); record that so + // the runner trusts the stored probe verdict over QEMU's non-zero + // exit status. + if let Some(socket) = qmp_socket { + match request_qmp_quit(&socket) { + Ok(true) => thread_killed.store(true, Ordering::SeqCst), + Ok(false) => {} + Err(err) => eprintln!( + " host http probe: {thread_case_name}: failed to quit QEMU via QMP: {err:#}" + ), + } + } }); if ready_rx.recv_timeout(Duration::from_secs(1)).is_err() { @@ -80,9 +140,25 @@ impl HostHttpProbeGuard { println!(" host http probe: {addr} -> guest:{}", config.guest_port); Ok(Self { stop, + result, + killed_by_probe, thread: Some(thread), }) } + + /// Take the probe's stored verdict, if the thread produced one. + /// + /// Called once, after QEMU has exited. The probe always stores a verdict + /// *before* it quits QEMU, so a clean QEMU exit implies a verdict exists. + pub(crate) fn take_result(&self) -> Option> { + self.result.lock().unwrap().take() + } + + /// Whether the probe had to SIGKILL QEMU (it ignored QMP `quit`). When + /// true, the runner prefers the stored probe verdict over QEMU's exit code. + pub(crate) fn killed_by_probe(&self) -> bool { + self.killed_by_probe.load(Ordering::SeqCst) + } } impl Drop for HostHttpProbeGuard { @@ -94,108 +170,48 @@ impl Drop for HostHttpProbeGuard { } } +/// Dispatch the probe to the scenario-specific assertion flow and report the +/// verdict. The result is also stored in the guard and drives the test result. fn run_probe( addr: &str, case_name: &str, connect_timeout: Duration, token: Option<&str>, + scenario: &HostHttpProbeScenario, stop: &AtomicBool, -) { - let started = Instant::now(); - - // Wait for the guest HTTP server to accept connections. `GET /api/vms` is a - // read-only route, so this readiness probe needs no token; it is retried - // until it yields a parsed status, the connect timeout elapses, or a stop - // is requested. A parsed but wrong status is still "ready"; the assertion - // below records it as a failure. - let mut passed = true; - let list = poll_status(addr, "/api/vms", started, connect_timeout, stop); - match list { - Some(status) => { - println!(" host http probe: {case_name}: GET /api/vms -> {status} (expect 200)"); - passed &= status == 200; +) -> anyhow::Result<()> { + let result = match scenario { + HostHttpProbeScenario::ReadOnly => { + run_readonly_probe(addr, case_name, connect_timeout, token, stop) } - None => { - eprintln!( - " host http probe: {case_name}: guest HTTP server never became reachable within \ - {connect_timeout:?}" - ); - passed = false; + HostHttpProbeScenario::Lifecycle { vm_id } => { + run_lifecycle_probe(addr, case_name, connect_timeout, token, *vm_id, stop) } - } - - // Access-denied regression (security review): an unauthenticated write to a - // mutating route must be rejected with 401. The auth gate runs before any - // VM lookup, so this holds regardless of whether VM 999 exists. - if passed { - match request_status(addr, "POST", "/api/vms/999/start", None, None) { - Some(status) => { - println!( - " host http probe: {case_name}: POST /api/vms/999/start (no token) -> \ - {status} (expect 401)" - ); - passed &= status == 401; - } - None => { - eprintln!(" host http probe: {case_name}: unauthenticated write request failed"); - passed = false; - } - } - } - - // The server is up; single attempt for the authenticated 404 path. - if passed { - match request_status(addr, "GET", "/api/vms/999", None, token) { - Some(status) => { - println!( - " host http probe: {case_name}: GET /api/vms/999 -> {status} (expect 404)" - ); - passed &= status == 404; - } - None => { - eprintln!(" host http probe: {case_name}: GET /api/vms/999 failed"); - passed = false; - } - } - } - - // Authenticated write: with the token the gate admits the request, and an - // unknown VM still yields the contract's 404. Proves writes are reachable - // with valid credentials rather than silently open or always denied. - if passed { - match request_status(addr, "POST", "/api/vms/999/start", None, token) { - Some(status) => { - println!( - " host http probe: {case_name}: POST /api/vms/999/start (with token) -> \ - {status} (expect 404)" - ); - passed &= status == 404; - } - None => { - eprintln!(" host http probe: {case_name}: authenticated write request failed"); - passed = false; - } + HostHttpProbeScenario::Dynamic { config_toml } => { + run_dynamic_probe(addr, case_name, connect_timeout, token, config_toml, stop) } + }; + match &result { + Ok(()) => println!(" host http probe: {case_name}: probe passed"), + Err(err) => eprintln!(" host http probe: {case_name}: probe failed: {err:#}"), } + result +} - // Relay the verdict (authenticated: `/__probe_result` is a protected route). - // The hypervisor mirrors it into the serial log, where the QEMU runner's - // stream matcher sees the sentinel and ends the run. If the relay itself - // fails (e.g. the server disappeared), no sentinel ever appears in serial - // and the run ends on the QEMU timeout — still a failure. - let verdict = if passed { "PASSED" } else { "FAILED" }; - match request_status(addr, "POST", "/__probe_result", Some(verdict), token) { - Some(status) => { - println!(" host http probe: {case_name}: verdict {verdict} relayed (status {status})") - } - None => eprintln!(" host http probe: {case_name}: failed to relay verdict {verdict}"), - } +/// Assert a single request's status against the contract, with a visible log +/// line for the runner's transcript. +fn check_status(case_name: &str, label: &str, actual: u16, expected: u16) -> anyhow::Result<()> { + println!(" host http probe: {case_name}: {label} -> {actual} (expect {expected})"); + ensure!( + actual == expected, + "{label} -> {actual}, expected {expected}" + ); + Ok(()) } -/// Retry a request until it yields a parsed status, the deadline elapses, or a -/// stop is requested. Used for the first request, which doubles as the -/// readiness probe. The readiness route (`GET /api/vms`) is open, so no token -/// is sent. +/// Poll `GET /api/vms` until it yields a parsed status, the deadline elapses, +/// or a stop is requested. This doubles as the readiness probe: the route is +/// open, so no token is needed. fn poll_status( addr: &str, path: &str, @@ -217,6 +233,235 @@ fn poll_status( } } +/// Poll `GET /api/vms/{id}` until its reported status equals `expected`, the +/// deadline elapses, or a stop is requested. +fn poll_vm_status( + addr: &str, + vm_id: u64, + expected: &str, + started: Instant, + connect_timeout: Duration, + stop: &AtomicBool, +) -> Option<()> { + loop { + if stop.load(Ordering::Acquire) { + return None; + } + if started.elapsed() >= connect_timeout { + return None; + } + if let Some(status) = vm_status(addr, vm_id, stop) + && status == expected + { + return Some(()); + } + thread::sleep(CONNECT_RETRY_INTERVAL); + } +} + +/// Poll `GET /api/vms/{id}` until it 404s (the VM is gone), the deadline +/// elapses, or a stop is requested. +fn poll_vm_gone( + addr: &str, + vm_id: u64, + started: Instant, + connect_timeout: Duration, + stop: &AtomicBool, +) -> Option<()> { + loop { + if stop.load(Ordering::Acquire) { + return None; + } + if started.elapsed() >= connect_timeout { + return None; + } + match request_status(addr, "GET", &format!("/api/vms/{vm_id}"), None, None) { + Some(404) => return Some(()), + _ => thread::sleep(CONNECT_RETRY_INTERVAL), + } + } +} + +/// Fetch the current status string of one VM, or `None` on a transport failure +/// or a non-200 response. The detail route is open (read-only), so no token is +/// needed. +fn vm_status(addr: &str, vm_id: u64, stop: &AtomicBool) -> Option { + if stop.load(Ordering::Acquire) { + return None; + } + let (status, json) = request_json(addr, "GET", &format!("/api/vms/{vm_id}"), None, None)?; + if status != 200 { + return None; + } + json.get("status")?.as_str().map(String::from) +} + +/// Read-only scenario: security boundary + read contract. +fn run_readonly_probe( + addr: &str, + case_name: &str, + connect_timeout: Duration, + token: Option<&str>, + stop: &AtomicBool, +) -> anyhow::Result<()> { + let started = Instant::now(); + + // Readiness: `GET /api/vms` is retried until it yields a parsed status. + let list = + poll_status(addr, "/api/vms", started, connect_timeout, stop).with_context(|| { + format!("guest HTTP server never became reachable within {connect_timeout:?}") + })?; + check_status(case_name, "GET /api/vms", list, 200)?; + + // Access-denied regression (security review): an unauthenticated write to a + // mutating route must be rejected with 401. The auth gate runs before any VM + // lookup, so this holds regardless of whether VM 999 exists. + let denied = request_status(addr, "POST", "/api/vms/999/start", None, None) + .with_context(|| "unauthenticated write request failed")?; + check_status(case_name, "POST /api/vms/999/start (no token)", denied, 401)?; + + // Unknown VM -> 404 on the read path. + let missing = request_status(addr, "GET", "/api/vms/999", None, token) + .with_context(|| "GET /api/vms/999 failed")?; + check_status(case_name, "GET /api/vms/999", missing, 404)?; + + // Authenticated write to an unknown VM -> 404: writes are reachable with + // valid credentials rather than silently open or always denied. + let authed_write = request_status(addr, "POST", "/api/vms/999/start", None, token) + .with_context(|| "authenticated write request failed")?; + check_status( + case_name, + "POST /api/vms/999/start (with token)", + authed_write, + 404, + )?; + + Ok(()) +} + +/// Lifecycle scenario: drive one registered VM through the start/stop contract. +fn run_lifecycle_probe( + addr: &str, + case_name: &str, + connect_timeout: Duration, + token: Option<&str>, + vm_id: u64, + stop: &AtomicBool, +) -> anyhow::Result<()> { + let started = Instant::now(); + + // Readiness, and confirm the target VM exists in `Ready` (a `no-auto-start` + // build keeps default VMs un-started). + let list = + poll_status(addr, "/api/vms", started, connect_timeout, stop).with_context(|| { + format!("guest HTTP server never became reachable within {connect_timeout:?}") + })?; + check_status(case_name, "GET /api/vms", list, 200)?; + poll_vm_status(addr, vm_id, "ready", started, connect_timeout, stop) + .with_context(|| format!("VM[{vm_id}] never became ready"))?; + + // Start, then poll until the vCPU task reports `running`. + let action = Instant::now(); + let start = request_status( + addr, + "POST", + &format!("/api/vms/{vm_id}/start"), + None, + token, + ) + .with_context(|| format!("POST /api/vms/{vm_id}/start failed"))?; + check_status( + case_name, + &format!("POST /api/vms/{vm_id}/start"), + start, + 200, + )?; + poll_vm_status(addr, vm_id, "running", action, connect_timeout, stop) + .with_context(|| format!("VM[{vm_id}] never became running after start"))?; + + // Stop is a request: the `Stopped` state arrives asynchronously once the + // vCPU observes the request and exits. + let action = Instant::now(); + let stop_status = request_status(addr, "POST", &format!("/api/vms/{vm_id}/stop"), None, token) + .with_context(|| format!("POST /api/vms/{vm_id}/stop failed"))?; + check_status( + case_name, + &format!("POST /api/vms/{vm_id}/stop"), + stop_status, + 200, + )?; + poll_vm_status(addr, vm_id, "stopped", action, connect_timeout, stop) + .with_context(|| format!("VM[{vm_id}] never became stopped after stop"))?; + + Ok(()) +} + +/// Dynamic scenario: create a VM from a host-side TOML config, verify the +/// duplicate-create conflict, then delete it and poll it gone. +fn run_dynamic_probe( + addr: &str, + case_name: &str, + connect_timeout: Duration, + token: Option<&str>, + config_toml: &str, + stop: &AtomicBool, +) -> anyhow::Result<()> { + let started = Instant::now(); + + // Readiness. + let list = + poll_status(addr, "/api/vms", started, connect_timeout, stop).with_context(|| { + format!("guest HTTP server never became reachable within {connect_timeout:?}") + })?; + check_status(case_name, "GET /api/vms", list, 200)?; + + // The create body is the host-side VM config TOML, sent verbatim. The + // config must reference a guest image the runtime can load (an fs-backed + // kernel on the rootfs, or an embedded image). + let toml_text = fs::read_to_string(config_toml) + .with_context(|| format!("failed to read VM config TOML `{config_toml}`"))?; + let create_body = serde_json::json!({ "toml": toml_text }).to_string(); + let (created, created_json) = + request_json(addr, "POST", "/api/vms/create", Some(&create_body), token) + .with_context(|| "POST /api/vms/create failed")?; + check_status(case_name, "POST /api/vms/create", created, 200)?; + let created_id = created_json + .get("id") + .and_then(Value::as_u64) + .with_context(|| "POST /api/vms/create response missing `id`")?; + + // Poll the new VM into `Ready`. + let created_at = Instant::now(); + poll_vm_status(addr, created_id, "ready", created_at, connect_timeout, stop) + .with_context(|| format!("VM[{created_id}] never became ready after create"))?; + + // Re-creating the same config must conflict (409): the id is registered. + let dup = request_status(addr, "POST", "/api/vms/create", Some(&create_body), token) + .with_context(|| "duplicate POST /api/vms/create failed")?; + check_status(case_name, "POST /api/vms/create (duplicate)", dup, 409)?; + + // Delete, then poll until the VM is gone (404). + let deleted = request_status( + addr, + "DELETE", + &format!("/api/vms/{created_id}"), + None, + token, + ) + .with_context(|| format!("DELETE /api/vms/{created_id} failed"))?; + check_status( + case_name, + &format!("DELETE /api/vms/{created_id}"), + deleted, + 204, + )?; + let gone_at = Instant::now(); + poll_vm_gone(addr, created_id, gone_at, connect_timeout, stop) + .with_context(|| format!("VM[{created_id}] never disappeared after delete"))?; + + Ok(()) +} + /// Send one HTTP/1.1 request over a fresh connection and parse the status code. /// `body` (when present) is sent as the request body with a JSON content type. /// `token` (when present) adds an `Authorization: Bearer ` header for @@ -228,6 +473,44 @@ fn request_status( body: Option<&str>, token: Option<&str>, ) -> Option { + request_response(addr, method, path, body, token).map(|(status, _)| status) +} + +/// Send one HTTP/1.1 request and parse the status code plus the JSON body. +/// Returns `None` on a transport failure or a non-JSON response. +fn request_json( + addr: &str, + method: &str, + path: &str, + body: Option<&str>, + token: Option<&str>, +) -> Option<(u16, Value)> { + let (status, response) = request_response(addr, method, path, body, token)?; + let json = serde_json::from_slice(response_body(&response)?).ok()?; + Some((status, json)) +} + +/// Extract the HTTP response body (everything after the header/body separator). +/// +/// The response is read whole (`Connection: close`, read-to-EOF), so the headers +/// prefix must be stripped before the body can be parsed as JSON. +fn response_body(response: &[u8]) -> Option<&[u8]> { + const SEPARATOR: &[u8] = b"\r\n\r\n"; + let pos = response + .windows(SEPARATOR.len()) + .position(|window| window == SEPARATOR)?; + Some(&response[pos + SEPARATOR.len()..]) +} + +/// Send one HTTP/1.1 request and return the status code plus the raw response +/// body. +fn request_response( + addr: &str, + method: &str, + path: &str, + body: Option<&str>, + token: Option<&str>, +) -> Option<(u16, Vec)> { let Ok(mut stream) = TcpStream::connect(addr) else { return None; }; @@ -256,7 +539,7 @@ fn request_status( if stream.read_to_end(&mut response).is_err() { return None; } - parse_status(&response) + Some((parse_status(&response)?, response)) } /// Extract the numeric HTTP status code from a response. @@ -269,74 +552,281 @@ fn parse_status(response: &[u8]) -> Option { status.parse().ok() } +/// Quit QEMU by connecting to its QMP monitor socket and issuing `quit`, then +/// wait for it to exit. The socket path comes from the `-qmp +/// unix:...,server=on,wait=off` argument the runner added. +/// +/// Returns `true` when QEMU had to be SIGKILL'd because it did not exit after +/// `quit` (a main-loop hang observed under the axbuild runner). In that case +/// the caller records the kill so the runner trusts the stored probe verdict +/// over QEMU's non-zero exit status. +#[cfg(unix)] +fn request_qmp_quit(socket: &Path) -> anyhow::Result { + use std::os::unix::net::UnixStream; + + let mut stream = None; + for _ in 0..QMP_CONNECT_RETRIES { + match UnixStream::connect(socket) { + Ok(stream_ok) => { + stream = Some(stream_ok); + break; + } + Err(_) => thread::sleep(QMP_CONNECT_RETRY_INTERVAL), + } + } + let mut stream = + stream.with_context(|| format!("failed to connect QMP socket {}", socket.display()))?; + stream + .set_read_timeout(Some(Duration::from_millis(200))) + .ok(); + stream + .set_write_timeout(Some(Duration::from_millis(200))) + .ok(); + #[cfg(target_os = "linux")] + let peer_pid = peer_pid(&stream); + let mut buf = [0_u8; 512]; + let _ = stream.read(&mut buf); // QMP greeting + stream.write_all(b"{\"execute\":\"qmp_capabilities\"}\r\n")?; + buf.fill(0); + let _ = stream.read(&mut buf); // capabilities response + stream.write_all(b"{\"execute\":\"quit\"}\r\n")?; + stream.flush()?; + + // Give QEMU a short window to honor `quit` and exit on its own. When it is + // still alive afterwards (the hang seen under the runner), SIGKILL it so + // the run ends promptly on the probe verdict instead of the serial + // timeout. + #[cfg(target_os = "linux")] + { + let Some(pid) = peer_pid else { + // Could not learn QEMU's PID; fall back to the case timeout. + return Ok(false); + }; + let deadline = Instant::now() + QMP_QUIT_GRACE; + while Instant::now() < deadline { + if !process_alive(pid) { + return Ok(false); // exited cleanly + } + thread::sleep(QMP_ALIVE_POLL_INTERVAL); + } + if process_alive(pid) { + kill_process(pid); + return Ok(true); // force-killed: probe verdict is authoritative + } + } + Ok(false) +} + +#[cfg(not(unix))] +fn request_qmp_quit(_socket: &Path) -> anyhow::Result { + bail!("QMP unix sockets are not supported on this host") +} + +/// Peer PID of a connected unix socket via `SO_PEERCRED` (Linux). Returns +/// `None` when the credential lookup fails. +#[cfg(target_os = "linux")] +fn peer_pid(stream: &std::os::unix::net::UnixStream) -> Option { + use std::os::fd::AsRawFd; + + let mut creds: libc::ucred = unsafe { std::mem::zeroed() }; + let mut len = std::mem::size_of::() as libc::socklen_t; + // SAFETY: `stream` is an open socket fd owned by the caller, and `creds` + // is a valid `ucred` buffer of the correct size. + let rc = unsafe { + libc::getsockopt( + stream.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_PEERCRED, + &mut creds as *mut libc::ucred as *mut libc::c_void, + &mut len, + ) + }; + if rc == 0 && creds.pid > 0 { + Some(creds.pid) + } else { + None + } +} + +/// Whether the process identified by `pid` is still alive. +#[cfg(target_os = "linux")] +fn process_alive(pid: i32) -> bool { + // SAFETY: `kill` with signal 0 only performs an existence check and never + // delivers a signal. + unsafe { libc::kill(pid, 0) == 0 } +} + +/// Force-kill the process identified by `pid`. +#[cfg(target_os = "linux")] +fn kill_process(pid: i32) { + // SAFETY: `pid` came from SO_PEERCRED, i.e. it is the QEMU we connected to. + unsafe { + libc::kill(pid, libc::SIGKILL); + } +} + #[cfg(test)] mod tests { use std::{ + collections::HashMap, io::{Read, Write}, - net::TcpListener, + net::{TcpListener, TcpStream}, sync::atomic::AtomicBool, thread, time::Duration, }; - use super::{parse_status, poll_status, request_status}; + use super::{ + parse_status, poll_status, poll_vm_gone, poll_vm_status, request_json, request_status, + run_dynamic_probe, run_lifecycle_probe, run_readonly_probe, vm_status, + }; + use crate::test::case::HostHttpProbeConfig; /// Bearer token the fake server accepts, mirroring the guest build's /// `[env] AXVM_HTTP_TOKEN` for the control-plane auth gate. const TEST_TOKEN: &str = "test-token"; + /// Fixed VM id the fake server registers (mirrors `linux-smp1.toml`). + const TEST_VM_ID: u64 = 1; + + /// Stateful fake of the in-guest control plane. Emulates the auth boundary + /// (mutating routes need the bearer token) and the VM registry lifecycle + /// (start -> running, stop -> stopped, create -> ready, delete -> gone). + #[derive(Default)] + struct FakeVmState { + vms: HashMap, + } + + impl FakeVmState { + fn serve(&mut self, stream: &mut TcpStream) { + let mut request = Vec::new(); + let mut buf = [0u8; 512]; + let headers_end = loop { + match stream.read(&mut buf) { + Ok(0) => break None, + Ok(n) => { + request.extend_from_slice(&buf[..n]); + if request.windows(4).any(|w| w == b"\r\n\r\n") { + break Some(request.len()); + } + } + Err(_) => break None, + } + }; + let Some(_headers_end) = headers_end else { + return; + }; + let head = String::from_utf8_lossy(&request); + let first_line = head.lines().next().unwrap_or("").to_string(); + let mut parts = first_line.split_whitespace(); + let method = parts.next().unwrap_or("").to_string(); + let path = parts.next().unwrap_or("/").to_string(); + let authorized = head + .lines() + .any(|line| line == format!("Authorization: Bearer {TEST_TOKEN}")); + let response = self.route(&method, &path, authorized); + let _ = stream.write_all(response.as_bytes()); + } + + fn route(&mut self, method: &str, path: &str, authorized: bool) -> String { + let is_write = method == "POST" || method == "DELETE"; + if is_write && !authorized { + return status_body("401 Unauthorized", ""); + } + match (method, path) { + ("GET", "/api/vms") => status_body("200 OK", "[]"), + ("GET", path) if path.starts_with(&format!("/api/vms/{TEST_VM_ID}")) => { + match self.vms.get(&TEST_VM_ID) { + Some(status) => { + status_body("200 OK", &format!(r#"{{"status":"{status}"}}"#)) + } + None => status_body("404 Not Found", ""), + } + } + ("GET", _) => status_body("404 Not Found", ""), + ("POST", path) if path == format!("/api/vms/{TEST_VM_ID}/start") => { + if self.vms.contains_key(&TEST_VM_ID) { + self.vms.insert(TEST_VM_ID, "running".to_string()); + status_body("200 OK", r#"{"ok":true,"status":"running"}"#) + } else { + status_body("404 Not Found", "") + } + } + ("POST", path) if path == format!("/api/vms/{TEST_VM_ID}/stop") => { + if self.vms.contains_key(&TEST_VM_ID) { + self.vms.insert(TEST_VM_ID, "stopped".to_string()); + status_body("200 OK", r#"{"ok":true,"status":"stopped"}"#) + } else { + status_body("404 Not Found", "") + } + } + ("POST", "/api/vms/create") => { + if self.vms.contains_key(&TEST_VM_ID) { + status_body("409 Conflict", "") + } else { + self.vms.insert(TEST_VM_ID, "ready".to_string()); + status_body("200 OK", &format!(r#"{{"id":{TEST_VM_ID}}}"#)) + } + } + ("DELETE", path) if path == format!("/api/vms/{TEST_VM_ID}") => { + self.vms.remove(&TEST_VM_ID); + status_body("204 No Content", "") + } + ("POST", _) => status_body("404 Not Found", ""), + _ => status_body("404 Not Found", ""), + } + } + } + + fn status_body(status: &str, body: &str) -> String { + format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + } - /// Serve canned responses on a background thread, emulating the guest's - /// control-plane auth boundary: - /// - `GET` routes are open: `/api/vms` -> 200, `/api/vms/999` -> 404. - /// - Any unauthenticated write (`POST` with no matching - /// `Authorization: Bearer ` header, including the `/__probe_result` - /// relay) -> 401. - /// - An authenticated write -> routed normally (404 for the unknown VM, - /// 200 for the `/__probe_result` relay). fn start_fake_server() -> u16 { + start_fake_server_seeded(true) + } + + /// Start a fake server whose VM registry is empty, so the create route + /// succeeds instead of hitting the seeded-id conflict. Mirrors the real + /// dynamic test build, whose `vm_configs = []` leaves id 1 free at boot. + fn start_fake_server_empty() -> u16 { + start_fake_server_seeded(false) + } + + fn start_fake_server_seeded(seed: bool) -> u16 { let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake server"); let port = listener.local_addr().unwrap().port(); thread::spawn(move || { + let mut state = FakeVmState::default(); + if seed { + state.vms.insert(TEST_VM_ID, "ready".to_string()); + } for stream in listener.incoming() { let mut stream = match stream { Ok(stream) => stream, Err(_) => break, }; - let mut request = Vec::new(); - let mut buf = [0u8; 512]; - loop { - match stream.read(&mut buf) { - Ok(0) => break, - Ok(n) => { - request.extend_from_slice(&buf[..n]); - if request.windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - } - Err(_) => break, - } - } - let head = String::from_utf8_lossy(&request); - let first_line = head.lines().next().unwrap_or(""); - let authorized = head - .lines() - .any(|line| line == format!("Authorization: Bearer {TEST_TOKEN}")); - let is_write = first_line.starts_with("POST "); - let status = if is_write && !authorized { - "401 Unauthorized" - } else if first_line.contains("/api/vms/999") { - "404 Not Found" - } else { - "200 OK" - }; - let body = - format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); - let _ = stream.write_all(body.as_bytes()); + state.serve(&mut stream); } }); port } + fn addr_for(port: u16) -> String { + format!("127.0.0.1:{port}") + } + + fn scenario_config(scenario: crate::test::case::HostHttpProbeScenario) -> HostHttpProbeConfig { + HostHttpProbeConfig { + guest_port: 8080, + connect_timeout_secs: 5, + token: Some(TEST_TOKEN.to_string()), + scenario, + } + } + #[test] fn parse_status_extracts_numeric_code() { assert_eq!( @@ -353,8 +843,7 @@ mod tests { #[test] fn request_status_returns_expected_codes_from_fake_server() { - let port = start_fake_server(); - let addr = format!("127.0.0.1:{port}"); + let addr = addr_for(start_fake_server()); assert_eq!( request_status(&addr, "GET", "/api/vms", None, None), Some(200) @@ -367,8 +856,7 @@ mod tests { #[test] fn unauthenticated_write_is_denied_with_401() { - let port = start_fake_server(); - let addr = format!("127.0.0.1:{port}"); + let addr = addr_for(start_fake_server()); assert_eq!( request_status(&addr, "POST", "/api/vms/999/start", None, None), Some(401) @@ -377,8 +865,7 @@ mod tests { #[test] fn authenticated_write_reaches_the_route() { - let port = start_fake_server(); - let addr = format!("127.0.0.1:{port}"); + let addr = addr_for(start_fake_server()); // With the token the gate admits the write; the unknown VM still yields // the contract's 404. assert_eq!( @@ -389,8 +876,7 @@ mod tests { #[test] fn poll_status_returns_once_server_is_up() { - let port = start_fake_server(); - let addr = format!("127.0.0.1:{port}"); + let addr = addr_for(start_fake_server()); let stop = AtomicBool::new(false); let status = poll_status( &addr, @@ -404,8 +890,7 @@ mod tests { #[test] fn poll_status_gives_up_on_stop() { - let port = start_fake_server(); - let addr = format!("127.0.0.1:{port}"); + let addr = addr_for(start_fake_server()); let stop = AtomicBool::new(true); let status = poll_status( &addr, @@ -418,25 +903,143 @@ mod tests { } #[test] - fn request_status_handles_body_relay() { - let port = start_fake_server(); - let addr = format!("127.0.0.1:{port}"); - // The relay endpoint is a protected route: authenticated -> 200, - // unauthenticated -> 401. + fn vm_status_parses_the_status_field() { + let addr = addr_for(start_fake_server()); + let stop = AtomicBool::new(false); + assert_eq!( + vm_status(&addr, TEST_VM_ID, &stop).as_deref(), + Some("ready") + ); + } + + #[test] + fn poll_vm_status_observes_lifecycle_transitions() { + let addr = addr_for(start_fake_server()); + let stop = AtomicBool::new(false); + let started = std::time::Instant::now(); + assert_eq!( + poll_vm_status( + &addr, + TEST_VM_ID, + "ready", + started, + Duration::from_secs(5), + &stop + ), + Some(()) + ); assert_eq!( request_status( &addr, "POST", - "/__probe_result", - Some("PASSED"), + &format!("/api/vms/{TEST_VM_ID}/start"), + None, Some(TEST_TOKEN) ), Some(200) ); assert_eq!( - request_status(&addr, "POST", "/__probe_result", Some("FAILED"), None), - Some(401) + poll_vm_status( + &addr, + TEST_VM_ID, + "running", + started, + Duration::from_secs(5), + &stop + ), + Some(()) + ); + } + + #[test] + fn request_json_parses_the_create_response() { + let addr = addr_for(start_fake_server_empty()); + let (status, json) = request_json( + &addr, + "POST", + "/api/vms/create", + Some(r#"{"toml":"[base]\nid = 1"}"#), + Some(TEST_TOKEN), + ) + .expect("create request failed"); + assert_eq!(status, 200); + assert_eq!( + json.get("id").and_then(serde_json::Value::as_u64), + Some(TEST_VM_ID) + ); + } + + #[test] + fn poll_vm_gone_observes_delete() { + let addr = addr_for(start_fake_server()); + assert_eq!( + request_status( + &addr, + "DELETE", + &format!("/api/vms/{TEST_VM_ID}"), + None, + Some(TEST_TOKEN) + ), + Some(204) ); + let stop = AtomicBool::new(false); + assert_eq!( + poll_vm_gone( + &addr, + TEST_VM_ID, + std::time::Instant::now(), + Duration::from_secs(5), + &stop + ), + Some(()) + ); + } + + #[test] + fn run_readonly_probe_passes() { + let addr = addr_for(start_fake_server()); + let stop = AtomicBool::new(false); + run_readonly_probe( + &addr, + "readonly", + Duration::from_secs(5), + Some(TEST_TOKEN), + &stop, + ) + .expect("readonly probe should pass"); + } + + #[test] + fn run_lifecycle_probe_passes() { + let addr = addr_for(start_fake_server()); + let stop = AtomicBool::new(false); + run_lifecycle_probe( + &addr, + "lifecycle", + Duration::from_secs(5), + Some(TEST_TOKEN), + TEST_VM_ID, + &stop, + ) + .expect("lifecycle probe should pass"); + } + + #[test] + fn run_dynamic_probe_passes() { + let dir = tempfile::tempdir().expect("tempdir"); + let config = dir.path().join("vm.toml"); + std::fs::write(&config, "[base]\nid = 1\n").expect("write config"); + let addr = addr_for(start_fake_server_empty()); + let stop = AtomicBool::new(false); + run_dynamic_probe( + &addr, + "dynamic", + Duration::from_secs(5), + Some(TEST_TOKEN), + config.to_str().expect("utf8 path"), + &stop, + ) + .expect("dynamic probe should pass"); } #[test] @@ -447,7 +1050,7 @@ mod tests { .local_addr() .unwrap() .port(); - let addr = format!("127.0.0.1:{port}"); + let addr = addr_for(port); assert_eq!(request_status(&addr, "GET", "/api/vms", None, None), None); } } diff --git a/test-suit/axvisor/normal/qemu-http-axum-control/aarch64-arceos-http-control.toml b/test-suit/axvisor/normal/qemu-http-axum-control/aarch64-arceos-http-control.toml deleted file mode 100644 index 1f1cdad846..0000000000 --- a/test-suit/axvisor/normal/qemu-http-axum-control/aarch64-arceos-http-control.toml +++ /dev/null @@ -1,44 +0,0 @@ -# AxVisor control-plane test guest (aarch64). -# -# Booted by the qemu-http-axum-control test via the HTTP start/stop API. The -# kernel is baked into the hypervisor image at build time (`image_location = -# "memory"` -> `build.rs` include_bytes!), so no `fs` feature is required. -# -# The guest kernel comes from the managed `qemu-aarch64` registry image, pulled -# by `cargo xtask image pull qemu-aarch64 --output-dir tmp/axbuild/images` -# (same provisioning step the gicv2/gicv3-timer-stress tests use; CI runs the -# pull before the test). The path is relative to this file: four levels up -# reaches the workspace root, then `tmp/axbuild/images/...`. -# -# `phys_cpu_ids = [1]` pins the vCPU to physical CPU 1, keeping the management -# plane (HTTP server) on CPU 0 as PR1's core isolation requires. -[base] -id = 1 -name = "arceos-qemu" -guest_type = "passthrough" -cpu_num = 1 -phys_cpu_ids = [1] - -[kernel] -entry_point = 0x8020_0000 -image_location = "memory" -kernel_path = "../../../../tmp/axbuild/images/qemu-aarch64/arceos/arceos-qemu" -kernel_load_addr = 0x8020_0000 -dtb_load_addr = 0x8000_0000 - -# Memory regions with format (`base_paddr`, `size`, `flags`, `map_type`). -# map_type: 0 = MAP_ALLOC, 1 = MAP_IDENTICAL, 2 = MAP_RESERVED. -# -# 256M MAP_IDENTICAL: enough for the 442KB ArceOS guest kernel and fits in a -# `-m 1g` QEMU alongside the hypervisor (a 1G region overruns `-m 1g`). For -# identical memory the hypervisor re-plans the kernel load address to wherever -# the region lands (`vm::boot::BootImagePlan`), so the `0x8020_0000` entry/load -# addresses in `[kernel]` are relative guidance only. -memory_regions = [ - [0x8000_0000, 0x1000_0000, 0x7, 1], # System RAM 256M MAP_IDENTICAL -] - -# Physical-device selection. Virtual platform devices are machine-owned. -[devices] -passthrough = [] -disabled = [{ path = "/pcie@10000000" }] diff --git a/test-suit/axvisor/normal/qemu-http-axum-control/build-aarch64-unknown-none-softfloat.toml b/test-suit/axvisor/normal/qemu-http-axum-control/build-aarch64-unknown-none-softfloat.toml index 9819a1cb88..a8149136bd 100644 --- a/test-suit/axvisor/normal/qemu-http-axum-control/build-aarch64-unknown-none-softfloat.toml +++ b/test-suit/axvisor/normal/qemu-http-axum-control/build-aarch64-unknown-none-softfloat.toml @@ -1,20 +1,24 @@ # PR C: axum start/stop control API runtime verification. -# `no-auto-start` keeps the default VMs in `Ready` so the self-test can drive -# one VM through the full lifecycle via the HTTP control API. The guest image is -# embedded at build time (`image_location = "memory"` -> build.rs -# include_bytes!), so no `fs` feature is required. The vmconfig is committed next -# to this file and references the managed `qemu-aarch64` registry image; CI runs -# `cargo xtask image pull qemu-aarch64 --output-dir tmp/axbuild/images` before -# the test to provision the guest kernel (see the timer-stress jobs). +# `no-auto-start` keeps the default VMs in `Ready` so the host probe can drive +# one through the full lifecycle via the HTTP control API. The default Linux +# guest (`linux-smp1.toml`) is loaded from the rootfs filesystem at runtime +# (`image_location = "fs"`, kernel at `/guest/linux/linux-qemu`), so the `fs` +# feature + NVMe driver are required. CI runs `cargo xtask image pull` before +# the test to provision the rootfs. features = [ - "http-test", + "http-axum", "no-auto-start", + "fs", + "ax-driver/nvme", ] log = "Info" target = "aarch64-unknown-none-softfloat" -vm_configs = ["test-suit/axvisor/normal/qemu-http-axum-control/aarch64-arceos-http-control.toml"] +vm_configs = ["os/axvisor/configs/vms/qemu/aarch64/linux-smp1.toml"] -# The lifecycle self-test drives start/stop through the router; the mutating -# routes require the build-time bearer token, so bake one here. +# Control-plane auth + bind. The mutating routes require +# `Authorization: Bearer `; the host probe sends this same token +# (`[host_http_probe] token`), and hostfwd reachability requires binding all +# interfaces. [env] AXVM_HTTP_TOKEN = "axvisor-http-test-token" +AXVM_HTTP_BIND = "0.0.0.0:8080" diff --git a/test-suit/axvisor/normal/qemu-http-axum-control/http-axum-control/qemu-aarch64.toml b/test-suit/axvisor/normal/qemu-http-axum-control/http-axum-control/qemu-aarch64.toml index a5f4b7649e..98dc209eeb 100644 --- a/test-suit/axvisor/normal/qemu-http-axum-control/http-axum-control/qemu-aarch64.toml +++ b/test-suit/axvisor/normal/qemu-http-axum-control/http-axum-control/qemu-aarch64.toml @@ -6,24 +6,36 @@ args = [ "virt,virtualization=on,gic-version=3", "-smp", "2", + # NVMe rootfs: the hypervisor's `fs` feature reads the guest kernel + # (`/guest/linux/linux-qemu`) from this disk. The driver rewrites the drive + # file to the concrete rootfs path (see `patch_qemu_rootfs_path`). + "-device", + "nvme,drive=disk0,serial=tgoskits,max_ioqpairs=64,msix_qsize=65", + "-drive", + "id=disk0,if=none,format=raw,file=${workspace}/tmp/axbuild/rootfs/rootfs-aarch64-alpine.img", # `-m 1g` (vs the readonly test's 512M): the control test boots a real guest # with a 256M MAP_IDENTICAL region, and the hypervisor plus that region must # both fit in QEMU's total RAM; 512M would be too tight. "-m", "1g", ] -timeout = 600 -# The runner's stream matcher stops at the FIRST match (fail checked before -# success), so per-step status lines cannot be asserted independently. The -# self-test verifies every step internally and prints exactly one sentinel: -# PASSED only if the full lifecycle (start -> running -> stop -> stopped -> 404) -# met every expectation. +# PR C: lifecycle verification via a host-side probe. +# `[host_http_probe]` makes the driver append hostfwd netdev + virtio-net-pci +# device and run a host probe over real TCP. Scenario `Lifecycle` drives the +# default VM (id 1, `linux-smp1.toml`, kept `Ready` by `no-auto-start`) through +# start -> running -> stop -> stopped, polling `GET /api/vms/1` for each state. +# The probe then quits QEMU over QMP, and the runner reads its stored verdict as +# the test result. `success_regex` is empty because the probe result, not serial +# output, is the verdict; a guest panic still fails via `fail_regex`. fail_regex = [ "(?i)\\bpanic(?:ked)?\\b", - "HTTP self-test: control lifecycle FAILED", -] -success_regex = [ - "HTTP self-test: control lifecycle PASSED", + "(?i)kernel panic", ] +success_regex = [] +timeout = 600 to_bin = true uefi = false + +[host_http_probe] +token = "axvisor-http-test-token" +scenario = { Lifecycle = { vm_id = 1 } } diff --git a/test-suit/axvisor/normal/qemu-http-axum-dynamic/aarch64-arceos-http-dynamic.toml b/test-suit/axvisor/normal/qemu-http-axum-dynamic/aarch64-arceos-http-dynamic.toml deleted file mode 100644 index e52392f7ef..0000000000 --- a/test-suit/axvisor/normal/qemu-http-axum-dynamic/aarch64-arceos-http-dynamic.toml +++ /dev/null @@ -1,49 +0,0 @@ -# AxVisor dynamic create/delete test guest (aarch64). -# -# Booted by the qemu-http-axum-dynamic test via the HTTP create/delete API. The -# kernel is baked into the hypervisor image at build time (`image_location = -# "memory"` -> `build.rs` include_bytes!), so no `fs` feature is required. -# -# The dynamic self-test removes this VM, then recreates it from the same TOML -# (the create body reuses the build-time config string). The runtime boot-image -# resolver matches the embedded image strictly by `base.id`, so the recreated VM -# must reuse this id and this embedded image. -# -# The guest kernel comes from the managed `qemu-aarch64` registry image, pulled -# by `cargo xtask image pull qemu-aarch64 --output-dir tmp/axbuild/images` -# (same provisioning step the control test uses; CI runs the pull before the -# test). The path is relative to this file: four levels up reaches the workspace -# root, then `tmp/axbuild/images/...`. -# -# `phys_cpu_ids = [1]` pins the vCPU to physical CPU 1, keeping the management -# plane (HTTP server) on CPU 0 as PR1's core isolation requires. -[base] -id = 1 -name = "arceos-qemu" -guest_type = "passthrough" -cpu_num = 1 -phys_cpu_ids = [1] - -[kernel] -entry_point = 0x8020_0000 -image_location = "memory" -kernel_path = "../../../../tmp/axbuild/images/qemu-aarch64/arceos/arceos-qemu" -kernel_load_addr = 0x8020_0000 -dtb_load_addr = 0x8000_0000 - -# Memory regions with format (`base_paddr`, `size`, `flags`, `map_type`). -# map_type: 0 = MAP_ALLOC, 1 = MAP_IDENTICAL, 2 = MAP_RESERVED. -# -# 256M MAP_IDENTICAL: enough for the 442KB ArceOS guest kernel and fits in a -# `-m 1g` QEMU alongside the hypervisor (a 1G region overruns `-m 1g`). For -# identical memory the hypervisor re-plans the kernel load address to wherever -# the region lands (`vm::boot::BootImagePlan`), so the `0x8020_0000` entry/load -# addresses in `[kernel]` are relative guidance only. -memory_regions = [ - [0x8000_0000, 0x1000_0000, 0x7, 1], # System RAM 256M MAP_IDENTICAL -] - -# Physical-device selection. Virtual platform devices are machine-owned. -[devices] -passthrough = [] -disabled = [{ path = "/pcie@10000000" }] diff --git a/test-suit/axvisor/normal/qemu-http-axum-dynamic/build-aarch64-unknown-none-softfloat.toml b/test-suit/axvisor/normal/qemu-http-axum-dynamic/build-aarch64-unknown-none-softfloat.toml index 9a9790061e..e7ca61dd21 100644 --- a/test-suit/axvisor/normal/qemu-http-axum-dynamic/build-aarch64-unknown-none-softfloat.toml +++ b/test-suit/axvisor/normal/qemu-http-axum-dynamic/build-aarch64-unknown-none-softfloat.toml @@ -1,22 +1,25 @@ # PR E: runtime create/delete over the axum control API. -# `http-dynamic-test` implies `http-test` + `no-auto-start`: the base control -# self-test runs first (start/stop lifecycle), then the dynamic self-test -# removes the default VM, recreates it from its own build-time config (the id -# owns an embedded guest image), and removes it again. The guest image is -# embedded at build time (`image_location = "memory"` -> build.rs -# include_bytes!), so no `fs` feature is required. The vmconfig is committed -# next to this file and references the managed `qemu-aarch64` registry image; CI -# runs `cargo xtask image pull qemu-aarch64 --output-dir tmp/axbuild/images` -# before the test to provision the guest kernel. +# No default VMs are registered (`vm_configs = []`): the host probe creates a VM +# entirely from scratch via `POST /api/vms/create`, driving the *default* Linux +# guest config (`linux-smp1.toml`) through create -> ready -> duplicate-create +# 409 -> delete -> gone. The config is sent verbatim as the create body and its +# kernel is loaded from the rootfs filesystem at runtime +# (`image_location = "fs"`, `/guest/linux/linux-qemu`), so the `fs` feature + +# NVMe driver are required. CI runs `cargo xtask image pull` before the test to +# provision the rootfs. features = [ - "http-dynamic-test", + "http-axum", + "fs", + "ax-driver/nvme", ] log = "Info" target = "aarch64-unknown-none-softfloat" -vm_configs = ["test-suit/axvisor/normal/qemu-http-axum-dynamic/aarch64-arceos-http-dynamic.toml"] +vm_configs = [] -# The lifecycle + create/delete self-tests drive write routes through the -# router; the mutating routes require the build-time bearer token, so bake one -# here. +# Control-plane auth + bind. The mutating routes require +# `Authorization: Bearer `; the host probe sends this same token +# (`[host_http_probe] token`), and hostfwd reachability requires binding all +# interfaces. [env] AXVM_HTTP_TOKEN = "axvisor-http-test-token" +AXVM_HTTP_BIND = "0.0.0.0:8080" diff --git a/test-suit/axvisor/normal/qemu-http-axum-dynamic/http-axum-dynamic/qemu-aarch64.toml b/test-suit/axvisor/normal/qemu-http-axum-dynamic/http-axum-dynamic/qemu-aarch64.toml index 8c63df49e5..5e572f568f 100644 --- a/test-suit/axvisor/normal/qemu-http-axum-dynamic/http-axum-dynamic/qemu-aarch64.toml +++ b/test-suit/axvisor/normal/qemu-http-axum-dynamic/http-axum-dynamic/qemu-aarch64.toml @@ -6,28 +6,38 @@ args = [ "virt,virtualization=on,gic-version=3", "-smp", "2", - # `-m 1g`: the dynamic test's guest uses a 256M MAP_IDENTICAL region, and the - # hypervisor plus that region must both fit in QEMU's total RAM (same sizing as - # the control test, whose guest config the dynamic guest mirrors). + # NVMe rootfs: the hypervisor's `fs` feature reads the guest kernel + # (`/guest/linux/linux-qemu`) from this disk, both for the create handler and + # for the created VM. The driver rewrites the drive file to the concrete + # rootfs path (see `patch_qemu_rootfs_path`). + "-device", + "nvme,drive=disk0,serial=tgoskits,max_ioqpairs=64,msix_qsize=65", + "-drive", + "id=disk0,if=none,format=raw,file=${workspace}/tmp/axbuild/rootfs/rootfs-aarch64-alpine.img", + # `-m 1g`: the created guest uses a 256M MAP_IDENTICAL region, and the + # hypervisor plus that region must both fit in QEMU's total RAM (same sizing + # as the control test). "-m", "1g", ] -timeout = 600 -# The runner's stream matcher stops at the FIRST match (fail checked before -# success), so per-step status lines cannot be asserted independently. The -# binary runs the base control lifecycle self-test first, then the create/delete -# self-test; each prints its own FAILED sentinel and each has an independent -# pass/fail result. The create/delete test only prints PASSED if the full -# remove -> create -> ready -> 409 -> remove -> 404 sequence met every -# expectation, so both FAILED sentinels must be caught or a control-lifecycle -# failure followed by a create/delete pass would falsely report success. +# PR E: create/delete verification via a host-side probe. +# `[host_http_probe]` makes the driver append hostfwd netdev + virtio-net-pci +# device and run a host probe over real TCP. Scenario `Dynamic` reads the +# default `linux-smp1.toml` config from the host, `POST /api/vms/create` it, +# polls the new VM into `ready`, verifies a duplicate create is rejected with +# 409, then `DELETE` it and polls it gone. The probe then quits QEMU over QMP, +# and the runner reads its stored verdict as the test result. `success_regex` is +# empty because the probe result, not serial output, is the verdict; a guest +# panic still fails via `fail_regex`. fail_regex = [ "(?i)\\bpanic(?:ked)?\\b", - "HTTP self-test: control lifecycle FAILED", - "HTTP self-test: dynamic create/delete FAILED", -] -success_regex = [ - "HTTP self-test: dynamic create/delete PASSED", + "(?i)kernel panic", ] +success_regex = [] +timeout = 600 to_bin = true uefi = false + +[host_http_probe] +token = "axvisor-http-test-token" +scenario = { Dynamic = { config_toml = "os/axvisor/configs/vms/qemu/aarch64/linux-smp1.toml" } } diff --git a/test-suit/axvisor/normal/qemu-http-axum-readonly/build-aarch64-unknown-none-softfloat.toml b/test-suit/axvisor/normal/qemu-http-axum-readonly/build-aarch64-unknown-none-softfloat.toml index e062487ad7..d9d1007f4f 100644 --- a/test-suit/axvisor/normal/qemu-http-axum-readonly/build-aarch64-unknown-none-softfloat.toml +++ b/test-suit/axvisor/normal/qemu-http-axum-readonly/build-aarch64-unknown-none-softfloat.toml @@ -1,17 +1,18 @@ # PR B: axum read-only HTTP API runtime verification. -# http-test implies http-axum (tokio + axum + serde_json). fs/nvme is not -# enabled: QEMU has no disk and fs would panic at boot; this test only -# verifies the management HTTP server runs. +# `http-axum` builds the tokio + axum + serde_json management server. fs/nvme is +# not enabled: QEMU has no disk and this test only verifies the management HTTP +# server runs. The host-side probe (QEMU hostfwd) drives the API over real TCP +# and asserts the read contract + auth boundary host-side. features = [ - "http-test", + "http-axum", ] log = "Info" target = "aarch64-unknown-none-softfloat" vm_configs = [] -# This test artifact is also used by the quickstart's manual hostfwd + curl -# flow, which needs the in-guest listener to accept connections on the guest -# NIC IP, so opt in to all interfaces. The self-test runs in-process before the -# bind, so this does not affect the assertions. +# Control-plane auth + bind. The probe's authenticated-write check needs a +# working bearer token, so one is baked here (`[host_http_probe] token` must +# match). The server binds all interfaces so QEMU hostfwd can reach it. [env] +AXVM_HTTP_TOKEN = "axvisor-http-test-token" AXVM_HTTP_BIND = "0.0.0.0:8080" diff --git a/test-suit/axvisor/normal/qemu-http-axum-readonly/build-x86_64-unknown-none.toml b/test-suit/axvisor/normal/qemu-http-axum-readonly/build-x86_64-unknown-none.toml index 61ac2ca269..40f876cdfc 100644 --- a/test-suit/axvisor/normal/qemu-http-axum-readonly/build-x86_64-unknown-none.toml +++ b/test-suit/axvisor/normal/qemu-http-axum-readonly/build-x86_64-unknown-none.toml @@ -1,8 +1,8 @@ # PR B: axum read-only HTTP API runtime verification (x86_64). -# http-test implies http-axum (tokio + axum + serde_json). fs/nvme is not -# enabled: this test only verifies the management HTTP server runs. +# `http-axum` builds the tokio + axum + serde_json management server. fs/nvme is +# not enabled: this test only verifies the management HTTP server runs. features = [ - "http-test", + "http-axum", ] log = "Info" target = "x86_64-unknown-none" @@ -14,10 +14,10 @@ vm_configs = [] # lowered. Disable SIMD via the official escape hatch (x86_64 only; the aarch64 # NEON path compiles fine and does not need this). # -# This test artifact is also used by the quickstart's manual hostfwd + curl -# flow, which needs the in-guest listener to accept connections on the guest -# NIC IP, so opt in to all interfaces. The self-test runs in-process before the -# bind, so this does not affect the assertions. +# Control-plane auth + bind: the probe's authenticated-write check needs a +# working bearer token, so one is baked here (`[host_http_probe] token` must +# match), and hostfwd reachability requires binding all interfaces. [env] CARGO_CFG_HTTPARSE_DISABLE_SIMD = "1" +AXVM_HTTP_TOKEN = "axvisor-http-test-token" AXVM_HTTP_BIND = "0.0.0.0:8080" diff --git a/test-suit/axvisor/normal/qemu-http-axum-readonly/http-axum-readonly/qemu-aarch64.toml b/test-suit/axvisor/normal/qemu-http-axum-readonly/http-axum-readonly/qemu-aarch64.toml index b77f341c65..e53ae1bf51 100644 --- a/test-suit/axvisor/normal/qemu-http-axum-readonly/http-axum-readonly/qemu-aarch64.toml +++ b/test-suit/axvisor/normal/qemu-http-axum-readonly/http-axum-readonly/qemu-aarch64.toml @@ -9,26 +9,24 @@ args = [ "-m", "512M", ] -# PR B: first in-hypervisor axum runtime verification. -# - tokio current_thread runtime is initialized with enable_io() only (needs -# only epoll, no timerfd syscall). -# - the tower::ServiceExt::oneshot self-test avoids TCP and prints the two -# read-only status codes, then a single PASSED/FAILED sentinel. -# - the runner's stream matcher stops at the FIRST marker, so the two per-request -# status lines cannot be asserted independently; success requires the final -# `readonly PASSED` sentinel, which the self-test prints only when both -# GET /api/vms -> 200 and GET /api/vms/999 -> 404 hold. -# - any assertion failure prints `readonly FAILED` (caught by fail_regex), and -# if enable_io() is insufficient (time driver / timerfd required) a panic -# also hits fail_regex. +# PR B: read-only management HTTP API verification via a host-side probe. +# - `[host_http_probe]` makes the driver append `-netdev user,id=net0,hostfwd= +# tcp::-:` + `-device virtio-net-pci,netdev=net0` and run a host +# probe that dials the in-guest API over real TCP. +# - The probe (scenario `ReadOnly`) asserts: GET /api/vms -> 200, an +# unauthenticated write -> 401 (access-denied regression), GET /api/vms/999 +# -> 404, and an authenticated write to an unknown VM -> 404. It then quits +# QEMU over QMP, and the runner reads its stored verdict as the test result. +# - `success_regex` is intentionally empty: the probe result, not serial output, +# is the verdict. A guest panic still fails via `fail_regex`. fail_regex = [ "(?i)\\bpanic(?:ked)?\\b", "(?i)kernel panic", - "HTTP self-test: readonly FAILED", -] -success_regex = [ - "HTTP self-test: readonly PASSED", ] +success_regex = [] timeout = 120 to_bin = true uefi = false + +[host_http_probe] +token = "axvisor-http-test-token" diff --git a/test-suit/axvisor/normal/qemu-http-axum-readonly/http-axum-readonly/qemu-x86_64.toml b/test-suit/axvisor/normal/qemu-http-axum-readonly/http-axum-readonly/qemu-x86_64.toml index 8696ba26be..4eb5309f54 100644 --- a/test-suit/axvisor/normal/qemu-http-axum-readonly/http-axum-readonly/qemu-x86_64.toml +++ b/test-suit/axvisor/normal/qemu-http-axum-readonly/http-axum-readonly/qemu-x86_64.toml @@ -19,19 +19,19 @@ args = [ "-vga", "none", ] -timeout = 600 -# Same single-sentinel contract as qemu-aarch64.toml: the runner's stream -# matcher stops at the FIRST marker, so success requires the final `readonly -# PASSED` sentinel (printed only when both GET /api/vms -> 200 and -# GET /api/vms/999 -> 404 hold); any assertion failure prints `readonly FAILED`, -# caught by fail_regex. The `+vmx-*` CPU flags require an Intel KVM host; on an -# AMD host, add a qemu-x86_64-svm.toml variant following the `smoke-svm` case. +# Same probe-driven contract as qemu-aarch64.toml: `[host_http_probe]` makes the +# driver append hostfwd netdev + virtio-net-pci device, and a host-side probe +# (scenario `ReadOnly`) asserts the read contract + auth boundary, then quits +# QEMU over QMP. `success_regex` is empty; the probe verdict is the result. The +# `+vmx-*` CPU flags require an Intel KVM host; on an AMD host, add a +# qemu-x86_64-svm.toml variant following the `smoke-svm` case. fail_regex = [ "(?i)\\bpanic(?:ked)?\\b", - "HTTP self-test: readonly FAILED", -] -success_regex = [ - "HTTP self-test: readonly PASSED", ] +success_regex = [] +timeout = 600 to_bin = true uefi = true + +[host_http_probe] +token = "axvisor-http-test-token" diff --git a/test-suit/axvisor/normal/qemu-http-axum-tcp/build-aarch64-unknown-none-softfloat.toml b/test-suit/axvisor/normal/qemu-http-axum-tcp/build-aarch64-unknown-none-softfloat.toml index 83df65fdf0..d04536a0df 100644 --- a/test-suit/axvisor/normal/qemu-http-axum-tcp/build-aarch64-unknown-none-softfloat.toml +++ b/test-suit/axvisor/normal/qemu-http-axum-tcp/build-aarch64-unknown-none-softfloat.toml @@ -1,11 +1,10 @@ # PR B: axum management HTTP API host->guest TCP integration verification. -# http-tcp-test implies http-axum (tokio + axum + serde_json) and adds the -# test-only POST /__probe_result relay endpoint. No tower/oneshot self-test is -# built: the TcpListener::bind + axum::serve path runs for real, and the host -# probe (QEMU hostfwd) makes actual HTTP requests to the in-guest API. +# `http-axum` builds the tokio + axum + serde_json management server with no +# test-only code: the `TcpListener::bind` + axum::serve path runs for real, and +# the host probe (QEMU hostfwd) makes actual HTTP requests to the in-guest API. # fs/nvme is not enabled: QEMU has no disk and fs would panic at boot. features = [ - "http-tcp-test", + "http-axum", ] log = "Info" target = "aarch64-unknown-none-softfloat" diff --git a/test-suit/axvisor/normal/qemu-http-axum-tcp/build-x86_64-unknown-none.toml b/test-suit/axvisor/normal/qemu-http-axum-tcp/build-x86_64-unknown-none.toml index 3ad21514b9..b01d4eca00 100644 --- a/test-suit/axvisor/normal/qemu-http-axum-tcp/build-x86_64-unknown-none.toml +++ b/test-suit/axvisor/normal/qemu-http-axum-tcp/build-x86_64-unknown-none.toml @@ -1,9 +1,9 @@ # PR B: axum management HTTP API host->guest TCP integration verification -# (x86_64). http-tcp-test implies http-axum (tokio + axum + serde_json) and -# adds the test-only POST /__probe_result relay endpoint. fs/nvme is not -# enabled: this test only verifies the management HTTP server runs. +# (x86_64). `http-axum` builds the tokio + axum + serde_json management server +# with no test-only code. fs/nvme is not enabled: this test only verifies the +# management HTTP server runs. features = [ - "http-tcp-test", + "http-axum", ] log = "Info" target = "x86_64-unknown-none" diff --git a/test-suit/axvisor/normal/qemu-http-axum-tcp/http-axum-tcp/qemu-aarch64.toml b/test-suit/axvisor/normal/qemu-http-axum-tcp/http-axum-tcp/qemu-aarch64.toml index 0dddc79417..9a45a5ea60 100644 --- a/test-suit/axvisor/normal/qemu-http-axum-tcp/http-axum-tcp/qemu-aarch64.toml +++ b/test-suit/axvisor/normal/qemu-http-axum-tcp/http-axum-tcp/qemu-aarch64.toml @@ -9,28 +9,23 @@ args = [ "-m", "512M", ] -# PR B: host->guest TCP integration verification over QEMU user-mode -# networking. The driver appends `-netdev user,id=net0,hostfwd=tcp::-:` -# and `-device virtio-net-pci,netdev=net0` when `[host_http_probe]` is present, -# then runs a host-side probe that dials the in-guest management API over real -# TCP. The probe asserts the control-plane security boundary (an unauthenticated -# write -> 401) and the authenticated contract (GET /api/vms -> 200, +# PR B: host->guest TCP integration verification over QEMU user-mode networking. +# `[host_http_probe]` makes the driver append `-netdev user,id=net0,hostfwd= +# tcp::-:` and `-device virtio-net-pci,netdev=net0`, then run a +# host-side probe that dials the in-guest management API over real TCP. Scenario +# `ReadOnly` asserts the control-plane security boundary (an unauthenticated +# write -> 401) and the authenticated read contract (GET /api/vms -> 200, # GET /api/vms/999 -> 404, authenticated write to an unknown VM -> 404), then -# POSTs a single PASSED/FAILED verdict to the test-only /__probe_result endpoint. -# That endpoint relays the verdict into the serial log, and the runner's stream -# matcher stops at the FIRST marker — so success requires the final `tcp PASSED` -# sentinel, which only appears when the probe observed every expected status -# over real TCP. `token` must match the guest build's `[env] AXVM_HTTP_TOKEN`. -# The `[host_http_probe]` connect timeout (default 120s) must be less than -# `timeout` so a broken server fails on the probe, not on the QEMU timeout. +# quits QEMU over QMP. The runner reads the stored probe verdict as the result; +# `success_regex` is empty by design. `token` must match the guest build's +# `[env] AXVM_HTTP_TOKEN`. The `[host_http_probe]` connect timeout (default 120s) +# must be less than `timeout` so a broken server fails on the probe, not on the +# QEMU timeout. fail_regex = [ "(?i)\\bpanic(?:ked)?\\b", "(?i)kernel panic", - "HTTP self-test: tcp FAILED", -] -success_regex = [ - "HTTP self-test: tcp PASSED", ] +success_regex = [] timeout = 180 to_bin = true uefi = false diff --git a/test-suit/axvisor/normal/qemu-http-axum-tcp/http-axum-tcp/qemu-x86_64.toml b/test-suit/axvisor/normal/qemu-http-axum-tcp/http-axum-tcp/qemu-x86_64.toml index 2dba6731a5..9c684a5097 100644 --- a/test-suit/axvisor/normal/qemu-http-axum-tcp/http-axum-tcp/qemu-x86_64.toml +++ b/test-suit/axvisor/normal/qemu-http-axum-tcp/http-axum-tcp/qemu-x86_64.toml @@ -19,22 +19,17 @@ args = [ "-vga", "none", ] -# Same single-sentinel contract as qemu-aarch64.toml: the driver appends the -# hostfwd netdev + virtio-net-pci device when `[host_http_probe]` is present, -# and a host-side probe drives the in-guest management API over real TCP, -# asserting the auth boundary (unauthenticated write -> 401) and the -# authenticated contract, then POSTing its PASSED/FAILED verdict to -# /__probe_result. Success requires the final `tcp PASSED` sentinel. `token` -# must match the guest build's `[env] AXVM_HTTP_TOKEN`. The `+vmx-*` CPU flags -# require an Intel KVM host; on an AMD host, add a qemu-x86_64-svm.toml variant -# following the `smoke-svm` case. +# Same probe-driven contract as qemu-aarch64.toml: `[host_http_probe]` makes the +# driver append hostfwd netdev + virtio-net-pci device, and a host-side probe +# (scenario `ReadOnly`) asserts the auth boundary + read contract, then quits +# QEMU over QMP. The runner reads the stored probe verdict as the result; +# `success_regex` is empty by design. `token` must match the guest build's +# `[env] AXVM_HTTP_TOKEN`. The `+vmx-*` CPU flags require an Intel KVM host; on +# an AMD host, add a qemu-x86_64-svm.toml variant following the `smoke-svm` case. fail_regex = [ "(?i)\\bpanic(?:ked)?\\b", - "HTTP self-test: tcp FAILED", -] -success_regex = [ - "HTTP self-test: tcp PASSED", ] +success_regex = [] timeout = 600 to_bin = true uefi = true From c763300405c9c9c5e726f8f4abbef64769d3b89b Mon Sep 17 00:00:00 2001 From: Xinhong Hu Date: Tue, 11 Aug 2026 21:12:52 +0800 Subject: [PATCH 19/40] =?UTF-8?q?refactor(axbuild):=20host=5Fhttp=5Fprobe?= =?UTF-8?q?=20=E5=9C=BA=E6=99=AF=20Rust=20=E4=BB=A3=E7=A0=81=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=20case=20=E7=9B=AE=E5=BD=95=20probe.sh=20=E8=84=9A?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit host_probe 原是场景专用 Rust 断言(ReadOnly/Lifecycle/Dynamic 三个枚举 变体 + 约 700 行 HTTP 请求逻辑),每加一个 HTTP 测试场景都要往 axbuild 塞 Rust 函数,违背"加测试不应影响构建"的原则。改为通用 host 脚本守卫后, axbuild 只提供一次性的编排原语,所有断言以脚本形式活在 case 目录。 Changes: - HostHttpProbeConfig 删除 HostHttpProbeScenario 枚举,新增 script/env 字段 - host_probe.rs 瘦身为通用脚本守卫:等待端口就绪、spawn 脚本、以退出码为 裁决、QMP quit + SIGKILL 兜底终止 QEMU - axvisor/test/qemu.rs 删除 Dynamic config_toml 分支,改为解析 script 并 spawn 守卫,裁决联合判定改为 (qemu_result, script_result) - 新增四个 case 的 probe.sh(POSIX sh + curl + grep),断言逐条对应 http-control-plane-quickstart.md 已验证命令 - 六个 qemu-.toml 的 [host_http_probe] 从 scenario = {...} 改为 script = "probe.sh" --- scripts/axbuild/src/axvisor/test/qemu.rs | 52 +- scripts/axbuild/src/test/case/types.rs | 52 +- scripts/axbuild/src/test/host_probe.rs | 907 ++---------------- .../http-axum-control/probe.sh | 100 ++ .../http-axum-control/qemu-aarch64.toml | 15 +- .../http-axum-dynamic/probe.sh | 145 +++ .../http-axum-dynamic/qemu-aarch64.toml | 16 +- .../http-axum-readonly/probe.sh | 68 ++ .../http-axum-readonly/qemu-aarch64.toml | 9 +- .../http-axum-readonly/qemu-x86_64.toml | 9 +- .../qemu-http-axum-tcp/http-axum-tcp/probe.sh | 70 ++ .../http-axum-tcp/qemu-aarch64.toml | 5 +- .../http-axum-tcp/qemu-x86_64.toml | 11 +- 13 files changed, 565 insertions(+), 894 deletions(-) create mode 100755 test-suit/axvisor/normal/qemu-http-axum-control/http-axum-control/probe.sh create mode 100755 test-suit/axvisor/normal/qemu-http-axum-dynamic/http-axum-dynamic/probe.sh create mode 100755 test-suit/axvisor/normal/qemu-http-axum-readonly/http-axum-readonly/probe.sh create mode 100755 test-suit/axvisor/normal/qemu-http-axum-tcp/http-axum-tcp/probe.sh diff --git a/scripts/axbuild/src/axvisor/test/qemu.rs b/scripts/axbuild/src/axvisor/test/qemu.rs index 7a1adfe69e..fdf31e3a66 100644 --- a/scripts/axbuild/src/axvisor/test/qemu.rs +++ b/scripts/axbuild/src/axvisor/test/qemu.rs @@ -342,37 +342,32 @@ impl Axvisor { // Optional host->guest TCP probe over QEMU user-mode networking. When // `[host_http_probe]` is configured, the host acts as a *client* that // dials a management API inside the guest through a hostfwd port and - // asserts the responses entirely host-side. The probe must live for the - // whole run, so its guard is spawned here and dropped at scope end - // (after QEMU exits). + // asserts the responses entirely host-side. The assertions live in a + // case-dir shell script (`probe.sh`), whose exit code is the verdict; + // axbuild only orchestrates: forward the port, spawn the script, and + // report its result. The guard must live for the whole run, so it is + // spawned here and dropped at scope end (after QEMU exits). // - // The probe also drives QEMU termination: after it stores its verdict it + // The guard also drives QEMU termination: after it stores its verdict it // connects to a QMP monitor socket and sends `quit`, so the run ends on - // the probe result instead of the serial-timeout path. That makes the - // probe verdict the authoritative test result (no `/__probe_result` + // the script result instead of the serial-timeout path. That makes the + // script verdict the authoritative test result (no `/__probe_result` // relay inside the guest). let mut host_probe_guard = None; - if let Some(mut probe_config) = + if let Some(probe_config) = test_qemu::load_qemu_case_extra_config(&case.case.case.qemu_config_path)? .host_http_probe { - // A relative `config_toml` in the dynamic scenario is resolved against - // the workspace root (axbuild may be invoked from any directory), so - // the probe reads the same file regardless of the caller's CWD. - if let test_case::HostHttpProbeScenario::Dynamic { - ref mut config_toml, - } = probe_config.scenario - { - let path = PathBuf::from(&*config_toml); - if !path.is_absolute() { - *config_toml = self - .app - .workspace_root() - .join(path) - .to_string_lossy() - .into(); + // A relative `script` is resolved against the case directory (where + // the qemu config lives), so axbuild may be invoked from any CWD. + let script = { + let path = PathBuf::from(&probe_config.script); + if path.is_absolute() { + path + } else { + case.case.case.case_dir.join(path) } - } + }; let host_port = pick_free_local_port()?; let qmp_socket = std::env::temp_dir().join(format!( "axvisor-qmp-{}-{}.sock", @@ -395,6 +390,7 @@ impl Axvisor { ]); host_probe_guard = Some(host_probe::HostHttpProbeGuard::start( &probe_config, + script, host_port, &case.case.case.name, Some(qmp_socket), @@ -422,7 +418,7 @@ impl Axvisor { // Joins the probe thread now that QEMU has exited. let probe_configured = host_probe_guard.is_some(); - let probe_result = host_probe_guard + let script_result = host_probe_guard .as_ref() .and_then(|guard| guard.take_result()); let killed_by_probe = host_probe_guard @@ -431,16 +427,16 @@ impl Axvisor { .unwrap_or(false); drop(host_probe_guard); - match (qemu_result, probe_configured, probe_result, killed_by_probe) { - // The probe force-killed QEMU (QMP `quit` was ignored): the stored - // probe verdict is authoritative, even though QEMU exited non-zero. + match (qemu_result, probe_configured, script_result, killed_by_probe) { + // The guard force-killed QEMU (QMP `quit` was ignored): the stored + // script verdict is authoritative, even though QEMU exited non-zero. (_, true, Some(verdict), true) => verdict, // A real QEMU failure (boot failure, guest crash, serial sentinel) // always wins regardless of probe configuration. (Err(err), ..) => Err(err), // Non-probe case: QEMU exit is the verdict. (Ok(()), false, _, _) => Ok(()), - // Probe case: the stored probe verdict decides. + // Probe case: the stored script verdict decides. (Ok(()), true, Some(Ok(())), _) => Ok(()), (Ok(()), true, Some(Err(err)), _) => Err(err), (Ok(()), true, None, _) => { diff --git a/scripts/axbuild/src/test/case/types.rs b/scripts/axbuild/src/test/case/types.rs index 64b4eacf4f..e0e6bbddd4 100644 --- a/scripts/axbuild/src/test/case/types.rs +++ b/scripts/axbuild/src/test/case/types.rs @@ -1,4 +1,8 @@ -use std::{collections::BTreeSet, path::PathBuf, time::Duration}; +use std::{ + collections::{BTreeMap, BTreeSet}, + path::PathBuf, + time::Duration, +}; use serde::Deserialize; @@ -65,16 +69,17 @@ pub(crate) struct HostHttpServerConfig { pub(crate) dir: Option, } -/// Host-side TCP probe configuration. +/// Host-side probe script configuration. /// /// Direction is the reverse of [`HostHttpServerConfig`]: instead of the host /// serving fixtures to the guest, the host acts as a *client* that probes a /// management API running *inside* the guest, over QEMU user-mode networking /// hostfwd (`-netdev user,hostfwd=tcp::-:`). The probe -/// makes real HTTP requests and asserts the responses entirely host-side — there -/// is no guest-side test relay endpoint. When the probe finishes (pass or fail) -/// it quits QEMU over its QMP monitor socket, and the runner reads the stored -/// verdict from the probe guard as the test result. +/// is a plain host-side shell script (`curl` + `grep`/`jq`) that makes real +/// HTTP requests and asserts the responses entirely host-side — there is no +/// guest-side test relay endpoint. The script's exit code is the verdict (0 = +/// pass, non-zero = fail); the runner then quits QEMU over its QMP monitor +/// socket. #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] pub(crate) struct HostHttpProbeConfig { /// Guest-side port the in-guest HTTP server binds to. The harness forwards a @@ -92,36 +97,13 @@ pub(crate) struct HostHttpProbeConfig { /// regression the management-control-plane security review requires). #[serde(default)] pub(crate) token: Option, - /// Which management-plane behavior the probe exercises. + /// Path of the host-side probe script, relative to the case directory. The + /// runner resolves it against the workspace root before spawning. + pub(crate) script: PathBuf, + /// Extra environment variables injected into the probe script process, e.g. + /// `{ TOKEN = "..." }` so the script can read `$TOKEN`. #[serde(default)] - pub(crate) scenario: HostHttpProbeScenario, -} - -/// Probe scenario: the set of management API behaviors the host probe drives -/// and asserts. The probe is host-side by design; nothing in the guest knows a -/// test is running. -#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)] -pub(crate) enum HostHttpProbeScenario { - /// Read-only routes: `GET /api/vms`, the 404 on an unknown VM, and the 401 - /// access-denied check on an unauthenticated write. No VM is started or - /// created, so it works with any axvisor build (even `vm_configs = []`). - #[default] - ReadOnly, - /// Lifecycle: `POST /api/vms/{id}/start`, poll until `running`, then - /// `POST /api/vms/{id}/stop` and poll until `stopped`. The target VM is a - /// default/static VM that must exist in `Ready` (`no-auto-start`). - Lifecycle { - /// VM id to drive through start -> running -> stop -> stopped. - vm_id: u64, - }, - /// Dynamic create/delete: read a VM config TOML from the host, `POST - /// /api/vms/create`, poll until the VM is `Ready`, then `DELETE` it and poll - /// until it is gone. The config may reference any guest image available to - /// the runtime (an fs-backed kernel on the rootfs, or an embedded image). - Dynamic { - /// Host path of the VM config TOML to send in the create body. - config_toml: String, - }, + pub(crate) env: BTreeMap, } fn default_probe_guest_port() -> u16 { diff --git a/scripts/axbuild/src/test/host_probe.rs b/scripts/axbuild/src/test/host_probe.rs index 372f8ad2ee..7dd2c3760a 100644 --- a/scripts/axbuild/src/test/host_probe.rs +++ b/scripts/axbuild/src/test/host_probe.rs @@ -1,38 +1,25 @@ -//! Host-side TCP probe for QEMU hostfwd integration tests. +//! Host-side probe script runner for QEMU hostfwd integration tests. //! //! The probe is the reverse of [`super::host_http`]: instead of serving host //! fixtures to the guest, it acts as a *client* that dials a management API //! running *inside* the guest through QEMU user-mode networking -//! (`-netdev user,hostfwd=tcp::-:`). It makes real HTTP -//! requests and asserts the responses entirely host-side — there is no -//! guest-side relay endpoint, so nothing in the hypervisor knows a test is -//! running. +//! (`-netdev user,hostfwd=tcp::-:`). The actual +//! assertions are written as a plain shell script (`curl` + `grep`/`jq`) that +//! lives in the test-case directory — axbuild only provides the generic +//! orchestration: wait for the forwarded port, spawn the script, and treat its +//! exit code as the verdict (0 = pass, non-zero = fail). Nothing in the +//! hypervisor knows a test is running. //! -//! When the probe finishes — pass or fail — it quits QEMU over the QMP monitor -//! socket the runner added (`-qmp unix:...,server=on,wait=off`), so the QEMU -//! process exits cleanly and the runner reads the stored verdict from the guard -//! as the test result. The case `timeout` remains the backstop if the probe or -//! its QMP quit fails. -//! -//! Scenarios (see [`HostHttpProbeScenario`]): -//! - `ReadOnly`: the security-boundary + read contract. An unauthenticated -//! write (`POST /api/vms/999/start` with no `Authorization` header) must be -//! rejected with 401; then the authenticated contract (`GET /api/vms -> 200`, -//! `GET /api/vms/999 -> 404`, authenticated write to an unknown VM -> 404). -//! - `Lifecycle { vm_id }`: drive one registered VM through -//! start -> running -> stop -> stopped via the mutating routes. -//! - `Dynamic { config_toml }`: create a VM from a host-side TOML config, poll -//! it `Ready`, verify a duplicate create conflicts (409), then delete it and -//! poll until it is gone. -//! -//! All authenticated requests carry the `token` from the case config, which must -//! match the guest build's `[env] AXVM_HTTP_TOKEN`. +//! When the script finishes — pass or fail — the guard quits QEMU over the QMP +//! monitor socket the runner added (`-qmp unix:...,server=on,wait=off`), so the +//! QEMU process exits cleanly and the runner reads the stored verdict from the +//! guard as the test result. The case `timeout` remains the backstop if the +//! script or its QMP quit fails. use std::{ - fs, - io::{Read, Write}, net::TcpStream, path::{Path, PathBuf}, + process::{Command, Stdio}, sync::{ Arc, Mutex, atomic::{AtomicBool, Ordering}, @@ -42,16 +29,11 @@ use std::{ time::{Duration, Instant}, }; -use anyhow::{Context, bail, ensure}; -use serde_json::Value; +use anyhow::{Context, bail}; +use std::collections::BTreeMap; -use crate::test::case::{HostHttpProbeConfig, HostHttpProbeScenario}; +use crate::test::case::HostHttpProbeConfig; -/// Per-attempt IO timeout for a single HTTP request/response exchange. The -/// dynamic `create` handler reads the guest kernel from the rootfs inside the -/// request (fs loading), which is the slowest single exchange; 30s covers it -/// without making a stuck server hold the probe for too long. -const IO_TIMEOUT: Duration = Duration::from_secs(30); /// Sleep between readiness retries. const CONNECT_RETRY_INTERVAL: Duration = Duration::from_millis(100); /// How long to keep retrying the QMP connect before giving up on quitting QEMU. @@ -59,33 +41,46 @@ const QMP_CONNECT_RETRY_INTERVAL: Duration = Duration::from_millis(100); const QMP_CONNECT_RETRIES: usize = 10; /// How long to wait after QMP `quit` for QEMU to exit on its own before /// force-killing it. QEMU can ignore `quit` when its main loop is stuck in a -/// busy poll (observed under the axbuild runner), so the probe must not wait +/// busy poll (observed under the axbuild runner), so the guard must not wait /// forever for a clean exit. #[cfg(target_os = "linux")] const QMP_QUIT_GRACE: Duration = Duration::from_secs(3); /// Interval for polling whether QEMU is still alive after `quit`. #[cfg(target_os = "linux")] const QMP_ALIVE_POLL_INTERVAL: Duration = Duration::from_millis(100); +/// Env var injected into the probe script carrying the forwarded host port. +const PROBE_PORT_ENV: &str = "AXBUILD_PROBE_PORT"; +/// Env var injected into the probe script carrying the bearer token, matching +/// the guest build's `[env] AXVM_HTTP_TOKEN`. +const PROBE_TOKEN_ENV: &str = "AXVM_HTTP_TOKEN"; pub(crate) struct HostHttpProbeGuard { stop: Arc, result: Arc>>>, - /// Set when the probe had to SIGKILL QEMU because it ignored QMP `quit`. + /// Set when the guard had to SIGKILL QEMU because it ignored QMP `quit`. /// The runner uses this to prefer the stored probe verdict over QEMU's /// non-zero exit status. killed_by_probe: Arc, + /// PID of the running probe-script child, so the guard can kill it on drop + /// if QEMU died before the script finished. Stored as a plain PID (not the + /// `Child`) so `Drop` never contends with the probe thread's `wait()` on + /// the same lock. + child_pid: Arc>>, thread: Option>, } impl HostHttpProbeGuard { - /// Spawn the probe thread and return a guard that owns its lifecycle. + /// Spawn the probe-script runner thread and return a guard that owns its + /// lifecycle. /// - /// `qmp_socket` is the path QEMU binds from its `-qmp unix:...` argument; - /// the probe connects to it after its assertions finish to quit QEMU. When - /// `None`, the probe only stores its verdict and relies on the case timeout - /// to end the run. + /// `script` is the resolved host-side probe script path. `qmp_socket` is + /// the path QEMU binds from its `-qmp unix:...` argument; the guard + /// connects to it after the script finishes to quit QEMU. When `None`, the + /// guard only stores the verdict and relies on the case timeout to end the + /// run. pub(crate) fn start( config: &HostHttpProbeConfig, + script: PathBuf, host_port: u16, case_name: &str, qmp_socket: Option, @@ -98,29 +93,32 @@ impl HostHttpProbeGuard { let thread_result = result.clone(); let killed_by_probe = Arc::new(AtomicBool::new(false)); let thread_killed = killed_by_probe.clone(); + let child_pid = Arc::new(Mutex::new(None)); + let thread_child_pid = child_pid.clone(); let case_name = case_name.to_string(); let (ready_tx, ready_rx) = mpsc::channel(); let thread_addr = addr.clone(); let thread_case_name = case_name.clone(); let token = config.token.clone(); - let scenario = config.scenario.clone(); + let env = config.env.clone(); let thread = thread::spawn(move || { let _ = ready_tx.send(()); - let verdict = run_probe( + let verdict = run_probe_script( + &script, &thread_addr, - &thread_case_name, connect_timeout, token.as_deref(), - &scenario, + &env, &thread_stop, + &thread_child_pid, ); *thread_result.lock().unwrap() = Some(verdict); - // Quit QEMU so the run ends on the probe verdict instead of the + // Quit QEMU so the run ends on the script verdict instead of the // serial-timeout path. `request_qmp_quit` force-kills QEMU when it // ignores `quit` (a hang observed under the runner); record that so - // the runner trusts the stored probe verdict over QEMU's non-zero - // exit status. + // the runner trusts the stored verdict over QEMU's non-zero exit + // status. if let Some(socket) = qmp_socket { match request_qmp_quit(&socket) { Ok(true) => thread_killed.store(true, Ordering::SeqCst), @@ -142,6 +140,7 @@ impl HostHttpProbeGuard { stop, result, killed_by_probe, + child_pid, thread: Some(thread), }) } @@ -164,394 +163,89 @@ impl HostHttpProbeGuard { impl Drop for HostHttpProbeGuard { fn drop(&mut self) { self.stop.store(true, Ordering::Release); + // If QEMU died before the script finished, kill the script child so the + // join below does not wait forever on a probe whose server is gone. The + // thread reaps the child, so the PID slot is cleared once it exits; + // killing a reaped/reused PID is avoided because the slot is only + // non-None while the thread is still waiting. + #[cfg(target_os = "linux")] + if let Some(pid) = self.child_pid.lock().unwrap().take() { + kill_process(pid); + } if let Some(thread) = self.thread.take() { let _ = thread.join(); } } } -/// Dispatch the probe to the scenario-specific assertion flow and report the -/// verdict. The result is also stored in the guard and drives the test result. -fn run_probe( +/// Run the probe script and report the verdict: wait for the forwarded host +/// port to accept connections (guest boot + network init), spawn the script +/// with the port/token/env injected, and map its exit code to a result. The +/// script's stdout/stderr are inherited so its output appears in the runner +/// log. +fn run_probe_script( + script: &Path, addr: &str, - case_name: &str, connect_timeout: Duration, token: Option<&str>, - scenario: &HostHttpProbeScenario, + env: &BTreeMap, stop: &AtomicBool, + child_pid_slot: &Mutex>, ) -> anyhow::Result<()> { - let result = match scenario { - HostHttpProbeScenario::ReadOnly => { - run_readonly_probe(addr, case_name, connect_timeout, token, stop) - } - HostHttpProbeScenario::Lifecycle { vm_id } => { - run_lifecycle_probe(addr, case_name, connect_timeout, token, *vm_id, stop) - } - HostHttpProbeScenario::Dynamic { config_toml } => { - run_dynamic_probe(addr, case_name, connect_timeout, token, config_toml, stop) - } - }; - match &result { - Ok(()) => println!(" host http probe: {case_name}: probe passed"), - Err(err) => eprintln!(" host http probe: {case_name}: probe failed: {err:#}"), - } - result -} - -/// Assert a single request's status against the contract, with a visible log -/// line for the runner's transcript. -fn check_status(case_name: &str, label: &str, actual: u16, expected: u16) -> anyhow::Result<()> { - println!(" host http probe: {case_name}: {label} -> {actual} (expect {expected})"); - ensure!( - actual == expected, - "{label} -> {actual}, expected {expected}" - ); - Ok(()) -} + wait_for_port_ready(addr, connect_timeout, stop) + .with_context(|| format!("guest HTTP server never became reachable within {connect_timeout:?}"))?; -/// Poll `GET /api/vms` until it yields a parsed status, the deadline elapses, -/// or a stop is requested. This doubles as the readiness probe: the route is -/// open, so no token is needed. -fn poll_status( - addr: &str, - path: &str, - started: Instant, - connect_timeout: Duration, - stop: &AtomicBool, -) -> Option { - loop { - if stop.load(Ordering::Acquire) { - return None; - } - if started.elapsed() >= connect_timeout { - return None; - } - if let Some(status) = request_status(addr, "GET", path, None, None) { - return Some(status); - } - thread::sleep(CONNECT_RETRY_INTERVAL); + let port = addr.rsplit_once(':').map(|(_, p)| p).unwrap_or(""); + let mut cmd = Command::new(script); + cmd.env(PROBE_PORT_ENV, port); + if let Some(token) = token { + cmd.env(PROBE_TOKEN_ENV, token); + } + cmd.envs(env); + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::inherit()); + cmd.stderr(Stdio::inherit()); + let mut child = cmd + .spawn() + .with_context(|| format!("failed to spawn probe script {}", script.display()))?; + // Publish the PID so `Drop` can SIGKILL the script if QEMU dies while it is + // still running. The slot is cleared after `wait` reaps the child, so a + // dead/reused PID is never killed. + *child_pid_slot.lock().unwrap() = Some(child.id() as i32); + let status = child + .wait() + .with_context(|| format!("failed to wait for probe script {}", script.display()))?; + *child_pid_slot.lock().unwrap() = None; + if status.success() { + println!(" host http probe: probe passed"); + Ok(()) + } else { + let code = status.code(); + eprintln!(" host http probe: probe failed (exit status {status})"); + bail!("probe script {} exited with status {status:?} (code {code:?})", script.display()) } } -/// Poll `GET /api/vms/{id}` until its reported status equals `expected`, the -/// deadline elapses, or a stop is requested. -fn poll_vm_status( - addr: &str, - vm_id: u64, - expected: &str, - started: Instant, - connect_timeout: Duration, - stop: &AtomicBool, -) -> Option<()> { +/// Poll the forwarded host port until a TCP connection succeeds, the deadline +/// elapses, or a stop is requested. A successful connect means the guest's +/// network stack is up; the in-guest server may still be booting, so the probe +/// script itself should retry its first request. +fn wait_for_port_ready(addr: &str, connect_timeout: Duration, stop: &AtomicBool) -> anyhow::Result<()> { + let started = Instant::now(); loop { if stop.load(Ordering::Acquire) { - return None; + bail!("host http probe stopped"); } if started.elapsed() >= connect_timeout { - return None; + bail!("timed out after {connect_timeout:?}"); } - if let Some(status) = vm_status(addr, vm_id, stop) - && status == expected - { - return Some(()); + if TcpStream::connect(addr).is_ok() { + return Ok(()); } thread::sleep(CONNECT_RETRY_INTERVAL); } } -/// Poll `GET /api/vms/{id}` until it 404s (the VM is gone), the deadline -/// elapses, or a stop is requested. -fn poll_vm_gone( - addr: &str, - vm_id: u64, - started: Instant, - connect_timeout: Duration, - stop: &AtomicBool, -) -> Option<()> { - loop { - if stop.load(Ordering::Acquire) { - return None; - } - if started.elapsed() >= connect_timeout { - return None; - } - match request_status(addr, "GET", &format!("/api/vms/{vm_id}"), None, None) { - Some(404) => return Some(()), - _ => thread::sleep(CONNECT_RETRY_INTERVAL), - } - } -} - -/// Fetch the current status string of one VM, or `None` on a transport failure -/// or a non-200 response. The detail route is open (read-only), so no token is -/// needed. -fn vm_status(addr: &str, vm_id: u64, stop: &AtomicBool) -> Option { - if stop.load(Ordering::Acquire) { - return None; - } - let (status, json) = request_json(addr, "GET", &format!("/api/vms/{vm_id}"), None, None)?; - if status != 200 { - return None; - } - json.get("status")?.as_str().map(String::from) -} - -/// Read-only scenario: security boundary + read contract. -fn run_readonly_probe( - addr: &str, - case_name: &str, - connect_timeout: Duration, - token: Option<&str>, - stop: &AtomicBool, -) -> anyhow::Result<()> { - let started = Instant::now(); - - // Readiness: `GET /api/vms` is retried until it yields a parsed status. - let list = - poll_status(addr, "/api/vms", started, connect_timeout, stop).with_context(|| { - format!("guest HTTP server never became reachable within {connect_timeout:?}") - })?; - check_status(case_name, "GET /api/vms", list, 200)?; - - // Access-denied regression (security review): an unauthenticated write to a - // mutating route must be rejected with 401. The auth gate runs before any VM - // lookup, so this holds regardless of whether VM 999 exists. - let denied = request_status(addr, "POST", "/api/vms/999/start", None, None) - .with_context(|| "unauthenticated write request failed")?; - check_status(case_name, "POST /api/vms/999/start (no token)", denied, 401)?; - - // Unknown VM -> 404 on the read path. - let missing = request_status(addr, "GET", "/api/vms/999", None, token) - .with_context(|| "GET /api/vms/999 failed")?; - check_status(case_name, "GET /api/vms/999", missing, 404)?; - - // Authenticated write to an unknown VM -> 404: writes are reachable with - // valid credentials rather than silently open or always denied. - let authed_write = request_status(addr, "POST", "/api/vms/999/start", None, token) - .with_context(|| "authenticated write request failed")?; - check_status( - case_name, - "POST /api/vms/999/start (with token)", - authed_write, - 404, - )?; - - Ok(()) -} - -/// Lifecycle scenario: drive one registered VM through the start/stop contract. -fn run_lifecycle_probe( - addr: &str, - case_name: &str, - connect_timeout: Duration, - token: Option<&str>, - vm_id: u64, - stop: &AtomicBool, -) -> anyhow::Result<()> { - let started = Instant::now(); - - // Readiness, and confirm the target VM exists in `Ready` (a `no-auto-start` - // build keeps default VMs un-started). - let list = - poll_status(addr, "/api/vms", started, connect_timeout, stop).with_context(|| { - format!("guest HTTP server never became reachable within {connect_timeout:?}") - })?; - check_status(case_name, "GET /api/vms", list, 200)?; - poll_vm_status(addr, vm_id, "ready", started, connect_timeout, stop) - .with_context(|| format!("VM[{vm_id}] never became ready"))?; - - // Start, then poll until the vCPU task reports `running`. - let action = Instant::now(); - let start = request_status( - addr, - "POST", - &format!("/api/vms/{vm_id}/start"), - None, - token, - ) - .with_context(|| format!("POST /api/vms/{vm_id}/start failed"))?; - check_status( - case_name, - &format!("POST /api/vms/{vm_id}/start"), - start, - 200, - )?; - poll_vm_status(addr, vm_id, "running", action, connect_timeout, stop) - .with_context(|| format!("VM[{vm_id}] never became running after start"))?; - - // Stop is a request: the `Stopped` state arrives asynchronously once the - // vCPU observes the request and exits. - let action = Instant::now(); - let stop_status = request_status(addr, "POST", &format!("/api/vms/{vm_id}/stop"), None, token) - .with_context(|| format!("POST /api/vms/{vm_id}/stop failed"))?; - check_status( - case_name, - &format!("POST /api/vms/{vm_id}/stop"), - stop_status, - 200, - )?; - poll_vm_status(addr, vm_id, "stopped", action, connect_timeout, stop) - .with_context(|| format!("VM[{vm_id}] never became stopped after stop"))?; - - Ok(()) -} - -/// Dynamic scenario: create a VM from a host-side TOML config, verify the -/// duplicate-create conflict, then delete it and poll it gone. -fn run_dynamic_probe( - addr: &str, - case_name: &str, - connect_timeout: Duration, - token: Option<&str>, - config_toml: &str, - stop: &AtomicBool, -) -> anyhow::Result<()> { - let started = Instant::now(); - - // Readiness. - let list = - poll_status(addr, "/api/vms", started, connect_timeout, stop).with_context(|| { - format!("guest HTTP server never became reachable within {connect_timeout:?}") - })?; - check_status(case_name, "GET /api/vms", list, 200)?; - - // The create body is the host-side VM config TOML, sent verbatim. The - // config must reference a guest image the runtime can load (an fs-backed - // kernel on the rootfs, or an embedded image). - let toml_text = fs::read_to_string(config_toml) - .with_context(|| format!("failed to read VM config TOML `{config_toml}`"))?; - let create_body = serde_json::json!({ "toml": toml_text }).to_string(); - let (created, created_json) = - request_json(addr, "POST", "/api/vms/create", Some(&create_body), token) - .with_context(|| "POST /api/vms/create failed")?; - check_status(case_name, "POST /api/vms/create", created, 200)?; - let created_id = created_json - .get("id") - .and_then(Value::as_u64) - .with_context(|| "POST /api/vms/create response missing `id`")?; - - // Poll the new VM into `Ready`. - let created_at = Instant::now(); - poll_vm_status(addr, created_id, "ready", created_at, connect_timeout, stop) - .with_context(|| format!("VM[{created_id}] never became ready after create"))?; - - // Re-creating the same config must conflict (409): the id is registered. - let dup = request_status(addr, "POST", "/api/vms/create", Some(&create_body), token) - .with_context(|| "duplicate POST /api/vms/create failed")?; - check_status(case_name, "POST /api/vms/create (duplicate)", dup, 409)?; - - // Delete, then poll until the VM is gone (404). - let deleted = request_status( - addr, - "DELETE", - &format!("/api/vms/{created_id}"), - None, - token, - ) - .with_context(|| format!("DELETE /api/vms/{created_id} failed"))?; - check_status( - case_name, - &format!("DELETE /api/vms/{created_id}"), - deleted, - 204, - )?; - let gone_at = Instant::now(); - poll_vm_gone(addr, created_id, gone_at, connect_timeout, stop) - .with_context(|| format!("VM[{created_id}] never disappeared after delete"))?; - - Ok(()) -} - -/// Send one HTTP/1.1 request over a fresh connection and parse the status code. -/// `body` (when present) is sent as the request body with a JSON content type. -/// `token` (when present) adds an `Authorization: Bearer ` header for -/// protected (mutating) routes. -fn request_status( - addr: &str, - method: &str, - path: &str, - body: Option<&str>, - token: Option<&str>, -) -> Option { - request_response(addr, method, path, body, token).map(|(status, _)| status) -} - -/// Send one HTTP/1.1 request and parse the status code plus the JSON body. -/// Returns `None` on a transport failure or a non-JSON response. -fn request_json( - addr: &str, - method: &str, - path: &str, - body: Option<&str>, - token: Option<&str>, -) -> Option<(u16, Value)> { - let (status, response) = request_response(addr, method, path, body, token)?; - let json = serde_json::from_slice(response_body(&response)?).ok()?; - Some((status, json)) -} - -/// Extract the HTTP response body (everything after the header/body separator). -/// -/// The response is read whole (`Connection: close`, read-to-EOF), so the headers -/// prefix must be stripped before the body can be parsed as JSON. -fn response_body(response: &[u8]) -> Option<&[u8]> { - const SEPARATOR: &[u8] = b"\r\n\r\n"; - let pos = response - .windows(SEPARATOR.len()) - .position(|window| window == SEPARATOR)?; - Some(&response[pos + SEPARATOR.len()..]) -} - -/// Send one HTTP/1.1 request and return the status code plus the raw response -/// body. -fn request_response( - addr: &str, - method: &str, - path: &str, - body: Option<&str>, - token: Option<&str>, -) -> Option<(u16, Vec)> { - let Ok(mut stream) = TcpStream::connect(addr) else { - return None; - }; - let _ = stream.set_read_timeout(Some(IO_TIMEOUT)); - let _ = stream.set_write_timeout(Some(IO_TIMEOUT)); - - let mut request = format!("{method} {path} HTTP/1.1\r\nHost: {addr}\r\n"); - if let Some(token) = token { - request.push_str(&format!("Authorization: Bearer {token}\r\n")); - } - if let Some(body) = body { - request.push_str(&format!( - "Content-Type: application/json\r\nContent-Length: {}\r\n", - body.len() - )); - } - request.push_str("Connection: close\r\n\r\n"); - if let Some(body) = body { - request.push_str(body); - } - - if stream.write_all(request.as_bytes()).is_err() { - return None; - } - let mut response = Vec::new(); - if stream.read_to_end(&mut response).is_err() { - return None; - } - Some((parse_status(&response)?, response)) -} - -/// Extract the numeric HTTP status code from a response. -fn parse_status(response: &[u8]) -> Option { - let head = String::from_utf8_lossy(response); - let status_line = head.lines().next()?; - let mut parts = status_line.split_whitespace(); - let _protocol = parts.next()?; - let status = parts.next()?; - status.parse().ok() -} - /// Quit QEMU by connecting to its QMP monitor socket and issuing `quit`, then /// wait for it to exit. The socket path comes from the `-qmp /// unix:...,server=on,wait=off` argument the runner added. @@ -563,6 +257,7 @@ fn parse_status(response: &[u8]) -> Option { #[cfg(unix)] fn request_qmp_quit(socket: &Path) -> anyhow::Result { use std::os::unix::net::UnixStream; + use std::io::{Read, Write}; let mut stream = None; for _ in 0..QMP_CONNECT_RETRIES { @@ -664,393 +359,3 @@ fn kill_process(pid: i32) { libc::kill(pid, libc::SIGKILL); } } - -#[cfg(test)] -mod tests { - use std::{ - collections::HashMap, - io::{Read, Write}, - net::{TcpListener, TcpStream}, - sync::atomic::AtomicBool, - thread, - time::Duration, - }; - - use super::{ - parse_status, poll_status, poll_vm_gone, poll_vm_status, request_json, request_status, - run_dynamic_probe, run_lifecycle_probe, run_readonly_probe, vm_status, - }; - use crate::test::case::HostHttpProbeConfig; - - /// Bearer token the fake server accepts, mirroring the guest build's - /// `[env] AXVM_HTTP_TOKEN` for the control-plane auth gate. - const TEST_TOKEN: &str = "test-token"; - /// Fixed VM id the fake server registers (mirrors `linux-smp1.toml`). - const TEST_VM_ID: u64 = 1; - - /// Stateful fake of the in-guest control plane. Emulates the auth boundary - /// (mutating routes need the bearer token) and the VM registry lifecycle - /// (start -> running, stop -> stopped, create -> ready, delete -> gone). - #[derive(Default)] - struct FakeVmState { - vms: HashMap, - } - - impl FakeVmState { - fn serve(&mut self, stream: &mut TcpStream) { - let mut request = Vec::new(); - let mut buf = [0u8; 512]; - let headers_end = loop { - match stream.read(&mut buf) { - Ok(0) => break None, - Ok(n) => { - request.extend_from_slice(&buf[..n]); - if request.windows(4).any(|w| w == b"\r\n\r\n") { - break Some(request.len()); - } - } - Err(_) => break None, - } - }; - let Some(_headers_end) = headers_end else { - return; - }; - let head = String::from_utf8_lossy(&request); - let first_line = head.lines().next().unwrap_or("").to_string(); - let mut parts = first_line.split_whitespace(); - let method = parts.next().unwrap_or("").to_string(); - let path = parts.next().unwrap_or("/").to_string(); - let authorized = head - .lines() - .any(|line| line == format!("Authorization: Bearer {TEST_TOKEN}")); - let response = self.route(&method, &path, authorized); - let _ = stream.write_all(response.as_bytes()); - } - - fn route(&mut self, method: &str, path: &str, authorized: bool) -> String { - let is_write = method == "POST" || method == "DELETE"; - if is_write && !authorized { - return status_body("401 Unauthorized", ""); - } - match (method, path) { - ("GET", "/api/vms") => status_body("200 OK", "[]"), - ("GET", path) if path.starts_with(&format!("/api/vms/{TEST_VM_ID}")) => { - match self.vms.get(&TEST_VM_ID) { - Some(status) => { - status_body("200 OK", &format!(r#"{{"status":"{status}"}}"#)) - } - None => status_body("404 Not Found", ""), - } - } - ("GET", _) => status_body("404 Not Found", ""), - ("POST", path) if path == format!("/api/vms/{TEST_VM_ID}/start") => { - if self.vms.contains_key(&TEST_VM_ID) { - self.vms.insert(TEST_VM_ID, "running".to_string()); - status_body("200 OK", r#"{"ok":true,"status":"running"}"#) - } else { - status_body("404 Not Found", "") - } - } - ("POST", path) if path == format!("/api/vms/{TEST_VM_ID}/stop") => { - if self.vms.contains_key(&TEST_VM_ID) { - self.vms.insert(TEST_VM_ID, "stopped".to_string()); - status_body("200 OK", r#"{"ok":true,"status":"stopped"}"#) - } else { - status_body("404 Not Found", "") - } - } - ("POST", "/api/vms/create") => { - if self.vms.contains_key(&TEST_VM_ID) { - status_body("409 Conflict", "") - } else { - self.vms.insert(TEST_VM_ID, "ready".to_string()); - status_body("200 OK", &format!(r#"{{"id":{TEST_VM_ID}}}"#)) - } - } - ("DELETE", path) if path == format!("/api/vms/{TEST_VM_ID}") => { - self.vms.remove(&TEST_VM_ID); - status_body("204 No Content", "") - } - ("POST", _) => status_body("404 Not Found", ""), - _ => status_body("404 Not Found", ""), - } - } - } - - fn status_body(status: &str, body: &str) -> String { - format!( - "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ) - } - - fn start_fake_server() -> u16 { - start_fake_server_seeded(true) - } - - /// Start a fake server whose VM registry is empty, so the create route - /// succeeds instead of hitting the seeded-id conflict. Mirrors the real - /// dynamic test build, whose `vm_configs = []` leaves id 1 free at boot. - fn start_fake_server_empty() -> u16 { - start_fake_server_seeded(false) - } - - fn start_fake_server_seeded(seed: bool) -> u16 { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake server"); - let port = listener.local_addr().unwrap().port(); - thread::spawn(move || { - let mut state = FakeVmState::default(); - if seed { - state.vms.insert(TEST_VM_ID, "ready".to_string()); - } - for stream in listener.incoming() { - let mut stream = match stream { - Ok(stream) => stream, - Err(_) => break, - }; - state.serve(&mut stream); - } - }); - port - } - - fn addr_for(port: u16) -> String { - format!("127.0.0.1:{port}") - } - - fn scenario_config(scenario: crate::test::case::HostHttpProbeScenario) -> HostHttpProbeConfig { - HostHttpProbeConfig { - guest_port: 8080, - connect_timeout_secs: 5, - token: Some(TEST_TOKEN.to_string()), - scenario, - } - } - - #[test] - fn parse_status_extracts_numeric_code() { - assert_eq!( - parse_status(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"), - Some(200) - ); - assert_eq!( - parse_status(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"), - Some(404) - ); - assert_eq!(parse_status(b"garbage"), None); - assert_eq!(parse_status(b""), None); - } - - #[test] - fn request_status_returns_expected_codes_from_fake_server() { - let addr = addr_for(start_fake_server()); - assert_eq!( - request_status(&addr, "GET", "/api/vms", None, None), - Some(200) - ); - assert_eq!( - request_status(&addr, "GET", "/api/vms/999", None, None), - Some(404) - ); - } - - #[test] - fn unauthenticated_write_is_denied_with_401() { - let addr = addr_for(start_fake_server()); - assert_eq!( - request_status(&addr, "POST", "/api/vms/999/start", None, None), - Some(401) - ); - } - - #[test] - fn authenticated_write_reaches_the_route() { - let addr = addr_for(start_fake_server()); - // With the token the gate admits the write; the unknown VM still yields - // the contract's 404. - assert_eq!( - request_status(&addr, "POST", "/api/vms/999/start", None, Some(TEST_TOKEN)), - Some(404) - ); - } - - #[test] - fn poll_status_returns_once_server_is_up() { - let addr = addr_for(start_fake_server()); - let stop = AtomicBool::new(false); - let status = poll_status( - &addr, - "/api/vms", - std::time::Instant::now(), - Duration::from_secs(5), - &stop, - ); - assert_eq!(status, Some(200)); - } - - #[test] - fn poll_status_gives_up_on_stop() { - let addr = addr_for(start_fake_server()); - let stop = AtomicBool::new(true); - let status = poll_status( - &addr, - "/api/vms", - std::time::Instant::now(), - Duration::from_secs(10), - &stop, - ); - assert_eq!(status, None); - } - - #[test] - fn vm_status_parses_the_status_field() { - let addr = addr_for(start_fake_server()); - let stop = AtomicBool::new(false); - assert_eq!( - vm_status(&addr, TEST_VM_ID, &stop).as_deref(), - Some("ready") - ); - } - - #[test] - fn poll_vm_status_observes_lifecycle_transitions() { - let addr = addr_for(start_fake_server()); - let stop = AtomicBool::new(false); - let started = std::time::Instant::now(); - assert_eq!( - poll_vm_status( - &addr, - TEST_VM_ID, - "ready", - started, - Duration::from_secs(5), - &stop - ), - Some(()) - ); - assert_eq!( - request_status( - &addr, - "POST", - &format!("/api/vms/{TEST_VM_ID}/start"), - None, - Some(TEST_TOKEN) - ), - Some(200) - ); - assert_eq!( - poll_vm_status( - &addr, - TEST_VM_ID, - "running", - started, - Duration::from_secs(5), - &stop - ), - Some(()) - ); - } - - #[test] - fn request_json_parses_the_create_response() { - let addr = addr_for(start_fake_server_empty()); - let (status, json) = request_json( - &addr, - "POST", - "/api/vms/create", - Some(r#"{"toml":"[base]\nid = 1"}"#), - Some(TEST_TOKEN), - ) - .expect("create request failed"); - assert_eq!(status, 200); - assert_eq!( - json.get("id").and_then(serde_json::Value::as_u64), - Some(TEST_VM_ID) - ); - } - - #[test] - fn poll_vm_gone_observes_delete() { - let addr = addr_for(start_fake_server()); - assert_eq!( - request_status( - &addr, - "DELETE", - &format!("/api/vms/{TEST_VM_ID}"), - None, - Some(TEST_TOKEN) - ), - Some(204) - ); - let stop = AtomicBool::new(false); - assert_eq!( - poll_vm_gone( - &addr, - TEST_VM_ID, - std::time::Instant::now(), - Duration::from_secs(5), - &stop - ), - Some(()) - ); - } - - #[test] - fn run_readonly_probe_passes() { - let addr = addr_for(start_fake_server()); - let stop = AtomicBool::new(false); - run_readonly_probe( - &addr, - "readonly", - Duration::from_secs(5), - Some(TEST_TOKEN), - &stop, - ) - .expect("readonly probe should pass"); - } - - #[test] - fn run_lifecycle_probe_passes() { - let addr = addr_for(start_fake_server()); - let stop = AtomicBool::new(false); - run_lifecycle_probe( - &addr, - "lifecycle", - Duration::from_secs(5), - Some(TEST_TOKEN), - TEST_VM_ID, - &stop, - ) - .expect("lifecycle probe should pass"); - } - - #[test] - fn run_dynamic_probe_passes() { - let dir = tempfile::tempdir().expect("tempdir"); - let config = dir.path().join("vm.toml"); - std::fs::write(&config, "[base]\nid = 1\n").expect("write config"); - let addr = addr_for(start_fake_server_empty()); - let stop = AtomicBool::new(false); - run_dynamic_probe( - &addr, - "dynamic", - Duration::from_secs(5), - Some(TEST_TOKEN), - config.to_str().expect("utf8 path"), - &stop, - ) - .expect("dynamic probe should pass"); - } - - #[test] - fn connection_refused_returns_none() { - // Bind and immediately drop to get a guaranteed-free port. - let port = std::net::TcpListener::bind("127.0.0.1:0") - .unwrap() - .local_addr() - .unwrap() - .port(); - let addr = addr_for(port); - assert_eq!(request_status(&addr, "GET", "/api/vms", None, None), None); - } -} diff --git a/test-suit/axvisor/normal/qemu-http-axum-control/http-axum-control/probe.sh b/test-suit/axvisor/normal/qemu-http-axum-control/http-axum-control/probe.sh new file mode 100755 index 0000000000..eb7586b023 --- /dev/null +++ b/test-suit/axvisor/normal/qemu-http-axum-control/http-axum-control/probe.sh @@ -0,0 +1,100 @@ +#!/bin/sh +# Host-side probe for the axum lifecycle control API +# (qemu-http-axum-control). Drives the default VM (id 1, linux-smp1.toml, kept +# `Ready` by `no-auto-start`) through the start/stop contract: +# GET /api/vms -> 200 (readiness) +# poll GET /api/vms/1 until status == "ready" +# POST /api/vms/1/start -> 200 (async:false, running) +# poll GET /api/vms/1 until status == "running" +# POST /api/vms/1/stop -> 200 (idempotent; async request) +# poll GET /api/vms/1 until status == "stopped" +# POST /api/vms/1/start -> 409 (restart-after-stop rejected) +# The runner injects AXBUILD_PROBE_PORT (forwarded host port) and +# AXVM_HTTP_TOKEN (the build's baked bearer token). Exit code is the verdict: +# 0 = pass, non-zero = fail. Contract mirrors +# os/axvisor/doc/http-control-plane-quickstart.md §2.3. +set -eu + +BASE="http://127.0.0.1:${AXBUILD_PROBE_PORT:?probe port not set}" +TOKEN="${AXVM_HTTP_TOKEN:-}" +AUTH="Authorization: Bearer ${TOKEN}" +DEADLINE="${AXBUILD_PROBE_DEADLINE:-120}" +VM_ID=1 + +fail() { + echo " host http probe: FAIL: $1" >&2 + exit 1 +} + +# http_code [curl args...]: print the HTTP status (000 on a +# connection failure, e.g. before the guest server is reachable). +http_code() { + method=$1 + path=$2 + shift 2 + out=$(curl -s -o /dev/null -w '%{http_code}' -X "$method" "${@}" "$BASE$path" 2>/dev/null || true) + printf '%s' "${out:-000}" +} + +# assert_code