node running, runtime version awareness

This commit is contained in:
2026-02-25 23:24:07 -06:00
parent e89b5991ec
commit 495b81236a
54 changed files with 4308 additions and 246 deletions

View File

@@ -0,0 +1,638 @@
//! Runtime version constraint matching
//!
//! Provides utilities for parsing and evaluating semver version constraints
//! against available runtime versions. Used by the worker to select the
//! appropriate runtime version when an action or sensor declares a
//! `runtime_version_constraint`.
//!
//! # Constraint Syntax
//!
//! Constraints follow standard semver range syntax:
//!
//! | Constraint | Meaning |
//! |-----------------|----------------------------------------|
//! | `3.12` | Exactly 3.12.x (any patch) |
//! | `=3.12.1` | Exactly 3.12.1 |
//! | `>=3.12` | 3.12.0 or newer |
//! | `>=3.12,<4.0` | 3.12.0 or newer, but below 4.0.0 |
//! | `~3.12` | Compatible with 3.12.x (>=3.12.0, <3.13.0) |
//! | `^3.12` | Compatible with 3.x.x (>=3.12.0, <4.0.0) |
//!
//! Multiple constraints can be separated by commas (AND logic).
//!
//! # Lenient Parsing
//!
//! Version strings are parsed leniently to handle real-world formats:
//! - `3.12` → `3.12.0`
//! - `3` → `3.0.0`
//! - `v3.12.1` → `3.12.1` (leading 'v' stripped)
//! - `3.12.1-beta.1` → parsed with pre-release info
//!
//! # Examples
//!
//! ```
//! use attune_common::version_matching::{parse_version, matches_constraint, select_best_version};
//! use attune_common::models::RuntimeVersion;
//!
//! // Simple constraint matching
//! assert!(matches_constraint("3.12.1", ">=3.12").unwrap());
//! assert!(!matches_constraint("3.11.0", ">=3.12").unwrap());
//!
//! // Range constraints
//! assert!(matches_constraint("3.12.5", ">=3.12,<4.0").unwrap());
//! assert!(!matches_constraint("4.0.0", ">=3.12,<4.0").unwrap());
//!
//! // Tilde (patch-level compatibility)
//! assert!(matches_constraint("3.12.5", "~3.12").unwrap());
//! assert!(!matches_constraint("3.13.0", "~3.12").unwrap());
//!
//! // Caret (minor-level compatibility)
//! assert!(matches_constraint("3.15.0", "^3.12").unwrap());
//! assert!(!matches_constraint("4.0.0", "^3.12").unwrap());
//! ```
use semver::{Version, VersionReq};
use tracing::{debug, warn};
use crate::models::RuntimeVersion;
/// Error type for version matching operations.
#[derive(Debug, thiserror::Error)]
pub enum VersionError {
#[error("Invalid version string '{0}': {1}")]
InvalidVersion(String, String),
#[error("Invalid version constraint '{0}': {1}")]
InvalidConstraint(String, String),
}
/// Result type for version matching operations.
pub type VersionResult<T> = std::result::Result<T, VersionError>;
/// Parse a version string leniently into a [`semver::Version`].
///
/// Handles common real-world formats:
/// - `"3.12"` → `Version { major: 3, minor: 12, patch: 0 }`
/// - `"3"` → `Version { major: 3, minor: 0, patch: 0 }`
/// - `"v3.12.1"` → `Version { major: 3, minor: 12, patch: 1 }`
/// - `"3.12.1"` → `Version { major: 3, minor: 12, patch: 1 }`
pub fn parse_version(version_str: &str) -> VersionResult<Version> {
let trimmed = version_str.trim();
// Strip leading 'v' or 'V'
let stripped = trimmed
.strip_prefix('v')
.or_else(|| trimmed.strip_prefix('V'))
.unwrap_or(trimmed);
// Try direct parse first (handles full semver like "3.12.1" and pre-release)
if let Ok(v) = Version::parse(stripped) {
return Ok(v);
}
// Try adding missing components
let parts: Vec<&str> = stripped.split('.').collect();
let padded = match parts.len() {
1 => format!("{}.0.0", parts[0]),
2 => format!("{}.{}.0", parts[0], parts[1]),
_ => {
// More than 3 parts or other issues — try joining first 3
if parts.len() >= 3 {
format!("{}.{}.{}", parts[0], parts[1], parts[2])
} else {
stripped.to_string()
}
}
};
Version::parse(&padded)
.map_err(|e| VersionError::InvalidVersion(version_str.to_string(), e.to_string()))
}
/// Parse a version constraint string into a [`semver::VersionReq`].
///
/// Handles comma-separated constraints (AND logic) and the standard
/// semver operators: `=`, `>=`, `<=`, `>`, `<`, `~`, `^`.
///
/// If a bare version is given (no operator), it is treated as a
/// compatibility constraint: `"3.12"` becomes `">=3.12.0, <3.13.0"` (tilde behavior).
///
/// Note: The `semver` crate's `VersionReq` natively handles comma-separated
/// constraints and all standard operators.
pub fn parse_constraint(constraint_str: &str) -> VersionResult<VersionReq> {
let trimmed = constraint_str.trim();
if trimmed.is_empty() {
// Empty constraint matches everything
return Ok(VersionReq::STAR);
}
// Preprocess each comma-separated part to handle lenient input.
// For each part, if it looks like a bare version (no operator prefix),
// we treat it as a tilde constraint so "3.12" means "~3.12".
let parts: Vec<String> = trimmed
.split(',')
.map(|part| {
let p = part.trim();
if p.is_empty() {
return String::new();
}
// Check if the first character is an operator
let first_char = p.chars().next().unwrap_or(' ');
if first_char.is_ascii_digit() || first_char == 'v' || first_char == 'V' {
// Bare version — treat as tilde range (compatible within minor)
let stripped = p
.strip_prefix('v')
.or_else(|| p.strip_prefix('V'))
.unwrap_or(p);
// Pad to at least major.minor for tilde semantics
let dot_count = stripped.chars().filter(|c| *c == '.').count();
let padded = match dot_count {
0 => format!("{}.0", stripped),
_ => stripped.to_string(),
};
format!("~{}", padded)
} else {
// Has operator prefix — normalize version part if needed
// Find where the version number starts
let version_start = p.find(|c: char| c.is_ascii_digit()).unwrap_or(p.len());
let (op, ver) = p.split_at(version_start);
let ver = ver
.strip_prefix('v')
.or_else(|| ver.strip_prefix('V'))
.unwrap_or(ver);
// Pad version if needed
let dot_count = ver.chars().filter(|c| *c == '.').count();
let padded = match dot_count {
0 if !ver.is_empty() => format!("{}.0.0", ver),
1 => format!("{}.0", ver),
_ => ver.to_string(),
};
format!("{}{}", op.trim(), padded)
}
})
.filter(|s| !s.is_empty())
.collect();
if parts.is_empty() {
return Ok(VersionReq::STAR);
}
let normalized = parts.join(", ");
VersionReq::parse(&normalized)
.map_err(|e| VersionError::InvalidConstraint(constraint_str.to_string(), e.to_string()))
}
/// Check whether a version string satisfies a constraint string.
///
/// Returns `true` if the version matches the constraint.
/// Returns an error if either the version or constraint cannot be parsed.
///
/// # Examples
///
/// ```
/// use attune_common::version_matching::matches_constraint;
///
/// assert!(matches_constraint("3.12.1", ">=3.12").unwrap());
/// assert!(!matches_constraint("3.11.0", ">=3.12").unwrap());
/// assert!(matches_constraint("3.12.5", ">=3.12,<4.0").unwrap());
/// ```
pub fn matches_constraint(version_str: &str, constraint_str: &str) -> VersionResult<bool> {
let version = parse_version(version_str)?;
let constraint = parse_constraint(constraint_str)?;
Ok(constraint.matches(&version))
}
/// Select the best matching runtime version from a list of candidates.
///
/// "Best" is defined as the highest version that satisfies the constraint
/// and is marked as available. If no constraint is given, the default version
/// is preferred; if no default exists, the highest available version is returned.
///
/// # Arguments
///
/// * `versions` - All registered versions for a runtime (any order)
/// * `constraint` - Optional version constraint string (e.g., `">=3.12"`)
///
/// # Returns
///
/// The best matching `RuntimeVersion`, or `None` if no version matches.
pub fn select_best_version<'a>(
versions: &'a [RuntimeVersion],
constraint: Option<&str>,
) -> Option<&'a RuntimeVersion> {
if versions.is_empty() {
return None;
}
// Only consider available versions
let available: Vec<&RuntimeVersion> = versions.iter().filter(|v| v.available).collect();
if available.is_empty() {
debug!("No available versions found");
return None;
}
match constraint {
Some(constraint_str) if !constraint_str.trim().is_empty() => {
let req = match parse_constraint(constraint_str) {
Ok(r) => r,
Err(e) => {
warn!("Invalid version constraint '{}': {}", constraint_str, e);
return None;
}
};
// Filter to versions that match the constraint, then pick the highest
let mut matching: Vec<(&RuntimeVersion, Version)> = available
.iter()
.filter_map(|rv| match parse_version(&rv.version) {
Ok(v) if req.matches(&v) => Some((*rv, v)),
Ok(_) => {
debug!(
"Version {} does not match constraint '{}'",
rv.version, constraint_str
);
None
}
Err(e) => {
warn!("Cannot parse version '{}' for matching: {}", rv.version, e);
None
}
})
.collect();
if matching.is_empty() {
debug!(
"No available versions match constraint '{}'",
constraint_str
);
return None;
}
// Sort by semver descending — highest version first
matching.sort_by(|a, b| b.1.cmp(&a.1));
Some(matching[0].0)
}
_ => {
// No constraint — prefer the default version, else the highest available
if let Some(default) = available.iter().find(|v| v.is_default) {
return Some(default);
}
// Pick highest available version
let mut with_parsed: Vec<(&RuntimeVersion, Version)> = available
.iter()
.filter_map(|rv| parse_version(&rv.version).ok().map(|v| (*rv, v)))
.collect();
with_parsed.sort_by(|a, b| b.1.cmp(&a.1));
with_parsed.first().map(|(rv, _)| *rv)
}
}
}
/// Extract semver components from a version string.
///
/// Returns `(major, minor, patch)` as `Option<i32>` values.
/// Useful for populating the `version_major`, `version_minor`, `version_patch`
/// columns in the `runtime_version` table.
pub fn extract_version_components(version_str: &str) -> (Option<i32>, Option<i32>, Option<i32>) {
match parse_version(version_str) {
Ok(v) => (
i32::try_from(v.major).ok(),
i32::try_from(v.minor).ok(),
i32::try_from(v.patch).ok(),
),
Err(_) => (None, None, None),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
// ========================================================================
// parse_version tests
// ========================================================================
#[test]
fn test_parse_version_full() {
let v = parse_version("3.12.1").unwrap();
assert_eq!(v, Version::new(3, 12, 1));
}
#[test]
fn test_parse_version_two_parts() {
let v = parse_version("3.12").unwrap();
assert_eq!(v, Version::new(3, 12, 0));
}
#[test]
fn test_parse_version_one_part() {
let v = parse_version("3").unwrap();
assert_eq!(v, Version::new(3, 0, 0));
}
#[test]
fn test_parse_version_leading_v() {
let v = parse_version("v3.12.1").unwrap();
assert_eq!(v, Version::new(3, 12, 1));
}
#[test]
fn test_parse_version_leading_v_uppercase() {
let v = parse_version("V20.11.0").unwrap();
assert_eq!(v, Version::new(20, 11, 0));
}
#[test]
fn test_parse_version_with_whitespace() {
let v = parse_version(" 3.12.1 ").unwrap();
assert_eq!(v, Version::new(3, 12, 1));
}
#[test]
fn test_parse_version_invalid() {
assert!(parse_version("not-a-version").is_err());
}
// ========================================================================
// parse_constraint tests
// ========================================================================
#[test]
fn test_parse_constraint_gte() {
let req = parse_constraint(">=3.12").unwrap();
assert!(req.matches(&Version::new(3, 12, 0)));
assert!(req.matches(&Version::new(3, 13, 0)));
assert!(req.matches(&Version::new(4, 0, 0)));
assert!(!req.matches(&Version::new(3, 11, 9)));
}
#[test]
fn test_parse_constraint_exact_with_eq() {
let req = parse_constraint("=3.12.1").unwrap();
assert!(req.matches(&Version::new(3, 12, 1)));
assert!(!req.matches(&Version::new(3, 12, 2)));
}
#[test]
fn test_parse_constraint_bare_version() {
// Bare "3.12" is treated as ~3.12 → >=3.12.0, <3.13.0
let req = parse_constraint("3.12").unwrap();
assert!(req.matches(&Version::new(3, 12, 0)));
assert!(req.matches(&Version::new(3, 12, 9)));
assert!(!req.matches(&Version::new(3, 13, 0)));
assert!(!req.matches(&Version::new(3, 11, 0)));
}
#[test]
fn test_parse_constraint_tilde() {
let req = parse_constraint("~3.12").unwrap();
assert!(req.matches(&Version::new(3, 12, 0)));
assert!(req.matches(&Version::new(3, 12, 99)));
assert!(!req.matches(&Version::new(3, 13, 0)));
}
#[test]
fn test_parse_constraint_caret() {
let req = parse_constraint("^3.12").unwrap();
assert!(req.matches(&Version::new(3, 12, 0)));
assert!(req.matches(&Version::new(3, 99, 0)));
assert!(!req.matches(&Version::new(4, 0, 0)));
}
#[test]
fn test_parse_constraint_range() {
let req = parse_constraint(">=3.12,<4.0").unwrap();
assert!(req.matches(&Version::new(3, 12, 0)));
assert!(req.matches(&Version::new(3, 99, 0)));
assert!(!req.matches(&Version::new(4, 0, 0)));
assert!(!req.matches(&Version::new(3, 11, 0)));
}
#[test]
fn test_parse_constraint_empty() {
let req = parse_constraint("").unwrap();
assert!(req.matches(&Version::new(0, 0, 1)));
assert!(req.matches(&Version::new(999, 0, 0)));
}
#[test]
fn test_parse_constraint_lt() {
let req = parse_constraint("<4.0").unwrap();
assert!(req.matches(&Version::new(3, 99, 99)));
assert!(!req.matches(&Version::new(4, 0, 0)));
}
#[test]
fn test_parse_constraint_lte() {
let req = parse_constraint("<=3.12").unwrap();
assert!(req.matches(&Version::new(3, 12, 0)));
// Note: semver <=3.12.0 means exactly ≤3.12.0
assert!(!req.matches(&Version::new(3, 12, 1)));
assert!(!req.matches(&Version::new(3, 13, 0)));
}
#[test]
fn test_parse_constraint_gt() {
let req = parse_constraint(">3.12").unwrap();
assert!(!req.matches(&Version::new(3, 12, 0)));
assert!(req.matches(&Version::new(3, 12, 1)));
assert!(req.matches(&Version::new(3, 13, 0)));
}
// ========================================================================
// matches_constraint tests
// ========================================================================
#[test]
fn test_matches_constraint_basic() {
assert!(matches_constraint("3.12.1", ">=3.12").unwrap());
assert!(!matches_constraint("3.11.0", ">=3.12").unwrap());
}
#[test]
fn test_matches_constraint_range() {
assert!(matches_constraint("3.12.5", ">=3.12,<4.0").unwrap());
assert!(!matches_constraint("4.0.0", ">=3.12,<4.0").unwrap());
}
#[test]
fn test_matches_constraint_tilde() {
assert!(matches_constraint("3.12.5", "~3.12").unwrap());
assert!(!matches_constraint("3.13.0", "~3.12").unwrap());
}
#[test]
fn test_matches_constraint_caret() {
assert!(matches_constraint("3.15.0", "^3.12").unwrap());
assert!(!matches_constraint("4.0.0", "^3.12").unwrap());
}
#[test]
fn test_matches_constraint_node_versions() {
assert!(matches_constraint("20.11.0", ">=18").unwrap());
assert!(matches_constraint("18.0.0", ">=18").unwrap());
assert!(!matches_constraint("16.20.0", ">=18").unwrap());
}
// ========================================================================
// extract_version_components tests
// ========================================================================
#[test]
fn test_extract_components_full() {
let (maj, min, pat) = extract_version_components("3.12.1");
assert_eq!(maj, Some(3));
assert_eq!(min, Some(12));
assert_eq!(pat, Some(1));
}
#[test]
fn test_extract_components_partial() {
let (maj, min, pat) = extract_version_components("20.11");
assert_eq!(maj, Some(20));
assert_eq!(min, Some(11));
assert_eq!(pat, Some(0));
}
#[test]
fn test_extract_components_invalid() {
let (maj, min, pat) = extract_version_components("not-a-version");
assert_eq!(maj, None);
assert_eq!(min, None);
assert_eq!(pat, None);
}
// ========================================================================
// select_best_version tests
// ========================================================================
fn make_version(
id: i64,
runtime: i64,
version: &str,
is_default: bool,
available: bool,
) -> RuntimeVersion {
let (major, minor, patch) = extract_version_components(version);
RuntimeVersion {
id,
runtime,
runtime_ref: "core.python".to_string(),
version: version.to_string(),
version_major: major,
version_minor: minor,
version_patch: patch,
execution_config: json!({}),
distributions: json!({}),
is_default,
available,
verified_at: None,
meta: json!({}),
created: chrono::Utc::now(),
updated: chrono::Utc::now(),
}
}
#[test]
fn test_select_best_no_constraint_prefers_default() {
let versions = vec![
make_version(1, 1, "3.11.0", false, true),
make_version(2, 1, "3.12.0", true, true), // default
make_version(3, 1, "3.14.0", false, true),
];
let best = select_best_version(&versions, None).unwrap();
assert_eq!(best.id, 2); // default version
}
#[test]
fn test_select_best_no_constraint_no_default_picks_highest() {
let versions = vec![
make_version(1, 1, "3.11.0", false, true),
make_version(2, 1, "3.12.0", false, true),
make_version(3, 1, "3.14.0", false, true),
];
let best = select_best_version(&versions, None).unwrap();
assert_eq!(best.id, 3); // highest version
}
#[test]
fn test_select_best_with_constraint() {
let versions = vec![
make_version(1, 1, "3.11.0", false, true),
make_version(2, 1, "3.12.0", false, true),
make_version(3, 1, "3.14.0", false, true),
];
// >=3.12,<3.14 should pick 3.12.0 (3.14.0 is excluded)
let best = select_best_version(&versions, Some(">=3.12,<3.14")).unwrap();
assert_eq!(best.id, 2);
}
#[test]
fn test_select_best_with_constraint_picks_highest_match() {
let versions = vec![
make_version(1, 1, "3.11.0", false, true),
make_version(2, 1, "3.12.0", false, true),
make_version(3, 1, "3.12.5", false, true),
make_version(4, 1, "3.13.0", false, true),
];
// ~3.12 → >=3.12.0, <3.13.0 → should pick 3.12.5
let best = select_best_version(&versions, Some("~3.12")).unwrap();
assert_eq!(best.id, 3);
}
#[test]
fn test_select_best_skips_unavailable() {
let versions = vec![
make_version(1, 1, "3.12.0", false, true),
make_version(2, 1, "3.14.0", false, false), // not available
];
let best = select_best_version(&versions, Some(">=3.12")).unwrap();
assert_eq!(best.id, 1); // 3.14 is unavailable
}
#[test]
fn test_select_best_no_match() {
let versions = vec![
make_version(1, 1, "3.11.0", false, true),
make_version(2, 1, "3.12.0", false, true),
];
let best = select_best_version(&versions, Some(">=4.0"));
assert!(best.is_none());
}
#[test]
fn test_select_best_empty_versions() {
let versions: Vec<RuntimeVersion> = vec![];
assert!(select_best_version(&versions, None).is_none());
}
#[test]
fn test_select_best_all_unavailable() {
let versions = vec![
make_version(1, 1, "3.12.0", false, false),
make_version(2, 1, "3.14.0", false, false),
];
assert!(select_best_version(&versions, None).is_none());
}
}