diff --git a/dstack/crates/qemu-acpi/fixtures/README.md b/dstack/crates/qemu-acpi/fixtures/README.md index 138c7f606..c6924f369 100644 --- a/dstack/crates/qemu-acpi/fixtures/README.md +++ b/dstack/crates/qemu-acpi/fixtures/README.md @@ -96,3 +96,12 @@ ninja qemu-system-x86_64 There was no genuine upstream QEMU 11.1 release at the time of this audit, so the `11.1` compatibility profile remains pinned to the production compatibility fork rather than being described as an upstream release profile. + +## Versions past the newest profile + +A QEMU version newer than the newest profile here is generated with that +profile (`Compatibility::LATEST`) rather than rejected: most releases leave the +Q35 ACPI ABI untouched, and when one does change it the generated blobs stop +matching the measured ones, which is strictly more informative than refusing to +generate. Adding a profile therefore means adding its fixtures **and** moving +`Compatibility::LATEST` to it. diff --git a/dstack/crates/qemu-acpi/src/lib.rs b/dstack/crates/qemu-acpi/src/lib.rs index 34013b313..42cabb7bb 100644 --- a/dstack/crates/qemu-acpi/src/lib.rs +++ b/dstack/crates/qemu-acpi/src/lib.rs @@ -7,6 +7,11 @@ //! This crate intentionally models the observable ACPI ABI rather than a //! virtual machine. Its output is expected to match QEMU byte for byte for a //! supported compatibility profile and machine topology. +//! +//! QEMU releases newer than the newest modeled profile are generated with that +//! profile (see [`QemuVersion::compatibility`]) instead of being rejected, so a +//! QEMU upgrade that leaves the ACPI ABI alone keeps working and one that does +//! not surfaces as a blob mismatch the caller can act on. mod aml_patch; mod cpu; @@ -33,7 +38,7 @@ pub struct AcpiBlobs { pub enum Error { #[error(transparent)] Topology(#[from] TopologyError), - #[error("unsupported QEMU compatibility profile: {0}")] + #[error("no ACPI compatibility profile for QEMU {0}; releases older than 8.0 are not modeled")] UnsupportedVersion(QemuVersion), #[error("malformed generated ACPI tables: missing {0}")] MalformedTables(String), diff --git a/dstack/crates/qemu-acpi/src/profile.rs b/dstack/crates/qemu-acpi/src/profile.rs index 3d993fc42..c74e7d43d 100644 --- a/dstack/crates/qemu-acpi/src/profile.rs +++ b/dstack/crates/qemu-acpi/src/profile.rs @@ -21,6 +21,19 @@ impl QemuVersion { } } + /// Map a QEMU version onto the ACPI compatibility profile to generate with. + /// + /// Versions newer than the newest modeled profile fall back to + /// [`Compatibility::LATEST`] rather than failing: most QEMU releases do not + /// change the Q35 ACPI ABI, so extrapolating is right far more often than + /// not, and when it is wrong the generated blobs simply do not match the + /// measured ones. Refusing to generate turns every such deployment into a + /// verification error, which is the same outcome with less information, so + /// the caller is left to decide what a mismatch means. + /// + /// Versions older than 8.0 return `None`: their ABI was never modeled, and + /// clamping *down* to the oldest profile would be a guess in the direction + /// where QEMU's ACPI output is known to differ. pub const fn compatibility(self) -> Option { match (self.major, self.minor) { (8, _) => Some(Compatibility::V8), @@ -29,6 +42,7 @@ impl QemuVersion { (10, _) => Some(Compatibility::V10), (11, 0) => Some(Compatibility::V11_0), (11, 1..) => Some(Compatibility::V11_1), + (12.., _) => Some(Compatibility::LATEST), _ => None, } } @@ -76,3 +90,54 @@ pub enum Compatibility { V11_0, V11_1, } + +impl Compatibility { + /// Newest modeled profile, used for QEMU versions released after it. + /// Update this together with every new profile. + pub const LATEST: Self = Self::V11_1; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn versions_map_to_their_own_profile() { + let cases = [ + ((8, 2, 2), Compatibility::V8), + ((9, 1, 0), Compatibility::V9Pre92), + ((9, 2, 1), Compatibility::V9_2), + ((10, 0, 0), Compatibility::V10), + ((11, 0, 3), Compatibility::V11_0), + ((11, 1, 0), Compatibility::V11_1), + ]; + for ((major, minor, micro), expected) in cases { + assert_eq!( + QemuVersion::new(major, minor, micro).compatibility(), + Some(expected), + "{major}.{minor}.{micro}" + ); + } + } + + /// A QEMU release newer than the newest modeled profile must still produce + /// blobs: if its ACPI ABI is unchanged they match, and if it changed the + /// caller sees a digest mismatch instead of a "cannot generate" error. + #[test] + fn versions_newer_than_the_newest_profile_clamp_to_it() { + for version in [ + QemuVersion::new(11, 9, 0), + QemuVersion::new(12, 0, 0), + QemuVersion::new(99, 4, 1), + ] { + assert_eq!(version.compatibility(), Some(Compatibility::LATEST)); + } + } + + #[test] + fn versions_older_than_the_oldest_profile_are_rejected() { + for version in [QemuVersion::new(7, 2, 0), QemuVersion::new(0, 0, 0)] { + assert_eq!(version.compatibility(), None); + } + } +} diff --git a/dstack/crates/qemu-acpi/src/tables.rs b/dstack/crates/qemu-acpi/src/tables.rs index 24468c768..d79f880c2 100644 --- a/dstack/crates/qemu-acpi/src/tables.rs +++ b/dstack/crates/qemu-acpi/src/tables.rs @@ -42,7 +42,9 @@ fn write_bytes(data: &mut [u8], offset: usize, value: &[u8], context: &str) -> R /// DSDT with `iasl`, map the relevant ASL object back to its AML byte sequence, /// and record the package-length or insertion offset relative to the DSDT /// signature. Verify every offset against the reference binary and its -/// one-device output. Never infer offsets from a Rust-generated blob. +/// one-device output. Never infer offsets from a Rust-generated blob. A new +/// profile also has to move `Compatibility::LATEST`, which decides what newer +/// QEMU versions are generated with. struct Layout { /// Trimmed QEMU `etc/acpi/tables` fixture used as the immutable template. base: &'static [u8], @@ -618,6 +620,18 @@ mod tests { } } + /// A QEMU release past the newest modeled profile generates with that + /// profile instead of erroring, so an ABI-preserving upgrade keeps + /// verifying and an ABI-changing one surfaces as a blob mismatch. + #[test] + fn unmodeled_newer_versions_generate_with_the_latest_profile() -> Result<(), Error> { + let latest = build(&config(1, 0))?; + let mut newer = config(1, 0); + newer.qemu_version = QemuVersion::new(12, 0, 0); + assert_eq!(build(&newer)?, latest); + Ok(()) + } + #[test] fn one_nic_matches_qemu_byte_for_byte() -> Result<(), Error> { let actual = build(&config(1, 0))?;