diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c55847d..9460afe3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,6 +101,9 @@ jobs: - 'rust-toolchain*' # clippy runs `cargo xtask manifest --check` against this file. - 'MANIFEST.md' + # rocm-deps compiles the pins into constants, so a pin change is a + # source change even though no *.rs file moved. + - 'runtime-deps.toml' - '.github/workflows/**' # build-and-test runs cargo AND the python/shell smoke steps plus the # install-lifecycle E2E (`cargo xtask package` + the real installer), @@ -111,6 +114,7 @@ jobs: - '**/Cargo.toml' - 'Cargo.lock' - 'rust-toolchain*' + - 'runtime-deps.toml' - 'scripts/**' - 'xtask/**' - 'engines/**' diff --git a/Cargo.lock b/Cargo.lock index b1ee9fa7..b117535e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3594,6 +3594,7 @@ dependencies = [ "rocm-core", "rocm-dash-daemon", "rocm-dash-tui", + "rocm-deps", "rocm-engine-lemonade", "rocm-engine-protocol", "rocm-engine-vllm", @@ -3699,6 +3700,7 @@ dependencies = [ "reqwest 0.13.4", "rig-core", "rocm-dash-core", + "rocm-deps", "serde", "serde_json", "thiserror 2.0.18", @@ -3709,6 +3711,13 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "rocm-deps" +version = "0.1.0" +dependencies = [ + "toml", +] + [[package]] name = "rocm-engine-lemonade" version = "0.1.0" @@ -3716,6 +3725,7 @@ dependencies = [ "anyhow", "clap", "rocm-core", + "rocm-deps", "rocm-engine-protocol", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 9d560de4..ff515120 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,8 @@ members = [ "apps/rocm", "apps/rocmd", "crates/rocm-core", + # Pinned third-party runtime versions, generated from `runtime-deps.toml`. + "crates/rocm-deps", "crates/rocm-engine-protocol", # rocm-dash telemetry/dashboard libraries (rocm-dash merge). "crates/rocm-dash-core", diff --git a/apps/rocm/Cargo.toml b/apps/rocm/Cargo.toml index 3984ca5a..00e7efca 100644 --- a/apps/rocm/Cargo.toml +++ b/apps/rocm/Cargo.toml @@ -18,6 +18,7 @@ crossterm.workspace = true flate2 = "1.1" keyring-core.workspace = true rocm-core = { path = "../../crates/rocm-core" } +rocm-deps = { path = "../../crates/rocm-deps" } # rocm-dash unified dashboard launch. The # telemetry daemon + ratatui-0.30 TUI are launched from the `dash` verb; tokio # drives the async daemon/TUI from the otherwise-sync `rocm` binary. diff --git a/apps/rocm/src/main.rs b/apps/rocm/src/main.rs index b0a3fd13..ba3ee5a5 100644 --- a/apps/rocm/src/main.rs +++ b/apps/rocm/src/main.rs @@ -3408,7 +3408,7 @@ fn resolve_engine_install_runtime_id( runtime_id: Option, ) -> Result { if engine_manages_own_runtime(engine) { - return Ok(runtime_id.unwrap_or_else(|| managed_engine_runtime_id(engine).to_owned())); + return Ok(runtime_id.unwrap_or_else(|| managed_engine_runtime_id(engine))); } let Some(selector) = runtime_id .or_else(|| config.active_runtime_key.clone()) @@ -3512,10 +3512,15 @@ fn env_root_for_service( } } -fn managed_engine_runtime_id(engine: &str) -> &'static str { +/// Label recorded for the runtime a self-managing engine installs for itself. +/// +/// For `lemonade` this must be the `env_id` its adapter reports, which is +/// derived from the single Lemonade pin — it was previously a hand-written +/// literal and had drifted several minor versions behind what is installed. +fn managed_engine_runtime_id(engine: &str) -> String { match engine { - "lemonade" => "lemonade-embeddable-10.6.0", - _ => "managed-engine-runtime", + "lemonade" => format!("lemonade-embeddable-{}", rocm_deps::lemonade_version()), + _ => "managed-engine-runtime".to_owned(), } } @@ -3527,7 +3532,7 @@ fn ensure_self_managed_engine_ready( if !engine_manages_own_runtime(engine) { return Ok(()); } - let runtime_id = managed_engine_runtime_id(engine).to_owned(); + let runtime_id = managed_engine_runtime_id(engine); let env_root = env_root_for_self_managed_engine(paths, config)?; let detect = engine_request::<_, DetectResponse>( Some(paths), @@ -3539,8 +3544,14 @@ fn ensure_self_managed_engine_ready( }, ) .ok(); + // For a self-managing engine the runtime id *is* the env id its adapter + // reports for the pinned version, so a version bump leaves an older + // install detected-but-not-current. Requiring the ids to match makes the + // bump trigger an install instead of silently keeping the old runtime. let installed = detect.as_ref().is_some_and(|detect| { - detect.installed && detect_runtime_matches_env_root(detect, env_root.as_deref()) + detect.installed + && detect.env_id.as_deref() == Some(runtime_id.as_str()) + && detect_runtime_matches_env_root(detect, env_root.as_deref()) }); let response = if installed { None @@ -21765,7 +21776,7 @@ ID_LIKE="suse opensuse" assert!(error.contains("no active ROCm runtime is configured")); assert_eq!( resolve_engine_install_runtime_id(&paths, &RocmCliConfig::default(), "lemonade", None)?, - "lemonade-embeddable-10.6.0" + format!("lemonade-embeddable-{}", rocm_deps::lemonade_version()), ); write_test_pip_runtime( &paths, diff --git a/crates/e2e-report/src/lib.rs b/crates/e2e-report/src/lib.rs index c5867e76..b7bf2109 100644 --- a/crates/e2e-report/src/lib.rs +++ b/crates/e2e-report/src/lib.rs @@ -2409,7 +2409,7 @@ mod tests { let platform = r#"{ "platform_slug": "mi300x", "capability": {"effective_serve_engine": "vllm"}, - "versions": {"os":"Ubuntu 24.04.3 LTS","rocm":"7.13.0","vllm":"0.23.0+rocm723","lemonade":"10.6.0"}, + "versions": {"os":"Ubuntu 24.04.3 LTS","rocm":"7.13.0","vllm":"0.23.0+rocm723","lemonade":"11.5.1"}, "expectations": [ {"id":"serve-x","effective_engine":"vllm","expected":"pass"} ] @@ -2421,7 +2421,7 @@ mod tests { "Ubuntu 24.04.3 LTS", "ROCm 7.13.0", "vLLM 0.23.0+rocm723", - "lemonade 10.6.0", + "lemonade 11.5.1", ] { assert!(md.contains(token), "matrix cell missing {token:?}:\n{md}"); } diff --git a/crates/rocm-dash-tui/Cargo.toml b/crates/rocm-dash-tui/Cargo.toml index d180bc4a..772e8dbb 100644 --- a/crates/rocm-dash-tui/Cargo.toml +++ b/crates/rocm-dash-tui/Cargo.toml @@ -16,6 +16,7 @@ path = "src/lib.rs" [dependencies] rocm-dash-core = { path = "../rocm-dash-core" } +rocm-deps = { path = "../rocm-deps" } # Unified dashboard TUI base: ratatui 0.30 + crossterm 0.28. This is the only # ratatui in the workspace — the `rocm` binary no longer carries a (dead) 0.29 # dependency, so no second major is pulled in. Exact wrapped-row counts must diff --git a/crates/rocm-dash-tui/src/skills.rs b/crates/rocm-dash-tui/src/skills.rs index 55968c7d..2915ad98 100644 --- a/crates/rocm-dash-tui/src/skills.rs +++ b/crates/rocm-dash-tui/src/skills.rs @@ -254,20 +254,29 @@ pub fn auto_config_change(detected_endpoint: Option<&str>) -> Option String { + rocm_deps::lemonade_version() +} /// A selected embeddable archive for a host triple — enough to download, extract, /// and locate the server binary. Pure data; no I/O. #[derive(Debug, Clone, PartialEq, Eq)] pub struct EmbeddableArtifact { - /// Version without a leading `v`, e.g. `10.6.0`. + /// Version without a leading `v`, e.g. `11.5.1`. pub version: String, /// The archive's `browser_download_url`. pub url: String, - /// The archive file name, e.g. `lemonade-embeddable-10.6.0-ubuntu-x64.tar.gz`. + /// The archive file name, e.g. `lemonade-embeddable-11.5.1-ubuntu-x64.tar.gz`. pub archive_name: String, /// The unpacked server executable name (`lemond` / `lemond.exe`). pub server_bin: String, @@ -275,7 +284,8 @@ pub struct EmbeddableArtifact { /// Map a host (os, arch) — `std::env::consts::{OS, ARCH}` values — to the /// embeddable asset's `` token + archive extension. `None` for an -/// unsupported triple (the release ships only these three). +/// unsupported triple (only these three are supported here; the release also +/// ships `ubuntu-arm64`, which we do not select). fn embeddable_os_arch(os: &str, arch: &str) -> Option<(&'static str, &'static str)> { match (os, arch) { ("linux", "x86_64") => Some(("ubuntu-x64", "tar.gz")), @@ -294,9 +304,9 @@ fn server_bin_for(os: &str) -> &'static str { } } -/// Strip a leading `v` from a release tag (`v10.6.0` → `10.6.0`). +/// Strip a leading `v` from a release tag (`v11.5.1` → `11.5.1`). fn strip_v(tag: &str) -> &str { - tag.strip_prefix('v').unwrap_or(tag) + rocm_deps::strip_v(tag) } /// PURE: build the canonical embeddable artifact for `(os, arch, version)` with no @@ -304,10 +314,8 @@ fn strip_v(tag: &str) -> &str { pub fn embeddable_artifact(os: &str, arch: &str, version: &str) -> Option { let (os_arch, ext) = embeddable_os_arch(os, arch)?; let ver = strip_v(version); - let archive_name = format!("lemonade-embeddable-{ver}-{os_arch}.{ext}"); - let url = format!( - "https://github.com/{LEMONADE_GITHUB_REPO}/releases/download/v{ver}/{archive_name}" - ); + let archive_name = rocm_deps::lemonade_archive_name(ver, os_arch, ext); + let url = rocm_deps::lemonade_download_url(ver, &archive_name); Some(EmbeddableArtifact { version: ver.to_string(), url, @@ -506,9 +514,26 @@ mod tests { assert_eq!(b.server_bin, "lemond.exe"); // Unsupported triple → None. assert!(embeddable_artifact("linux", "aarch64", "10.6.0").is_none()); - // The fallback const resolves for the common host. + } + + /// The offline fallback and the version the `lemonade` engine adapter + /// installs must never disagree. They cannot: both come from the single + /// `runtime-deps.toml` pin, and this pins that down against a regression + /// that reintroduces a second constant. + #[test] + fn fallback_version_is_the_single_pinned_version() { + let fallback = lemonade_embeddable_fallback_version(); + assert_eq!(fallback, rocm_deps::lemonade_version()); + let artifact = embeddable_artifact("linux", "x86_64", &fallback).expect("linux"); + assert_eq!(artifact.version, fallback); + assert_eq!( + artifact.archive_name, + rocm_deps::lemonade_archive_name(&fallback, "ubuntu-x64", "tar.gz") + ); assert!( - embeddable_artifact("linux", "x86_64", LEMONADE_EMBEDDABLE_FALLBACK_VERSION).is_some() + artifact.url.contains(&format!("/download/v{fallback}/")), + "url {} does not carry the pinned version", + artifact.url ); } diff --git a/crates/rocm-deps/Cargo.toml b/crates/rocm-deps/Cargo.toml new file mode 100644 index 00000000..16aa13bb --- /dev/null +++ b/crates/rocm-deps/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "rocm-deps" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +publish.workspace = true +# Turns the workspace-root `runtime-deps.toml` pins into constants. Declared +# explicitly so the file is not mistaken for a stray script. +build = "build.rs" + +[lints] +workspace = true + +[build-dependencies] +toml = "0.8" diff --git a/crates/rocm-deps/build.rs b/crates/rocm-deps/build.rs new file mode 100644 index 00000000..97254126 --- /dev/null +++ b/crates/rocm-deps/build.rs @@ -0,0 +1,84 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// +// SPDX-License-Identifier: MIT + +//! Turn the workspace-root `runtime-deps.toml` pins into Rust constants. +//! +//! Every `[runtime.]` field becomes a `pub const _: &str` +//! written to `$OUT_DIR/pins.rs`, which `src/lib.rs` includes. Generating at +//! build time (rather than committing a generated source file) means the pin +//! exists exactly once in the tree, so there is nothing to drift and no +//! `--check` gate to keep honest; a missing or malformed pin is a build error. + +use std::collections::BTreeMap; +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; +use std::{env, fs}; + +/// Pin file, relative to this crate's manifest directory. +const PINS_FILE: &str = "../../runtime-deps.toml"; + +fn main() { + let manifest_dir = PathBuf::from( + env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by cargo"), + ); + let pins_path = manifest_dir.join(PINS_FILE); + println!("cargo:rerun-if-changed={}", pins_path.display()); + + let text = fs::read_to_string(&pins_path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", pins_path.display())); + let generated = render(&text, &pins_path); + + let out_path = + PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR is set by cargo")).join("pins.rs"); + fs::write(&out_path, generated) + .unwrap_or_else(|err| panic!("failed to write {}: {err}", out_path.display())); +} + +/// Render the constants for one pin file. Panics with a pointed message on any +/// schema violation so a bad pin fails the build instead of the runtime. +fn render(text: &str, path: &Path) -> String { + let doc: toml::Value = toml::from_str(text) + .unwrap_or_else(|err| panic!("{} is not valid TOML: {err}", path.display())); + let runtimes = doc + .get("runtime") + .and_then(toml::Value::as_table) + .unwrap_or_else(|| panic!("{} must define a [runtime] table", path.display())); + + let mut out = String::from( + "// @generated by build.rs from runtime-deps.toml -- do not edit; edit the pin file.\n", + ); + // `a-b` and `a.b` both fold to `A_B`, so two differently spelled entries + // could generate the same constant. Reject that here rather than emitting + // a duplicate definition and a confusing compile error in the generated + // file. + let mut seen: BTreeMap = BTreeMap::new(); + for (name, entry) in runtimes { + let fields = entry + .as_table() + .unwrap_or_else(|| panic!("[runtime.{name}] must be a table of string fields")); + for (field, value) in fields { + let literal = value + .as_str() + .unwrap_or_else(|| panic!("runtime.{name}.{field} must be a string")); + let ident = format!("{}_{}", const_ident(name), const_ident(field)); + let source = format!("runtime.{name}.{field}"); + if let Some(previous) = seen.insert(ident.clone(), source.clone()) { + panic!("{previous} and {source} both generate {ident}; rename one"); + } + // `{:?}` renders a correctly escaped Rust string literal, so any + // quote, backslash or control character in the pin stays valid. + writeln!( + out, + "/// `{source}` from `runtime-deps.toml`.\npub const {ident}: &str = {literal:?};" + ) + .expect("writing to a String cannot fail"); + } + } + out +} + +/// `rocm-abi` -> `ROCM_ABI`: upper-case, with separators folded to `_`. +fn const_ident(raw: &str) -> String { + raw.to_uppercase().replace(['-', '.'], "_") +} diff --git a/crates/rocm-deps/src/lib.rs b/crates/rocm-deps/src/lib.rs new file mode 100644 index 00000000..b074472d --- /dev/null +++ b/crates/rocm-deps/src/lib.rs @@ -0,0 +1,163 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// +// SPDX-License-Identifier: MIT + +//! Pinned versions of the third-party runtimes rocm-cli manages, plus the +//! helpers that derive artifact names and URLs from them. +//! +//! The pins live in the workspace-root `runtime-deps.toml` and are turned into +//! the constants below by `build.rs`. Every consumer — the engine adapters and +//! the dashboard TUI alike — derives what it needs from those constants, so a +//! runtime version is spelled exactly once in the repository and two crates +//! cannot disagree about it. +//! +//! Each pin can be overridden per invocation by an environment variable +//! following the existing `ROCM_CLI_*` convention. Overrides are for trying an +//! untested release; the pin is the only combination the project tests, so +//! callers should surface [`lemonade_version_override_notice`] to the user. + +// `pub const LEMONADE_VERSION: &str = "..."`, one per `runtime-deps.toml` field. +include!(concat!(env!("OUT_DIR"), "/pins.rs")); + +/// GitHub repository publishing the Lemonade embeddable release archives. +pub const LEMONADE_GITHUB_REPO: &str = "lemonade-sdk/lemonade"; + +/// Environment variable overriding [`LEMONADE_VERSION`]. +pub const LEMONADE_VERSION_ENV: &str = "ROCM_CLI_LEMONADE_VERSION"; + +/// The Lemonade embeddable version to use, honoring [`LEMONADE_VERSION_ENV`]. +/// +/// Reads the process environment; the decision itself lives in the pure +/// [`resolve_lemonade_version`] so it can be tested without touching env. +#[must_use] +pub fn lemonade_version() -> String { + resolve_lemonade_version(std::env::var(LEMONADE_VERSION_ENV).ok().as_deref()) +} + +/// Pure resolution of the Lemonade version from an optional override. +/// +/// Falls back to [`LEMONADE_VERSION`] when the override is absent, blank, or +/// nothing but a `v`. A usable override is trimmed and has any leading `v` +/// stripped, so `v11.6.0` and `11.6.0` behave identically. +#[must_use] +pub fn resolve_lemonade_version(override_value: Option<&str>) -> String { + override_value + .map(str::trim) + .map(strip_v) + .filter(|value| !value.is_empty()) + .unwrap_or(LEMONADE_VERSION) + .to_owned() +} + +/// A warning to show the user when `version` is not the tested pin. +/// +/// `None` for the pinned version — the normal case — so callers can +/// unconditionally print whatever comes back. +#[must_use] +pub fn lemonade_version_override_notice(version: &str) -> Option { + (version != LEMONADE_VERSION).then(|| { + format!( + "Warning: using Lemonade {version} from {LEMONADE_VERSION_ENV}; \ + the tested version is {LEMONADE_VERSION}." + ) + }) +} + +/// Strip a leading `v` from a release tag (`v11.5.1` -> `11.5.1`). +#[must_use] +pub fn strip_v(tag: &str) -> &str { + tag.strip_prefix('v').unwrap_or(tag) +} + +/// Name of the Lemonade embeddable archive for a host token and extension, +/// e.g. `lemonade-embeddable-11.5.1-ubuntu-x64.tar.gz`. +#[must_use] +pub fn lemonade_archive_name(version: &str, os_arch: &str, extension: &str) -> String { + format!( + "lemonade-embeddable-{}-{os_arch}.{extension}", + strip_v(version) + ) +} + +/// Download URL of a Lemonade embeddable archive published under `v`. +#[must_use] +pub fn lemonade_download_url(version: &str, archive_name: &str) -> String { + format!( + "https://github.com/{LEMONADE_GITHUB_REPO}/releases/download/v{}/{archive_name}", + strip_v(version) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pinned_version_is_a_bare_release_number() { + assert!( + !LEMONADE_VERSION.is_empty() && !LEMONADE_VERSION.starts_with('v'), + "pin must be a bare version, got {LEMONADE_VERSION:?}" + ); + } + + #[test] + fn absent_or_blank_override_keeps_the_pin() { + for override_value in [None, Some(""), Some(" "), Some("v"), Some(" v ")] { + assert_eq!( + resolve_lemonade_version(override_value), + LEMONADE_VERSION, + "override {override_value:?} should have kept the pin" + ); + } + } + + #[test] + fn override_is_trimmed_and_v_stripped() { + assert_eq!(resolve_lemonade_version(Some(" v11.6.0 ")), "11.6.0"); + assert_eq!(resolve_lemonade_version(Some("11.6.0")), "11.6.0"); + } + + #[test] + fn archive_names_and_urls_match_the_published_shape() { + let version = resolve_lemonade_version(None); + let linux = lemonade_archive_name(&version, "ubuntu-x64", "tar.gz"); + let windows = lemonade_archive_name(&version, "windows-x64", "zip"); + assert_eq!( + linux, + format!("lemonade-embeddable-{version}-ubuntu-x64.tar.gz") + ); + assert_eq!( + windows, + format!("lemonade-embeddable-{version}-windows-x64.zip") + ); + assert_eq!( + lemonade_download_url(&version, &linux), + format!( + "https://github.com/lemonade-sdk/lemonade/releases/download/v{version}/{linux}" + ) + ); + } + + #[test] + fn override_flows_through_to_names_and_urls() { + let version = resolve_lemonade_version(Some("v11.6.0")); + let archive = lemonade_archive_name(&version, "ubuntu-x64", "tar.gz"); + assert_eq!(archive, "lemonade-embeddable-11.6.0-ubuntu-x64.tar.gz"); + let url = lemonade_download_url(&version, &archive); + assert_eq!( + url, + "https://github.com/lemonade-sdk/lemonade/releases/download/v11.6.0/lemonade-embeddable-11.6.0-ubuntu-x64.tar.gz" + ); + assert!( + !url.contains(LEMONADE_VERSION), + "overridden URL still references the pin: {url}" + ); + } + + #[test] + fn only_an_override_produces_a_notice() { + assert_eq!(lemonade_version_override_notice(LEMONADE_VERSION), None); + let notice = lemonade_version_override_notice("11.6.0").expect("override notice"); + assert!(notice.contains("11.6.0") && notice.contains(LEMONADE_VERSION_ENV)); + } +} diff --git a/docs/engine-plugins.md b/docs/engine-plugins.md index fa707862..c39697b6 100644 --- a/docs/engine-plugins.md +++ b/docs/engine-plugins.md @@ -32,6 +32,26 @@ The `lemonade` adapter uses Lemonade embeddable and prefers Lemonade's `llamacpp:rocm` backend, falling back to `llamacpp:vulkan` when ROCm is unsupported. rocm-cli does not use a CPU fallback for this path. +## Pinned runtime versions + +The versions of the third-party runtimes rocm-cli downloads are pinned in +`runtime-deps.toml` at the repository root — one `[runtime.]` table per +runtime. That file is the only place a runtime version is written down: +archive names, download URLs, and the dashboard's offline fallback are all +derived from it, so a bump is a one-line edit and the tree cannot end up +holding two different versions of the same runtime. + +To try a release other than the pinned one, set the runtime's environment +variable; the derived archive names and URLs follow it automatically. A +leading `v` is optional, and an empty value falls back to the pin. + +```bash +ROCM_CLI_LEMONADE_VERSION=11.6.0 rocm engines install lemonade +``` + +Only the pinned version is tested. An override prints a warning naming the +version in use and the version the project tests. + `rocm engines list` shows the exact plugin directories for the current host. The same output is available in the TUI with `/engine`. diff --git a/engines/lemonade/Cargo.toml b/engines/lemonade/Cargo.toml index e840bb0e..9dafc9a0 100644 --- a/engines/lemonade/Cargo.toml +++ b/engines/lemonade/Cargo.toml @@ -18,6 +18,7 @@ path = "src/main.rs" anyhow.workspace = true clap.workspace = true rocm-core = { path = "../../crates/rocm-core" } +rocm-deps = { path = "../../crates/rocm-deps" } rocm-engine-protocol = { path = "../../crates/rocm-engine-protocol" } serde.workspace = true serde_json.workspace = true diff --git a/engines/lemonade/src/lib.rs b/engines/lemonade/src/lib.rs index 260a8318..fb2ece70 100644 --- a/engines/lemonade/src/lib.rs +++ b/engines/lemonade/src/lib.rs @@ -31,7 +31,6 @@ use std::process::{Command as ProcessCommand, Stdio}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; const ENGINE_NAME: &str = "lemonade"; -const LEMONADE_VERSION: &str = "10.10.0"; const DEFAULT_HOST: &str = "127.0.0.1"; const DEFAULT_MODEL: &str = "Qwen3-4B-Instruct-2507-GGUF"; const DEFAULT_MODEL_REPO_DIR: &str = "models--unsloth--Qwen3-4B-Instruct-2507-GGUF"; @@ -56,10 +55,16 @@ const STARTUP_FAILURE_LOG_TAIL_LINES: usize = 80; /// Prevents reading entire gigabyte-sized logs on startup timeout. const MAX_TAIL_READ: u64 = 4 * 1024 * 1024; // 4MB -const EMBEDDABLE_WINDOWS_ARCHIVE_NAME: &str = "lemonade-embeddable-10.10.0-windows-x64.zip"; -const EMBEDDABLE_LINUX_ARCHIVE_NAME: &str = "lemonade-embeddable-10.10.0-ubuntu-x64.tar.gz"; -const EMBEDDABLE_WINDOWS_URL: &str = "https://github.com/lemonade-sdk/lemonade/releases/download/v10.10.0/lemonade-embeddable-10.10.0-windows-x64.zip"; -const EMBEDDABLE_LINUX_URL: &str = "https://github.com/lemonade-sdk/lemonade/releases/download/v10.10.0/lemonade-embeddable-10.10.0-ubuntu-x64.tar.gz"; +/// Embeddable asset token and archive extension for the host this adapter runs +/// on. Only `windows-x64` and `ubuntu-x64` are wired up here; the release also +/// publishes `macos-arm64` and `ubuntu-arm64`, which this adapter never selects. +const fn embeddable_os_arch() -> (&'static str, &'static str) { + if runtime_is_windows() { + ("windows-x64", "zip") + } else { + ("ubuntu-x64", "tar.gz") + } +} #[derive(Parser)] #[command(name = "rocm-engine-lemonade")] @@ -391,7 +396,11 @@ fn detect_response() -> DetectResponse { fn install_response(request: InstallRequest) -> Result { let paths = AppPaths::discover()?; paths.ensure()?; - eprintln!("Preparing Lemonade embeddable {LEMONADE_VERSION}..."); + let version = rocm_deps::lemonade_version(); + if let Some(notice) = rocm_deps::lemonade_version_override_notice(&version) { + eprintln!("{notice}"); + } + eprintln!("Preparing Lemonade embeddable {version}..."); let env_root = request .env_root .as_deref() @@ -965,25 +974,52 @@ fn stop_service(request: StopRequest) -> Result { Ok(StopResponse { stopped, graceful }) } +/// Whether the embeddable archive must be extracted over the runtime tree. +/// +/// Pure so the upgrade case is testable without a real archive. `installed` +/// is the version recorded for the tree already on disk, if any. +fn needs_extraction( + reinstall: bool, + installed: Option<&str>, + wanted: &str, + server_present: bool, +) -> bool { + reinstall || !server_present || installed != Some(wanted) +} + fn prepare_embeddable( paths: &AppPaths, env_root: Option<&Path>, reinstall: bool, ) -> Result { let root = lemonade_root(paths, env_root); - let archive_name = embeddable_archive_name(); - let archive_url = embeddable_url(); + let version = rocm_deps::lemonade_version(); + let archive_name = embeddable_archive_name(&version); + let archive_url = embeddable_url(&version); let downloads = root.join("downloads"); - let archive = downloads.join(archive_name); + let archive = downloads.join(&archive_name); fs::create_dir_all(&downloads)?; if archive.is_file() { eprintln!("Using cached {archive_name}."); } else { eprintln!("Downloading {archive_name}..."); - download_file(archive_url, &archive)?; + download_file(&archive_url, &archive)?; } let runtime_dir = runtime_dir_in(&root); - if reinstall || !lemond_path_in(&runtime_dir).is_file() { + // The runtime directory is not version-scoped, so a bump downloads a new + // archive into a tree that already holds `lemond` from the previous + // version. Without comparing versions the extraction would be skipped and + // the old binaries reported as the new version. + let installed_version = read_manifest(paths) + .ok() + .filter(|manifest| manifest.runtime_dir == runtime_dir) + .map(|manifest| manifest.version); + if needs_extraction( + reinstall, + installed_version.as_deref(), + &version, + lemond_path_in(&runtime_dir).is_file(), + ) { if runtime_dir.exists() { fs::remove_dir_all(&runtime_dir) .with_context(|| format!("failed to clear {}", runtime_dir.display()))?; @@ -1006,8 +1042,8 @@ fn prepare_embeddable( ); } Ok(LemonadeInstallManifest { - env_id: format!("lemonade-embeddable-{LEMONADE_VERSION}"), - version: LEMONADE_VERSION.to_owned(), + env_id: format!("lemonade-embeddable-{version}"), + version, runtime_dir, lemond, lemonade, @@ -1225,20 +1261,13 @@ fn platform_binary_name(name: &str) -> String { } } -const fn embeddable_archive_name() -> &'static str { - if runtime_is_windows() { - EMBEDDABLE_WINDOWS_ARCHIVE_NAME - } else { - EMBEDDABLE_LINUX_ARCHIVE_NAME - } +fn embeddable_archive_name(version: &str) -> String { + let (os_arch, extension) = embeddable_os_arch(); + rocm_deps::lemonade_archive_name(version, os_arch, extension) } -const fn embeddable_url() -> &'static str { - if runtime_is_windows() { - EMBEDDABLE_WINDOWS_URL - } else { - EMBEDDABLE_LINUX_URL - } +fn embeddable_url(version: &str) -> String { + rocm_deps::lemonade_download_url(version, &embeddable_archive_name(version)) } fn download_file(url: &str, destination: &Path) -> Result<()> { @@ -3079,7 +3108,7 @@ mod tests { fn test_manifest(runtime_dir: PathBuf) -> LemonadeInstallManifest { LemonadeInstallManifest { env_id: "test".to_owned(), - version: LEMONADE_VERSION.to_owned(), + version: rocm_deps::lemonade_version(), runtime_dir, lemond: PathBuf::from("lemond"), lemonade: PathBuf::from("lemonade"), @@ -3106,17 +3135,49 @@ mod tests { #[test] fn embeddable_package_matches_runtime_os() { + let version = rocm_deps::lemonade_version(); + let (os_arch, extension) = embeddable_os_arch(); + let suffix = format!("{os_arch}.{extension}"); + assert!(embeddable_archive_name(&version).ends_with(&suffix)); + assert!(embeddable_url(&version).ends_with(&suffix)); if runtime_is_windows() { - assert!(embeddable_archive_name().ends_with("windows-x64.zip")); - assert!(embeddable_url().ends_with("windows-x64.zip")); + assert_eq!(suffix, "windows-x64.zip"); assert_eq!(platform_binary_name("lemond"), "lemond.exe"); } else { - assert!(embeddable_archive_name().ends_with("ubuntu-x64.tar.gz")); - assert!(embeddable_url().ends_with("ubuntu-x64.tar.gz")); + assert_eq!(suffix, "ubuntu-x64.tar.gz"); assert_eq!(platform_binary_name("lemond"), "lemond"); } } + #[test] + fn a_version_change_forces_re_extraction_over_the_existing_runtime() { + let pin = rocm_deps::LEMONADE_VERSION; + // Same version already unpacked: nothing to do. + assert!(!needs_extraction(false, Some(pin), pin, true)); + // A bump must not be silently skipped just because `lemond` exists. + assert!(needs_extraction(false, Some("10.10.0"), pin, true)); + // Nothing recorded, nothing unpacked, or an explicit reinstall. + assert!(needs_extraction(false, None, pin, true)); + assert!(needs_extraction(false, Some(pin), pin, false)); + assert!(needs_extraction(true, Some(pin), pin, true)); + } + + #[test] + fn overridden_version_reaches_both_the_archive_name_and_the_url() { + let version = rocm_deps::resolve_lemonade_version(Some("v99.0.0")); + let (os_arch, extension) = embeddable_os_arch(); + assert_eq!( + embeddable_archive_name(&version), + format!("lemonade-embeddable-99.0.0-{os_arch}.{extension}") + ); + let url = embeddable_url(&version); + assert!(url.contains("/download/v99.0.0/"), "url: {url}"); + assert!( + !url.contains(rocm_deps::LEMONADE_VERSION), + "override left the pinned version in the url: {url}" + ); + } + #[test] fn lemonade_root_uses_requested_engine_root() { let paths = AppPaths { diff --git a/runtime-deps.toml b/runtime-deps.toml new file mode 100644 index 00000000..85162274 --- /dev/null +++ b/runtime-deps.toml @@ -0,0 +1,22 @@ +# Pinned versions of the third-party runtimes rocm-cli downloads and manages. +# +# This file is the SINGLE SOURCE OF TRUTH for those pins. Nothing else in the +# tree may spell a runtime version literal: archive names, download URLs, and +# offline fallbacks are all derived from the values below by the `rocm-deps` +# crate, whose build script turns each entry into a Rust constant. Editing a +# version here is the whole bump. +# +# Schema: one `[runtime.]` table per managed runtime, with one or more +# string fields. Every field becomes a `pub const _: &str` in +# `rocm-deps` (uppercased, `-` and `.` folded to `_`), so a runtime that needs +# more than a version — say a version plus an ABI tag — just adds a field. +# +# A pin can be overridden at run time by the runtime's `ROCM_CLI_*` environment +# variable (see `rocm-deps` and `docs/engine-plugins.md`); the pin here is the +# default and the only combination the project tests. + +[runtime.lemonade] +# Lemonade embeddable release, published as +# `lemonade-embeddable--.{tar.gz|zip}` under the +# `v` tag of the lemonade-sdk/lemonade GitHub releases. +version = "11.5.1"