crates/heimdall-container/tests/iolimit.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! io limits as data (no kernel needed): device resolution against a fake
//! sysfs, the OCI `blockIO` throttles, the `io.max` read-back check, and the
//! refusal to start without the io controller. Heimdall is a defensive
//! sandbox; these tests check only its own limits.
use std::path::{Path, PathBuf};
use heimdall_container::iolimit::*;
use heimdall_container::kernel::KernelReport;
use heimdall_container::*;
use heimdall_core::*;
use serde_json::json;
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("hc-io-{name}-{}", JobId::random()));
std::fs::create_dir_all(&d).unwrap();
d
}
/// A fake sysfs: disk 8:0 (sda) with partition 8:1 (sda1), and disk 259:0.
fn sysfs() -> PathBuf {
let s = tmp("sysfs");
let sda = s.join("devices/pci0/ata1/block/sda");
std::fs::create_dir_all(sda.join("sda1")).unwrap();
std::fs::write(sda.join("dev"), "8:0\n").unwrap();
std::fs::write(sda.join("sda1/dev"), "8:1\n").unwrap();
std::fs::write(sda.join("sda1/partition"), "1\n").unwrap();
let nv = s.join("devices/pci0/nvme/block/nvme0n1");
std::fs::create_dir_all(&nv).unwrap();
std::fs::write(nv.join("dev"), "259:0\n").unwrap();
std::fs::create_dir_all(s.join("dev/block")).unwrap();
let link = |dev: &str, to: &Path| {
std::os::unix::fs::symlink(to, s.join("dev/block").join(dev)).unwrap()
};
link("8:0", &sda);
link("8:1", &sda.join("sda1"));
link("259:0", &nv);
s
}
fn d(major: u32, minor: u32) -> DevNum {
DevNum { major, minor }
}
fn rbps(n: u64) -> IoRates {
IoRates {
rbps: Some(n),
..IoRates::default()
}
}
fn num(major: u32, minor: u32, rates: IoRates) -> IoMax {
IoMax {
device: IoDevice::Number(d(major, minor)),
rates,
}
}
#[test]
fn a_partition_resolves_to_its_whole_disk_and_a_disk_to_itself() {
let s = sysfs();
assert_eq!(whole_disk(&s, d(8, 1)).unwrap(), d(8, 0));
assert_eq!(whole_disk(&s, d(8, 0)).unwrap(), d(8, 0));
assert_eq!(whole_disk(&s, d(259, 0)).unwrap(), d(259, 0));
let e = whole_disk(&s, d(0, 42)).unwrap_err();
assert!(e.contains("0:42") && e.contains("by number"), "{e}");
}
#[test]
fn resolve_maps_every_limit_to_its_disk() {
let s = sysfs();
let r = resolve(
&[
num(8, 1, rbps(1 << 20)),
num(
259,
0,
IoRates {
wiops: Some(100),
..IoRates::default()
},
),
],
&s,
)
.unwrap();
assert_eq!(r.len(), 2);
assert_eq!(r[0].dev, d(8, 0));
assert_eq!(r[0].rates.rbps, Some(1 << 20));
assert_eq!(r[1].dev, d(259, 0));
}
#[test]
fn a_path_on_a_file_system_without_a_block_device_is_refused() {
// The fake sysfs knows none of this host's devices.
let s = sysfs();
let here = tmp("holding");
let e = resolve(
&[IoMax {
device: IoDevice::Holding(here.clone()),
rates: rbps(1 << 20),
}],
&s,
)
.unwrap_err();
assert!(e.contains("by number"), "{e}");
let e = resolve(
&[IoMax {
device: IoDevice::Holding(here.join("missing")),
rates: rbps(1),
}],
&s,
)
.unwrap_err();
assert!(e.contains("missing"), "{e}");
}
#[test]
fn zero_rates_no_rates_and_two_limits_on_one_disk_are_refused() {
let s = sysfs();
let e = resolve(&[num(8, 0, rbps(0))], &s).unwrap_err();
assert!(e.contains("rbps=0"), "{e}");
let e = resolve(&[num(8, 0, IoRates::default())], &s).unwrap_err();
assert!(e.contains("no rate"), "{e}");
// 8:1 is on 8:0: the same disk twice.
let e = resolve(&[num(8, 0, rbps(1)), num(8, 1, rbps(2))], &s).unwrap_err();
assert!(e.contains("8:0"), "{e}");
}
#[test]
fn apply_writes_the_throttles_and_keeps_the_weight() {
let mut c = json!({"linux": {"resources": {"blockIO": {"weight": 50}}}});
apply(
&mut c,
&[
ResolvedIoMax {
dev: d(8, 0),
rates: IoRates {
rbps: Some(1000),
wbps: Some(2000),
riops: Some(30),
wiops: None,
},
},
ResolvedIoMax {
dev: d(259, 0),
rates: IoRates {
wiops: Some(40),
..IoRates::default()
},
},
],
);
let b = &c["linux"]["resources"]["blockIO"];
assert_eq!(b["weight"], 50);
assert_eq!(
b["throttleReadBpsDevice"],
json!([{"major": 8, "minor": 0, "rate": 1000}])
);
assert_eq!(
b["throttleWriteBpsDevice"],
json!([{"major": 8, "minor": 0, "rate": 2000}])
);
assert_eq!(
b["throttleReadIOPSDevice"],
json!([{"major": 8, "minor": 0, "rate": 30}])
);
assert_eq!(
b["throttleWriteIOPSDevice"],
json!([{"major": 259, "minor": 0, "rate": 40}])
);
// No limits: the config is untouched.
let mut c = json!({"linux": {"resources": {}}});
apply(&mut c, &[]);
assert!(c["linux"]["resources"].get("blockIO").is_none());
}
#[test]
fn the_read_back_must_match_exactly() {
let l = [ResolvedIoMax {
dev: d(7, 3),
rates: IoRates {
rbps: Some(2097152),
..IoRates::default()
},
}];
assert_eq!(l[0].line(), "7:3 rbps=2097152 wbps=max riops=max wiops=max");
check("7:3 rbps=2097152 wbps=max riops=max wiops=max\n", &l).unwrap();
check(
"8:0 rbps=1 wbps=max riops=max wiops=max\n7:3 rbps=2097152 wbps=max riops=max wiops=max\n",
&l,
)
.unwrap();
let e = check("7:3 rbps=max wbps=max riops=max wiops=max\n", &l).unwrap_err();
assert!(e.contains("rbps=max"), "{e}");
let e = check("", &l).unwrap_err();
assert!(e.contains("no line for 7:3"), "{e}");
let e = check("7:3 rbps=2097152 wbps=5 riops=max wiops=max\n", &l).unwrap_err();
assert!(e.contains("wbps=5"), "{e}");
}
fn kernel(io: bool) -> KernelReport {
let mut c = vec!["cpu".to_string(), "memory".into(), "pids".into()];
if io {
c.push("io".into());
}
KernelReport {
release: "7.0.0".into(),
seccomp_filter: true,
user_notif: true,
notif_addfd: true,
landlock_abi: Some(8),
cgroup_v2: true,
cgroup_controllers: c,
user_namespaces: true,
}
}
fn cfg_io(sysfs: &Path) -> HeimdallConfig {
let mut c = HeimdallConfig::new("/nonexistent/layout", "/nonexistent/work");
c.sysfs_root = sysfs.to_path_buf();
c.io_max = vec![num(8, 1, rbps(1 << 20))];
c
}
#[test]
fn io_limits_without_the_io_controller_refuse_to_start_strict_or_not() {
let s = sysfs();
for strict in [true, false] {
let mut c = cfg_io(&s);
c.strict = strict;
match admit(&c, &kernel(false)) {
Err(RunError::Unsupported { what }) => assert!(what.contains("io"), "{what}"),
other => panic!("expected a refusal, got {other:?}"),
}
}
admit(&cfg_io(&s), &kernel(true)).unwrap();
}
#[test]
fn a_bad_io_limit_refuses_to_start_and_a_good_one_is_resolved_at_start() {
let s = sysfs();
let mut c = cfg_io(&s);
c.io_max = vec![num(8, 0, rbps(0))];
match HeimdallRunner::with_kernel(c, BootKey::generate(), kernel(true)) {
Err(RunError::Unsupported { what }) => assert!(what.contains("rbps=0"), "{what}"),
other => panic!("expected a refusal, got {other:?}"),
}
let r = HeimdallRunner::with_kernel(cfg_io(&s), BootKey::generate(), kernel(true)).unwrap();
assert_eq!(
r.io_max(),
&[ResolvedIoMax {
dev: d(8, 0),
rates: rbps(1 << 20)
}]
);
}
#[test]
fn an_io_weight_outside_1_to_10000_refuses_to_start() {
for w in [0u16, 10001] {
let mut c = HeimdallConfig::new("/nonexistent/layout", "/nonexistent/work");
c.io_weight = w;
match admit(&c, &kernel(true)) {
Err(RunError::Unsupported { what }) => assert!(what.contains("io_weight"), "{what}"),
other => panic!("expected a refusal for {w}, got {other:?}"),
}
}
}