Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/heimdall

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:?}"),
        }
    }
}