From 05d83a4d2589252d051f86bc2df0d8a4a6c7adb2 Mon Sep 17 00:00:00 2001 From: Sergio Lopez Date: Mon, 16 Mar 2026 11:27:16 +0100 Subject: [PATCH 01/18] init: embed a simple DHCP client If there's a eth0 interface present, configure it with DHCP. Signed-off-by: Sergio Lopez --- init/dhcp.c | 514 +++++++++++++++++++++++++++++++++++++++++++ init/dhcp.h | 59 +++++ init/init.c | 15 ++ src/devices/build.rs | 7 + 4 files changed, 595 insertions(+) create mode 100644 init/dhcp.c create mode 100644 init/dhcp.h diff --git a/init/dhcp.c b/init/dhcp.c new file mode 100644 index 000000000..d923c7da7 --- /dev/null +++ b/init/dhcp.c @@ -0,0 +1,514 @@ +/* + * DHCP Client Implementation + * + * Standalone DHCP client for configuring IPv4 network interfaces. + * Translated from Rust implementation in muvm/src/guest/net.rs + */ + +#include "dhcp.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define DHCP_BUFFER_SIZE 576 + +/* Helper function to send netlink message */ +static int nl_send(int sock, struct nlmsghdr *nlh) +{ + struct sockaddr_nl sa = { + .nl_family = AF_NETLINK, + }; + + struct iovec iov = { + .iov_base = nlh, + .iov_len = nlh->nlmsg_len, + }; + + struct msghdr msg = { + .msg_name = &sa, + .msg_namelen = sizeof(sa), + .msg_iov = &iov, + .msg_iovlen = 1, + }; + + return sendmsg(sock, &msg, 0); +} + +/* Helper function to receive netlink response */ +static int nl_recv(int sock, char *buf, size_t len) +{ + struct sockaddr_nl sa; + struct iovec iov = { + .iov_base = buf, + .iov_len = len, + }; + + struct msghdr msg = { + .msg_name = &sa, + .msg_namelen = sizeof(sa), + .msg_iov = &iov, + .msg_iovlen = 1, + }; + + return recvmsg(sock, &msg, 0); +} + +/* Add routing attribute to netlink message */ +static void add_rtattr(struct nlmsghdr *nlh, int type, const void *data, + int len) +{ + int rtalen = RTA_SPACE(len); + struct rtattr *rta = + (struct rtattr *)(((char *)nlh) + NLMSG_ALIGN(nlh->nlmsg_len)); + rta->rta_type = type; + rta->rta_len = RTA_LENGTH(len); + memcpy(RTA_DATA(rta), data, len); + nlh->nlmsg_len = NLMSG_ALIGN(nlh->nlmsg_len) + rtalen; +} + +/* Set MTU */ +static int set_mtu(int nl_sock, int iface_index, unsigned int mtu) +{ + char buf[4096]; + struct nlmsghdr *nlh; + struct nlmsgerr *err; + struct ifinfomsg *ifi; + + memset(buf, 0, sizeof(buf)); + nlh = (struct nlmsghdr *)buf; + nlh->nlmsg_len = NLMSG_LENGTH(sizeof(struct ifinfomsg)); + nlh->nlmsg_type = RTM_NEWLINK; + nlh->nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK; + nlh->nlmsg_seq = 1; + nlh->nlmsg_pid = getpid(); + + ifi = (struct ifinfomsg *)NLMSG_DATA(nlh); + ifi->ifi_family = AF_UNSPEC; + ifi->ifi_type = ARPHRD_ETHER; + ifi->ifi_index = iface_index; + + add_rtattr(nlh, IFLA_MTU, &mtu, sizeof(mtu)); + + if (nl_send(nl_sock, nlh) < 0) { + perror("nl_send failed for set_mtu"); + return -1; + } + + /* Receive ACK */ + int len = nl_recv(nl_sock, buf, sizeof(buf)); + if (len < 0) { + perror("nl_recv failed for set_mtu"); + return -1; + } + + if (nlh->nlmsg_type != NLMSG_ERROR) { + printf("netlink didn't return a valid answer for set_mtu\n"); + return -1; + } + + err = (struct nlmsgerr *)NLMSG_DATA(nlh); + if (err->error != 0) { + printf("netlink returned an error for set_mtu: %d\n", err->error); + return -1; + } + + return 0; +} + +/* Add or delete IPv4 route */ +static int mod_route4(int nl_sock, int iface_index, int cmd, struct in_addr gw) +{ + char buf[4096]; + struct nlmsghdr *nlh; + struct nlmsgerr *err; + struct rtmsg *rtm; + struct in_addr dst = {.s_addr = INADDR_ANY}; + + memset(buf, 0, sizeof(buf)); + nlh = (struct nlmsghdr *)buf; + nlh->nlmsg_len = NLMSG_LENGTH(sizeof(struct rtmsg)); + nlh->nlmsg_type = cmd; + nlh->nlmsg_flags = NLM_F_REQUEST | NLM_F_CREATE | NLM_F_ACK; + nlh->nlmsg_seq = 1; + nlh->nlmsg_pid = getpid(); + + rtm = (struct rtmsg *)NLMSG_DATA(nlh); + rtm->rtm_family = AF_INET; + rtm->rtm_dst_len = 0; + rtm->rtm_src_len = 0; + rtm->rtm_tos = 0; + rtm->rtm_table = RT_TABLE_MAIN; + rtm->rtm_protocol = RTPROT_BOOT; + rtm->rtm_scope = RT_SCOPE_UNIVERSE; + rtm->rtm_type = RTN_UNICAST; + rtm->rtm_flags = 0; + + add_rtattr(nlh, RTA_OIF, &iface_index, sizeof(iface_index)); + add_rtattr(nlh, RTA_DST, &dst, sizeof(dst)); + add_rtattr(nlh, RTA_GATEWAY, &gw, sizeof(gw)); + + if (nl_send(nl_sock, nlh) < 0) { + perror("nl_send failed for mod_route4"); + return -1; + } + + /* Receive ACK */ + int len = nl_recv(nl_sock, buf, sizeof(buf)); + if (len < 0) { + perror("nl_recv failed for mod_route4"); + return -1; + } + + if (nlh->nlmsg_type != NLMSG_ERROR) { + printf("netlink didn't return a valid answer for mod_route4\n"); + return -1; + } + + err = (struct nlmsgerr *)NLMSG_DATA(nlh); + if (err->error != 0) { + printf("netlink returned an error for mod_route4: %d\n", err->error); + return -1; + } + + return 0; +} + +/* Add or delete IPv4 address */ +static int mod_addr4(int nl_sock, int iface_index, int cmd, struct in_addr addr, + unsigned char prefix_len) +{ + char buf[4096]; + struct nlmsghdr *nlh; + struct nlmsgerr *err; + struct ifaddrmsg *ifa; + + memset(buf, 0, sizeof(buf)); + nlh = (struct nlmsghdr *)buf; + nlh->nlmsg_len = NLMSG_LENGTH(sizeof(struct ifaddrmsg)); + nlh->nlmsg_type = cmd; + nlh->nlmsg_flags = NLM_F_REQUEST | NLM_F_CREATE | NLM_F_ACK; + nlh->nlmsg_seq = 1; + nlh->nlmsg_pid = getpid(); + + ifa = (struct ifaddrmsg *)NLMSG_DATA(nlh); + ifa->ifa_family = AF_INET; + ifa->ifa_prefixlen = prefix_len; + ifa->ifa_flags = 0; + ifa->ifa_scope = RT_SCOPE_UNIVERSE; + ifa->ifa_index = iface_index; + + add_rtattr(nlh, IFA_LOCAL, &addr, sizeof(addr)); + add_rtattr(nlh, IFA_ADDRESS, &addr, sizeof(addr)); + + if (nl_send(nl_sock, nlh) < 0) { + perror("nl_send failed for mod_addr4"); + return -1; + } + + /* Receive ACK */ + int len = nl_recv(nl_sock, buf, sizeof(buf)); + if (len < 0) { + perror("nl_recv failed for mod_addr4"); + return -1; + } + + if (nlh->nlmsg_type != NLMSG_ERROR) { + printf("netlink didn't return a valid answer for mod_addr4\n"); + return -1; + } + + err = (struct nlmsgerr *)NLMSG_DATA(nlh); + if (err->error != 0) { + printf("netlink returned an error for mod_addr4: %d\n", err->error); + return -1; + } + + return 0; +} + +/* Count leading ones in a 32-bit value */ +static unsigned char count_leading_ones(uint32_t val) +{ + unsigned char count = 0; + for (int i = 31; i >= 0; i--) { + if (val & (1U << i)) { + count++; + } else { + break; + } + } + return count; +} + +/* Send DISCOVER with Rapid Commit, process ACK, configure address and route */ +int do_dhcp(const char *iface) +{ + struct sockaddr_in bind_addr, dest_addr; + struct dhcp_packet request = {0}; + unsigned char response[DHCP_BUFFER_SIZE]; + struct timeval timeout; + int iface_index; + int broadcast = 1; + int nl_sock = -1; + int sock = -1; + int ret = -1; + + iface_index = if_nametoindex(iface); + if (iface_index == 0) { + perror("Failed to find index for network interface"); + return ret; + } + + nl_sock = socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE); + if (nl_sock < 0) { + perror("Failed to create netlink socket"); + return ret; + } + + struct sockaddr_nl sa = { + .nl_family = AF_NETLINK, + .nl_pid = getpid(), + .nl_groups = 0, + }; + + if (bind(nl_sock, (struct sockaddr *)&sa, sizeof(sa)) < 0) { + perror("Failed to bind netlink socket"); + goto cleanup; + } + + /* Temporary link-local address and route avoid the need for raw sockets */ + struct in_addr temp_addr; + inet_pton(AF_INET, "169.254.1.1", &temp_addr); + struct in_addr temp_gw = {.s_addr = INADDR_ANY}; + + if (mod_route4(nl_sock, iface_index, RTM_NEWROUTE, temp_gw) != 0) { + printf("couldn't add temporary route\n"); + goto cleanup; + } + if (mod_addr4(nl_sock, iface_index, RTM_NEWADDR, temp_addr, 16) != 0) { + printf("couldn't add temporary address\n"); + goto cleanup; + } + + /* Send request (DHCPDISCOVER) */ + sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (sock < 0) { + perror("socket failed"); + goto cleanup; + } + + /* Allow broadcast */ + if (setsockopt(sock, SOL_SOCKET, SO_BROADCAST, &broadcast, + sizeof(broadcast)) < 0) { + perror("setsockopt SO_BROADCAST failed"); + goto cleanup; + } + + /* Bind to port 68 (DHCP client) */ + memset(&bind_addr, 0, sizeof(bind_addr)); + bind_addr.sin_family = AF_INET; + bind_addr.sin_port = htons(68); + bind_addr.sin_addr.s_addr = INADDR_ANY; + + if (bind(sock, (struct sockaddr *)&bind_addr, sizeof(bind_addr)) < 0) { + perror("bind failed"); + goto cleanup; + } + + request.op = 1; /* BOOTREQUEST */ + request.htype = 1; /* Hardware address type: Ethernet */ + request.hlen = 6; /* Hardware address length */ + request.hops = 0; /* DHCP relay Hops */ + request.xid = + htonl(getpid()); /* Transaction ID: use PID for some randomness */ + request.secs = + 0; /* Seconds elapsed since beginning of acquisition or renewal */ + request.flags = htons(0x8000); /* DHCP message flags: Broadcast */ + request.ciaddr = 0; /* Client IP address (not set yet) */ + request.yiaddr = 0; /* 'your' IP address (server will fill) */ + request.siaddr = 0; /* Server IP address (not set) */ + request.giaddr = 0; /* Relay agent IP address (not set) */ + request.magic = htonl(0x63825363); /* Magic cookie */ + + /* chaddr, sname, and file are already zeroed by struct initialization */ + + /* Build DHCP options */ + int opt_offset = 0; + + /* Option 53: DHCP Message Type = DISCOVER (1) */ + request.options[opt_offset++] = 53; + request.options[opt_offset++] = 1; + request.options[opt_offset++] = 1; + + /* Option 80: Rapid Commit (RFC 4039) */ + request.options[opt_offset++] = 80; + request.options[opt_offset++] = 0; + + /* Option 255: End of options */ + request.options[opt_offset++] = 0xff; + + /* Remaining bytes are padding (up to 300 bytes) */ + + /* Send DHCP DISCOVER */ + memset(&dest_addr, 0, sizeof(dest_addr)); + dest_addr.sin_family = AF_INET; + dest_addr.sin_port = htons(67); + dest_addr.sin_addr.s_addr = INADDR_BROADCAST; + + if (sendto(sock, &request, sizeof(request), 0, + (struct sockaddr *)&dest_addr, sizeof(dest_addr)) < 0) { + perror("sendto failed"); + goto cleanup; + } + + /* Keep IPv6-only fast: set receive timeout to 100ms */ + timeout.tv_sec = 0; + timeout.tv_usec = 100000; + if (setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)) < + 0) { + perror("setsockopt SO_RCVTIMEO failed"); + goto cleanup; + } + + /* Get and process response (DHCPACK) if any */ + struct sockaddr_in from_addr; + socklen_t from_len = sizeof(from_addr); + ssize_t len = recvfrom(sock, response, sizeof(response), 0, + (struct sockaddr *)&from_addr, &from_len); + + close(sock); + sock = -1; + + if (len > 0) { + /* Parse DHCP response */ + struct in_addr addr; + /* yiaddr is at offset 16-19 in network byte order */ + memcpy(&addr.s_addr, &response[16], sizeof(addr.s_addr)); + + struct in_addr netmask = {.s_addr = INADDR_ANY}; + struct in_addr router = {.s_addr = INADDR_ANY}; + /* Clamp MTU to passt's limit */ + uint16_t mtu = 65520; + + FILE *resolv = fopen("/etc/resolv.conf", "w"); + if (!resolv) { + perror("Failed to open /etc/resolv.conf"); + } + + /* Parse DHCP options (start at offset 240 after magic cookie) */ + size_t p = 240; + while (p < (size_t)len) { + unsigned char opt = response[p]; + + if (opt == 0xff) { + /* Option 255: End (of options) */ + break; + } + + if (opt == 0) { /* Padding */ + p++; + continue; + } + + unsigned char opt_len = response[p + 1]; + p += 2; /* Length doesn't include code and length field itself */ + + if (p + opt_len > (size_t)len) { + /* Malformed packet, option length exceeds packet boundary */ + break; + } + + if (opt == 1) { + /* Option 1: Subnet Mask */ + memcpy(&netmask.s_addr, &response[p], sizeof(netmask.s_addr)); + } else if (opt == 3) { + /* Option 3: Router */ + memcpy(&router.s_addr, &response[p], sizeof(router.s_addr)); + } else if (opt == 6) { + /* Option 6: Domain Name Server */ + if (resolv) { + for (int dns_p = p; dns_p + 3 < p + opt_len; dns_p += 4) { + fprintf(resolv, "nameserver %d.%d.%d.%d\n", + response[dns_p], response[dns_p + 1], + response[dns_p + 2], response[dns_p + 3]); + } + } + } else if (opt == 26) { + /* Option 26: Interface MTU */ + mtu = (response[p] << 8) | response[p + 1]; + + /* We don't know yet if IPv6 is available: don't go below 1280 B + */ + if (mtu < 1280) + mtu = 1280; + if (mtu > 65520) + mtu = 65520; + } + + p += opt_len; + } + + if (resolv) { + fclose(resolv); + } + + /* Calculate prefix length from netmask */ + unsigned char prefix_len = count_leading_ones(ntohl(netmask.s_addr)); + + /* Drop temporary address and route, configure what we got instead */ + if (mod_route4(nl_sock, iface_index, RTM_DELROUTE, temp_gw) != 0) { + printf("couldn't remove temporary route\n"); + goto cleanup; + } + if (mod_addr4(nl_sock, iface_index, RTM_DELADDR, temp_addr, 16) != 0) { + printf("couldn't remove temporary address\n"); + goto cleanup; + } + + if (mod_addr4(nl_sock, iface_index, RTM_NEWADDR, addr, prefix_len) != + 0) { + printf("couldn't add the address provided by the DHCP server\n"); + goto cleanup; + } + if (mod_route4(nl_sock, iface_index, RTM_NEWROUTE, router) != 0) { + printf( + "couldn't add the default route provided by the DHCP server\n"); + goto cleanup; + } + + set_mtu(nl_sock, iface_index, mtu); + } else { + /* Clean up: we're clearly too cool for IPv4 */ + if (mod_route4(nl_sock, iface_index, RTM_DELROUTE, temp_gw) != 0) { + printf("couldn't remove temporary route\n"); + } + if (mod_addr4(nl_sock, iface_index, RTM_DELADDR, temp_addr, 16) != 0) { + printf("couldn't remove temporary address\n"); + } + } + + ret = 0; + +cleanup: + if (sock >= 0) { + close(sock); + } + if (nl_sock >= 0) { + close(nl_sock); + } + return ret; +} diff --git a/init/dhcp.h b/init/dhcp.h new file mode 100644 index 000000000..2a4abfb1a --- /dev/null +++ b/init/dhcp.h @@ -0,0 +1,59 @@ +/* + * DHCP Client Implementation + * + * Standalone DHCP client for configuring IPv4 network interfaces. + * Translated from Rust implementation in muvm/src/guest/net.rs + */ + +#ifndef DHCP_H +#define DHCP_H + +#include + +/* BOOTP vendor-specific area size (64) - magic cookie (4) */ +#define DHCP_OPTIONS_SIZE 60 + +/* DHCP packet structure (RFC 2131) */ +struct dhcp_packet { + uint8_t op; /* Message op code / message type (1 = BOOTREQUEST) */ + uint8_t htype; /* Hardware address type (1 = Ethernet) */ + uint8_t hlen; /* Hardware address length (6 for Ethernet) */ + uint8_t hops; /* Client sets to zero */ + uint32_t xid; /* Transaction ID */ + uint16_t secs; /* Seconds elapsed since client began address acquisition */ + uint16_t flags; /* Flags (0x8000 = Broadcast) */ + uint32_t ciaddr; /* Client IP address */ + uint32_t yiaddr; /* 'your' (client) IP address */ + uint32_t siaddr; /* IP address of next server to use in bootstrap */ + uint32_t giaddr; /* Relay agent IP address */ + uint8_t chaddr[16]; /* Client hardware address */ + uint8_t sname[64]; /* Optional server host name */ + uint8_t file[128]; /* Boot file name */ + uint32_t magic; /* Magic cookie (0x63825363) */ + uint8_t options[DHCP_OPTIONS_SIZE]; /* Options field */ +} __attribute__((packed)); + +/* + * Perform DHCP discovery and configuration for a network interface + * + * This function: + * 1. Sets up a temporary link-local address (169.254.1.1/16) + * 2. Sends a DHCP DISCOVER message with Rapid Commit option + * 3. Waits up to 100ms for a DHCP ACK response + * 4. Parses the response and configures: + * - IPv4 address with appropriate prefix length + * - Default gateway route + * - DNS servers (overwriting /etc/resolv.conf) + * - Interface MTU + * 5. Cleans up temporary configuration + * + * Parameters: + * iface - The name of the network interface to be configured. + * + * Returns: + * 0 on success (whether or not DHCP response was received) + * -1 on error + */ +int do_dhcp(const char *iface); + +#endif /* DHCP_H */ diff --git a/init/init.c b/init/init.c index 5f753c958..897e3f293 100644 --- a/init/init.c +++ b/init/init.c @@ -24,6 +24,7 @@ #include +#include "dhcp.h" #include "jsmn.h" #ifdef SEV @@ -1168,6 +1169,20 @@ int main(int argc, char **argv) strncpy(ifr.ifr_name, "lo", IFNAMSIZ); ifr.ifr_flags |= IFF_UP; ioctl(sockfd, SIOCSIFFLAGS, &ifr); + + memset(&ifr, 0, sizeof ifr); + strncpy(ifr.ifr_name, "eth0", IFNAMSIZ); + if (ioctl(sockfd, SIOCGIFFLAGS, &ifr) == 0) { + /* eth0 exists, bring it up first */ + ifr.ifr_flags |= IFF_UP; + ioctl(sockfd, SIOCSIFFLAGS, &ifr); + + /* Configure eth0 with DHCP */ + if (do_dhcp("eth0") != 0) { + printf("Warning: DHCP configuration for eth0 failed\n"); + } + } + close(sockfd); } diff --git a/src/devices/build.rs b/src/devices/build.rs index 0d5cc0c97..49a4346d2 100644 --- a/src/devices/build.rs +++ b/src/devices/build.rs @@ -6,6 +6,7 @@ fn build_default_init() -> PathBuf { let manifest_dir = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()); let libkrun_root = manifest_dir.join("../.."); let init_src = libkrun_root.join("init/init.c"); + let dhcp_src = libkrun_root.join("init/dhcp.c"); let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").unwrap()); let init_bin = out_dir.join("init"); @@ -14,10 +15,15 @@ fn build_default_init() -> PathBuf { println!("cargo:rerun-if-env-changed=CC"); println!("cargo:rerun-if-env-changed=TIMESYNC"); println!("cargo:rerun-if-changed={}", init_src.display()); + println!("cargo:rerun-if-changed={}", dhcp_src.display()); println!( "cargo:rerun-if-changed={}", libkrun_root.join("init/jsmn.h").display() ); + println!( + "cargo:rerun-if-changed={}", + libkrun_root.join("init/dhcp.h").display() + ); let mut init_cc_flags = vec!["-O2", "-static", "-Wall"]; if std::env::var_os("TIMESYNC").as_deref() == Some(OsStr::new("1")) { @@ -35,6 +41,7 @@ fn build_default_init() -> PathBuf { .arg("-o") .arg(&init_bin) .arg(&init_src) + .arg(&dhcp_src) .status() .unwrap_or_else(|e| panic!("failed to execute {cc}: {e}")); From 32dd4af9d62a1612677bc635bff2011f4b6ce27e Mon Sep 17 00:00:00 2001 From: Matej Hrica Date: Mon, 23 Mar 2026 18:16:26 +0100 Subject: [PATCH 02/18] init: use SO_BINDTODEVICE instead of temp address for DHCP Replace the temporary link-local address (169.254.1.1) workaround with SO_BINDTODEVICE. The temp address caused the kernel to use 169.254.1.1 as the source IP in DHCP packets; gvproxy then tried to reply to that address and failed with "no route to host". With this change the source IP should be 0.0.0.0, which is what RFC 2131 requires for DHCPDISCOVER. Signed-off-by: Matej Hrica --- init/dhcp.c | 39 ++++++--------------------------------- 1 file changed, 6 insertions(+), 33 deletions(-) diff --git a/init/dhcp.c b/init/dhcp.c index d923c7da7..ddaa1a5d7 100644 --- a/init/dhcp.c +++ b/init/dhcp.c @@ -290,20 +290,6 @@ int do_dhcp(const char *iface) goto cleanup; } - /* Temporary link-local address and route avoid the need for raw sockets */ - struct in_addr temp_addr; - inet_pton(AF_INET, "169.254.1.1", &temp_addr); - struct in_addr temp_gw = {.s_addr = INADDR_ANY}; - - if (mod_route4(nl_sock, iface_index, RTM_NEWROUTE, temp_gw) != 0) { - printf("couldn't add temporary route\n"); - goto cleanup; - } - if (mod_addr4(nl_sock, iface_index, RTM_NEWADDR, temp_addr, 16) != 0) { - printf("couldn't add temporary address\n"); - goto cleanup; - } - /* Send request (DHCPDISCOVER) */ sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); if (sock < 0) { @@ -318,6 +304,12 @@ int do_dhcp(const char *iface) goto cleanup; } + if (setsockopt(sock, SOL_SOCKET, SO_BINDTODEVICE, iface, + strlen(iface) + 1) < 0) { + perror("setsockopt SO_BINDTODEVICE failed"); + goto cleanup; + } + /* Bind to port 68 (DHCP client) */ memset(&bind_addr, 0, sizeof(bind_addr)); bind_addr.sin_family = AF_INET; @@ -469,16 +461,6 @@ int do_dhcp(const char *iface) /* Calculate prefix length from netmask */ unsigned char prefix_len = count_leading_ones(ntohl(netmask.s_addr)); - /* Drop temporary address and route, configure what we got instead */ - if (mod_route4(nl_sock, iface_index, RTM_DELROUTE, temp_gw) != 0) { - printf("couldn't remove temporary route\n"); - goto cleanup; - } - if (mod_addr4(nl_sock, iface_index, RTM_DELADDR, temp_addr, 16) != 0) { - printf("couldn't remove temporary address\n"); - goto cleanup; - } - if (mod_addr4(nl_sock, iface_index, RTM_NEWADDR, addr, prefix_len) != 0) { printf("couldn't add the address provided by the DHCP server\n"); @@ -489,16 +471,7 @@ int do_dhcp(const char *iface) "couldn't add the default route provided by the DHCP server\n"); goto cleanup; } - set_mtu(nl_sock, iface_index, mtu); - } else { - /* Clean up: we're clearly too cool for IPv4 */ - if (mod_route4(nl_sock, iface_index, RTM_DELROUTE, temp_gw) != 0) { - printf("couldn't remove temporary route\n"); - } - if (mod_addr4(nl_sock, iface_index, RTM_DELADDR, temp_addr, 16) != 0) { - printf("couldn't remove temporary address\n"); - } } ret = 0; From 7abbdaa923cccd3adbed1beb27e725d741b85b82 Mon Sep 17 00:00:00 2001 From: Matej Hrica Date: Mon, 23 Mar 2026 14:42:46 +0100 Subject: [PATCH 03/18] init: Implement fallback for DHCP servers without Rapid Commit When a server answers DHCPDISCOVER with DHCPOFFER instead of an immediate ACK, send DHCPREQUEST for the and wait for the final ACK. This makes DHCP work on macOS hosts when using gvproxy for networking. Signed-off-by: Matej Hrica --- init/dhcp.c | 266 ++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 183 insertions(+), 83 deletions(-) diff --git a/init/dhcp.c b/init/dhcp.c index ddaa1a5d7..b0978cfd6 100644 --- a/init/dhcp.c +++ b/init/dhcp.c @@ -25,6 +25,8 @@ #include #define DHCP_BUFFER_SIZE 576 +#define DHCP_MSG_OFFER 2 +#define DHCP_MSG_ACK 5 /* Helper function to send netlink message */ static int nl_send(int sock, struct nlmsghdr *nlh) @@ -254,6 +256,126 @@ static unsigned char count_leading_ones(uint32_t val) return count; } +/* Return the DHCP message type (option 53) from a response, or 0 */ +static unsigned char get_dhcp_msg_type(const unsigned char *response, + ssize_t len) +{ + /* Walk DHCP options (TLV chain starting after the magic cookie) */ + size_t p = 240; + while (p < (size_t)len) { + unsigned char opt = response[p]; + + if (opt == 0xff) /* end */ + break; + if (opt == 0) { /* padding */ + p++; + continue; + } + + unsigned char opt_len = response[p + 1]; + p += 2; + + if (p + opt_len > (size_t)len) + break; + if (opt == 53 && opt_len >= 1) /* Message Type */ + return response[p]; + + p += opt_len; + } + return 0; +} + +/* Parse a DHCP ACK and configure the interface. Returns 0 or -1 on error. */ +static int handle_dhcp_ack(int nl_sock, int iface_index, + const unsigned char *response, ssize_t len) +{ + /* Parse DHCP response */ + struct in_addr addr; + /* yiaddr is at offset 16-19 in network byte order */ + memcpy(&addr.s_addr, &response[16], sizeof(addr.s_addr)); + + struct in_addr netmask = {.s_addr = INADDR_ANY}; + struct in_addr router = {.s_addr = INADDR_ANY}; + /* Clamp MTU to passt's limit */ + uint16_t mtu = 65520; + + FILE *resolv = fopen("/etc/resolv.conf", "w"); + if (!resolv) { + perror("Failed to open /etc/resolv.conf"); + } + + /* Parse DHCP options (start at offset 240 after magic cookie) */ + size_t p = 240; + while (p < (size_t)len) { + unsigned char opt = response[p]; + + if (opt == 0xff) { + /* Option 255: End (of options) */ + break; + } + + if (opt == 0) { /* Padding */ + p++; + continue; + } + + unsigned char opt_len = response[p + 1]; + p += 2; /* Length doesn't include code and length field itself */ + + if (p + opt_len > (size_t)len) { + /* Malformed packet, option length exceeds packet boundary */ + break; + } + + if (opt == 1) { + /* Option 1: Subnet Mask */ + memcpy(&netmask.s_addr, &response[p], sizeof(netmask.s_addr)); + } else if (opt == 3) { + /* Option 3: Router */ + memcpy(&router.s_addr, &response[p], sizeof(router.s_addr)); + } else if (opt == 6) { + /* Option 6: Domain Name Server */ + if (resolv) { + for (int dns_p = p; dns_p + 3 < p + opt_len; dns_p += 4) { + fprintf(resolv, "nameserver %d.%d.%d.%d\n", response[dns_p], + response[dns_p + 1], response[dns_p + 2], + response[dns_p + 3]); + } + } + } else if (opt == 26) { + /* Option 26: Interface MTU */ + mtu = (response[p] << 8) | response[p + 1]; + + /* We don't know yet if IPv6 is available: don't go below 1280 B + */ + if (mtu < 1280) + mtu = 1280; + if (mtu > 65520) + mtu = 65520; + } + + p += opt_len; + } + + if (resolv) { + fclose(resolv); + } + + /* Calculate prefix length from netmask */ + unsigned char prefix_len = count_leading_ones(ntohl(netmask.s_addr)); + + if (mod_addr4(nl_sock, iface_index, RTM_NEWADDR, addr, prefix_len) != 0) { + printf("couldn't add the address provided by the DHCP server\n"); + return -1; + } + if (mod_route4(nl_sock, iface_index, RTM_NEWROUTE, router) != 0) { + printf("couldn't add the default route provided by the DHCP server\n"); + return -1; + } + set_mtu(nl_sock, iface_index, mtu); + return 0; +} + /* Send DISCOVER with Rapid Commit, process ACK, configure address and route */ int do_dhcp(const char *iface) { @@ -376,106 +498,84 @@ int do_dhcp(const char *iface) goto cleanup; } - /* Get and process response (DHCPACK) if any */ + /* Get response: DHCPACK (Rapid Commit) or DHCPOFFER */ struct sockaddr_in from_addr; socklen_t from_len = sizeof(from_addr); ssize_t len = recvfrom(sock, response, sizeof(response), 0, (struct sockaddr *)&from_addr, &from_len); - close(sock); - sock = -1; - - if (len > 0) { - /* Parse DHCP response */ - struct in_addr addr; - /* yiaddr is at offset 16-19 in network byte order */ - memcpy(&addr.s_addr, &response[16], sizeof(addr.s_addr)); - - struct in_addr netmask = {.s_addr = INADDR_ANY}; - struct in_addr router = {.s_addr = INADDR_ANY}; - /* Clamp MTU to passt's limit */ - uint16_t mtu = 65520; - - FILE *resolv = fopen("/etc/resolv.conf", "w"); - if (!resolv) { - perror("Failed to open /etc/resolv.conf"); - } - - /* Parse DHCP options (start at offset 240 after magic cookie) */ - size_t p = 240; - while (p < (size_t)len) { - unsigned char opt = response[p]; - - if (opt == 0xff) { - /* Option 255: End (of options) */ - break; - } - - if (opt == 0) { /* Padding */ - p++; - continue; - } - - unsigned char opt_len = response[p + 1]; - p += 2; /* Length doesn't include code and length field itself */ - - if (p + opt_len > (size_t)len) { - /* Malformed packet, option length exceeds packet boundary */ - break; - } + if (len <= 0) + goto done; /* No DHCP response — not an error, VM may be IPv6-only */ - if (opt == 1) { - /* Option 1: Subnet Mask */ - memcpy(&netmask.s_addr, &response[p], sizeof(netmask.s_addr)); - } else if (opt == 3) { - /* Option 3: Router */ - memcpy(&router.s_addr, &response[p], sizeof(router.s_addr)); - } else if (opt == 6) { - /* Option 6: Domain Name Server */ - if (resolv) { - for (int dns_p = p; dns_p + 3 < p + opt_len; dns_p += 4) { - fprintf(resolv, "nameserver %d.%d.%d.%d\n", - response[dns_p], response[dns_p + 1], - response[dns_p + 2], response[dns_p + 3]); - } - } - } else if (opt == 26) { - /* Option 26: Interface MTU */ - mtu = (response[p] << 8) | response[p + 1]; - - /* We don't know yet if IPv6 is available: don't go below 1280 B - */ - if (mtu < 1280) - mtu = 1280; - if (mtu > 65520) - mtu = 65520; - } + unsigned char msg_type = get_dhcp_msg_type(response, len); - p += opt_len; + if (msg_type == DHCP_MSG_ACK) { + /* Rapid Commit — server sent ACK directly */ + close(sock); + sock = -1; + if (handle_dhcp_ack(nl_sock, iface_index, response, len) != 0) + goto cleanup; + } else if (msg_type == DHCP_MSG_OFFER) { + /* + * DHCPOFFER — complete the 4-way handshake by sending DHCPREQUEST + * and waiting for DHCPACK. Servers without Rapid Commit (e.g. + * gvproxy) require this. + */ + struct in_addr offered_addr; + memcpy(&offered_addr.s_addr, &response[16], + sizeof(offered_addr.s_addr)); + + /* Build DHCPREQUEST */ + memset(request.options, 0, sizeof(request.options)); + opt_offset = 0; + + /* Option 53: DHCP Message Type = REQUEST (3) */ + request.options[opt_offset++] = 53; + request.options[opt_offset++] = 1; + request.options[opt_offset++] = 3; + + /* Option 50: Requested IP Address */ + request.options[opt_offset++] = 50; + request.options[opt_offset++] = 4; + memcpy(&request.options[opt_offset], &offered_addr.s_addr, 4); + opt_offset += 4; + + /* Option 54: Server Identifier (from_addr) */ + request.options[opt_offset++] = 54; + request.options[opt_offset++] = 4; + memcpy(&request.options[opt_offset], &from_addr.sin_addr.s_addr, 4); + opt_offset += 4; + + /* Option 255: End */ + request.options[opt_offset++] = 0xff; + + if (sendto(sock, &request, sizeof(request), 0, + (struct sockaddr *)&dest_addr, sizeof(dest_addr)) < 0) { + perror("sendto DHCPREQUEST failed"); + goto cleanup; } - if (resolv) { - fclose(resolv); - } + from_len = sizeof(from_addr); + len = recvfrom(sock, response, sizeof(response), 0, + (struct sockaddr *)&from_addr, &from_len); - /* Calculate prefix length from netmask */ - unsigned char prefix_len = count_leading_ones(ntohl(netmask.s_addr)); + close(sock); + sock = -1; - if (mod_addr4(nl_sock, iface_index, RTM_NEWADDR, addr, prefix_len) != - 0) { - printf("couldn't add the address provided by the DHCP server\n"); + if (len <= 0) { + printf("no DHCPACK received\n"); goto cleanup; } - if (mod_route4(nl_sock, iface_index, RTM_NEWROUTE, router) != 0) { - printf( - "couldn't add the default route provided by the DHCP server\n"); + + if (handle_dhcp_ack(nl_sock, iface_index, response, len) != 0) goto cleanup; - } - set_mtu(nl_sock, iface_index, mtu); + } else { + printf("unexpected DHCP message type %d\n", msg_type); + goto cleanup; } +done: ret = 0; - cleanup: if (sock >= 0) { close(sock); From 0b4eed30aa0e28c62b3a8e21cece8389ecf3645a Mon Sep 17 00:00:00 2001 From: Matej Hrica Date: Wed, 4 Mar 2026 11:49:58 +0100 Subject: [PATCH 04/18] tests: Make TcpTester server ip adress configurable Signed-off-by: Matej Hrica --- tests/test_cases/src/tcp_tester.rs | 11 ++++++----- tests/test_cases/src/test_tsi_tcp_guest_connect.rs | 3 ++- tests/test_cases/src/test_tsi_tcp_guest_listen.rs | 3 ++- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/test_cases/src/tcp_tester.rs b/tests/test_cases/src/tcp_tester.rs index 8a4268730..941badbe0 100644 --- a/tests/test_cases/src/tcp_tester.rs +++ b/tests/test_cases/src/tcp_tester.rs @@ -26,8 +26,8 @@ fn set_timeouts(stream: &mut TcpStream) { .unwrap(); } -fn connect(port: u16) -> TcpStream { - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), port); +fn connect(server_ip: Ipv4Addr, port: u16) -> TcpStream { + let addr = SocketAddr::new(IpAddr::V4(server_ip), port); let mut tries = 0; loop { match TcpStream::connect(addr) { @@ -45,12 +45,13 @@ fn connect(port: u16) -> TcpStream { #[derive(Debug, Copy, Clone)] pub struct TcpTester { + server_ip: Ipv4Addr, port: u16, } impl TcpTester { - pub const fn new(port: u16) -> Self { - Self { port } + pub const fn new(server_ip: Ipv4Addr, port: u16) -> Self { + Self { server_ip, port } } pub fn create_server_socket(&self) -> TcpListener { @@ -70,7 +71,7 @@ impl TcpTester { } pub fn run_client(&self) { - let mut stream = connect(self.port); + let mut stream = connect(self.server_ip, self.port); set_timeouts(&mut stream); expect_msg(&mut stream, b"ping!"); expect_wouldblock(&mut stream); diff --git a/tests/test_cases/src/test_tsi_tcp_guest_connect.rs b/tests/test_cases/src/test_tsi_tcp_guest_connect.rs index 038501b37..426cdad05 100644 --- a/tests/test_cases/src/test_tsi_tcp_guest_connect.rs +++ b/tests/test_cases/src/test_tsi_tcp_guest_connect.rs @@ -1,5 +1,6 @@ use crate::tcp_tester::TcpTester; use macros::{guest, host}; +use std::net::Ipv4Addr; const PORT: u16 = 8000; @@ -10,7 +11,7 @@ pub struct TestTsiTcpGuestConnect { impl TestTsiTcpGuestConnect { pub fn new() -> TestTsiTcpGuestConnect { Self { - tcp_tester: TcpTester::new(PORT), + tcp_tester: TcpTester::new(Ipv4Addr::LOCALHOST, PORT), } } } diff --git a/tests/test_cases/src/test_tsi_tcp_guest_listen.rs b/tests/test_cases/src/test_tsi_tcp_guest_listen.rs index 9838ed893..41e0ffc2d 100644 --- a/tests/test_cases/src/test_tsi_tcp_guest_listen.rs +++ b/tests/test_cases/src/test_tsi_tcp_guest_listen.rs @@ -1,5 +1,6 @@ use crate::tcp_tester::TcpTester; use macros::{guest, host}; +use std::net::Ipv4Addr; const PORT: u16 = 8001; @@ -10,7 +11,7 @@ pub struct TestTsiTcpGuestListen { impl TestTsiTcpGuestListen { pub fn new() -> Self { Self { - tcp_tester: TcpTester::new(PORT), + tcp_tester: TcpTester::new(Ipv4Addr::LOCALHOST, PORT), } } } From 232c02f3157ae172690fc9baab10282b589c9f8a Mon Sep 17 00:00:00 2001 From: Matej Hrica Date: Wed, 4 Mar 2026 17:53:32 +0100 Subject: [PATCH 05/18] tests: Move per-test unshare into the runner Instead of wrapping the entire test runner in a single unshare namespace from run.sh, perform per-test network namespace isolation directly in the runner when spawning each test subprocess. On Linux, each test is wrapped with `unshare --user --map-root-user --net` and loopback is brought up inside the namespace. If unshare is unavailable, tests run without isolation (with a warning). On macOS, tests run directly without unshare. Signed-off-by: Matej Hrica --- tests/run.sh | 9 +------ tests/runner/src/main.rs | 54 ++++++++++++++++++++++++++++++++-------- 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/tests/run.sh b/tests/run.sh index cd4a58606..0ddc64d14 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -68,11 +68,4 @@ if [ -n "${KRUN_TEST_BASE_DIR}" ]; then RUNNER_ARGS="${RUNNER_ARGS} --base-dir ${KRUN_TEST_BASE_DIR}" fi -if [ "$OS" != "Darwin" ] && [ -z "${KRUN_NO_UNSHARE}" ] && which unshare 2>&1 >/dev/null; then - unshare --user --map-root-user --net -- /bin/sh -c "ifconfig lo 127.0.0.1 && exec target/debug/runner ${RUNNER_ARGS}" -else - echo "WARNING: Running tests without a network namespace." - echo "Tests may fail if the required network ports are already in use." - echo - target/debug/runner ${RUNNER_ARGS} -fi +target/debug/runner ${RUNNER_ARGS} diff --git a/tests/runner/src/main.rs b/tests/runner/src/main.rs index ec816daa2..3299b3063 100644 --- a/tests/runner/src/main.rs +++ b/tests/runner/src/main.rs @@ -75,17 +75,49 @@ fn run_single_test( let log_path = test_dir.join("log.txt"); let log_file = File::create(&log_path).context("Failed to create log file")?; - let child = Command::new(&executable) - .arg("start-vm") - .arg("--test-case") - .arg(test_case.name) - .arg("--tmp-dir") - .arg(&test_dir) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(log_file) - .spawn() - .context("Failed to start subprocess for test")?; + // Wrap start-vm in unshare on Linux for network namespace isolation. + // Fall back to running directly (with a warning) if unshare isn't available. + let use_unshare = cfg!(target_os = "linux") + && std::env::var_os("KRUN_NO_UNSHARE").is_none() + && Command::new("unshare") + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + + let child = if use_unshare { + let exe = executable.display(); + let name = test_case.name; + let dir = test_dir.display(); + Command::new("unshare") + .args(["--user", "--map-root-user", "--net", "--", "sh", "-c"]) + .arg(format!( + "ifconfig lo 127.0.0.1 && exec {exe} start-vm --test-case {name} --tmp-dir {dir}" + )) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(log_file) + .spawn() + .context("Failed to start subprocess for test")? + } else { + if cfg!(target_os = "linux") { + eprintln!("WARNING: unshare not available, running without network namespace."); + eprintln!("Tests may fail if the required network ports are already in use."); + } + Command::new(&executable) + .arg("start-vm") + .arg("--test-case") + .arg(test_case.name) + .arg("--tmp-dir") + .arg(&test_dir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(log_file) + .spawn() + .context("Failed to start subprocess for test")? + }; let test_name = test_case.name.to_string(); let result = catch_unwind(|| { From 51cd1d9ec241e08677fbc96e861b9d723d9b9e03 Mon Sep 17 00:00:00 2001 From: Matej Hrica Date: Thu, 26 Mar 2026 12:47:31 +0100 Subject: [PATCH 06/18] tests: Use buildah unshare for proper subuid/subgid namespace mapping Use `buildah unshare -- unshare --net` instead of `unshare --user --map-root-user --net` to get proper UIDs/GIDs inside the test namespace via /etc/subuid and /etc/subgid. Signed-off-by: Matej Hrica --- tests/runner/src/main.rs | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/tests/runner/src/main.rs b/tests/runner/src/main.rs index 3299b3063..394a80b94 100644 --- a/tests/runner/src/main.rs +++ b/tests/runner/src/main.rs @@ -75,26 +75,31 @@ fn run_single_test( let log_path = test_dir.join("log.txt"); let log_file = File::create(&log_path).context("Failed to create log file")?; - // Wrap start-vm in unshare on Linux for network namespace isolation. - // Fall back to running directly (with a warning) if unshare isn't available. - let use_unshare = cfg!(target_os = "linux") - && std::env::var_os("KRUN_NO_UNSHARE").is_none() - && Command::new("unshare") + // Use `buildah unshare` for full subuid/subgid mapping + `unshare --net` + // for network namespace isolation. + // Fall back to running directly (with a warning) if buildah or unshare isn't available. + let has_cmd = |cmd: &str| { + Command::new(cmd) .arg("--version") .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .map(|s| s.success()) - .unwrap_or(false); + .unwrap_or(false) + }; + let use_buildah_unshare = cfg!(target_os = "linux") + && std::env::var_os("KRUN_NO_UNSHARE").is_none() + && has_cmd("buildah") + && has_cmd("unshare"); - let child = if use_unshare { + let child = if use_buildah_unshare { let exe = executable.display(); let name = test_case.name; let dir = test_dir.display(); - Command::new("unshare") - .args(["--user", "--map-root-user", "--net", "--", "sh", "-c"]) + Command::new("buildah") + .args(["unshare", "--", "unshare", "--net", "--", "sh", "-c"]) .arg(format!( - "ifconfig lo 127.0.0.1 && exec {exe} start-vm --test-case {name} --tmp-dir {dir}" + "echo '=== namespace debug ===' >&2; id >&2; cat /proc/self/uid_map >&2; cat /proc/self/gid_map >&2; ip link >&2; ifconfig lo 127.0.0.1; echo '=== ifconfig exit: '$?' ===' >&2; ip addr >&2; echo '=== end debug ===' >&2; exec {exe} start-vm --test-case {name} --tmp-dir {dir}" )) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -103,7 +108,7 @@ fn run_single_test( .context("Failed to start subprocess for test")? } else { if cfg!(target_os = "linux") { - eprintln!("WARNING: unshare not available, running without network namespace."); + eprintln!("WARNING: buildah not available, running without namespace isolation."); eprintln!("Tests may fail if the required network ports are already in use."); } Command::new(&executable) From fe23defd9dd3d754b08cccaa9f5a975945e3a77d Mon Sep 17 00:00:00 2001 From: Matej Hrica Date: Wed, 4 Mar 2026 17:54:10 +0100 Subject: [PATCH 07/18] tests: Support running tests on virtiofs generated via podman Signed-off-by: Matej Hrica --- tests/runner/src/main.rs | 16 ++++ tests/test_cases/src/common.rs | 16 ++-- tests/test_cases/src/lib.rs | 15 ++++ tests/test_cases/src/rootfs.rs | 129 +++++++++++++++++++++++++++++++++ 4 files changed, 168 insertions(+), 8 deletions(-) create mode 100644 tests/test_cases/src/rootfs.rs diff --git a/tests/runner/src/main.rs b/tests/runner/src/main.rs index 394a80b94..876d88cf0 100644 --- a/tests/runner/src/main.rs +++ b/tests/runner/src/main.rs @@ -72,6 +72,22 @@ fn run_single_test( let test_dir = base_dir.join(test_case.name); fs::create_dir(&test_dir).context("Failed to create test directory")?; + // Prepare rootfs: build from container image if needed, otherwise create empty dir + let rootfs_dir = test_dir.join("rootfs"); + if let Some(containerfile) = test_case.rootfs_image() { + use test_cases::rootfs; + if let Err(e) = rootfs::prepare_rootfs(containerfile, &rootfs_dir) { + eprintln!("SKIP ({e})"); + return Ok(TestResult { + name: test_case.name.to_string(), + outcome: TestOutcome::Skip("rootfs image build failed"), + log_path: None, + }); + } + } else { + fs::create_dir(&rootfs_dir).context("Failed to create rootfs directory")?; + } + let log_path = test_dir.join("log.txt"); let log_file = File::create(&log_path).context("Failed to create log file")?; diff --git a/tests/test_cases/src/common.rs b/tests/test_cases/src/common.rs index 6a3ee2483..efc531f00 100644 --- a/tests/test_cases/src/common.rs +++ b/tests/test_cases/src/common.rs @@ -20,16 +20,16 @@ fn copy_guest_agent(dir: &Path) -> anyhow::Result<()> { Ok(()) } -/// Common part of most test. This setups an empty root filesystem, copies the guest agent there -/// and runs the guest agent in the VM. -/// Note that some tests might want to use a different root file system (perhaps a qcow image), -/// in which case the test can implement the equivalent functionality itself, or better if there -/// are more test doing that, add another utility method in this file. +/// Sets up the root filesystem, copies the guest agent into it, and enters the VM. /// -/// The returned object is used for deleting the temporary files. +/// The rootfs directory (`test_setup.tmp_dir/rootfs`) is expected to already exist, +/// either empty or pre-populated by the runner from a container image. pub fn setup_fs_and_enter(ctx: u32, test_setup: TestSetup) -> anyhow::Result<()> { - let root_dir = test_setup.tmp_dir.join("root"); - create_dir(&root_dir).context("Failed to create root directory")?; + let root_dir = test_setup.tmp_dir.join("rootfs"); + + if !root_dir.exists() { + create_dir(&root_dir).context("Failed to create rootfs directory")?; + } let path_str = CString::new(root_dir.as_os_str().as_bytes()).context("CString::new")?; copy_guest_agent(&root_dir)?; diff --git a/tests/test_cases/src/lib.rs b/tests/test_cases/src/lib.rs index f164ed87e..a34944975 100644 --- a/tests/test_cases/src/lib.rs +++ b/tests/test_cases/src/lib.rs @@ -76,6 +76,9 @@ mod common; #[cfg(feature = "host")] mod krun; + +#[cfg(feature = "host")] +pub mod rootfs; mod tcp_tester; #[host] @@ -101,6 +104,13 @@ pub trait Test { fn should_run(&self) -> ShouldRun { ShouldRun::Yes } + + /// Return Containerfile content if this test needs a custom rootfs image. + /// The runner will build the image via podman and extract it before launching the VM. + /// If podman is unavailable, the test is skipped. + fn rootfs_image(&self) -> Option<&'static str> { + None + } } #[guest] @@ -127,6 +137,11 @@ impl TestCase { self.test.should_run() } + #[host] + pub fn rootfs_image(&self) -> Option<&'static str> { + self.test.rootfs_image() + } + #[allow(dead_code)] pub fn name(&self) -> &'static str { self.name diff --git a/tests/test_cases/src/rootfs.rs b/tests/test_cases/src/rootfs.rs new file mode 100644 index 000000000..7a9f5f86e --- /dev/null +++ b/tests/test_cases/src/rootfs.rs @@ -0,0 +1,129 @@ +//! Podman-based rootfs provisioning for tests that need a full Linux rootfs. +//! +//! `prepare_rootfs` builds a podman image from a Containerfile string, creates a container, +//! and pipes `podman export` directly into `tar -x` to populate the destination directory. +//! The image tag is derived from the hash of the Containerfile content (`krun-test-`), +//! so podman's layer cache makes rebuilds fast when the Containerfile hasn't changed. + +use anyhow::{bail, Context}; +use std::fs; +use std::io::Write; +use std::path::Path; +use std::process::{Command, Stdio}; + +fn containerfile_tag(containerfile: &str) -> anyhow::Result { + let output = Command::new("sha256sum") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .and_then(|mut child| { + child + .stdin + .take() + .unwrap() + .write_all(containerfile.as_bytes())?; + child.wait_with_output() + }) + .context("sha256sum")?; + + let hash = String::from_utf8(output.stdout).context("sha256sum output not utf-8")?; + let short = hash.get(..16).context("sha256sum output too short")?; + Ok(format!("krun-test-{short}")) +} + +fn podman_available() -> bool { + Command::new("podman") + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Builds a podman image from `containerfile`, creates a container, and extracts +/// its filesystem directly into `dest` (no intermediate tarball). +/// +/// Returns an error if podman is unavailable or the build/export fails. +pub fn prepare_rootfs(containerfile: &str, dest: &Path) -> anyhow::Result<()> { + if !podman_available() { + bail!("podman not available"); + } + + let tag = containerfile_tag(containerfile)?; + + // Build image (podman layer cache makes this fast when unchanged) + let mut build = Command::new("podman") + .args(["build", "-t", &tag, "-f", "-", "."]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .context("spawning podman build")?; + + build + .stdin + .take() + .unwrap() + .write_all(containerfile.as_bytes()) + .context("writing containerfile to podman stdin")?; + + let output = build + .wait_with_output() + .context("waiting for podman build")?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("podman build failed: {stderr}"); + } + + // Create a container from the image + let create_out = Command::new("podman") + .args(["create", &tag]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .context("podman create")?; + + if !create_out.status.success() { + let stderr = String::from_utf8_lossy(&create_out.stderr); + bail!("podman create failed: {stderr}"); + } + + let ctr_id = String::from_utf8(create_out.stdout) + .context("container id not utf-8")? + .trim() + .to_string(); + + // Pipe podman export directly into tar extract + fs::create_dir_all(dest).context("creating rootfs destination directory")?; + + let mut export = Command::new("podman") + .args(["export", &ctr_id]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("podman export")?; + + let export_stdout = export.stdout.take().unwrap(); + + let tar_status = Command::new("tar") + .args(["-x", "--no-same-owner", "-C"]) + .arg(dest) + .stdin(export_stdout) + .status() + .context("tar extract from podman export")?; + + let export_out = export + .wait_with_output() + .context("waiting for podman export")?; + + if !export_out.status.success() { + bail!("podman export failed"); + } + if !tar_status.success() { + bail!("tar extraction failed"); + } + + Ok(()) +} From 64d5af019fcacede36be2a8f2cbdb0d51aa5d451 Mon Sep 17 00:00:00 2001 From: Matej Hrica Date: Wed, 4 Mar 2026 17:59:47 +0100 Subject: [PATCH 08/18] tests: Make check() return TestOutcome Move TestOutcome from the runner into test_cases so individual tests can return their own outcome from check(). The runner now uses the returned value directly instead of relying solely on catch_unwind to distinguish pass from fail. Signed-off-by: Matej Hrica --- tests/runner/src/main.rs | 40 ++++++++++++++++++------------------- tests/test_cases/src/lib.rs | 14 +++++++++++-- 2 files changed, 32 insertions(+), 22 deletions(-) diff --git a/tests/runner/src/main.rs b/tests/runner/src/main.rs index 876d88cf0..6a0a5188f 100644 --- a/tests/runner/src/main.rs +++ b/tests/runner/src/main.rs @@ -8,14 +8,7 @@ use std::panic::catch_unwind; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use tempdir::TempDir; -use test_cases::{test_cases, ShouldRun, Test, TestCase, TestSetup}; - -#[derive(Clone)] -enum TestOutcome { - Pass, - Fail, - Skip(&'static str), -} +use test_cases::{test_cases, ShouldRun, Test, TestCase, TestOutcome, TestSetup}; struct TestResult { name: String, @@ -141,21 +134,28 @@ fn run_single_test( }; let test_name = test_case.name.to_string(); - let result = catch_unwind(|| { + let outcome = match catch_unwind(|| { let test = get_test(&test_name).unwrap(); - test.check(child); - }); + test.check(child) + }) { + Ok(outcome) => outcome, + Err(_) => TestOutcome::Fail, + }; - let outcome = if result.is_ok() { - eprintln!("OK"); - if !keep_all { - let _ = fs::remove_dir_all(&test_dir); + match &outcome { + TestOutcome::Pass => { + eprintln!("OK"); + if !keep_all { + let _ = fs::remove_dir_all(&test_dir); + } } - TestOutcome::Pass - } else { - eprintln!("FAIL"); - TestOutcome::Fail - }; + TestOutcome::Fail => { + eprintln!("FAIL"); + } + TestOutcome::Skip(reason) => { + eprintln!("SKIP ({})", reason); + } + } Ok(TestResult { name: test_case.name.to_string(), diff --git a/tests/test_cases/src/lib.rs b/tests/test_cases/src/lib.rs index a34944975..9dcbaa122 100644 --- a/tests/test_cases/src/lib.rs +++ b/tests/test_cases/src/lib.rs @@ -13,6 +13,12 @@ use test_tsi_tcp_guest_listen::TestTsiTcpGuestListen; mod test_multiport_console; use test_multiport_console::TestMultiportConsole; +pub enum TestOutcome { + Pass, + Fail, + Skip(&'static str), +} + pub enum ShouldRun { Yes, No(&'static str), @@ -95,9 +101,13 @@ pub trait Test { fn start_vm(self: Box, test_setup: TestSetup) -> anyhow::Result<()>; /// Checks the output of the (host) process which started the VM - fn check(self: Box, child: Child) { + fn check(self: Box, child: Child) -> TestOutcome { let output = child.wait_with_output().unwrap(); - assert_eq!(String::from_utf8(output.stdout).unwrap(), "OK\n"); + if String::from_utf8(output.stdout).unwrap() == "OK\n" { + TestOutcome::Pass + } else { + TestOutcome::Fail + } } /// Check if this test should run on this platform. From cde6ad59a9122b08de643a87b66db13a27447039 Mon Sep 17 00:00:00 2001 From: Matej Hrica Date: Wed, 4 Mar 2026 18:01:29 +0100 Subject: [PATCH 09/18] tests: Introduce Report test outcome Add a Report variant to TestOutcome that carries a ReportImpl trait object, allowing tests to produce structured output (text for the terminal, GitHub-flavored markdown for CI summaries) instead of a simple pass/fail. Signed-off-by: Matej Hrica --- tests/runner/src/main.rs | 53 ++++++++++++++++++++++++++++--------- tests/test_cases/src/lib.rs | 35 ++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 12 deletions(-) diff --git a/tests/runner/src/main.rs b/tests/runner/src/main.rs index 6a0a5188f..068378095 100644 --- a/tests/runner/src/main.rs +++ b/tests/runner/src/main.rs @@ -8,7 +8,7 @@ use std::panic::catch_unwind; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use tempdir::TempDir; -use test_cases::{test_cases, ShouldRun, Test, TestCase, TestOutcome, TestSetup}; +use test_cases::{test_cases, Report, ShouldRun, Test, TestCase, TestOutcome, TestSetup}; struct TestResult { name: String, @@ -155,6 +155,13 @@ fn run_single_test( TestOutcome::Skip(reason) => { eprintln!("SKIP ({})", reason); } + TestOutcome::Report(report) => { + eprintln!("REPORT"); + eprintln!("{:2}", report.text()); + if !keep_all { + let _ = fs::remove_dir_all(&test_dir); + } + } } Ok(TestResult { @@ -169,6 +176,7 @@ fn write_github_summary( num_pass: usize, num_fail: usize, num_skip: usize, + num_report: usize, ) -> anyhow::Result<()> { let summary_path = env::var("GITHUB_STEP_SUMMARY") .context("GITHUB_STEP_SUMMARY environment variable not set")?; @@ -181,15 +189,22 @@ fn write_github_summary( let num_ran = num_pass + num_fail; let status = if num_fail == 0 { "✅" } else { "❌" }; - let skip_msg = if num_skip > 0 { - format!(" ({num_skip} skipped)") - } else { + let mut extra = Vec::new(); + if num_skip > 0 { + extra.push(format!("{num_skip} skipped")); + } + if num_report > 0 { + extra.push(format!("{num_report} reports")); + } + let extra_msg = if extra.is_empty() { String::new() + } else { + format!(" ({})", extra.join(", ")) }; writeln!( file, - "## {status} Integration Tests - {num_pass}/{num_ran} passed{skip_msg}\n" + "## {status} Integration Tests - {num_pass}/{num_ran} passed{extra_msg}\n" )?; for result in results { @@ -197,6 +212,7 @@ fn write_github_summary( TestOutcome::Pass => ("✅", String::new()), TestOutcome::Fail => ("❌", String::new()), TestOutcome::Skip(reason) => ("⏭️", format!(" - {}", reason)), + TestOutcome::Report(_) => ("📊", String::new()), }; writeln!(file, "
")?; @@ -206,7 +222,9 @@ fn write_github_summary( result.name, status_text )?; - if let Some(log_path) = &result.log_path { + if let TestOutcome::Report(report) = &result.outcome { + writeln!(file, "{}", report.gh_markdown())?; + } else if let Some(log_path) = &result.log_path { let log_content = fs::read_to_string(log_path).unwrap_or_default(); writeln!(file, "```")?; // Limit log size to avoid huge summaries (2 MiB limit) @@ -280,28 +298,39 @@ fn run_tests( .iter() .filter(|r| matches!(r.outcome, TestOutcome::Skip(_))) .count(); + let num_report = results + .iter() + .filter(|r| matches!(r.outcome, TestOutcome::Report(_))) + .count(); let num_ran = num_pass + num_fail; // Write GitHub Actions summary if requested if github_summary { - write_github_summary(&results, num_pass, num_fail, num_skip)?; + write_github_summary(&results, num_pass, num_fail, num_skip, num_report)?; } - let skip_msg = if num_skip > 0 { - format!(" ({num_skip} skipped)") - } else { + let mut extra = Vec::new(); + if num_skip > 0 { + extra.push(format!("{num_skip} skipped")); + } + if num_report > 0 { + extra.push(format!("{num_report} reports")); + } + let extra_msg = if extra.is_empty() { String::new() + } else { + format!(" ({})", extra.join(", ")) }; if num_fail > 0 { eprintln!("(See test artifacts at: {})", base_dir.display()); - println!("\nFAIL - {num_pass}/{num_ran} passed{skip_msg}"); + println!("\nFAIL - {num_pass}/{num_ran} passed{extra_msg}"); anyhow::bail!("") } else { if keep_all { eprintln!("(See test artifacts at: {})", base_dir.display()); } - eprintln!("\nOK - {num_pass}/{num_ran} passed{skip_msg}"); + eprintln!("\nOK - {num_pass}/{num_ran} passed{extra_msg}"); } Ok(()) diff --git a/tests/test_cases/src/lib.rs b/tests/test_cases/src/lib.rs index 9dcbaa122..283779c50 100644 --- a/tests/test_cases/src/lib.rs +++ b/tests/test_cases/src/lib.rs @@ -17,6 +17,7 @@ pub enum TestOutcome { Pass, Fail, Skip(&'static str), + Report(Box), } pub enum ShouldRun { @@ -68,6 +69,40 @@ pub fn test_cases() -> Vec { //////////////////// // Implementation details: ////////////////// + +pub trait ReportImpl { + fn fmt_text(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result; + fn fmt_gh_markdown(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result; +} + +pub trait Report: ReportImpl { + fn text(&self) -> ReportText<'_, Self> { + ReportText(self) + } + + fn gh_markdown(&self) -> ReportGhMarkdown<'_, Self> { + ReportGhMarkdown(self) + } +} + +impl Report for T {} + +pub struct ReportText<'a, T: ReportImpl + ?Sized>(pub &'a T); + +impl std::fmt::Display for ReportText<'_, T> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt_text(f) + } +} + +pub struct ReportGhMarkdown<'a, T: ReportImpl + ?Sized>(pub &'a T); + +impl std::fmt::Display for ReportGhMarkdown<'_, T> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt_gh_markdown(f) + } +} + use macros::{guest, host}; #[host] use std::path::PathBuf; From 124d77b413d1ffdccec6452e9be23d3eef18ead8 Mon Sep 17 00:00:00 2001 From: Matej Hrica Date: Fri, 6 Mar 2026 16:06:17 +0100 Subject: [PATCH 10/18] tests: Add cleanup PID tracking for background processes Register background process PIDs (gvproxy, vmnet-helper) for automatic cleanup after each test. The runner sends SIGTERM, waits up to 5s, then SIGKILL any survivors. Signed-off-by: Matej Hrica --- tests/runner/Cargo.toml | 2 +- tests/runner/src/main.rs | 47 +++++++++++++++++++++++++++++++++++++ tests/test_cases/src/lib.rs | 19 +++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/tests/runner/Cargo.toml b/tests/runner/Cargo.toml index b74e9ad7c..8133341b8 100644 --- a/tests/runner/Cargo.toml +++ b/tests/runner/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" [dependencies] test_cases = { path = "../test_cases", features = ["host"] } anyhow = "1.0.95" -nix = { version = "0.29.0", features = ["resource", "fs"] } +nix = { version = "0.29.0", features = ["resource", "fs", "signal", "process"] } macros = { path = "../macros" } clap = { version = "4.5.27", features = ["derive"] } tempdir = "0.3.7" diff --git a/tests/runner/src/main.rs b/tests/runner/src/main.rs index 068378095..9d6753d5e 100644 --- a/tests/runner/src/main.rs +++ b/tests/runner/src/main.rs @@ -38,6 +38,48 @@ fn start_vm(test_setup: TestSetup) -> anyhow::Result<()> { Ok(()) } +/// Kill background processes registered for cleanup via +/// [`TestSetup::register_cleanup_pid`]. Sends SIGTERM first, waits up to 5s +/// for graceful exit, then SIGKILL any survivors. +fn kill_cleanup_pids(test_dir: &Path) { + use nix::sys::signal::{kill, Signal}; + use nix::unistd::Pid; + use std::io::BufRead; + + let Ok(file) = File::open(test_dir.join("cleanup.pids")) else { + return; + }; + + let mut pids = Vec::new(); + for line in std::io::BufReader::new(file).lines() { + let Ok(line) = line else { continue }; + if let Ok(raw) = line.trim().parse::() { + pids.push(Pid::from_raw(raw)); + } + } + + for &pid in &pids { + let _ = kill(pid, Signal::SIGTERM); + } + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + pids.retain(|&pid| kill(pid, None).is_ok()); + if pids.is_empty() || std::time::Instant::now() >= deadline { + break; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + + for &pid in &pids { + eprintln!( + "WARNING: cleanup process {} did not exit after SIGTERM, sending SIGKILL", + pid + ); + let _ = kill(pid, Signal::SIGKILL); + } +} + fn run_single_test( test_case: &TestCase, base_dir: &Path, @@ -142,6 +184,11 @@ fn run_single_test( Err(_) => TestOutcome::Fail, }; + // Kill any background processes registered for cleanup (e.g. gvproxy). + // Runs after check() regardless of outcome, so leaked processes are + // cleaned up even if the test crashed. + kill_cleanup_pids(&test_dir); + match &outcome { TestOutcome::Pass => { eprintln!("OK"); diff --git a/tests/test_cases/src/lib.rs b/tests/test_cases/src/lib.rs index 283779c50..e62add0c4 100644 --- a/tests/test_cases/src/lib.rs +++ b/tests/test_cases/src/lib.rs @@ -130,6 +130,25 @@ pub struct TestSetup { pub tmp_dir: PathBuf, } +#[host] +impl TestSetup { + /// Register a PID to be killed after the test finishes. + /// + /// The runner will SIGKILL these PIDs after check() returns, even if the + /// test crashed. Use this for background processes (e.g. gvproxy) that + /// outlive the VM. + pub fn register_cleanup_pid(&self, pid: u32) { + use std::io::Write; + let path = self.tmp_dir.join("cleanup.pids"); + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .expect("Failed to open cleanup.pids"); + writeln!(file, "{}", pid).expect("Failed to write cleanup PID"); + } +} + #[host] pub trait Test { /// Start the VM From d448cbccf2c09f13217953a47f8e62fe9e875271 Mon Sep 17 00:00:00 2001 From: Matej Hrica Date: Fri, 6 Mar 2026 16:06:56 +0100 Subject: [PATCH 11/18] tests: Add per-test timeout to prevent hanging the suite Each test has a configurable timeout (default 15s). If the child process doesn't exit within the deadline, the runner kills it, dumps any captured stdout, cleans up registered PIDs, and reports FAIL. Signed-off-by: Matej Hrica --- tests/runner/src/main.rs | 34 ++++++++++++++++++++++++++++++++++ tests/test_cases/src/lib.rs | 11 +++++++++++ 2 files changed, 45 insertions(+) diff --git a/tests/runner/src/main.rs b/tests/runner/src/main.rs index 9d6753d5e..6cc019828 100644 --- a/tests/runner/src/main.rs +++ b/tests/runner/src/main.rs @@ -175,6 +175,36 @@ fn run_single_test( .context("Failed to start subprocess for test")? }; + // Enforce a per-test timeout. If the child doesn't exit within the + // deadline, kill it so we don't hang the entire suite. + let timeout = std::time::Duration::from_secs(test_case.timeout_secs()); + let mut child = child; + let deadline = std::time::Instant::now() + timeout; + loop { + if child.try_wait().unwrap_or(None).is_some() { + break; + } + if std::time::Instant::now() >= deadline { + eprintln!("TIMEOUT ({}s)", timeout.as_secs()); + let _ = child.kill(); + if let Ok(output) = child.wait_with_output() { + if !output.stdout.is_empty() { + let stdout_path = test_dir.join("stdout.txt"); + let _ = fs::write(&stdout_path, &output.stdout); + let stdout = String::from_utf8_lossy(&output.stdout); + eprintln!("--- stdout from timed-out test ---\n{stdout}---"); + } + } + kill_cleanup_pids(&test_dir); + return Ok(TestResult { + name: test_case.name.to_string(), + outcome: TestOutcome::Timeout, + log_path: Some(log_path), + }); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + let test_name = test_case.name.to_string(); let outcome = match catch_unwind(|| { let test = get_test(&test_name).unwrap(); @@ -202,6 +232,9 @@ fn run_single_test( TestOutcome::Skip(reason) => { eprintln!("SKIP ({})", reason); } + TestOutcome::Timeout => { + eprintln!("TIMEOUT"); + } TestOutcome::Report(report) => { eprintln!("REPORT"); eprintln!("{:2}", report.text()); @@ -259,6 +292,7 @@ fn write_github_summary( TestOutcome::Pass => ("✅", String::new()), TestOutcome::Fail => ("❌", String::new()), TestOutcome::Skip(reason) => ("⏭️", format!(" - {}", reason)), + TestOutcome::Timeout => ("⏳", String::from(" - Timeout")), TestOutcome::Report(_) => ("📊", String::new()), }; diff --git a/tests/test_cases/src/lib.rs b/tests/test_cases/src/lib.rs index e62add0c4..0596bc657 100644 --- a/tests/test_cases/src/lib.rs +++ b/tests/test_cases/src/lib.rs @@ -16,6 +16,7 @@ use test_multiport_console::TestMultiportConsole; pub enum TestOutcome { Pass, Fail, + Timeout, Skip(&'static str), Report(Box), } @@ -175,6 +176,11 @@ pub trait Test { fn rootfs_image(&self) -> Option<&'static str> { None } + + /// Per-test timeout in seconds. The runner kills the test if it exceeds this. + fn timeout_secs(&self) -> u64 { + 15 + } } #[guest] @@ -206,6 +212,11 @@ impl TestCase { self.test.rootfs_image() } + #[host] + pub fn timeout_secs(&self) -> u64 { + self.test.timeout_secs() + } + #[allow(dead_code)] pub fn name(&self) -> &'static str { self.name From 54342b3284497baa7cce71c771aeca5de71b5305 Mon Sep 17 00:00:00 2001 From: Matej Hrica Date: Thu, 26 Mar 2026 12:50:14 +0100 Subject: [PATCH 12/18] tests: Save test stdout to file for debugging, read it back for asertion Write test stdout directly to stdout.txt in the test artifacts directory instead of buffering in memory. Read it back for check(). This ensures raw output (e.g. iperf3 JSON) is always available in artifacts, and also shows where the test got stuck if it times out. Signed-off-by: Matej Hrica --- tests/runner/src/main.rs | 30 ++++++++++++++---------------- tests/test_cases/src/lib.rs | 12 +++++------- 2 files changed, 19 insertions(+), 23 deletions(-) diff --git a/tests/runner/src/main.rs b/tests/runner/src/main.rs index 6cc019828..c454dcc3e 100644 --- a/tests/runner/src/main.rs +++ b/tests/runner/src/main.rs @@ -125,6 +125,8 @@ fn run_single_test( let log_path = test_dir.join("log.txt"); let log_file = File::create(&log_path).context("Failed to create log file")?; + let stdout_path = test_dir.join("stdout.txt"); + let stdout_file = File::create(&stdout_path).context("Failed to create stdout file")?; // Use `buildah unshare` for full subuid/subgid mapping + `unshare --net` // for network namespace isolation. @@ -153,7 +155,7 @@ fn run_single_test( "echo '=== namespace debug ===' >&2; id >&2; cat /proc/self/uid_map >&2; cat /proc/self/gid_map >&2; ip link >&2; ifconfig lo 127.0.0.1; echo '=== ifconfig exit: '$?' ===' >&2; ip addr >&2; echo '=== end debug ===' >&2; exec {exe} start-vm --test-case {name} --tmp-dir {dir}" )) .stdin(Stdio::piped()) - .stdout(Stdio::piped()) + .stdout(stdout_file) .stderr(log_file) .spawn() .context("Failed to start subprocess for test")? @@ -169,7 +171,7 @@ fn run_single_test( .arg("--tmp-dir") .arg(&test_dir) .stdin(Stdio::piped()) - .stdout(Stdio::piped()) + .stdout(stdout_file) .stderr(log_file) .spawn() .context("Failed to start subprocess for test")? @@ -187,14 +189,7 @@ fn run_single_test( if std::time::Instant::now() >= deadline { eprintln!("TIMEOUT ({}s)", timeout.as_secs()); let _ = child.kill(); - if let Ok(output) = child.wait_with_output() { - if !output.stdout.is_empty() { - let stdout_path = test_dir.join("stdout.txt"); - let _ = fs::write(&stdout_path, &output.stdout); - let stdout = String::from_utf8_lossy(&output.stdout); - eprintln!("--- stdout from timed-out test ---\n{stdout}---"); - } - } + let _ = child.wait(); kill_cleanup_pids(&test_dir); return Ok(TestResult { name: test_case.name.to_string(), @@ -205,13 +200,15 @@ fn run_single_test( std::thread::sleep(std::time::Duration::from_millis(100)); } + let stdout = fs::read(&stdout_path).unwrap_or_default(); + let test_name = test_case.name.to_string(); let outcome = match catch_unwind(|| { let test = get_test(&test_name).unwrap(); - test.check(child) + test.check(stdout) }) { Ok(outcome) => outcome, - Err(_) => TestOutcome::Fail, + Err(_) => TestOutcome::Fail("test.check() panicked".to_string()), }; // Kill any background processes registered for cleanup (e.g. gvproxy). @@ -226,8 +223,9 @@ fn run_single_test( let _ = fs::remove_dir_all(&test_dir); } } - TestOutcome::Fail => { - eprintln!("FAIL"); + TestOutcome::Fail(reason) => { + eprintln!("FAIL:"); + eprintln!("{reason}"); } TestOutcome::Skip(reason) => { eprintln!("SKIP ({})", reason); @@ -290,7 +288,7 @@ fn write_github_summary( for result in results { let (icon, status_text) = match &result.outcome { TestOutcome::Pass => ("✅", String::new()), - TestOutcome::Fail => ("❌", String::new()), + TestOutcome::Fail(_) => ("❌", String::new()), TestOutcome::Skip(reason) => ("⏭️", format!(" - {}", reason)), TestOutcome::Timeout => ("⏳", String::from(" - Timeout")), TestOutcome::Report(_) => ("📊", String::new()), @@ -373,7 +371,7 @@ fn run_tests( .count(); let num_fail = results .iter() - .filter(|r| matches!(r.outcome, TestOutcome::Fail)) + .filter(|r| matches!(r.outcome, TestOutcome::Fail(_))) .count(); let num_skip = results .iter() diff --git a/tests/test_cases/src/lib.rs b/tests/test_cases/src/lib.rs index 0596bc657..13ee72bcf 100644 --- a/tests/test_cases/src/lib.rs +++ b/tests/test_cases/src/lib.rs @@ -15,7 +15,7 @@ use test_multiport_console::TestMultiportConsole; pub enum TestOutcome { Pass, - Fail, + Fail(String), Timeout, Skip(&'static str), Report(Box), @@ -107,8 +107,6 @@ impl std::fmt::Display for ReportGhMarkdown<'_, T> { use macros::{guest, host}; #[host] use std::path::PathBuf; -#[host] -use std::process::Child; #[cfg(all(feature = "guest", feature = "host"))] compile_error!("Cannot enable both guest and host in the same binary!"); @@ -156,12 +154,12 @@ pub trait Test { fn start_vm(self: Box, test_setup: TestSetup) -> anyhow::Result<()>; /// Checks the output of the (host) process which started the VM - fn check(self: Box, child: Child) -> TestOutcome { - let output = child.wait_with_output().unwrap(); - if String::from_utf8(output.stdout).unwrap() == "OK\n" { + fn check(self: Box, stdout: Vec) -> TestOutcome { + let output = String::from_utf8(stdout).unwrap(); + if output == "OK\n" { TestOutcome::Pass } else { - TestOutcome::Fail + TestOutcome::Fail(format!("expected exactly {:?}, got {:?}", "OK\n", output)) } } From 593e7630e2f7bd1c3bca33d2310550345562d05a Mon Sep 17 00:00:00 2001 From: Matej Hrica Date: Tue, 31 Mar 2026 16:27:55 +0200 Subject: [PATCH 13/18] tests: show failure reason in GitHub summary Display the full error message in a code block within the test's details section, separated from the log output by a horizontal rule. Signed-off-by: Matej Hrica --- tests/runner/src/main.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/runner/src/main.rs b/tests/runner/src/main.rs index c454dcc3e..a44b021dc 100644 --- a/tests/runner/src/main.rs +++ b/tests/runner/src/main.rs @@ -301,6 +301,10 @@ fn write_github_summary( result.name, status_text )?; + if let TestOutcome::Fail(reason) = &result.outcome { + writeln!(file, "**Error:**\n```\n{reason}\n```\n---\n")?; + } + if let TestOutcome::Report(report) = &result.outcome { writeln!(file, "{}", report.gh_markdown())?; } else if let Some(log_path) = &result.log_path { From cdc4efa7a3f5987dc3a1a75176cb1e8b8499f68a Mon Sep 17 00:00:00 2001 From: Matej Hrica Date: Mon, 23 Mar 2026 15:17:21 +0100 Subject: [PATCH 14/18] tests: Introduce virtio-net tests Add tests for passt, tap, gvproxy, and vmnet-helper using guest DHCP setup across the supported network backends. Signed-off-by: Matej Hrica --- tests/test_cases/Cargo.toml | 2 +- tests/test_cases/src/lib.rs | 7 + tests/test_cases/src/test_net/gvproxy.rs | 109 +++++++++ tests/test_cases/src/test_net/mod.rs | 144 ++++++++++++ tests/test_cases/src/test_net/passt.rs | 94 ++++++++ tests/test_cases/src/test_net/tap.rs | 217 ++++++++++++++++++ tests/test_cases/src/test_net/vmnet_helper.rs | 186 +++++++++++++++ 7 files changed, 758 insertions(+), 1 deletion(-) create mode 100644 tests/test_cases/src/test_net/gvproxy.rs create mode 100644 tests/test_cases/src/test_net/mod.rs create mode 100644 tests/test_cases/src/test_net/passt.rs create mode 100644 tests/test_cases/src/test_net/tap.rs create mode 100644 tests/test_cases/src/test_net/vmnet_helper.rs diff --git a/tests/test_cases/Cargo.toml b/tests/test_cases/Cargo.toml index 34d646797..cf0213975 100644 --- a/tests/test_cases/Cargo.toml +++ b/tests/test_cases/Cargo.toml @@ -12,6 +12,6 @@ name = "test_cases" [dependencies] krun-sys = { path = "../../krun-sys", optional = true } macros = { path = "../macros" } -nix = { version = "0.29.0", features = ["socket"] } +nix = { version = "0.29.0", features = ["socket", "ioctl"] } anyhow = "1.0.95" tempdir = "0.3.7" \ No newline at end of file diff --git a/tests/test_cases/src/lib.rs b/tests/test_cases/src/lib.rs index 13ee72bcf..5bea2f882 100644 --- a/tests/test_cases/src/lib.rs +++ b/tests/test_cases/src/lib.rs @@ -10,6 +10,9 @@ use test_tsi_tcp_guest_connect::TestTsiTcpGuestConnect; mod test_tsi_tcp_guest_listen; use test_tsi_tcp_guest_listen::TestTsiTcpGuestListen; +mod test_net; +use test_net::TestNet; + mod test_multiport_console; use test_multiport_console::TestMultiportConsole; @@ -63,6 +66,10 @@ pub fn test_cases() -> Vec { "tsi-tcp-guest-listen", Box::new(TestTsiTcpGuestListen::new()), ), + TestCase::new("net-passt", Box::new(TestNet::new_passt())), + TestCase::new("net-tap", Box::new(TestNet::new_tap())), + TestCase::new("net-gvproxy", Box::new(TestNet::new_gvproxy())), + TestCase::new("net-vmnet-helper", Box::new(TestNet::new_vmnet_helper())), TestCase::new("multiport-console", Box::new(TestMultiportConsole)), ] } diff --git a/tests/test_cases/src/test_net/gvproxy.rs b/tests/test_cases/src/test_net/gvproxy.rs new file mode 100644 index 000000000..96da5aa00 --- /dev/null +++ b/tests/test_cases/src/test_net/gvproxy.rs @@ -0,0 +1,109 @@ +//! Gvproxy backend for virtio-net test (macOS only) + +use crate::{krun_call, ShouldRun, TestSetup}; +use krun_sys::{COMPAT_NET_FEATURES, NET_FLAG_VFKIT}; +use nix::libc; +use std::ffi::CString; + +type KrunAddNetUnixgramFn = unsafe extern "C" fn( + ctx_id: u32, + c_path: *const std::ffi::c_char, + fd: i32, + c_mac: *mut u8, + features: u32, + flags: u32, +) -> i32; + +fn get_krun_add_net_unixgram() -> KrunAddNetUnixgramFn { + let symbol = CString::new("krun_add_net_unixgram").unwrap(); + let ptr = unsafe { libc::dlsym(libc::RTLD_DEFAULT, symbol.as_ptr()) }; + assert!(!ptr.is_null(), "krun_add_net_unixgram not found"); + unsafe { std::mem::transmute(ptr) } +} + +const GVPROXY_PATH: &str = match option_env!("GVPROXY_PATH") { + Some(path) => path, + None => "/opt/homebrew/opt/podman/libexec/podman/gvproxy", +}; + +fn gvproxy_path() -> Option<&'static str> { + std::path::Path::new(GVPROXY_PATH) + .exists() + .then_some(GVPROXY_PATH) +} + +fn start_gvproxy( + socket_path: &str, + log_path: &std::path::Path, +) -> std::io::Result { + use std::process::{Command, Stdio}; + + let gvproxy = gvproxy_path().expect("gvproxy not found"); + + let log_file = std::fs::File::create(log_path)?; + + Command::new(gvproxy) + .arg("--listen-vfkit") + .arg(format!("unixgram:{}", socket_path)) + .arg("-debug") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(log_file) + .spawn() +} + +fn wait_for_socket(path: &std::path::Path, timeout_ms: u64) -> bool { + let start = std::time::Instant::now(); + while start.elapsed().as_millis() < timeout_ms as u128 { + if path.exists() { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + false +} + +pub(crate) fn should_run() -> ShouldRun { + #[cfg(not(target_os = "macos"))] + return ShouldRun::No("gvproxy unixgram only supported on macOS"); + + #[cfg(target_os = "macos")] + { + if gvproxy_path().is_none() { + return ShouldRun::No("gvproxy not installed"); + } + ShouldRun::Yes + } +} + +pub(crate) fn setup_backend(ctx: u32, test_setup: &TestSetup) -> anyhow::Result<()> { + let tmp_dir = test_setup + .tmp_dir + .canonicalize() + .unwrap_or_else(|_| test_setup.tmp_dir.clone()); + let socket_path = tmp_dir.join("gvproxy.sock"); + let gvproxy_log = tmp_dir.join("gvproxy.log"); + + let gvproxy_child = start_gvproxy(socket_path.to_str().unwrap(), &gvproxy_log)?; + test_setup.register_cleanup_pid(gvproxy_child.id()); + + anyhow::ensure!( + wait_for_socket(&socket_path, 5000), + "gvproxy failed to create socket" + ); + + let mut mac: [u8; 6] = [0x5a, 0x94, 0xef, 0xe4, 0x0c, 0xee]; + let c_socket_path = CString::new(socket_path.to_str().unwrap()).unwrap(); + + unsafe { + krun_call!(get_krun_add_net_unixgram()( + ctx, + c_socket_path.as_ptr(), + -1, + mac.as_mut_ptr(), + COMPAT_NET_FEATURES, + NET_FLAG_VFKIT, + ))?; + } + Ok(()) +} diff --git a/tests/test_cases/src/test_net/mod.rs b/tests/test_cases/src/test_net/mod.rs new file mode 100644 index 000000000..6f52d7fe3 --- /dev/null +++ b/tests/test_cases/src/test_net/mod.rs @@ -0,0 +1,144 @@ +//! Unified virtio-net integration tests +//! +//! All tests follow the same pattern: +//! 1. Host: Start backend + TCP server +//! 2. Guest: Connect to host TCP server (eth0 configured via DHCP by init) + +use crate::tcp_tester::TcpTester; +use macros::{guest, host}; + +#[host] +use crate::{ShouldRun, TestSetup}; + +#[cfg(feature = "host")] +pub(crate) mod gvproxy; +#[cfg(feature = "host")] +pub(crate) mod passt; +#[cfg(feature = "host")] +pub(crate) mod tap; +#[cfg(feature = "host")] +pub(crate) mod vmnet_helper; + +/// Virtio-net test with configurable backend +pub struct TestNet { + tcp_tester: TcpTester, + #[cfg(feature = "host")] + should_run: fn() -> ShouldRun, + #[cfg(feature = "host")] + setup_backend: fn(u32, &TestSetup) -> anyhow::Result<()>, + #[cfg(feature = "host")] + cleanup: Option, +} + +impl TestNet { + pub fn new_passt() -> Self { + Self { + tcp_tester: TcpTester::new([169, 254, 2, 2].into(), 9000), + #[cfg(feature = "host")] + should_run: passt::should_run, + #[cfg(feature = "host")] + setup_backend: passt::setup_backend, + #[cfg(feature = "host")] + cleanup: None, + } + } + + pub fn new_tap() -> Self { + Self { + tcp_tester: TcpTester::new([10, 0, 0, 1].into(), 9001), + #[cfg(feature = "host")] + should_run: tap::should_run, + #[cfg(feature = "host")] + setup_backend: tap::setup_backend, + #[cfg(feature = "host")] + cleanup: Some(tap::cleanup), + } + } + + pub fn new_gvproxy() -> Self { + Self { + tcp_tester: TcpTester::new([192, 168, 127, 254].into(), 9002), + #[cfg(feature = "host")] + should_run: gvproxy::should_run, + #[cfg(feature = "host")] + setup_backend: gvproxy::setup_backend, + #[cfg(feature = "host")] + cleanup: None, + } + } + + pub fn new_vmnet_helper() -> Self { + Self { + tcp_tester: TcpTester::new([192, 168, 105, 1].into(), 9003), + #[cfg(feature = "host")] + should_run: vmnet_helper::should_run, + #[cfg(feature = "host")] + setup_backend: vmnet_helper::setup_backend, + #[cfg(feature = "host")] + cleanup: None, + } + } +} + +#[host] +mod host { + use super::*; + use crate::common::setup_fs_and_enter; + use crate::{krun_call, krun_call_u32, Test, TestSetup}; + use krun_sys::*; + use std::thread; + + impl Test for TestNet { + fn should_run(&self) -> ShouldRun { + if unsafe { krun_call_u32!(krun_has_feature(KRUN_FEATURE_NET.into())) }.ok() != Some(1) + { + return ShouldRun::No("libkrun compiled without NET"); + } + (self.should_run)() + } + + fn check(self: Box, stdout: Vec) -> crate::TestOutcome { + if let Some(cleanup) = self.cleanup { + cleanup(); + } + if String::from_utf8(stdout).unwrap() == "OK\n" { + crate::TestOutcome::Pass + } else { + crate::TestOutcome::Fail + } + } + + fn start_vm(self: Box, test_setup: TestSetup) -> anyhow::Result<()> { + // Start TCP server + let tcp_tester = self.tcp_tester; + let listener = tcp_tester.create_server_socket(); + thread::spawn(move || tcp_tester.run_server(listener)); + + unsafe { + krun_call!(krun_set_log_level(KRUN_LOG_LEVEL_TRACE))?; + let ctx = krun_call_u32!(krun_create_ctx())?; + krun_call!(krun_set_vm_config(ctx, 1, 512))?; + + // Backend-specific setup + (self.setup_backend)(ctx, &test_setup)?; + + setup_fs_and_enter(ctx, test_setup)?; + } + Ok(()) + } + } +} + +#[guest] +mod guest { + use super::*; + use crate::Test; + + impl Test for TestNet { + fn in_guest(self: Box) { + self.tcp_tester.run_client(); + + println!("OK"); + } + } +} diff --git a/tests/test_cases/src/test_net/passt.rs b/tests/test_cases/src/test_net/passt.rs new file mode 100644 index 000000000..f769fb77f --- /dev/null +++ b/tests/test_cases/src/test_net/passt.rs @@ -0,0 +1,94 @@ +//! Passt backend for virtio-net test + +use crate::{krun_call, ShouldRun, TestSetup}; +use krun_sys::COMPAT_NET_FEATURES; +use nix::libc; +use std::ffi::CString; +use std::os::unix::io::RawFd; + +type KrunAddNetUnixstreamFn = unsafe extern "C" fn( + ctx_id: u32, + c_path: *const std::ffi::c_char, + fd: std::ffi::c_int, + c_mac: *mut u8, + features: u32, + flags: u32, +) -> i32; + +fn get_krun_add_net_unixstream() -> KrunAddNetUnixstreamFn { + let symbol = CString::new("krun_add_net_unixstream").unwrap(); + let ptr = unsafe { libc::dlsym(libc::RTLD_DEFAULT, symbol.as_ptr()) }; + assert!(!ptr.is_null(), "krun_add_net_unixstream not found"); + unsafe { std::mem::transmute(ptr) } +} + +fn passt_available() -> bool { + std::process::Command::new("which") + .arg("passt") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn start_passt() -> std::io::Result { + let mut fds = [0 as libc::c_int; 2]; + if unsafe { libc::socketpair(libc::AF_UNIX, libc::SOCK_STREAM, 0, fds.as_mut_ptr()) } < 0 { + return Err(std::io::Error::last_os_error()); + } + let (parent_fd, child_fd) = (fds[0], fds[1]); + let child_fd_str = child_fd.to_string(); + //FIXME? use low level stuff isntead! + let pid = unsafe { libc::fork() }; + if pid < 0 { + return Err(std::io::Error::last_os_error()); + } + + if pid == 0 { + unsafe { libc::close(parent_fd) }; + let passt = CString::new("passt").unwrap(); + let arg_f = CString::new("-f").unwrap(); + let arg_fd = CString::new("--fd").unwrap(); + let arg_fd_val = CString::new(child_fd_str).unwrap(); + unsafe { + libc::execlp( + passt.as_ptr(), + passt.as_ptr(), + arg_f.as_ptr(), + arg_fd.as_ptr(), + arg_fd_val.as_ptr(), + std::ptr::null::(), + ); + } + std::process::exit(1); + } + + unsafe { libc::close(child_fd) }; + Ok(parent_fd) +} + +pub(crate) fn should_run() -> ShouldRun { + if cfg!(target_os = "macos") { + return ShouldRun::No("passt not supported on macOS"); + } + if !passt_available() { + return ShouldRun::No("passt not installed"); + } + ShouldRun::Yes +} + +pub(crate) fn setup_backend(ctx: u32, _test_setup: &TestSetup) -> anyhow::Result<()> { + let passt_fd = start_passt()?; + let mut mac: [u8; 6] = [0x5a, 0x94, 0xef, 0xe4, 0x0c, 0xee]; + + unsafe { + krun_call!(get_krun_add_net_unixstream()( + ctx, + std::ptr::null(), + passt_fd, + mac.as_mut_ptr(), + COMPAT_NET_FEATURES, + 0, + ))?; + } + Ok(()) +} diff --git a/tests/test_cases/src/test_net/tap.rs b/tests/test_cases/src/test_net/tap.rs new file mode 100644 index 000000000..7f36925ed --- /dev/null +++ b/tests/test_cases/src/test_net/tap.rs @@ -0,0 +1,217 @@ +//! TAP backend for virtio-net test + +use crate::{krun_call, ShouldRun, TestSetup}; +use krun_sys::COMPAT_NET_FEATURES; +use nix::libc; +use nix::sys::socket::{socket, AddressFamily, SockFlag, SockType}; +use std::ffi::CString; +use std::fs::OpenOptions; +use std::net::{Ipv4Addr, SocketAddrV4, UdpSocket}; +use std::os::fd::AsRawFd; +use std::process::{Command, Stdio}; + +const DEFAULT_TAP_NAME: &str = "tap0"; +const HOST_IP: [u8; 4] = [10, 0, 0, 1]; +const NETMASK: [u8; 4] = [255, 255, 255, 0]; + +type KrunAddNetTapFn = unsafe extern "C" fn( + ctx_id: u32, + c_tap_name: *const std::ffi::c_char, + c_mac: *mut u8, + features: u32, + flags: u32, +) -> i32; + +fn get_krun_add_net_tap() -> KrunAddNetTapFn { + let symbol = CString::new("krun_add_net_tap").unwrap(); + let ptr = unsafe { libc::dlsym(libc::RTLD_DEFAULT, symbol.as_ptr()) }; + assert!(!ptr.is_null(), "krun_add_net_tap not found"); + unsafe { std::mem::transmute(ptr) } +} + +fn interface_exists(name: &str) -> bool { + std::path::Path::new(&format!("/sys/class/net/{}", name)).exists() +} + +// TAP device setup +const TUNSETIFF: libc::c_ulong = 0x400454ca; +const TUNSETPERSIST: libc::c_ulong = 0x400454cb; +const IFF_TAP: libc::c_short = 0x0002; +const IFF_NO_PI: libc::c_short = 0x1000; +const IFF_VNET_HDR: libc::c_short = 0x4000; +const IFNAMSIZ: usize = 16; +const IFF_UP: libc::c_short = 0x1; +const IFF_RUNNING: libc::c_short = 0x40; + +#[repr(C)] +struct Ifreq { + ifr_name: [u8; IFNAMSIZ], + ifr_ifru: IfreqIfru, +} + +#[repr(C)] +#[derive(Copy, Clone)] +union IfreqIfru { + ifru_flags: libc::c_short, + ifru_addr: libc::sockaddr, + _pad: [u8; 24], +} + +nix::ioctl_write_ptr_bad!(ioctl_tunsetiff, TUNSETIFF, Ifreq); +nix::ioctl_write_int_bad!(ioctl_tunsetpersist, TUNSETPERSIST); +nix::ioctl_readwrite_bad!(ioctl_siocsifaddr, 0x8916, Ifreq); +nix::ioctl_readwrite_bad!(ioctl_siocsifnetmask, 0x891c, Ifreq); +nix::ioctl_readwrite_bad!(ioctl_siocgifflags, 0x8913, Ifreq); +nix::ioctl_readwrite_bad!(ioctl_siocsifflags, 0x8914, Ifreq); + +fn set_interface_name(ifr: &mut Ifreq, name: &str) { + let bytes = name.as_bytes(); + let len = bytes.len().min(IFNAMSIZ - 1); + ifr.ifr_name = [0u8; IFNAMSIZ]; + ifr.ifr_name[..len].copy_from_slice(&bytes[..len]); +} + +fn make_sockaddr_in(ip: [u8; 4]) -> libc::sockaddr { + let mut addr: libc::sockaddr_in = unsafe { std::mem::zeroed() }; + addr.sin_family = libc::AF_INET as libc::sa_family_t; + addr.sin_addr.s_addr = u32::from_ne_bytes(ip); + unsafe { std::mem::transmute(addr) } +} + +fn create_tap(name: &str) -> std::io::Result<()> { + let tun = OpenOptions::new() + .read(true) + .write(true) + .open("/dev/net/tun")?; + let mut ifr: Ifreq = unsafe { std::mem::zeroed() }; + set_interface_name(&mut ifr, name); + ifr.ifr_ifru.ifru_flags = IFF_TAP | IFF_NO_PI | IFF_VNET_HDR; + unsafe { ioctl_tunsetiff(tun.as_raw_fd(), &ifr) }.map_err(std::io::Error::other)?; + unsafe { ioctl_tunsetpersist(tun.as_raw_fd(), 1) }.map_err(std::io::Error::other)?; + Ok(()) +} + +fn configure_host_interface(name: &str, ip: [u8; 4], netmask: [u8; 4]) -> nix::Result<()> { + let sock = socket( + AddressFamily::Inet, + SockType::Datagram, + SockFlag::empty(), + None, + )?; + let fd = sock.as_raw_fd(); + + let mut ifr: Ifreq = unsafe { std::mem::zeroed() }; + set_interface_name(&mut ifr, name); + ifr.ifr_ifru.ifru_addr = make_sockaddr_in(ip); + unsafe { ioctl_siocsifaddr(fd, &mut ifr)? }; + + let mut ifr: Ifreq = unsafe { std::mem::zeroed() }; + set_interface_name(&mut ifr, name); + ifr.ifr_ifru.ifru_addr = make_sockaddr_in(netmask); + unsafe { ioctl_siocsifnetmask(fd, &mut ifr)? }; + + let mut ifr: Ifreq = unsafe { std::mem::zeroed() }; + set_interface_name(&mut ifr, name); + unsafe { ioctl_siocgifflags(fd, &mut ifr)? }; + unsafe { ifr.ifr_ifru.ifru_flags |= IFF_UP | IFF_RUNNING }; + unsafe { ioctl_siocsifflags(fd, &mut ifr)? }; + + Ok(()) +} + +fn dnsmasq_available() -> bool { + Command::new("which") + .arg("dnsmasq") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +pub(crate) fn should_run() -> ShouldRun { + if cfg!(target_os = "macos") { + return ShouldRun::No("TAP not supported on macOS"); + } + if let Ok(tap_name) = std::env::var("LIBKRUN_TAP_NAME") { + if !interface_exists(&tap_name) { + return ShouldRun::No("TAP interface not found"); + } + } else if !std::path::Path::new("/dev/net/tun").exists() { + return ShouldRun::No("/dev/net/tun not available"); + } + if !dnsmasq_available() { + return ShouldRun::No("dnsmasq not installed"); + } + ShouldRun::Yes +} + +pub(crate) fn cleanup() { + if let Ok(tun) = OpenOptions::new() + .read(true) + .write(true) + .open("/dev/net/tun") + { + let mut ifr: Ifreq = unsafe { std::mem::zeroed() }; + set_interface_name(&mut ifr, DEFAULT_TAP_NAME); + ifr.ifr_ifru.ifru_flags = IFF_TAP | IFF_NO_PI; + if unsafe { ioctl_tunsetiff(tun.as_raw_fd(), &ifr) }.is_ok() { + let _ = unsafe { ioctl_tunsetpersist(tun.as_raw_fd(), 0) }; + } + } +} + +fn start_dhcp_server(tap_name: &str, test_setup: &TestSetup) -> anyhow::Result<()> { + let lease_file = test_setup.tmp_dir.join("dnsmasq.leases"); + let child = Command::new("dnsmasq") + .arg("--no-daemon") + .arg(format!("--interface={tap_name}")) + .arg("--dhcp-range=10.0.0.2,10.0.0.10,255.255.255.0") + .arg("--dhcp-option=3,10.0.0.1") // gateway + .arg("--dhcp-rapid-commit") // init's DHCP client uses Rapid Commit + .arg("--no-ping") // skip ARP probe delay before assigning + .arg(format!("--dhcp-leasefile={}", lease_file.display())) + .arg("--bind-dynamic") + .arg("--except-interface=lo") + .arg("--port=0") // disable DNS, we only need DHCP + .arg("--no-resolv") + .arg("--no-hosts") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| anyhow::anyhow!("Failed to start dnsmasq: {e}"))?; + test_setup.register_cleanup_pid(child.id()); + + // Wait for dnsmasq to bind port 67 before proceeding + for _ in 0..50 { + if UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 67)).is_err() { + return Ok(()); // port 67 is taken — dnsmasq is ready + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + anyhow::bail!("dnsmasq did not start in time"); +} + +pub(crate) fn setup_backend(ctx: u32, test_setup: &TestSetup) -> anyhow::Result<()> { + let tap_name = if let Ok(name) = std::env::var("LIBKRUN_TAP_NAME") { + name + } else { + create_tap(DEFAULT_TAP_NAME)?; + configure_host_interface(DEFAULT_TAP_NAME, HOST_IP, NETMASK) + .map_err(|e| anyhow::anyhow!("Failed to configure TAP: {}", e))?; + start_dhcp_server(DEFAULT_TAP_NAME, test_setup)?; + DEFAULT_TAP_NAME.to_string() + }; + + let mut mac: [u8; 6] = [0x5a, 0x94, 0xef, 0xe4, 0x0c, 0xee]; + let tap_name_c = CString::new(tap_name).unwrap(); + + unsafe { + krun_call!(get_krun_add_net_tap()( + ctx, + tap_name_c.as_ptr(), + mac.as_mut_ptr(), + COMPAT_NET_FEATURES, + 0, + ))?; + } + Ok(()) +} diff --git a/tests/test_cases/src/test_net/vmnet_helper.rs b/tests/test_cases/src/test_net/vmnet_helper.rs new file mode 100644 index 000000000..05069603a --- /dev/null +++ b/tests/test_cases/src/test_net/vmnet_helper.rs @@ -0,0 +1,186 @@ +//! vmnet-helper backend for virtio-net test (macOS only) + +use crate::{krun_call, ShouldRun, TestSetup}; +use krun_sys::{ + NET_FEATURE_CSUM, NET_FEATURE_GUEST_CSUM, NET_FEATURE_GUEST_TSO4, NET_FEATURE_HOST_TSO4, +}; +use nix::libc; +use std::ffi::CString; +use std::io::{BufRead, BufReader, Read}; +use std::process::{Command, Stdio}; + +type KrunAddNetUnixgramFn = unsafe extern "C" fn( + ctx_id: u32, + c_path: *const std::ffi::c_char, + fd: i32, + c_mac: *mut u8, + features: u32, + flags: u32, +) -> i32; + +fn get_krun_add_net_unixgram() -> KrunAddNetUnixgramFn { + let symbol = CString::new("krun_add_net_unixgram").unwrap(); + let ptr = unsafe { libc::dlsym(libc::RTLD_DEFAULT, symbol.as_ptr()) }; + assert!(!ptr.is_null(), "krun_add_net_unixgram not found"); + unsafe { std::mem::transmute(ptr) } +} + +const VMNET_HELPER_PATH: &str = match option_env!("VMNET_HELPER_PATH") { + Some(path) => path, + None => "/opt/homebrew/opt/vmnet-helper/libexec/vmnet-helper", +}; + +fn vmnet_helper_path() -> Option<&'static str> { + std::path::Path::new(VMNET_HELPER_PATH) + .exists() + .then_some(VMNET_HELPER_PATH) +} + +/// Parse a MAC address string like "1e:d4:d1:27:4b:bf" into 6 bytes. +fn parse_mac(s: &str) -> Option<[u8; 6]> { + let parts: Vec<&str> = s.split(':').collect(); + if parts.len() != 6 { + return None; + } + let mut mac = [0u8; 6]; + for (i, part) in parts.iter().enumerate() { + mac[i] = u8::from_str_radix(part, 16).ok()?; + } + Some(mac) +} + +struct VmnetConfig { + fd: i32, + mac: [u8; 6], + pid: u32, +} + +/// Start vmnet-helper with `--fd 3`, wait for its JSON config on stdout, +/// and return the fd + MAC address from vmnet. +/// +/// Creates a `SOCK_DGRAM` socketpair, passes one end to vmnet-helper as fd 3 +/// (matching what `vmnet-client` does), and returns the other end for use +/// with `krun_add_net_unixgram`. +fn start_vmnet_helper(log_path: &std::path::Path) -> std::io::Result { + let helper = vmnet_helper_path().expect("vmnet-helper not found"); + + // Create a SOCK_DGRAM socketpair + let mut fds = [0 as libc::c_int; 2]; + if unsafe { libc::socketpair(libc::AF_UNIX, libc::SOCK_DGRAM, 0, fds.as_mut_ptr()) } < 0 { + return Err(std::io::Error::last_os_error()); + } + let (our_fd, helper_fd) = (fds[0], fds[1]); + + // On macOS SOCK_DGRAM, SO_SNDBUF determines the maximum frame size (not + // buffering). Must be >= 65550 for TSO frames. + // TODO: SO_RCVBUF at 65550 causes "network unreachable" — DHCP issue? + const SNDBUF_SIZE: libc::c_int = 65550; + const RCVBUF_SIZE: libc::c_int = 1024 * 1024; + for fd in [our_fd, helper_fd] { + unsafe { + libc::setsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_SNDBUF, + &SNDBUF_SIZE as *const _ as *const libc::c_void, + std::mem::size_of_val(&SNDBUF_SIZE) as libc::socklen_t, + ); + libc::setsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_RCVBUF, + &RCVBUF_SIZE as *const _ as *const libc::c_void, + std::mem::size_of_val(&RCVBUF_SIZE) as libc::socklen_t, + ); + } + } + + let log_file = std::fs::File::create(log_path)?; + + let mut child = Command::new(helper) + .arg("--fd") + .arg(helper_fd.to_string()) + .arg("--enable-tso") + .arg("--enable-checksum-offload") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(log_file) + .spawn()?; + + // Parent: close helper's end of the socketpair + unsafe { libc::close(helper_fd) }; + + // Read the JSON config line from vmnet-helper's stdout. + // vmnet-helper writes a single JSON line then keeps running. + let stdout = child.stdout.take().unwrap(); + let reader = BufReader::new(stdout); + let mut config_line = String::new(); + reader + .take(4096) + .read_line(&mut config_line) + .map_err(|e| std::io::Error::other(format!("failed to read vmnet-helper config: {e}")))?; + + if config_line.is_empty() { + return Err(std::io::Error::other( + "vmnet-helper exited without producing config", + )); + } + + eprintln!("vmnet-helper config: {}", config_line.trim()); + + // Parse the MAC address from the JSON config. + // The JSON looks like: {"vmnet_mac_address":"1e:d4:d1:27:4b:bf",...} + let mac_str = config_line + .split("\"vmnet_mac_address\":\"") + .nth(1) + .and_then(|s| s.split('"').next()) + .ok_or_else(|| std::io::Error::other("vmnet_mac_address not found in config"))?; + + let mac = parse_mac(mac_str) + .ok_or_else(|| std::io::Error::other(format!("invalid MAC address: {mac_str}")))?; + + Ok(VmnetConfig { + fd: our_fd, + mac, + pid: child.id(), + }) +} + +pub(crate) fn should_run() -> ShouldRun { + #[cfg(not(target_os = "macos"))] + return ShouldRun::No("vmnet-helper only supported on macOS"); + + #[cfg(target_os = "macos")] + { + if vmnet_helper_path().is_none() { + return ShouldRun::No("vmnet-helper not installed"); + } + ShouldRun::Yes + } +} + +pub(crate) fn setup_backend(ctx: u32, test_setup: &TestSetup) -> anyhow::Result<()> { + let tmp_dir = test_setup + .tmp_dir + .canonicalize() + .unwrap_or_else(|_| test_setup.tmp_dir.clone()); + let vmnet_log = tmp_dir.join("vmnet-helper.log"); + + let mut config = start_vmnet_helper(&vmnet_log)?; + test_setup.register_cleanup_pid(config.pid); + + unsafe { + krun_call!(get_krun_add_net_unixgram()( + ctx, + std::ptr::null(), + config.fd, + config.mac.as_mut_ptr(), + NET_FEATURE_CSUM + | NET_FEATURE_GUEST_CSUM + | NET_FEATURE_GUEST_TSO4 + | NET_FEATURE_HOST_TSO4, + 0, // no VFKIT flag + ))?; + } + Ok(()) +} From 58c4b26c91ff23cccd14d5d8a5969c7a03aefbd7 Mon Sep 17 00:00:00 2001 From: Matej Hrica Date: Mon, 23 Mar 2026 15:03:01 +0100 Subject: [PATCH 15/18] tests: Introduce iperf3 network performance tests Add parametrized performance tests for each virtio-net backend (passt, tap, gvproxy, vmnet-helper) in both upload and download directions. Each test starts an iperf3 server on the host, runs the iperf3 client inside a Fedora-based guest VM, and reports throughput results as structured text/markdown via the Report outcome. Tests require IPERF_DURATION to be set at compile time and use a podman-built rootfs with iperf3 pre-installed. They are skipped when prerequisites are unavailable. Signed-off-by: Matej Hrica --- tests/Cargo.lock | 247 +++++++------- tests/test_cases/Cargo.toml | 4 +- tests/test_cases/src/lib.rs | 25 +- tests/test_cases/src/test_net/mod.rs | 11 +- tests/test_cases/src/test_net_perf.rs | 446 ++++++++++++++++++++++++++ 5 files changed, 602 insertions(+), 131 deletions(-) create mode 100644 tests/test_cases/src/test_net_perf.rs diff --git a/tests/Cargo.lock b/tests/Cargo.lock index 157d21c5f..fd0a8a588 100644 --- a/tests/Cargo.lock +++ b/tests/Cargo.lock @@ -4,18 +4,18 @@ version = 4 [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] [[package]] name = "anstream" -version = "0.6.18" +version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", "anstyle-parse", @@ -28,50 +28,50 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.10" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.2" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ "windows-sys", ] [[package]] name = "anstyle-wincon" -version = "3.0.7" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", - "once_cell", + "once_cell_polyfill", "windows-sys", ] [[package]] name = "anyhow" -version = "1.0.95" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "autocfg" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "bindgen" @@ -93,9 +93,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.8.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "cexpr" @@ -108,9 +108,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" @@ -130,9 +130,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.27" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "769b0145982b4b48713e01ec42d61614425f27b7058bda7180a3a41f30104796" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" dependencies = [ "clap_builder", "clap_derive", @@ -140,9 +140,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.27" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b26884eb4b57140e4d2d93652abfa49498b938b3c9179f9fc487b0acc3edad7" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" dependencies = [ "anstream", "anstyle", @@ -152,9 +152,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.24" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54b755194d6389280185988721fffba69495eed5ee9feeee9a599b53db80318c" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ "heck", "proc-macro2", @@ -164,21 +164,21 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.4" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "colorchoice" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "either" -version = "1.13.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "fuchsia-cprng" @@ -188,9 +188,9 @@ checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" [[package]] name = "glob" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "guest-agent" @@ -208,19 +208,25 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "is_terminal_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" -version = "0.12.1" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + [[package]] name = "krun-sys" version = "1.11.1" @@ -231,9 +237,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.169" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "macros" @@ -245,9 +251,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.4" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memoffset" @@ -288,31 +294,31 @@ dependencies = [ ] [[package]] -name = "once_cell" -version = "1.20.2" +name = "once_cell_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "pkg-config" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "proc-macro2" -version = "1.0.93" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.38" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -356,9 +362,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.1" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -368,9 +374,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -379,9 +385,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "remove_dir_all" @@ -410,6 +416,49 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "shlex" version = "1.3.0" @@ -424,9 +473,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.98" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36147f1a48ae0ec2b5b3bc5b537d267457555a10dc06f3dbc8cb11ba3006d3b1" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -451,14 +500,16 @@ dependencies = [ "krun-sys", "macros", "nix", + "serde", + "serde_json", "tempdir", ] [[package]] name = "unicode-ident" -version = "1.0.16" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a210d160f08b701c8721ba1c726c11662f877ea6b7094007e1ca9a1041945034" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "utf8parse" @@ -489,74 +540,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] -name = "windows-sys" -version = "0.59.0" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", -] +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "windows-targets" -version = "0.52.6" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows-link", ] [[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" +name = "zmij" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/tests/test_cases/Cargo.toml b/tests/test_cases/Cargo.toml index cf0213975..96a06564e 100644 --- a/tests/test_cases/Cargo.toml +++ b/tests/test_cases/Cargo.toml @@ -3,7 +3,7 @@ name = "test_cases" edition = "2021" [features] -host = ["krun-sys"] +host = ["krun-sys", "serde", "serde_json"] guest = [] [lib] @@ -14,4 +14,6 @@ krun-sys = { path = "../../krun-sys", optional = true } macros = { path = "../macros" } nix = { version = "0.29.0", features = ["socket", "ioctl"] } anyhow = "1.0.95" +serde = { version = "1", features = ["derive"], optional = true } +serde_json = { version = "1", optional = true } tempdir = "0.3.7" \ No newline at end of file diff --git a/tests/test_cases/src/lib.rs b/tests/test_cases/src/lib.rs index 5bea2f882..bda659e62 100644 --- a/tests/test_cases/src/lib.rs +++ b/tests/test_cases/src/lib.rs @@ -10,9 +10,12 @@ use test_tsi_tcp_guest_connect::TestTsiTcpGuestConnect; mod test_tsi_tcp_guest_listen; use test_tsi_tcp_guest_listen::TestTsiTcpGuestListen; -mod test_net; +pub(crate) mod test_net; use test_net::TestNet; +mod test_net_perf; +use test_net_perf::TestNetPerf; + mod test_multiport_console; use test_multiport_console::TestMultiportConsole; @@ -71,6 +74,26 @@ pub fn test_cases() -> Vec { TestCase::new("net-gvproxy", Box::new(TestNet::new_gvproxy())), TestCase::new("net-vmnet-helper", Box::new(TestNet::new_vmnet_helper())), TestCase::new("multiport-console", Box::new(TestMultiportConsole)), + TestCase::new("perf-net-passt-tx", Box::new(TestNetPerf::new_passt_tx())), + TestCase::new("perf-net-passt-rx", Box::new(TestNetPerf::new_passt_rx())), + TestCase::new("perf-net-tap-tx", Box::new(TestNetPerf::new_tap_tx())), + TestCase::new("perf-net-tap-rx", Box::new(TestNetPerf::new_tap_rx())), + TestCase::new( + "perf-net-gvproxy-tx", + Box::new(TestNetPerf::new_gvproxy_tx()), + ), + TestCase::new( + "perf-net-gvproxy-rx", + Box::new(TestNetPerf::new_gvproxy_rx()), + ), + TestCase::new( + "perf-net-vmnet-helper-tx", + Box::new(TestNetPerf::new_vmnet_helper_tx()), + ), + TestCase::new( + "perf-net-vmnet-helper-rx", + Box::new(TestNetPerf::new_vmnet_helper_rx()), + ), ] } diff --git a/tests/test_cases/src/test_net/mod.rs b/tests/test_cases/src/test_net/mod.rs index 6f52d7fe3..9f792635b 100644 --- a/tests/test_cases/src/test_net/mod.rs +++ b/tests/test_cases/src/test_net/mod.rs @@ -84,7 +84,7 @@ impl TestNet { mod host { use super::*; use crate::common::setup_fs_and_enter; - use crate::{krun_call, krun_call_u32, Test, TestSetup}; + use crate::{krun_call, krun_call_u32, Test, TestOutcome, TestSetup}; use krun_sys::*; use std::thread; @@ -97,14 +97,15 @@ mod host { (self.should_run)() } - fn check(self: Box, stdout: Vec) -> crate::TestOutcome { + fn check(self: Box, stdout: Vec) -> TestOutcome { if let Some(cleanup) = self.cleanup { cleanup(); } - if String::from_utf8(stdout).unwrap() == "OK\n" { - crate::TestOutcome::Pass + let output = String::from_utf8(stdout).unwrap(); + if output == "OK\n" { + TestOutcome::Pass } else { - crate::TestOutcome::Fail + TestOutcome::Fail(format!("expected exactly {:?}, got {:?}", "OK\n", output)) } } diff --git a/tests/test_cases/src/test_net_perf.rs b/tests/test_cases/src/test_net_perf.rs new file mode 100644 index 000000000..8b1e79840 --- /dev/null +++ b/tests/test_cases/src/test_net_perf.rs @@ -0,0 +1,446 @@ +//! iperf3-based performance tests for virtio-net backends +//! +//! Each test: +//! 1. Host: Start iperf3 server + network backend +//! 2. Guest: Run iperf3 client (eth0 configured via DHCP by init) +//! 3. Host: Parse iperf3 JSON output, produce markdown report +//! +//! Tests are parametrized by backend and direction (TX = guest→host, RX = host→guest). + +use macros::{guest, host}; + +#[host] +use crate::{ShouldRun, TestSetup}; + +/// Virtio-net performance test with configurable backend and direction +pub struct TestNetPerf { + #[cfg(feature = "guest")] + host_ip: [u8; 4], + port: u16, + /// If true, run iperf3 with -R (reverse: server sends, client receives = RX) + reverse: bool, + #[cfg(feature = "host")] + should_run: fn() -> ShouldRun, + #[cfg(feature = "host")] + setup_backend: fn(u32, &TestSetup) -> anyhow::Result<()>, + #[cfg(feature = "host")] + cleanup: Option, +} + +impl TestNetPerf { + pub fn new_passt_tx() -> Self { + Self { + #[cfg(feature = "guest")] + host_ip: [169, 254, 2, 2], + port: 15100, + reverse: false, + #[cfg(feature = "host")] + should_run: crate::test_net::passt::should_run, + #[cfg(feature = "host")] + setup_backend: crate::test_net::passt::setup_backend, + #[cfg(feature = "host")] + cleanup: None, + } + } + + pub fn new_passt_rx() -> Self { + Self { + #[cfg(feature = "guest")] + host_ip: [169, 254, 2, 2], + port: 15110, + reverse: true, + #[cfg(feature = "host")] + should_run: crate::test_net::passt::should_run, + #[cfg(feature = "host")] + setup_backend: crate::test_net::passt::setup_backend, + #[cfg(feature = "host")] + cleanup: None, + } + } + + pub fn new_tap_tx() -> Self { + Self { + #[cfg(feature = "guest")] + host_ip: [10, 0, 0, 1], + port: 15101, + reverse: false, + #[cfg(feature = "host")] + should_run: crate::test_net::tap::should_run, + #[cfg(feature = "host")] + setup_backend: crate::test_net::tap::setup_backend, + #[cfg(feature = "host")] + cleanup: Some(crate::test_net::tap::cleanup), + } + } + + pub fn new_tap_rx() -> Self { + Self { + #[cfg(feature = "guest")] + host_ip: [10, 0, 0, 1], + port: 15111, + reverse: true, + #[cfg(feature = "host")] + should_run: crate::test_net::tap::should_run, + #[cfg(feature = "host")] + setup_backend: crate::test_net::tap::setup_backend, + #[cfg(feature = "host")] + cleanup: Some(crate::test_net::tap::cleanup), + } + } + + pub fn new_gvproxy_tx() -> Self { + Self { + #[cfg(feature = "guest")] + host_ip: [192, 168, 127, 254], + port: 15102, + reverse: false, + #[cfg(feature = "host")] + should_run: crate::test_net::gvproxy::should_run, + #[cfg(feature = "host")] + setup_backend: crate::test_net::gvproxy::setup_backend, + #[cfg(feature = "host")] + cleanup: None, + } + } + + pub fn new_gvproxy_rx() -> Self { + Self { + #[cfg(feature = "guest")] + host_ip: [192, 168, 127, 254], + port: 15112, + reverse: true, + #[cfg(feature = "host")] + should_run: crate::test_net::gvproxy::should_run, + #[cfg(feature = "host")] + setup_backend: crate::test_net::gvproxy::setup_backend, + #[cfg(feature = "host")] + cleanup: None, + } + } + + pub fn new_vmnet_helper_tx() -> Self { + Self { + #[cfg(feature = "guest")] + host_ip: [192, 168, 105, 1], + port: 15103, + reverse: false, + #[cfg(feature = "host")] + should_run: crate::test_net::vmnet_helper::should_run, + #[cfg(feature = "host")] + setup_backend: crate::test_net::vmnet_helper::setup_backend, + #[cfg(feature = "host")] + cleanup: None, + } + } + + pub fn new_vmnet_helper_rx() -> Self { + Self { + #[cfg(feature = "guest")] + host_ip: [192, 168, 105, 1], + port: 15113, + reverse: true, + #[cfg(feature = "host")] + should_run: crate::test_net::vmnet_helper::should_run, + #[cfg(feature = "host")] + setup_backend: crate::test_net::vmnet_helper::setup_backend, + #[cfg(feature = "host")] + cleanup: None, + } + } +} + +#[host] +mod host { + use super::*; + use crate::common::setup_fs_and_enter; + use crate::{krun_call, krun_call_u32, Test, TestOutcome, TestSetup}; + use krun_sys::*; + use std::process::{Child, Command, Stdio}; + + const CONTAINERFILE: &str = "\ +FROM fedora:43 +RUN dnf install -y iperf3 && dnf clean all +"; + + fn iperf3_available() -> bool { + Command::new("iperf3") + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } + + fn start_iperf_server(port: u16) -> std::io::Result { + Command::new("iperf3") + .arg("-s") + .arg("-p") + .arg(port.to_string()) + .arg("-1") // one-off: exit after first client + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + } + + #[derive(serde::Deserialize)] + struct Iperf3Output { + intervals: Vec, + end: Iperf3End, + } + + #[derive(serde::Deserialize)] + struct Iperf3Interval { + sum: Iperf3Sum, + } + + #[derive(serde::Deserialize)] + struct Iperf3End { + sum_sent: Iperf3Sum, + sum_received: Iperf3Sum, + } + + #[derive(serde::Deserialize)] + #[allow(dead_code)] + struct Iperf3Sum { + start: f64, + end: f64, + seconds: f64, + bytes: f64, + bits_per_second: f64, + } + + struct Iperf3Report { + output: Iperf3Output, + reverse: bool, + } + + impl Iperf3Report { + fn label(&self) -> &'static str { + if self.reverse { + "RX (host→guest)" + } else { + "TX (guest→host)" + } + } + + fn summary(&self) -> &Iperf3Sum { + if self.reverse { + &self.output.end.sum_received + } else { + &self.output.end.sum_sent + } + } + } + + fn fmt_throughput(bits_per_second: f64) -> String { + if bits_per_second >= 1_000_000_000.0 { + format!("{:.2} Gbit/s", bits_per_second / 1_000_000_000.0) + } else { + format!("{:.2} Mbit/s", bits_per_second / 1_000_000.0) + } + } + + fn fmt_transferred(bytes: f64) -> String { + if bytes >= 1024.0 * 1024.0 * 1024.0 { + format!("{:.2} GiB", bytes / (1024.0 * 1024.0 * 1024.0)) + } else { + format!("{:.2} MiB", bytes / (1024.0 * 1024.0)) + } + } + + impl crate::ReportImpl for Iperf3Report { + fn fmt_text(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let i = f.width().unwrap_or(0); + writeln!(f, "{:i$}iperf3 — {}\n", "", self.label())?; + writeln!( + f, + "{:i$}{:<9} {:>18} {:>14}", + "", "Interval", "Throughput", "Transferred" + )?; + writeln!(f, "{:i$}{:-<9} {:-<18} {:-<14}", "", "", "", "")?; + for interval in &self.output.intervals { + let s = &interval.sum; + let iv = format!("{:.0}-{:.0}s", s.start, s.end); + writeln!( + f, + "{:i$}{:<9} {:>18} {:>14}", + "", + iv, + fmt_throughput(s.bits_per_second), + fmt_transferred(s.bytes), + )?; + } + let s = self.summary(); + writeln!(f, "{:i$}{:-<9} {:-<18} {:-<14}", "", "", "", "")?; + write!( + f, + "{:i$}{:<9} {:>18} {:>14}", + "", + "Total", + fmt_throughput(s.bits_per_second), + fmt_transferred(s.bytes), + ) + } + + fn fmt_gh_markdown(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "### iperf3 — {}\n", self.label())?; + writeln!(f, "| Interval | Throughput | Transferred |")?; + writeln!(f, "|----------|-----------|-------------|")?; + for interval in &self.output.intervals { + let s = &interval.sum; + writeln!( + f, + "| {:.0}-{:.0}s | {} | {} |", + s.start, + s.end, + fmt_throughput(s.bits_per_second), + fmt_transferred(s.bytes), + )?; + } + let s = self.summary(); + write!( + f, + "| **Total** | **{}** | **{}** |", + fmt_throughput(s.bits_per_second), + fmt_transferred(s.bytes), + ) + } + } + + impl Test for TestNetPerf { + fn should_run(&self) -> ShouldRun { + if option_env!("IPERF_DURATION").is_none() { + return ShouldRun::No("IPERF_DURATION not set"); + } + if unsafe { krun_call_u32!(krun_has_feature(KRUN_FEATURE_NET.into())) }.ok() != Some(1) + { + return ShouldRun::No("libkrun compiled without NET"); + } + let backend_result = (self.should_run)(); + if let ShouldRun::No(_) = backend_result { + return backend_result; + } + if !iperf3_available() { + return ShouldRun::No("iperf3 not installed on host"); + } + ShouldRun::Yes + } + + fn rootfs_image(&self) -> Option<&'static str> { + Some(CONTAINERFILE) + } + + fn timeout_secs(&self) -> u64 { + let iperf_secs: u64 = option_env!("IPERF_DURATION") + .and_then(|s| s.parse().ok()) + .unwrap_or(10); + // iperf duration + overhead for VM boot, retries, and network setup + iperf_secs + 15 + } + + fn start_vm(self: Box, test_setup: TestSetup) -> anyhow::Result<()> { + // Start iperf3 server on host (one-off, exits after first client) + let iperf_server = start_iperf_server(self.port)?; + test_setup.register_cleanup_pid(iperf_server.id()); + + // Give iperf3 server a moment to start + std::thread::sleep(std::time::Duration::from_millis(200)); + + // Check it's still running + let mut iperf_server = iperf_server; + if let Some(status) = iperf_server.try_wait()? { + anyhow::bail!("iperf3 server exited early: {status}"); + } + + unsafe { + let ctx = krun_call_u32!(krun_create_ctx())?; + krun_call!(krun_set_vm_config(ctx, 1, 512))?; + + // Backend-specific setup + (self.setup_backend)(ctx, &test_setup)?; + + setup_fs_and_enter(ctx, test_setup)?; + } + Ok(()) + } + + fn check(self: Box, stdout: Vec) -> TestOutcome { + if let Some(cleanup) = self.cleanup { + cleanup(); + } + let stdout = String::from_utf8_lossy(&stdout).to_string(); + + match serde_json::from_str::(&stdout) { + Ok(iperf_output) => TestOutcome::Report(Box::new(Iperf3Report { + output: iperf_output, + reverse: self.reverse, + })), + Err(e) => TestOutcome::Fail(format!( + "expected valid iperf3 JSON, got error: {e}\nstdout: {stdout}" + )), + } + } + } +} + +#[guest] +mod guest { + use super::*; + use crate::Test; + use std::process::Command; + use std::time::Duration; + + impl Test for TestNetPerf { + fn in_guest(self: Box) { + let host_ip = format!( + "{}.{}.{}.{}", + self.host_ip[0], self.host_ip[1], self.host_ip[2], self.host_ip[3] + ); + + let Some(iperf_duration) = option_env!("IPERF_DURATION") else { + unreachable!() + }; + + // Run iperf3 client with JSON output, retry up to 5 times + let mut last_output = None; + for attempt in 0..5 { + if attempt > 0 { + std::thread::sleep(Duration::from_secs(2)); + } + + let mut cmd = Command::new("/usr/bin/iperf3"); + cmd.arg("-c") + .arg(&host_ip) + .arg("-p") + .arg(self.port.to_string()) + .arg("-t") + .arg(iperf_duration) + .arg("-J"); + + if self.reverse { + cmd.arg("-R"); + } + + let output = cmd.output().expect("Failed to run iperf3"); + + if output.status.success() { + // Print JSON output to stdout (host will read it) + let stdout = String::from_utf8(output.stdout).expect("iperf3 output not UTF-8"); + print!("{}", stdout); + return; + } + + last_output = Some(output); + } + + let output = last_output.unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + panic!( + "iperf3 failed after 5 attempts (exit={}):\nstderr: {}\nstdout: {}", + output.status, stderr, stdout + ); + } + } +} From dbfb51887bbd2d58288dd43a43f1ff729f8db53b Mon Sep 17 00:00:00 2001 From: Matej Hrica Date: Thu, 26 Mar 2026 12:56:01 +0100 Subject: [PATCH 16/18] CI: install buildah, passt from source, dnsmasq, iperf3 - Install buildah for namespace isolation in tests - Build passt from source (Ubuntu 24.04 apt version is too old) - Install dnsmasq and iperf3 for tap and perf tests Signed-off-by: Matej Hrica --- .github/workflows/integration_tests.yml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index 956710d0a..bc46aa60d 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -45,7 +45,14 @@ jobs: sudo usermod -a -G kvm $USER - name: Install additional packages - run: sudo apt-get install -y --no-install-recommends build-essential patchelf pkg-config net-tools + run: sudo apt-get install -y --no-install-recommends build-essential patchelf pkg-config net-tools buildah dnsmasq iperf3 + + - name: Install passt from source + run: | + curl -L https://passt.top/passt/snapshot/passt-2026_01_20.386b5f5.tar.gz | tar xz + cd passt-2026_01_20.386b5f5 + make + sudo make install - name: Install libkrunfw run: TAG=`curl -sL https://api.github.com/repos/containers/libkrunfw/releases/latest |jq -r .tag_name` && curl -L -o /tmp/libkrunfw-x86_64.tgz https://github.com/containers/libkrunfw/releases/download/$TAG/libkrunfw-x86_64.tgz && mkdir tmp && tar xf /tmp/libkrunfw-x86_64.tgz -C tmp && sudo mv tmp/lib64/* /lib/x86_64-linux-gnu @@ -99,7 +106,14 @@ jobs: cargo clippy --locked --target aarch64-unknown-linux-musl -p guest-agent -- -D warnings - name: Install additional packages - run: sudo apt-get install -y --no-install-recommends build-essential patchelf pkg-config net-tools + run: sudo apt-get install -y --no-install-recommends build-essential patchelf pkg-config net-tools dnsmasq iperf3 git uidmap + + - name: Install passt from source + run: | + curl -L https://passt.top/passt/snapshot/passt-2026_01_20.386b5f5.tar.gz | tar xz + cd passt-2026_01_20.386b5f5 + make + sudo make install - name: Install libkrunfw run: TAG=`curl -sL https://api.github.com/repos/containers/libkrunfw/releases/latest |jq -r .tag_name` && curl -L -o /tmp/libkrunfw-aarch64.tgz https://github.com/containers/libkrunfw/releases/download/$TAG/libkrunfw-aarch64.tgz && mkdir tmp && tar xf /tmp/libkrunfw-aarch64.tgz -C tmp && sudo mv tmp/lib64/* /lib/aarch64-linux-gnu From 490d864b2a9216d7d7168e675843e9f7cb7b8169 Mon Sep 17 00:00:00 2001 From: Matej Hrica Date: Thu, 26 Mar 2026 12:56:26 +0100 Subject: [PATCH 17/18] CI: exclude rootfs from artifact uploads Rootfs directories contain files with mapped UIDs that the runner can't read, breaking the artifact zip upload. Signed-off-by: Matej Hrica --- .github/workflows/integration_tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index bc46aa60d..309699145 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -68,6 +68,7 @@ jobs: path: | /tmp/libkrun-tests/ !/tmp/libkrun-tests/**/guest-agent + !/tmp/libkrun-tests/**/rootfs if-no-files-found: ignore integration-tests-aarch64: @@ -132,4 +133,5 @@ jobs: path: | /tmp/libkrun-tests/ !/tmp/libkrun-tests/**/guest-agent + !/tmp/libkrun-tests/**/rootfs if-no-files-found: ignore From 32aad4f587946ced24d9cb742cc9ce5b34c9701c Mon Sep 17 00:00:00 2001 From: Matej Hrica Date: Thu, 26 Mar 2026 14:01:54 +0100 Subject: [PATCH 18/18] CI: enable virtio-net tests Build with NET=1 and run network/iperf3 tests in CI. Signed-off-by: Matej Hrica --- .github/workflows/integration_tests.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index 309699145..811ecf3fd 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -15,7 +15,7 @@ jobs: run: rustup target add x86_64-unknown-linux-musl - name: Build and install libkrun to test prefix - run: make test-prefix + run: make test-prefix NET=1 - name: Clippy (test_cases guest) run: | @@ -58,7 +58,7 @@ jobs: run: TAG=`curl -sL https://api.github.com/repos/containers/libkrunfw/releases/latest |jq -r .tag_name` && curl -L -o /tmp/libkrunfw-x86_64.tgz https://github.com/containers/libkrunfw/releases/download/$TAG/libkrunfw-x86_64.tgz && mkdir tmp && tar xf /tmp/libkrunfw-x86_64.tgz -C tmp && sudo mv tmp/lib64/* /lib/x86_64-linux-gnu - name: Integration tests - run: KRUN_ENOMEM_WORKAROUND=1 KRUN_NO_UNSHARE=1 KRUN_TEST_BASE_DIR=/tmp/libkrun-tests make test TEST_FLAGS="--keep-all --github-summary" + run: KRUN_ENOMEM_WORKAROUND=1 KRUN_TEST_BASE_DIR=/tmp/libkrun-tests make test NET=1 IPERF_DURATION=3 TEST_FLAGS="--keep-all --github-summary" - name: Upload test logs if: always() @@ -84,7 +84,7 @@ jobs: run: rustup target add aarch64-unknown-linux-musl - name: Build and install libkrun to test prefix - run: make test-prefix + run: make test-prefix NET=1 - name: Clippy (test_cases guest) run: | @@ -123,7 +123,7 @@ jobs: run: rm -fr /tmp/libkrun-tests - name: Integration tests - run: KRUN_ENOMEM_WORKAROUND=1 KRUN_NO_UNSHARE=1 KRUN_TEST_BASE_DIR=/tmp/libkrun-tests make test TEST_FLAGS="--keep-all --github-summary" + run: KRUN_ENOMEM_WORKAROUND=1 KRUN_NO_UNSHARE=1 KRUN_TEST_BASE_DIR=/tmp/libkrun-tests make test NET=1 IPERF_DURATION=3 TEST_FLAGS="--keep-all --github-summary" - name: Upload test logs if: always()