diff --git a/Cargo.lock b/Cargo.lock index 837398ddd46..d7aa3456ea2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5600,6 +5600,14 @@ dependencies = [ "openvmm_resources", ] +[[package]] +name = "openvmm_build_info" +version = "0.0.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "openvmm_core" version = "0.0.0" @@ -5770,6 +5778,7 @@ dependencies = [ "net_tap", "netvsp_resources", "nvme_resources", + "openvmm_build_info", "openvmm_defs", "openvmm_helpers", "openvmm_pcat_locator", diff --git a/Cargo.toml b/Cargo.toml index 607b6283d28..154d1000ddc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,6 +81,7 @@ flowey_lib_hvlite = { path = "flowey/flowey_lib_hvlite" } schema_ado_yaml = { path = "flowey/schema_ado_yaml" } openvmm_core = { path = "openvmm/openvmm_core" } +openvmm_build_info = { path = "openvmm/openvmm_build_info" } openvmm_defs = { path = "openvmm/openvmm_defs" } openvmm_entry = { path = "openvmm/openvmm_entry" } openvmm_helpers = { path = "openvmm/openvmm_helpers" } diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 3a862b4921a..9a5ea45f39f 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -7,6 +7,12 @@ The most up to date reference is always the [code itself](https://openvmm.dev/ru as well as the generated CLI help (via `cargo run -- --help`). ``` +* `--version`, `-V`: Print the displayed OpenVMM source version and exit. An + exact release tag prints `MAJOR.MINOR.PATCH`, with `+dirty` appended for a + tracked-file change. An untagged Git checkout prints + `0.0.0-dev+g`, where `` is the first nine + revision characters, with `.dirty` appended for a tracked-file change. Source + without usable identity metadata prints `0.0.0-dev`. * `--processors `: The number of processors. Defaults to 1. * `--memory `: Configure guest RAM. Defaults to `size=1G`. `SPEC` can be a size-only shorthand, such as `--memory 4G`, or a diff --git a/openvmm/openvmm_build_info/Cargo.toml b/openvmm/openvmm_build_info/Cargo.toml new file mode 100644 index 00000000000..ebf05144569 --- /dev/null +++ b/openvmm/openvmm_build_info/Cargo.toml @@ -0,0 +1,14 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[package] +name = "openvmm_build_info" +edition.workspace = true +rust-version.workspace = true + +[build-dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true, features = ["std"] } + +[lints] +workspace = true diff --git a/openvmm/openvmm_build_info/build.rs b/openvmm/openvmm_build_info/build.rs new file mode 100644 index 00000000000..886bf1f788e --- /dev/null +++ b/openvmm/openvmm_build_info/build.rs @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#![expect(missing_docs)] + +use serde::Deserialize; + +mod version; + +const RELEASE_METADATA_SCHEMA: u32 = 1; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SerializedReleaseMetadata { + schema_version: u32, + version: String, + tag: String, + revision: String, +} + +fn read_release_metadata(path: &std::path::Path) -> Option { + let contents = match std::fs::read(path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None, + Err(error) => panic!("failed to read {}: {error}", path.display()), + }; + let metadata: SerializedReleaseMetadata = serde_json::from_slice(&contents) + .unwrap_or_else(|error| panic!("failed to parse {}: {error}", path.display())); + assert_eq!( + metadata.schema_version, RELEASE_METADATA_SCHEMA, + "unsupported OpenVMM release metadata schema" + ); + let metadata = version::ReleaseMetadata { + version: metadata.version, + tag: metadata.tag, + revision: metadata.revision, + }; + version::validate_release_metadata(&metadata) + .unwrap_or_else(|error| panic!("invalid {}: {error}", path.display())); + Some(metadata) +} + +fn git_output(repo: &std::path::Path, args: &[&str]) -> Option { + let output = std::process::Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .output() + .ok()?; + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned()) +} + +fn watch_tracked_files(repo: &std::path::Path) { + let output = std::process::Command::new("git") + .arg("-C") + .arg(repo) + .args(["ls-files", "-z"]) + .output() + .expect("failed to list tracked OpenVMM files"); + assert!( + output.status.success(), + "git ls-files failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + for path in output.stdout.split(|byte| *byte == 0) { + if path.is_empty() { + continue; + } + let path = std::str::from_utf8(path).expect("tracked OpenVMM file path is not valid UTF-8"); + assert!( + !path.contains(['\r', '\n']), + "tracked OpenVMM file path contains a line break" + ); + println!("cargo:rerun-if-changed={}", repo.join(path).display()); + } +} + +fn watch_git_inputs(repo: &std::path::Path, github_actions: bool) { + for path in ["HEAD", "index", "refs/tags", "packed-refs"] { + if let Some(path) = git_output(repo, &["rev-parse", "--git-path", path]) { + println!("cargo:rerun-if-changed={}", repo.join(path).display()); + } + } + if let Some(head_ref) = git_output(repo, &["symbolic-ref", "HEAD"]) { + if let Some(path) = git_output(repo, &["rev-parse", "--git-path", &head_ref]) { + println!("cargo:rerun-if-changed={}", repo.join(path).display()); + } + } + if let Some(tag_refs) = git_output( + repo, + &["for-each-ref", "--format=%(refname)", "refs/tags/openvmm-v"], + ) { + for tag_ref in tag_refs.lines() { + if let Some(path) = git_output(repo, &["rev-parse", "--git-path", tag_ref]) { + println!("cargo:rerun-if-changed={}", repo.join(path).display()); + } + } + } + if !github_actions { + watch_tracked_files(repo); + } +} + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-env-changed=GITHUB_ACTIONS"); + + let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let release_metadata_path = repo_root.join(".openvmm-release.json"); + println!("cargo:rerun-if-changed={}", release_metadata_path.display()); + + let release_metadata = read_release_metadata(&release_metadata_path); + let github_actions = std::env::var("GITHUB_ACTIONS").as_deref() == Ok("true"); + let git = version::collect_git_source(&repo_root, github_actions) + .unwrap_or_else(|error| panic!("failed to collect OpenVMM Git identity: {error}")); + if git.is_some() { + watch_git_inputs(&repo_root, github_actions); + } + let version = version::resolve_version(git.as_ref(), release_metadata.as_ref()) + .unwrap_or_else(|error| panic!("failed to resolve OpenVMM version: {error}")); + + if git.is_none() && release_metadata.is_none() { + println!( + "cargo:warning=OpenVMM release metadata is unavailable. This build will report \ + 0.0.0-dev and must not be treated as an official release build. Use a Git checkout \ + with the release tag available or the official source bundle attached to the GitHub \ + Release." + ); + } + + println!( + "cargo:rustc-env=OPENVMM_PRODUCT_VERSION={}", + version.product_version + ); + println!("cargo:rustc-env=OPENVMM_VERSION={}", version.version); + println!("cargo:rustc-env=BUILD_GIT_SHA={}", version.revision); +} diff --git a/openvmm/openvmm_build_info/src/lib.rs b/openvmm/openvmm_build_info/src/lib.rs new file mode 100644 index 00000000000..18e2b7ae075 --- /dev/null +++ b/openvmm/openvmm_build_info/src/lib.rs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! OpenVMM product version and source revision. + +#![expect(missing_docs)] + +#[cfg(test)] +#[path = "../version.rs"] +mod version; + +#[derive(Debug)] +pub struct BuildInfo { + product_version: &'static str, + version: &'static str, + revision: &'static str, +} + +impl BuildInfo { + pub const fn new() -> Self { + Self { + product_version: env!("OPENVMM_PRODUCT_VERSION"), + version: env!("OPENVMM_VERSION"), + revision: env!("BUILD_GIT_SHA"), + } + } + + pub const fn product_version(&self) -> &'static str { + self.product_version + } + + pub const fn version(&self) -> &'static str { + self.version + } + + pub const fn scm_revision(&self) -> &'static str { + self.revision + } +} + +// Keep the build information easy to discover without a debugger. +// +// The static remains reachable through `get`, so `#[used]` is not required. +// +// UNSAFETY: `link_section` and `export_name` are unsafe attributes. +#[expect(unsafe_code)] +// SAFETY: These are custom metadata sections with no safety requirements. +#[cfg_attr(target_os = "windows", unsafe(link_section = ".build_i"))] +#[cfg_attr(target_vendor = "apple", unsafe(link_section = "__DATA,__build_info"))] +#[cfg_attr( + not(any(target_os = "windows", target_vendor = "apple")), + unsafe(link_section = ".build_info") +)] +// SAFETY: This symbol is uniquely named for OpenVMM and has no runtime ABI. +#[unsafe(export_name = "OPENVMM_BUILD_INFO")] +static OPENVMM_BUILD_INFO: BuildInfo = BuildInfo::new(); + +pub fn get() -> &'static BuildInfo { + // Prevent fat LTO from optimizing away the metadata static. + std::hint::black_box(&OPENVMM_BUILD_INFO) +} diff --git a/openvmm/openvmm_build_info/version.rs b/openvmm/openvmm_build_info/version.rs new file mode 100644 index 00000000000..e5a9a43a50a --- /dev/null +++ b/openvmm/openvmm_build_info/version.rs @@ -0,0 +1,407 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +const RELEASE_TAG_PREFIX: &str = "openvmm-v"; + +pub struct ReleaseMetadata { + pub version: String, + pub tag: String, + pub revision: String, +} + +pub struct GitSource { + pub revision: String, + pub release_tag: Option, + pub dirty: bool, +} + +pub struct VersionInfo { + pub product_version: String, + pub version: String, + pub revision: String, +} + +pub fn parse_version(version: &str) -> Result<[u16; 3], String> { + let components = version.split('.').collect::>(); + let [major, minor, patch] = components.as_slice() else { + return Err(format!( + "OpenVMM release version must contain exactly three components, got {version:?}" + )); + }; + let parse = |name: &str, component: &str| { + if component.len() > 1 && component.starts_with('0') { + return Err(format!( + "OpenVMM release {name} component is not canonical: {component:?}" + )); + } + component.parse::().map_err(|_| { + format!("OpenVMM release {name} component must be an unsigned 16-bit integer") + }) + }; + Ok([ + parse("major", major)?, + parse("minor", minor)?, + parse("patch", patch)?, + ]) +} + +pub fn parse_release_tag(tag: &str) -> Result<&str, String> { + let version = tag.strip_prefix(RELEASE_TAG_PREFIX).ok_or_else(|| { + format!("OpenVMM release tag must start with {RELEASE_TAG_PREFIX:?}, got {tag:?}") + })?; + parse_version(version)?; + Ok(version) +} + +fn validate_revision(revision: &str) -> Result<(), String> { + if !matches!(revision.len(), 40 | 64) || !revision.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err("OpenVMM revision must be a full hexadecimal Git object ID".into()); + } + Ok(()) +} + +pub fn validate_release_metadata(metadata: &ReleaseMetadata) -> Result<(), String> { + let version = parse_release_tag(&metadata.tag)?; + if metadata.version != version { + return Err("OpenVMM release metadata tag and version do not match".into()); + } + validate_revision(&metadata.revision) +} + +fn select_release_tag(tags: Vec) -> Result, String> { + match tags.as_slice() { + [] => Ok(None), + [tag] => { + parse_release_tag(tag)?; + Ok(Some(tag.clone())) + } + _ => Err(format!( + "multiple OpenVMM release tags point at HEAD: {tags:?}" + )), + } +} + +fn git_command(repo: &std::path::Path, args: &[&str]) -> std::io::Result { + std::process::Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .output() +} + +// This collector is intentionally OpenVMM-specific. The shared +// build_rs_git_info helper remains a compatibility emitter for its existing +// revision and branch variables. +fn git_output(repo: &std::path::Path, args: &[&str]) -> Result { + let output = git_command(repo, args).map_err(|error| format!("failed to run git: {error}"))?; + if !output.status.success() { + return Err(format!( + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + String::from_utf8(output.stdout) + .map(|output| output.trim().to_owned()) + .map_err(|error| format!("git {args:?} returned non-UTF8 output: {error}")) +} + +fn ci_config_rewrite_is_only_change(repo: &std::path::Path, github_actions: bool) -> bool { + if !github_actions { + return false; + } + + let config_path = repo.join(".cargo/config.toml"); + let Ok(current) = std::fs::read_to_string(config_path) else { + return false; + }; + let Ok(committed) = git_command(repo, &["show", "HEAD:.cargo/config.toml"]) else { + return false; + }; + if !committed.status.success() + || current != String::from_utf8_lossy(&committed.stdout).replace("### ENABLE_IN_CI", "") + { + return false; + } + + let Ok(other_changes) = git_command( + repo, + &[ + "status", + "--porcelain", + "--untracked-files=no", + "--", + ".", + ":(exclude).cargo/config.toml", + ], + ) else { + return false; + }; + other_changes.status.success() && other_changes.stdout.is_empty() +} + +pub fn collect_git_source( + repo: &std::path::Path, + github_actions: bool, +) -> Result, String> { + let Ok(prefix) = git_command(repo, &["rev-parse", "--show-prefix"]) else { + return Ok(None); + }; + if !prefix.status.success() { + return Ok(None); + } + let prefix = String::from_utf8(prefix.stdout).map_err(|error| { + format!("git rev-parse --show-prefix returned non-UTF8 output: {error}") + })?; + if !prefix.trim().is_empty() { + return Ok(None); + } + + let revision = git_output(repo, &["rev-parse", "HEAD"])?; + validate_revision(&revision)?; + let tag_glob = format!("{RELEASE_TAG_PREFIX}*"); + let tags = git_output(repo, &["tag", "--points-at", "HEAD", "--list", &tag_glob])? + .lines() + .map(str::to_owned) + .collect(); + let release_tag = select_release_tag(tags)?; + let status = git_command(repo, &["status", "--porcelain", "--untracked-files=no"]) + .map_err(|error| format!("failed to run git: {error}"))?; + if !status.status.success() { + return Err(format!( + "git status failed: {}", + String::from_utf8_lossy(&status.stderr) + )); + } + let dirty = + !status.stdout.is_empty() && !ci_config_rewrite_is_only_change(repo, github_actions); + + Ok(Some(GitSource { + revision, + release_tag, + dirty, + })) +} + +pub fn resolve_version( + git: Option<&GitSource>, + metadata: Option<&ReleaseMetadata>, +) -> Result { + if let Some(git) = git { + if let Some(tag) = &git.release_tag { + let product_version = parse_release_tag(tag)?.to_owned(); + let version = if git.dirty { + format!("{product_version}+dirty") + } else { + product_version.clone() + }; + return Ok(VersionInfo { + product_version, + version, + revision: git.revision.clone(), + }); + } + } + + if let Some(git) = git { + let revision = git + .revision + .get(..9) + .ok_or_else(|| format!("OpenVMM Git revision is too short: {:?}", git.revision))?; + let dirty = if git.dirty { ".dirty" } else { "" }; + return Ok(VersionInfo { + product_version: "0.0.0".into(), + version: format!("0.0.0-dev+g{revision}{dirty}"), + revision: git.revision.clone(), + }); + } + + if let Some(metadata) = metadata { + validate_release_metadata(metadata)?; + return Ok(VersionInfo { + product_version: metadata.version.clone(), + version: metadata.version.clone(), + revision: metadata.revision.clone(), + }); + } + + Ok(VersionInfo { + product_version: "0.0.0".into(), + version: "0.0.0-dev".into(), + revision: String::new(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + use std::process::Command; + use std::time::{SystemTime, UNIX_EPOCH}; + + const SHA: &str = "0123456789abcdef0123456789abcdef01234567"; + + fn git(tag: Option<&str>, dirty: bool) -> GitSource { + GitSource { + revision: SHA.into(), + release_tag: tag.map(str::to_owned), + dirty, + } + } + + fn run(repo: &Path, args: &[&str]) { + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + fn temporary_repo() -> std::path::PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let repo = (0..100) + .find_map(|attempt| { + let repo = std::env::temp_dir().join(format!( + "openvmm-version-info-{}-{nonce}-{attempt}", + std::process::id() + )); + match std::fs::create_dir(&repo) { + Ok(()) => Some(repo), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => None, + Err(error) => panic!("failed to create {}: {error}", repo.display()), + } + }) + .expect("failed to create a unique temporary repository"); + run(&repo, &["init", "--quiet"]); + run(&repo, &["config", "core.autocrlf", "false"]); + run(&repo, &["config", "core.safecrlf", "false"]); + run(&repo, &["config", "user.email", "test@example.com"]); + run(&repo, &["config", "user.name", "Build Test"]); + std::fs::create_dir(repo.join(".cargo")).unwrap(); + std::fs::write( + repo.join(".cargo/config.toml"), + "[build]\n### ENABLE_IN_CI rustflags = [\"-Dwarnings\"]\n", + ) + .unwrap(); + run(&repo, &["add", ".cargo/config.toml"]); + run(&repo, &["commit", "--quiet", "-m", "initial"]); + repo + } + + #[test] + fn resolves_release_and_development_versions() { + let release = git(Some("openvmm-v0.12.3"), false); + let version = resolve_version(Some(&release), None).unwrap(); + assert_eq!(version.product_version, "0.12.3"); + assert_eq!(version.version, "0.12.3"); + assert_eq!(version.revision, SHA); + + let dirty_release = git(Some("openvmm-v0.12.3"), true); + assert_eq!( + resolve_version(Some(&dirty_release), None).unwrap().version, + "0.12.3+dirty" + ); + + let development = git(None, true); + assert_eq!( + resolve_version(Some(&development), None).unwrap().version, + "0.0.0-dev+g012345678.dirty" + ); + } + + #[test] + fn generated_metadata_restores_release_identity() { + let metadata = ReleaseMetadata { + version: "0.12.3".into(), + tag: "openvmm-v0.12.3".into(), + revision: SHA.into(), + }; + let version = resolve_version(None, Some(&metadata)).unwrap(); + assert_eq!(version.version, "0.12.3"); + assert_eq!(version.revision, SHA); + + let development = git(None, false); + assert_eq!( + resolve_version(Some(&development), Some(&metadata)) + .unwrap() + .version, + "0.0.0-dev+g012345678" + ); + } + + #[test] + fn rejects_invalid_or_ambiguous_release_identity() { + assert!(parse_release_tag("openvmm-v0.01.0").is_err()); + assert!(parse_release_tag("openvmm-v0.1").is_err()); + assert!(select_release_tag(vec!["openvmm-vnext".into()]).is_err()); + assert!(select_release_tag(vec!["openvmm-v0.1.0-rc1".into()]).is_err()); + assert!( + select_release_tag(vec!["openvmm-v0.1.0".into(), "openvmm-v0.2.0".into()]).is_err() + ); + + let metadata = ReleaseMetadata { + version: "0.12.4".into(), + tag: "openvmm-v0.12.3".into(), + revision: SHA.into(), + }; + assert!(validate_release_metadata(&metadata).is_err()); + } + + #[test] + fn falls_back_without_git_or_release_metadata() { + let version = resolve_version(None, None).unwrap(); + assert_eq!(version.version, "0.0.0-dev"); + assert!(version.revision.is_empty()); + } + + #[test] + fn collects_only_repository_root_git_identity() { + let repo = temporary_repo(); + run( + &repo, + &[ + "tag", + "--annotate", + "openvmm-v0.12.3", + "--message", + "release", + ], + ); + + let source = collect_git_source(&repo, false).unwrap().unwrap(); + assert_eq!(source.release_tag.as_deref(), Some("openvmm-v0.12.3")); + assert!(!source.dirty); + + let nested = repo.join("vendored-openvmm"); + std::fs::create_dir(&nested).unwrap(); + assert!(collect_git_source(&nested, false).unwrap().is_none()); + + std::fs::remove_dir_all(repo).unwrap(); + } + + #[test] + fn ignores_untracked_files_and_the_known_github_ci_config_rewrite() { + let repo = temporary_repo(); + let config_path = repo.join(".cargo/config.toml"); + std::fs::write(&config_path, "[build]\n rustflags = [\"-Dwarnings\"]\n").unwrap(); + + assert!(!collect_git_source(&repo, true).unwrap().unwrap().dirty); + assert!(collect_git_source(&repo, false).unwrap().unwrap().dirty); + + std::fs::write(repo.join("other.txt"), "dirty\n").unwrap(); + assert!(!collect_git_source(&repo, true).unwrap().unwrap().dirty); + run(&repo, &["add", "other.txt"]); + assert!(collect_git_source(&repo, true).unwrap().unwrap().dirty); + + std::fs::remove_dir_all(repo).unwrap(); + } +} diff --git a/openvmm/openvmm_entry/Cargo.toml b/openvmm/openvmm_entry/Cargo.toml index 73a4936441f..f3341876b27 100644 --- a/openvmm/openvmm_entry/Cargo.toml +++ b/openvmm/openvmm_entry/Cargo.toml @@ -21,6 +21,7 @@ debug_worker_defs.workspace = true vmotherboard.workspace = true diag_client.workspace = true memory_range.workspace = true +openvmm_build_info.workspace = true openvmm_defs.workspace = true openvmm_helpers.workspace = true vmm_core_defs.workspace = true diff --git a/openvmm/openvmm_entry/src/cli_args.rs b/openvmm/openvmm_entry/src/cli_args.rs index 9cab9f1ce0a..a6ce0b28ecc 100644 --- a/openvmm/openvmm_entry/src/cli_args.rs +++ b/openvmm/openvmm_entry/src/cli_args.rs @@ -143,6 +143,7 @@ pub struct NumaDistanceCli { /// This is not yet a stable interface and may change radically between /// versions. #[derive(Parser)] +#[command(name = "openvmm", version = openvmm_build_info::get().version())] pub struct Options { /// processor count #[clap(short = 'p', long, value_name = "COUNT", default_value = "1")] @@ -3388,10 +3389,21 @@ impl FromStr for VhostUserCli { #[cfg(test)] mod tests { use super::*; - use std::path::Path; use test_with_tracing::test; + #[test] + fn test_version_uses_source_identity() { + let Err(error) = Options::try_parse_from(["openvmm", "--version"]) else { + panic!("--version unexpectedly parsed as runtime options"); + }; + assert_eq!(error.kind(), clap::error::ErrorKind::DisplayVersion); + assert_eq!( + error.to_string(), + format!("openvmm {}\n", openvmm_build_info::get().version()) + ); + } + #[test] fn test_parse_rpc() { // explicit path, default transport