node running, runtime version awareness
This commit is contained in:
@@ -66,6 +66,9 @@ walkdir = { workspace = true }
|
||||
# Regular expressions
|
||||
regex = { workspace = true }
|
||||
|
||||
# Version matching
|
||||
semver = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
mockall = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod schema;
|
||||
pub mod template_resolver;
|
||||
pub mod test_executor;
|
||||
pub mod utils;
|
||||
pub mod version_matching;
|
||||
pub mod workflow;
|
||||
|
||||
// Re-export commonly used types
|
||||
|
||||
@@ -443,6 +443,16 @@ pub mod runtime {
|
||||
/// Optional dependency management configuration
|
||||
#[serde(default)]
|
||||
pub dependencies: Option<DependencyConfig>,
|
||||
|
||||
/// Optional environment variables to set during action execution.
|
||||
///
|
||||
/// Values support the same template variables as other fields:
|
||||
/// `{pack_dir}`, `{env_dir}`, `{interpreter}`, `{manifest_path}`.
|
||||
///
|
||||
/// Example: `{"NODE_PATH": "{env_dir}/node_modules"}` ensures Node.js
|
||||
/// can find packages installed in the isolated runtime environment.
|
||||
#[serde(default)]
|
||||
pub env_vars: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Describes the interpreter binary and how it invokes action scripts.
|
||||
@@ -756,6 +766,51 @@ pub mod runtime {
|
||||
}
|
||||
}
|
||||
|
||||
/// A specific version of a runtime (e.g., Python 3.12.1, Node.js 20.11.0).
|
||||
///
|
||||
/// Each version stores its own complete `execution_config` so the worker can
|
||||
/// use a version-specific interpreter binary, environment commands, etc.
|
||||
/// Actions and sensors declare an optional version constraint (semver range)
|
||||
/// which is matched against available `RuntimeVersion` rows at execution time.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct RuntimeVersion {
|
||||
pub id: Id,
|
||||
/// Parent runtime ID (FK → runtime.id)
|
||||
pub runtime: Id,
|
||||
/// Parent runtime ref for display/filtering (e.g., "core.python")
|
||||
pub runtime_ref: String,
|
||||
/// Semantic version string (e.g., "3.12.1", "20.11.0")
|
||||
pub version: String,
|
||||
/// Major version component (nullable for non-numeric schemes)
|
||||
pub version_major: Option<i32>,
|
||||
/// Minor version component
|
||||
pub version_minor: Option<i32>,
|
||||
/// Patch version component
|
||||
pub version_patch: Option<i32>,
|
||||
/// Complete execution configuration for this version
|
||||
/// (same structure as `runtime.execution_config`)
|
||||
pub execution_config: JsonDict,
|
||||
/// Version-specific distribution/verification metadata
|
||||
pub distributions: JsonDict,
|
||||
/// Whether this is the default version for the parent runtime
|
||||
pub is_default: bool,
|
||||
/// Whether this version is verified as available on the system
|
||||
pub available: bool,
|
||||
/// When this version was last verified
|
||||
pub verified_at: Option<DateTime<Utc>>,
|
||||
/// Arbitrary version-specific metadata
|
||||
pub meta: JsonDict,
|
||||
pub created: DateTime<Utc>,
|
||||
pub updated: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl RuntimeVersion {
|
||||
/// Parse the `execution_config` JSONB into a typed `RuntimeExecutionConfig`.
|
||||
pub fn parsed_execution_config(&self) -> RuntimeExecutionConfig {
|
||||
serde_json::from_value(self.execution_config.clone()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Worker {
|
||||
pub id: Id,
|
||||
@@ -808,6 +863,9 @@ pub mod trigger {
|
||||
pub entrypoint: String,
|
||||
pub runtime: Id,
|
||||
pub runtime_ref: String,
|
||||
/// Optional semver version constraint for the runtime
|
||||
/// (e.g., ">=3.12", ">=3.12,<4.0", "~18.0"). NULL means any version.
|
||||
pub runtime_version_constraint: Option<String>,
|
||||
pub trigger: Id,
|
||||
pub trigger_ref: String,
|
||||
pub enabled: bool,
|
||||
@@ -832,6 +890,9 @@ pub mod action {
|
||||
pub description: String,
|
||||
pub entrypoint: String,
|
||||
pub runtime: Option<Id>,
|
||||
/// Optional semver version constraint for the runtime
|
||||
/// (e.g., ">=3.12", ">=3.12,<4.0", "~18.0"). NULL means any version.
|
||||
pub runtime_version_constraint: Option<String>,
|
||||
pub param_schema: Option<JsonSchema>,
|
||||
pub out_schema: Option<JsonSchema>,
|
||||
pub is_workflow: bool,
|
||||
|
||||
@@ -20,10 +20,12 @@ use crate::error::{Error, Result};
|
||||
use crate::models::Id;
|
||||
use crate::repositories::action::ActionRepository;
|
||||
use crate::repositories::runtime::{CreateRuntimeInput, RuntimeRepository};
|
||||
use crate::repositories::runtime_version::{CreateRuntimeVersionInput, RuntimeVersionRepository};
|
||||
use crate::repositories::trigger::{
|
||||
CreateSensorInput, CreateTriggerInput, SensorRepository, TriggerRepository,
|
||||
};
|
||||
use crate::repositories::{Create, FindById, FindByRef, Update};
|
||||
use crate::version_matching::extract_version_components;
|
||||
|
||||
/// Result of loading pack components into the database.
|
||||
#[derive(Debug, Default)]
|
||||
@@ -201,6 +203,10 @@ impl<'a> PackComponentLoader<'a> {
|
||||
Ok(rt) => {
|
||||
info!("Created runtime '{}' (ID: {})", runtime_ref, rt.id);
|
||||
result.runtimes_loaded += 1;
|
||||
|
||||
// Load version entries from the optional `versions` array
|
||||
self.load_runtime_versions(&data, rt.id, &runtime_ref, result)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
// Check for unique constraint violation (race condition)
|
||||
@@ -226,6 +232,141 @@ impl<'a> PackComponentLoader<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load version entries from the `versions` array in a runtime YAML.
|
||||
///
|
||||
/// Each entry in the array describes a specific version of the runtime
|
||||
/// with its own `execution_config` and `distributions`. Example:
|
||||
///
|
||||
/// ```yaml
|
||||
/// versions:
|
||||
/// - version: "3.12"
|
||||
/// is_default: true
|
||||
/// execution_config:
|
||||
/// interpreter:
|
||||
/// binary: python3.12
|
||||
/// ...
|
||||
/// distributions:
|
||||
/// verification:
|
||||
/// commands:
|
||||
/// - binary: python3.12
|
||||
/// args: ["--version"]
|
||||
/// ...
|
||||
/// ```
|
||||
async fn load_runtime_versions(
|
||||
&self,
|
||||
data: &serde_yaml_ng::Value,
|
||||
runtime_id: Id,
|
||||
runtime_ref: &str,
|
||||
result: &mut PackLoadResult,
|
||||
) {
|
||||
let versions = match data.get("versions").and_then(|v| v.as_sequence()) {
|
||||
Some(seq) => seq,
|
||||
None => return, // No versions defined — that's fine
|
||||
};
|
||||
|
||||
info!(
|
||||
"Loading {} version(s) for runtime '{}'",
|
||||
versions.len(),
|
||||
runtime_ref
|
||||
);
|
||||
|
||||
for entry in versions {
|
||||
let version_str = match entry.get("version").and_then(|v| v.as_str()) {
|
||||
Some(v) => v.to_string(),
|
||||
None => {
|
||||
let msg = format!(
|
||||
"Runtime '{}' has a version entry without a 'version' field, skipping",
|
||||
runtime_ref
|
||||
);
|
||||
warn!("{}", msg);
|
||||
result.warnings.push(msg);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if this version already exists
|
||||
if let Ok(Some(_existing)) = RuntimeVersionRepository::find_by_runtime_and_version(
|
||||
self.pool,
|
||||
runtime_id,
|
||||
&version_str,
|
||||
)
|
||||
.await
|
||||
{
|
||||
info!(
|
||||
"Version '{}' for runtime '{}' already exists, skipping",
|
||||
version_str, runtime_ref
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let (version_major, version_minor, version_patch) =
|
||||
extract_version_components(&version_str);
|
||||
|
||||
let execution_config = entry
|
||||
.get("execution_config")
|
||||
.and_then(|v| serde_json::to_value(v).ok())
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
|
||||
let distributions = entry
|
||||
.get("distributions")
|
||||
.and_then(|v| serde_json::to_value(v).ok())
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
|
||||
let is_default = entry
|
||||
.get("is_default")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let meta = entry
|
||||
.get("meta")
|
||||
.and_then(|v| serde_json::to_value(v).ok())
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
|
||||
let input = CreateRuntimeVersionInput {
|
||||
runtime: runtime_id,
|
||||
runtime_ref: runtime_ref.to_string(),
|
||||
version: version_str.clone(),
|
||||
version_major,
|
||||
version_minor,
|
||||
version_patch,
|
||||
execution_config,
|
||||
distributions,
|
||||
is_default,
|
||||
available: true, // Assume available until verification runs
|
||||
meta,
|
||||
};
|
||||
|
||||
match RuntimeVersionRepository::create(self.pool, input).await {
|
||||
Ok(rv) => {
|
||||
info!(
|
||||
"Created version '{}' for runtime '{}' (ID: {})",
|
||||
version_str, runtime_ref, rv.id
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
// Check for unique constraint violation (race condition)
|
||||
if let Error::Database(ref db_err) = e {
|
||||
if let sqlx::Error::Database(ref inner) = db_err {
|
||||
if inner.is_unique_violation() {
|
||||
info!(
|
||||
"Version '{}' for runtime '{}' already exists (concurrent), skipping",
|
||||
version_str, runtime_ref
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
let msg = format!(
|
||||
"Failed to create version '{}' for runtime '{}': {}",
|
||||
version_str, runtime_ref, e
|
||||
);
|
||||
warn!("{}", msg);
|
||||
result.warnings.push(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_triggers(
|
||||
&self,
|
||||
pack_dir: &Path,
|
||||
@@ -424,16 +565,22 @@ impl<'a> PackComponentLoader<'a> {
|
||||
.unwrap_or("text")
|
||||
.to_lowercase();
|
||||
|
||||
// Optional runtime version constraint (e.g., ">=3.12", "~18.0")
|
||||
let runtime_version_constraint = data
|
||||
.get("runtime_version")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Use raw SQL to include parameter_delivery, parameter_format,
|
||||
// output_format which are not in CreateActionInput
|
||||
let create_result = sqlx::query_scalar::<_, i64>(
|
||||
r#"
|
||||
INSERT INTO action (
|
||||
ref, pack, pack_ref, label, description, entrypoint,
|
||||
runtime, param_schema, out_schema, is_adhoc,
|
||||
runtime, runtime_version_constraint, param_schema, out_schema, is_adhoc,
|
||||
parameter_delivery, parameter_format, output_format
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
@@ -444,6 +591,7 @@ impl<'a> PackComponentLoader<'a> {
|
||||
.bind(&description)
|
||||
.bind(&entrypoint)
|
||||
.bind(runtime_id)
|
||||
.bind(&runtime_version_constraint)
|
||||
.bind(¶m_schema)
|
||||
.bind(&out_schema)
|
||||
.bind(false) // is_adhoc
|
||||
@@ -601,6 +749,12 @@ impl<'a> PackComponentLoader<'a> {
|
||||
.and_then(|v| serde_json::to_value(v).ok())
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
|
||||
// Optional runtime version constraint (e.g., ">=3.12", "~18.0")
|
||||
let runtime_version_constraint = data
|
||||
.get("runtime_version")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Upsert: update existing sensors so re-registration corrects
|
||||
// stale metadata (especially runtime assignments).
|
||||
if let Some(existing) = SensorRepository::find_by_ref(self.pool, &sensor_ref).await? {
|
||||
@@ -612,6 +766,7 @@ impl<'a> PackComponentLoader<'a> {
|
||||
entrypoint: Some(entrypoint),
|
||||
runtime: Some(sensor_runtime_id),
|
||||
runtime_ref: Some(sensor_runtime_ref.clone()),
|
||||
runtime_version_constraint: Some(runtime_version_constraint.clone()),
|
||||
trigger: Some(trigger_id.unwrap_or(existing.trigger)),
|
||||
trigger_ref: Some(trigger_ref.unwrap_or(existing.trigger_ref.clone())),
|
||||
enabled: Some(enabled),
|
||||
@@ -645,6 +800,7 @@ impl<'a> PackComponentLoader<'a> {
|
||||
entrypoint,
|
||||
runtime: sensor_runtime_id,
|
||||
runtime_ref: sensor_runtime_ref.clone(),
|
||||
runtime_version_constraint,
|
||||
trigger: trigger_id.unwrap_or(0),
|
||||
trigger_ref: trigger_ref.unwrap_or_default(),
|
||||
enabled,
|
||||
|
||||
@@ -29,6 +29,7 @@ pub struct CreateActionInput {
|
||||
pub description: String,
|
||||
pub entrypoint: String,
|
||||
pub runtime: Option<Id>,
|
||||
pub runtime_version_constraint: Option<String>,
|
||||
pub param_schema: Option<JsonSchema>,
|
||||
pub out_schema: Option<JsonSchema>,
|
||||
pub is_adhoc: bool,
|
||||
@@ -41,6 +42,7 @@ pub struct UpdateActionInput {
|
||||
pub description: Option<String>,
|
||||
pub entrypoint: Option<String>,
|
||||
pub runtime: Option<Id>,
|
||||
pub runtime_version_constraint: Option<Option<String>>,
|
||||
pub param_schema: Option<JsonSchema>,
|
||||
pub out_schema: Option<JsonSchema>,
|
||||
}
|
||||
@@ -54,7 +56,8 @@ impl FindById for ActionRepository {
|
||||
let action = sqlx::query_as::<_, Action>(
|
||||
r#"
|
||||
SELECT id, ref, pack, pack_ref, label, description, entrypoint,
|
||||
runtime, param_schema, out_schema, is_workflow, workflow_def, is_adhoc, created, updated
|
||||
runtime, runtime_version_constraint,
|
||||
param_schema, out_schema, is_workflow, workflow_def, is_adhoc, created, updated
|
||||
FROM action
|
||||
WHERE id = $1
|
||||
"#,
|
||||
@@ -76,7 +79,8 @@ impl FindByRef for ActionRepository {
|
||||
let action = sqlx::query_as::<_, Action>(
|
||||
r#"
|
||||
SELECT id, ref, pack, pack_ref, label, description, entrypoint,
|
||||
runtime, param_schema, out_schema, is_workflow, workflow_def, is_adhoc, created, updated
|
||||
runtime, runtime_version_constraint,
|
||||
param_schema, out_schema, is_workflow, workflow_def, is_adhoc, created, updated
|
||||
FROM action
|
||||
WHERE ref = $1
|
||||
"#,
|
||||
@@ -98,7 +102,8 @@ impl List for ActionRepository {
|
||||
let actions = sqlx::query_as::<_, Action>(
|
||||
r#"
|
||||
SELECT id, ref, pack, pack_ref, label, description, entrypoint,
|
||||
runtime, param_schema, out_schema, is_workflow, workflow_def, is_adhoc, created, updated
|
||||
runtime, runtime_version_constraint,
|
||||
param_schema, out_schema, is_workflow, workflow_def, is_adhoc, created, updated
|
||||
FROM action
|
||||
ORDER BY ref ASC
|
||||
"#,
|
||||
@@ -133,10 +138,11 @@ impl Create for ActionRepository {
|
||||
let action = sqlx::query_as::<_, Action>(
|
||||
r#"
|
||||
INSERT INTO action (ref, pack, pack_ref, label, description, entrypoint,
|
||||
runtime, param_schema, out_schema, is_adhoc)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
runtime, runtime_version_constraint, param_schema, out_schema, is_adhoc)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id, ref, pack, pack_ref, label, description, entrypoint,
|
||||
runtime, param_schema, out_schema, is_workflow, workflow_def, is_adhoc, created, updated
|
||||
runtime, runtime_version_constraint,
|
||||
param_schema, out_schema, is_workflow, workflow_def, is_adhoc, created, updated
|
||||
"#,
|
||||
)
|
||||
.bind(&input.r#ref)
|
||||
@@ -146,6 +152,7 @@ impl Create for ActionRepository {
|
||||
.bind(&input.description)
|
||||
.bind(&input.entrypoint)
|
||||
.bind(input.runtime)
|
||||
.bind(&input.runtime_version_constraint)
|
||||
.bind(&input.param_schema)
|
||||
.bind(&input.out_schema)
|
||||
.bind(input.is_adhoc)
|
||||
@@ -213,6 +220,15 @@ impl Update for ActionRepository {
|
||||
has_updates = true;
|
||||
}
|
||||
|
||||
if let Some(runtime_version_constraint) = &input.runtime_version_constraint {
|
||||
if has_updates {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("runtime_version_constraint = ");
|
||||
query.push_bind(runtime_version_constraint);
|
||||
has_updates = true;
|
||||
}
|
||||
|
||||
if let Some(param_schema) = &input.param_schema {
|
||||
if has_updates {
|
||||
query.push(", ");
|
||||
@@ -240,7 +256,7 @@ impl Update for ActionRepository {
|
||||
|
||||
query.push(", updated = NOW() WHERE id = ");
|
||||
query.push_bind(id);
|
||||
query.push(" RETURNING id, ref, pack, pack_ref, label, description, entrypoint, runtime, param_schema, out_schema, is_workflow, workflow_def, is_adhoc, created, updated");
|
||||
query.push(" RETURNING id, ref, pack, pack_ref, label, description, entrypoint, runtime, runtime_version_constraint, param_schema, out_schema, is_workflow, workflow_def, is_adhoc, created, updated");
|
||||
|
||||
let action = query
|
||||
.build_query_as::<Action>()
|
||||
@@ -279,7 +295,8 @@ impl ActionRepository {
|
||||
let actions = sqlx::query_as::<_, Action>(
|
||||
r#"
|
||||
SELECT id, ref, pack, pack_ref, label, description, entrypoint,
|
||||
runtime, param_schema, out_schema, is_workflow, workflow_def, is_adhoc, created, updated
|
||||
runtime, runtime_version_constraint,
|
||||
param_schema, out_schema, is_workflow, workflow_def, is_adhoc, created, updated
|
||||
FROM action
|
||||
WHERE pack = $1
|
||||
ORDER BY ref ASC
|
||||
@@ -300,7 +317,8 @@ impl ActionRepository {
|
||||
let actions = sqlx::query_as::<_, Action>(
|
||||
r#"
|
||||
SELECT id, ref, pack, pack_ref, label, description, entrypoint,
|
||||
runtime, param_schema, out_schema, is_workflow, workflow_def, is_adhoc, created, updated
|
||||
runtime, runtime_version_constraint,
|
||||
param_schema, out_schema, is_workflow, workflow_def, is_adhoc, created, updated
|
||||
FROM action
|
||||
WHERE runtime = $1
|
||||
ORDER BY ref ASC
|
||||
@@ -322,7 +340,8 @@ impl ActionRepository {
|
||||
let actions = sqlx::query_as::<_, Action>(
|
||||
r#"
|
||||
SELECT id, ref, pack, pack_ref, label, description, entrypoint,
|
||||
runtime, param_schema, out_schema, is_workflow, workflow_def, is_adhoc, created, updated
|
||||
runtime, runtime_version_constraint,
|
||||
param_schema, out_schema, is_workflow, workflow_def, is_adhoc, created, updated
|
||||
FROM action
|
||||
WHERE LOWER(ref) LIKE $1 OR LOWER(label) LIKE $1 OR LOWER(description) LIKE $1
|
||||
ORDER BY ref ASC
|
||||
@@ -343,7 +362,8 @@ impl ActionRepository {
|
||||
let actions = sqlx::query_as::<_, Action>(
|
||||
r#"
|
||||
SELECT id, ref, pack, pack_ref, label, description, entrypoint,
|
||||
runtime, param_schema, out_schema, is_workflow, workflow_def, is_adhoc, created, updated
|
||||
runtime, runtime_version_constraint,
|
||||
param_schema, out_schema, is_workflow, workflow_def, is_adhoc, created, updated
|
||||
FROM action
|
||||
WHERE is_workflow = true
|
||||
ORDER BY ref ASC
|
||||
@@ -366,7 +386,8 @@ impl ActionRepository {
|
||||
let action = sqlx::query_as::<_, Action>(
|
||||
r#"
|
||||
SELECT id, ref, pack, pack_ref, label, description, entrypoint,
|
||||
runtime, param_schema, out_schema, is_workflow, workflow_def, is_adhoc, created, updated
|
||||
runtime, runtime_version_constraint,
|
||||
param_schema, out_schema, is_workflow, workflow_def, is_adhoc, created, updated
|
||||
FROM action
|
||||
WHERE workflow_def = $1
|
||||
"#,
|
||||
@@ -393,7 +414,8 @@ impl ActionRepository {
|
||||
SET is_workflow = true, workflow_def = $2, updated = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, ref, pack, pack_ref, label, description, entrypoint,
|
||||
runtime, param_schema, out_schema, is_workflow, workflow_def, is_adhoc, created, updated
|
||||
runtime, runtime_version_constraint,
|
||||
param_schema, out_schema, is_workflow, workflow_def, is_adhoc, created, updated
|
||||
"#,
|
||||
)
|
||||
.bind(action_id)
|
||||
|
||||
@@ -40,6 +40,7 @@ pub mod pack_test;
|
||||
pub mod queue_stats;
|
||||
pub mod rule;
|
||||
pub mod runtime;
|
||||
pub mod runtime_version;
|
||||
pub mod trigger;
|
||||
pub mod workflow;
|
||||
|
||||
@@ -57,6 +58,7 @@ pub use pack_test::PackTestRepository;
|
||||
pub use queue_stats::QueueStatsRepository;
|
||||
pub use rule::RuleRepository;
|
||||
pub use runtime::{RuntimeRepository, WorkerRepository};
|
||||
pub use runtime_version::RuntimeVersionRepository;
|
||||
pub use trigger::{SensorRepository, TriggerRepository};
|
||||
pub use workflow::{WorkflowDefinitionRepository, WorkflowExecutionRepository};
|
||||
|
||||
|
||||
447
crates/common/src/repositories/runtime_version.rs
Normal file
447
crates/common/src/repositories/runtime_version.rs
Normal file
@@ -0,0 +1,447 @@
|
||||
//! Repository for runtime version operations
|
||||
//!
|
||||
//! Provides CRUD operations and specialized queries for the `runtime_version`
|
||||
//! table, which stores version-specific execution configurations for runtimes.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::models::{Id, RuntimeVersion};
|
||||
use crate::repositories::{Create, Delete, FindById, List, Repository, Update};
|
||||
use sqlx::{Executor, Postgres, QueryBuilder};
|
||||
|
||||
/// Repository for runtime version database operations
|
||||
pub struct RuntimeVersionRepository;
|
||||
|
||||
impl Repository for RuntimeVersionRepository {
|
||||
type Entity = RuntimeVersion;
|
||||
|
||||
fn table_name() -> &'static str {
|
||||
"runtime_version"
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for creating a new runtime version
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CreateRuntimeVersionInput {
|
||||
pub runtime: Id,
|
||||
pub runtime_ref: String,
|
||||
pub version: String,
|
||||
pub version_major: Option<i32>,
|
||||
pub version_minor: Option<i32>,
|
||||
pub version_patch: Option<i32>,
|
||||
pub execution_config: serde_json::Value,
|
||||
pub distributions: serde_json::Value,
|
||||
pub is_default: bool,
|
||||
pub available: bool,
|
||||
pub meta: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Input for updating an existing runtime version
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct UpdateRuntimeVersionInput {
|
||||
pub version: Option<String>,
|
||||
pub version_major: Option<Option<i32>>,
|
||||
pub version_minor: Option<Option<i32>>,
|
||||
pub version_patch: Option<Option<i32>>,
|
||||
pub execution_config: Option<serde_json::Value>,
|
||||
pub distributions: Option<serde_json::Value>,
|
||||
pub is_default: Option<bool>,
|
||||
pub available: Option<bool>,
|
||||
pub verified_at: Option<Option<chrono::DateTime<chrono::Utc>>>,
|
||||
pub meta: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
const SELECT_COLUMNS: &str = r#"
|
||||
id, runtime, runtime_ref, version,
|
||||
version_major, version_minor, version_patch,
|
||||
execution_config, distributions,
|
||||
is_default, available, verified_at, meta,
|
||||
created, updated
|
||||
"#;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl FindById for RuntimeVersionRepository {
|
||||
async fn find_by_id<'e, E>(executor: E, id: i64) -> Result<Option<RuntimeVersion>>
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
let row = sqlx::query_as::<_, RuntimeVersion>(&format!(
|
||||
"SELECT {} FROM runtime_version WHERE id = $1",
|
||||
SELECT_COLUMNS
|
||||
))
|
||||
.bind(id)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
Ok(row)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl List for RuntimeVersionRepository {
|
||||
async fn list<'e, E>(executor: E) -> Result<Vec<RuntimeVersion>>
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
let rows = sqlx::query_as::<_, RuntimeVersion>(&format!(
|
||||
"SELECT {} FROM runtime_version ORDER BY runtime_ref ASC, version ASC",
|
||||
SELECT_COLUMNS
|
||||
))
|
||||
.fetch_all(executor)
|
||||
.await?;
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Create for RuntimeVersionRepository {
|
||||
type CreateInput = CreateRuntimeVersionInput;
|
||||
|
||||
async fn create<'e, E>(executor: E, input: Self::CreateInput) -> Result<RuntimeVersion>
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
let row = sqlx::query_as::<_, RuntimeVersion>(&format!(
|
||||
r#"
|
||||
INSERT INTO runtime_version (
|
||||
runtime, runtime_ref, version,
|
||||
version_major, version_minor, version_patch,
|
||||
execution_config, distributions,
|
||||
is_default, available, meta
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING {}
|
||||
"#,
|
||||
SELECT_COLUMNS
|
||||
))
|
||||
.bind(input.runtime)
|
||||
.bind(&input.runtime_ref)
|
||||
.bind(&input.version)
|
||||
.bind(input.version_major)
|
||||
.bind(input.version_minor)
|
||||
.bind(input.version_patch)
|
||||
.bind(&input.execution_config)
|
||||
.bind(&input.distributions)
|
||||
.bind(input.is_default)
|
||||
.bind(input.available)
|
||||
.bind(&input.meta)
|
||||
.fetch_one(executor)
|
||||
.await?;
|
||||
|
||||
Ok(row)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Update for RuntimeVersionRepository {
|
||||
type UpdateInput = UpdateRuntimeVersionInput;
|
||||
|
||||
async fn update<'e, E>(executor: E, id: i64, input: Self::UpdateInput) -> Result<RuntimeVersion>
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
let mut query: QueryBuilder<Postgres> = QueryBuilder::new("UPDATE runtime_version SET ");
|
||||
let mut has_updates = false;
|
||||
|
||||
if let Some(version) = &input.version {
|
||||
query.push("version = ");
|
||||
query.push_bind(version);
|
||||
has_updates = true;
|
||||
}
|
||||
|
||||
if let Some(version_major) = &input.version_major {
|
||||
if has_updates {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("version_major = ");
|
||||
query.push_bind(*version_major);
|
||||
has_updates = true;
|
||||
}
|
||||
|
||||
if let Some(version_minor) = &input.version_minor {
|
||||
if has_updates {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("version_minor = ");
|
||||
query.push_bind(*version_minor);
|
||||
has_updates = true;
|
||||
}
|
||||
|
||||
if let Some(version_patch) = &input.version_patch {
|
||||
if has_updates {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("version_patch = ");
|
||||
query.push_bind(*version_patch);
|
||||
has_updates = true;
|
||||
}
|
||||
|
||||
if let Some(execution_config) = &input.execution_config {
|
||||
if has_updates {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("execution_config = ");
|
||||
query.push_bind(execution_config);
|
||||
has_updates = true;
|
||||
}
|
||||
|
||||
if let Some(distributions) = &input.distributions {
|
||||
if has_updates {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("distributions = ");
|
||||
query.push_bind(distributions);
|
||||
has_updates = true;
|
||||
}
|
||||
|
||||
if let Some(is_default) = input.is_default {
|
||||
if has_updates {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("is_default = ");
|
||||
query.push_bind(is_default);
|
||||
has_updates = true;
|
||||
}
|
||||
|
||||
if let Some(available) = input.available {
|
||||
if has_updates {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("available = ");
|
||||
query.push_bind(available);
|
||||
has_updates = true;
|
||||
}
|
||||
|
||||
if let Some(verified_at) = &input.verified_at {
|
||||
if has_updates {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("verified_at = ");
|
||||
query.push_bind(*verified_at);
|
||||
has_updates = true;
|
||||
}
|
||||
|
||||
if let Some(meta) = &input.meta {
|
||||
if has_updates {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("meta = ");
|
||||
query.push_bind(meta);
|
||||
has_updates = true;
|
||||
}
|
||||
|
||||
if !has_updates {
|
||||
// Nothing to update — just fetch the current row
|
||||
return Self::find_by_id(executor, id)
|
||||
.await?
|
||||
.ok_or_else(|| crate::Error::not_found("runtime_version", "id", id.to_string()));
|
||||
}
|
||||
|
||||
query.push(" WHERE id = ");
|
||||
query.push_bind(id);
|
||||
query.push(&format!(" RETURNING {}", SELECT_COLUMNS));
|
||||
|
||||
let row = query
|
||||
.build_query_as::<RuntimeVersion>()
|
||||
.fetch_optional(executor)
|
||||
.await?
|
||||
.ok_or_else(|| crate::Error::not_found("runtime_version", "id", id.to_string()))?;
|
||||
|
||||
Ok(row)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Delete for RuntimeVersionRepository {
|
||||
async fn delete<'e, E>(executor: E, id: i64) -> Result<bool>
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
let result = sqlx::query("DELETE FROM runtime_version WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Specialized queries
|
||||
impl RuntimeVersionRepository {
|
||||
/// Find all versions for a given runtime ID.
|
||||
///
|
||||
/// Returns versions ordered by major, minor, patch descending
|
||||
/// (newest version first).
|
||||
pub async fn find_by_runtime<'e, E>(executor: E, runtime_id: Id) -> Result<Vec<RuntimeVersion>>
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
let rows = sqlx::query_as::<_, RuntimeVersion>(&format!(
|
||||
r#"
|
||||
SELECT {}
|
||||
FROM runtime_version
|
||||
WHERE runtime = $1
|
||||
ORDER BY version_major DESC NULLS LAST,
|
||||
version_minor DESC NULLS LAST,
|
||||
version_patch DESC NULLS LAST
|
||||
"#,
|
||||
SELECT_COLUMNS
|
||||
))
|
||||
.bind(runtime_id)
|
||||
.fetch_all(executor)
|
||||
.await?;
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Find all versions for a given runtime ref (e.g., "core.python").
|
||||
pub async fn find_by_runtime_ref<'e, E>(
|
||||
executor: E,
|
||||
runtime_ref: &str,
|
||||
) -> Result<Vec<RuntimeVersion>>
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
let rows = sqlx::query_as::<_, RuntimeVersion>(&format!(
|
||||
r#"
|
||||
SELECT {}
|
||||
FROM runtime_version
|
||||
WHERE runtime_ref = $1
|
||||
ORDER BY version_major DESC NULLS LAST,
|
||||
version_minor DESC NULLS LAST,
|
||||
version_patch DESC NULLS LAST
|
||||
"#,
|
||||
SELECT_COLUMNS
|
||||
))
|
||||
.bind(runtime_ref)
|
||||
.fetch_all(executor)
|
||||
.await?;
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Find all available versions for a given runtime ID.
|
||||
///
|
||||
/// Only returns versions where `available = true`.
|
||||
pub async fn find_available_by_runtime<'e, E>(
|
||||
executor: E,
|
||||
runtime_id: Id,
|
||||
) -> Result<Vec<RuntimeVersion>>
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
let rows = sqlx::query_as::<_, RuntimeVersion>(&format!(
|
||||
r#"
|
||||
SELECT {}
|
||||
FROM runtime_version
|
||||
WHERE runtime = $1 AND available = TRUE
|
||||
ORDER BY version_major DESC NULLS LAST,
|
||||
version_minor DESC NULLS LAST,
|
||||
version_patch DESC NULLS LAST
|
||||
"#,
|
||||
SELECT_COLUMNS
|
||||
))
|
||||
.bind(runtime_id)
|
||||
.fetch_all(executor)
|
||||
.await?;
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Find the default version for a given runtime ID.
|
||||
///
|
||||
/// Returns `None` if no version is marked as default.
|
||||
pub async fn find_default_by_runtime<'e, E>(
|
||||
executor: E,
|
||||
runtime_id: Id,
|
||||
) -> Result<Option<RuntimeVersion>>
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
let row = sqlx::query_as::<_, RuntimeVersion>(&format!(
|
||||
r#"
|
||||
SELECT {}
|
||||
FROM runtime_version
|
||||
WHERE runtime = $1 AND is_default = TRUE
|
||||
LIMIT 1
|
||||
"#,
|
||||
SELECT_COLUMNS
|
||||
))
|
||||
.bind(runtime_id)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
/// Find a specific version by runtime ID and version string.
|
||||
pub async fn find_by_runtime_and_version<'e, E>(
|
||||
executor: E,
|
||||
runtime_id: Id,
|
||||
version: &str,
|
||||
) -> Result<Option<RuntimeVersion>>
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
let row = sqlx::query_as::<_, RuntimeVersion>(&format!(
|
||||
r#"
|
||||
SELECT {}
|
||||
FROM runtime_version
|
||||
WHERE runtime = $1 AND version = $2
|
||||
"#,
|
||||
SELECT_COLUMNS
|
||||
))
|
||||
.bind(runtime_id)
|
||||
.bind(version)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
/// Clear the `is_default` flag on all versions for a runtime.
|
||||
///
|
||||
/// Useful before setting a new default version.
|
||||
pub async fn clear_default_for_runtime<'e, E>(executor: E, runtime_id: Id) -> Result<u64>
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
let result = sqlx::query(
|
||||
"UPDATE runtime_version SET is_default = FALSE WHERE runtime = $1 AND is_default = TRUE",
|
||||
)
|
||||
.bind(runtime_id)
|
||||
.execute(executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
/// Mark a version's availability and update the verification timestamp.
|
||||
pub async fn set_availability<'e, E>(executor: E, id: Id, available: bool) -> Result<bool>
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
let result = sqlx::query(
|
||||
"UPDATE runtime_version SET available = $1, verified_at = NOW() WHERE id = $2",
|
||||
)
|
||||
.bind(available)
|
||||
.bind(id)
|
||||
.execute(executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Delete all versions for a given runtime ID.
|
||||
pub async fn delete_by_runtime<'e, E>(executor: E, runtime_id: Id) -> Result<u64>
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
let result = sqlx::query("DELETE FROM runtime_version WHERE runtime = $1")
|
||||
.bind(runtime_id)
|
||||
.execute(executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
@@ -518,6 +518,7 @@ pub struct CreateSensorInput {
|
||||
pub entrypoint: String,
|
||||
pub runtime: Id,
|
||||
pub runtime_ref: String,
|
||||
pub runtime_version_constraint: Option<String>,
|
||||
pub trigger: Id,
|
||||
pub trigger_ref: String,
|
||||
pub enabled: bool,
|
||||
@@ -533,6 +534,7 @@ pub struct UpdateSensorInput {
|
||||
pub entrypoint: Option<String>,
|
||||
pub runtime: Option<Id>,
|
||||
pub runtime_ref: Option<String>,
|
||||
pub runtime_version_constraint: Option<Option<String>>,
|
||||
pub trigger: Option<Id>,
|
||||
pub trigger_ref: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
@@ -549,7 +551,8 @@ impl FindById for SensorRepository {
|
||||
let sensor = sqlx::query_as::<_, Sensor>(
|
||||
r#"
|
||||
SELECT id, ref, pack, pack_ref, label, description, entrypoint,
|
||||
runtime, runtime_ref, trigger, trigger_ref, enabled,
|
||||
runtime, runtime_ref, runtime_version_constraint,
|
||||
trigger, trigger_ref, enabled,
|
||||
param_schema, config, created, updated
|
||||
FROM sensor
|
||||
WHERE id = $1
|
||||
@@ -572,7 +575,8 @@ impl FindByRef for SensorRepository {
|
||||
let sensor = sqlx::query_as::<_, Sensor>(
|
||||
r#"
|
||||
SELECT id, ref, pack, pack_ref, label, description, entrypoint,
|
||||
runtime, runtime_ref, trigger, trigger_ref, enabled,
|
||||
runtime, runtime_ref, runtime_version_constraint,
|
||||
trigger, trigger_ref, enabled,
|
||||
param_schema, config, created, updated
|
||||
FROM sensor
|
||||
WHERE ref = $1
|
||||
@@ -595,7 +599,8 @@ impl List for SensorRepository {
|
||||
let sensors = sqlx::query_as::<_, Sensor>(
|
||||
r#"
|
||||
SELECT id, ref, pack, pack_ref, label, description, entrypoint,
|
||||
runtime, runtime_ref, trigger, trigger_ref, enabled,
|
||||
runtime, runtime_ref, runtime_version_constraint,
|
||||
trigger, trigger_ref, enabled,
|
||||
param_schema, config, created, updated
|
||||
FROM sensor
|
||||
ORDER BY ref ASC
|
||||
@@ -619,11 +624,13 @@ impl Create for SensorRepository {
|
||||
let sensor = sqlx::query_as::<_, Sensor>(
|
||||
r#"
|
||||
INSERT INTO sensor (ref, pack, pack_ref, label, description, entrypoint,
|
||||
runtime, runtime_ref, trigger, trigger_ref, enabled,
|
||||
runtime, runtime_ref, runtime_version_constraint,
|
||||
trigger, trigger_ref, enabled,
|
||||
param_schema, config)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING id, ref, pack, pack_ref, label, description, entrypoint,
|
||||
runtime, runtime_ref, trigger, trigger_ref, enabled,
|
||||
runtime, runtime_ref, runtime_version_constraint,
|
||||
trigger, trigger_ref, enabled,
|
||||
param_schema, config, created, updated
|
||||
"#,
|
||||
)
|
||||
@@ -635,6 +642,7 @@ impl Create for SensorRepository {
|
||||
.bind(&input.entrypoint)
|
||||
.bind(input.runtime)
|
||||
.bind(&input.runtime_ref)
|
||||
.bind(&input.runtime_version_constraint)
|
||||
.bind(input.trigger)
|
||||
.bind(&input.trigger_ref)
|
||||
.bind(input.enabled)
|
||||
@@ -711,6 +719,15 @@ impl Update for SensorRepository {
|
||||
has_updates = true;
|
||||
}
|
||||
|
||||
if let Some(runtime_version_constraint) = &input.runtime_version_constraint {
|
||||
if has_updates {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("runtime_version_constraint = ");
|
||||
query.push_bind(runtime_version_constraint);
|
||||
has_updates = true;
|
||||
}
|
||||
|
||||
if let Some(trigger) = input.trigger {
|
||||
if has_updates {
|
||||
query.push(", ");
|
||||
@@ -754,7 +771,7 @@ impl Update for SensorRepository {
|
||||
|
||||
query.push(", updated = NOW() WHERE id = ");
|
||||
query.push_bind(id);
|
||||
query.push(" RETURNING id, ref, pack, pack_ref, label, description, entrypoint, runtime, runtime_ref, trigger, trigger_ref, enabled, param_schema, config, created, updated");
|
||||
query.push(" RETURNING id, ref, pack, pack_ref, label, description, entrypoint, runtime, runtime_ref, runtime_version_constraint, trigger, trigger_ref, enabled, param_schema, config, created, updated");
|
||||
|
||||
let sensor = query.build_query_as::<Sensor>().fetch_one(executor).await?;
|
||||
|
||||
@@ -786,7 +803,8 @@ impl SensorRepository {
|
||||
let sensors = sqlx::query_as::<_, Sensor>(
|
||||
r#"
|
||||
SELECT id, ref, pack, pack_ref, label, description, entrypoint,
|
||||
runtime, runtime_ref, trigger, trigger_ref, enabled,
|
||||
runtime, runtime_ref, runtime_version_constraint,
|
||||
trigger, trigger_ref, enabled,
|
||||
param_schema, config, created, updated
|
||||
FROM sensor
|
||||
WHERE trigger = $1
|
||||
@@ -808,7 +826,8 @@ impl SensorRepository {
|
||||
let sensors = sqlx::query_as::<_, Sensor>(
|
||||
r#"
|
||||
SELECT id, ref, pack, pack_ref, label, description, entrypoint,
|
||||
runtime, runtime_ref, trigger, trigger_ref, enabled,
|
||||
runtime, runtime_ref, runtime_version_constraint,
|
||||
trigger, trigger_ref, enabled,
|
||||
param_schema, config, created, updated
|
||||
FROM sensor
|
||||
WHERE enabled = true
|
||||
@@ -829,7 +848,8 @@ impl SensorRepository {
|
||||
let sensors = sqlx::query_as::<_, Sensor>(
|
||||
r#"
|
||||
SELECT id, ref, pack, pack_ref, label, description, entrypoint,
|
||||
runtime, runtime_ref, trigger, trigger_ref, enabled,
|
||||
runtime, runtime_ref, runtime_version_constraint,
|
||||
trigger, trigger_ref, enabled,
|
||||
param_schema, config, created, updated
|
||||
FROM sensor
|
||||
WHERE pack = $1
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
//! 1. Environment variable override (highest priority)
|
||||
//! 2. Config file specification (medium priority)
|
||||
//! 3. Database-driven detection with verification (lowest priority)
|
||||
//!
|
||||
//! Also provides [`normalize_runtime_name`] for alias-aware runtime name
|
||||
//! comparison across the codebase (worker filters, env setup, etc.).
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::error::Result;
|
||||
@@ -15,6 +18,49 @@ use std::collections::HashMap;
|
||||
use std::process::Command;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Normalize a runtime name to its canonical short form.
|
||||
///
|
||||
/// This ensures that different ways of referring to the same runtime
|
||||
/// (e.g., "node", "nodejs", "node.js") all resolve to a single canonical
|
||||
/// name. Used by worker runtime filters and environment setup to match
|
||||
/// database runtime names against short filter values.
|
||||
///
|
||||
/// The canonical names mirror the alias groups in
|
||||
/// `PackComponentLoader::resolve_runtime`.
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// use attune_common::runtime_detection::normalize_runtime_name;
|
||||
/// assert_eq!(normalize_runtime_name("node.js"), "node");
|
||||
/// assert_eq!(normalize_runtime_name("nodejs"), "node");
|
||||
/// assert_eq!(normalize_runtime_name("python3"), "python");
|
||||
/// assert_eq!(normalize_runtime_name("shell"), "shell");
|
||||
/// ```
|
||||
pub fn normalize_runtime_name(name: &str) -> &str {
|
||||
match name {
|
||||
"node" | "nodejs" | "node.js" => "node",
|
||||
"python" | "python3" => "python",
|
||||
"bash" | "sh" | "shell" => "shell",
|
||||
"native" | "builtin" | "standalone" => "native",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a runtime name matches a filter entry, supporting common aliases.
|
||||
///
|
||||
/// Both sides are lowercased and then normalized before comparison so that,
|
||||
/// e.g., a filter value of `"node"` matches a database runtime name `"Node.js"`.
|
||||
pub fn runtime_matches_filter(rt_name: &str, filter_entry: &str) -> bool {
|
||||
let rt_lower = rt_name.to_ascii_lowercase();
|
||||
let filter_lower = filter_entry.to_ascii_lowercase();
|
||||
normalize_runtime_name(&rt_lower) == normalize_runtime_name(&filter_lower)
|
||||
}
|
||||
|
||||
/// Check if a runtime name matches any entry in a filter list.
|
||||
pub fn runtime_in_filter(rt_name: &str, filter: &[String]) -> bool {
|
||||
filter.iter().any(|f| runtime_matches_filter(rt_name, f))
|
||||
}
|
||||
|
||||
/// Runtime detection service
|
||||
pub struct RuntimeDetector {
|
||||
pool: PgPool,
|
||||
@@ -290,6 +336,72 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_normalize_runtime_name_node_variants() {
|
||||
assert_eq!(normalize_runtime_name("node"), "node");
|
||||
assert_eq!(normalize_runtime_name("nodejs"), "node");
|
||||
assert_eq!(normalize_runtime_name("node.js"), "node");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_runtime_name_python_variants() {
|
||||
assert_eq!(normalize_runtime_name("python"), "python");
|
||||
assert_eq!(normalize_runtime_name("python3"), "python");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_runtime_name_shell_variants() {
|
||||
assert_eq!(normalize_runtime_name("shell"), "shell");
|
||||
assert_eq!(normalize_runtime_name("bash"), "shell");
|
||||
assert_eq!(normalize_runtime_name("sh"), "shell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_runtime_name_native_variants() {
|
||||
assert_eq!(normalize_runtime_name("native"), "native");
|
||||
assert_eq!(normalize_runtime_name("builtin"), "native");
|
||||
assert_eq!(normalize_runtime_name("standalone"), "native");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_runtime_name_passthrough() {
|
||||
assert_eq!(normalize_runtime_name("custom_runtime"), "custom_runtime");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_runtime_matches_filter() {
|
||||
// Node.js DB name lowercased vs worker filter "node"
|
||||
assert!(runtime_matches_filter("node.js", "node"));
|
||||
assert!(runtime_matches_filter("node", "nodejs"));
|
||||
assert!(runtime_matches_filter("nodejs", "node.js"));
|
||||
// Exact match
|
||||
assert!(runtime_matches_filter("shell", "shell"));
|
||||
// No match
|
||||
assert!(!runtime_matches_filter("python", "node"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_runtime_matches_filter_case_insensitive() {
|
||||
// Database stores capitalized names (e.g., "Node.js", "Python")
|
||||
// Worker capabilities store lowercase (e.g., "node", "python")
|
||||
assert!(runtime_matches_filter("Node.js", "node"));
|
||||
assert!(runtime_matches_filter("node", "Node.js"));
|
||||
assert!(runtime_matches_filter("Python", "python"));
|
||||
assert!(runtime_matches_filter("python", "Python"));
|
||||
assert!(runtime_matches_filter("Shell", "shell"));
|
||||
assert!(runtime_matches_filter("NODEJS", "node"));
|
||||
assert!(!runtime_matches_filter("Python", "node"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_runtime_in_filter() {
|
||||
let filter = vec!["shell".to_string(), "node".to_string()];
|
||||
assert!(runtime_in_filter("shell", &filter));
|
||||
assert!(runtime_in_filter("node.js", &filter));
|
||||
assert!(runtime_in_filter("nodejs", &filter));
|
||||
assert!(!runtime_in_filter("python", &filter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verification_command_structure() {
|
||||
let cmd = json!({
|
||||
|
||||
638
crates/common/src/version_matching.rs
Normal file
638
crates/common/src/version_matching.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@
|
||||
//!
|
||||
//! This module handles registering workflows as workflow definitions in the database.
|
||||
//! Workflows are stored in the `workflow_definition` table with their full YAML definition
|
||||
//! as JSON. Optionally, actions can be created that reference workflow definitions.
|
||||
//! as JSON. A companion action record is also created so that workflows appear in
|
||||
//! action lists and the workflow builder's action palette.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::repositories::action::{ActionRepository, CreateActionInput, UpdateActionInput};
|
||||
use crate::repositories::workflow::{CreateWorkflowDefinitionInput, UpdateWorkflowDefinitionInput};
|
||||
use crate::repositories::{
|
||||
Create, Delete, FindByRef, PackRepository, Update, WorkflowDefinitionRepository,
|
||||
@@ -102,12 +104,34 @@ impl WorkflowRegistrar {
|
||||
let workflow_def_id = self
|
||||
.update_workflow(&existing.id, &loaded.workflow, &pack.r#ref)
|
||||
.await?;
|
||||
|
||||
// Update or create the companion action record
|
||||
self.ensure_companion_action(
|
||||
workflow_def_id,
|
||||
&loaded.workflow,
|
||||
pack.id,
|
||||
&pack.r#ref,
|
||||
&loaded.file.name,
|
||||
)
|
||||
.await?;
|
||||
|
||||
(workflow_def_id, false)
|
||||
} else {
|
||||
info!("Creating new workflow: {}", loaded.file.ref_name);
|
||||
let workflow_def_id = self
|
||||
.create_workflow(&loaded.workflow, &loaded.file.pack, pack.id, &pack.r#ref)
|
||||
.await?;
|
||||
|
||||
// Create a companion action record so the workflow appears in action lists
|
||||
self.create_companion_action(
|
||||
workflow_def_id,
|
||||
&loaded.workflow,
|
||||
pack.id,
|
||||
&pack.r#ref,
|
||||
&loaded.file.name,
|
||||
)
|
||||
.await?;
|
||||
|
||||
(workflow_def_id, true)
|
||||
};
|
||||
|
||||
@@ -158,13 +182,104 @@ impl WorkflowRegistrar {
|
||||
.await?
|
||||
.ok_or_else(|| Error::not_found("workflow", "ref", ref_name))?;
|
||||
|
||||
// Delete workflow definition (cascades to workflow_execution and related executions)
|
||||
// Delete workflow definition (cascades to workflow_execution, and the companion
|
||||
// action is cascade-deleted via the FK on action.workflow_def)
|
||||
WorkflowDefinitionRepository::delete(&self.pool, workflow.id).await?;
|
||||
|
||||
info!("Unregistered workflow: {}", ref_name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a companion action record for a workflow definition.
|
||||
///
|
||||
/// This ensures the workflow appears in action lists and the action palette
|
||||
/// in the workflow builder. The action is linked to the workflow definition
|
||||
/// via `is_workflow = true` and `workflow_def` FK.
|
||||
async fn create_companion_action(
|
||||
&self,
|
||||
workflow_def_id: i64,
|
||||
workflow: &WorkflowYaml,
|
||||
pack_id: i64,
|
||||
pack_ref: &str,
|
||||
workflow_name: &str,
|
||||
) -> Result<()> {
|
||||
let entrypoint = format!("workflows/{}.workflow.yaml", workflow_name);
|
||||
|
||||
let action_input = CreateActionInput {
|
||||
r#ref: workflow.r#ref.clone(),
|
||||
pack: pack_id,
|
||||
pack_ref: pack_ref.to_string(),
|
||||
label: workflow.label.clone(),
|
||||
description: workflow.description.clone().unwrap_or_default(),
|
||||
entrypoint,
|
||||
runtime: None,
|
||||
runtime_version_constraint: None,
|
||||
param_schema: workflow.parameters.clone(),
|
||||
out_schema: workflow.output.clone(),
|
||||
is_adhoc: false,
|
||||
};
|
||||
|
||||
let action = ActionRepository::create(&self.pool, action_input).await?;
|
||||
|
||||
// Link the action to the workflow definition (sets is_workflow = true and workflow_def)
|
||||
ActionRepository::link_workflow_def(&self.pool, action.id, workflow_def_id).await?;
|
||||
|
||||
info!(
|
||||
"Created companion action '{}' (ID: {}) for workflow definition (ID: {})",
|
||||
workflow.r#ref, action.id, workflow_def_id
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensure a companion action record exists for a workflow definition.
|
||||
///
|
||||
/// If the action already exists, update it. If it doesn't exist (e.g., for
|
||||
/// workflows registered before the companion-action fix), create it.
|
||||
async fn ensure_companion_action(
|
||||
&self,
|
||||
workflow_def_id: i64,
|
||||
workflow: &WorkflowYaml,
|
||||
pack_id: i64,
|
||||
pack_ref: &str,
|
||||
workflow_name: &str,
|
||||
) -> Result<()> {
|
||||
let existing_action =
|
||||
ActionRepository::find_by_workflow_def(&self.pool, workflow_def_id).await?;
|
||||
|
||||
if let Some(action) = existing_action {
|
||||
// Update the existing companion action to stay in sync
|
||||
let update_input = UpdateActionInput {
|
||||
label: Some(workflow.label.clone()),
|
||||
description: workflow.description.clone(),
|
||||
entrypoint: Some(format!("workflows/{}.workflow.yaml", workflow_name)),
|
||||
runtime: None,
|
||||
runtime_version_constraint: None,
|
||||
param_schema: workflow.parameters.clone(),
|
||||
out_schema: workflow.output.clone(),
|
||||
};
|
||||
|
||||
ActionRepository::update(&self.pool, action.id, update_input).await?;
|
||||
|
||||
debug!(
|
||||
"Updated companion action '{}' (ID: {}) for workflow definition (ID: {})",
|
||||
action.r#ref, action.id, workflow_def_id
|
||||
);
|
||||
} else {
|
||||
// Backfill: create companion action for pre-fix workflows
|
||||
self.create_companion_action(
|
||||
workflow_def_id,
|
||||
workflow,
|
||||
pack_id,
|
||||
pack_ref,
|
||||
workflow_name,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new workflow definition
|
||||
async fn create_workflow(
|
||||
&self,
|
||||
|
||||
@@ -198,6 +198,7 @@ async fn test_update_action() {
|
||||
description: Some("Updated description".to_string()),
|
||||
entrypoint: None,
|
||||
runtime: None,
|
||||
runtime_version_constraint: None,
|
||||
param_schema: None,
|
||||
out_schema: None,
|
||||
};
|
||||
@@ -329,6 +330,7 @@ async fn test_action_foreign_key_constraint() {
|
||||
description: "Test".to_string(),
|
||||
entrypoint: "main.py".to_string(),
|
||||
runtime: None,
|
||||
runtime_version_constraint: None,
|
||||
param_schema: None,
|
||||
out_schema: None,
|
||||
is_adhoc: false,
|
||||
|
||||
@@ -457,6 +457,7 @@ impl ActionFixture {
|
||||
description: self.description,
|
||||
entrypoint: self.entrypoint,
|
||||
runtime: self.runtime,
|
||||
runtime_version_constraint: None,
|
||||
param_schema: self.param_schema,
|
||||
out_schema: self.out_schema,
|
||||
is_adhoc: false,
|
||||
@@ -1088,6 +1089,7 @@ impl SensorFixture {
|
||||
entrypoint: self.entrypoint,
|
||||
runtime: self.runtime_id,
|
||||
runtime_ref: self.runtime_ref,
|
||||
runtime_version_constraint: None,
|
||||
trigger: self.trigger_id,
|
||||
trigger_ref: self.trigger_ref,
|
||||
enabled: self.enabled,
|
||||
|
||||
@@ -179,6 +179,7 @@ async fn test_create_sensor_duplicate_ref_fails() {
|
||||
entrypoint: "sensors/dup.py".to_string(),
|
||||
runtime: runtime.id,
|
||||
runtime_ref: runtime.r#ref.clone(),
|
||||
runtime_version_constraint: None,
|
||||
trigger: trigger.id,
|
||||
trigger_ref: trigger.r#ref.clone(),
|
||||
enabled: true,
|
||||
@@ -233,6 +234,7 @@ async fn test_create_sensor_invalid_ref_format_fails() {
|
||||
entrypoint: "sensors/invalid.py".to_string(),
|
||||
runtime: runtime.id,
|
||||
runtime_ref: runtime.r#ref.clone(),
|
||||
runtime_version_constraint: None,
|
||||
trigger: trigger.id,
|
||||
trigger_ref: trigger.r#ref.clone(),
|
||||
enabled: true,
|
||||
@@ -272,6 +274,7 @@ async fn test_create_sensor_invalid_pack_fails() {
|
||||
entrypoint: "sensors/invalid.py".to_string(),
|
||||
runtime: runtime.id,
|
||||
runtime_ref: runtime.r#ref.clone(),
|
||||
runtime_version_constraint: None,
|
||||
trigger: trigger.id,
|
||||
trigger_ref: trigger.r#ref.clone(),
|
||||
enabled: true,
|
||||
@@ -302,6 +305,7 @@ async fn test_create_sensor_invalid_trigger_fails() {
|
||||
entrypoint: "sensors/invalid.py".to_string(),
|
||||
runtime: runtime.id,
|
||||
runtime_ref: runtime.r#ref.clone(),
|
||||
runtime_version_constraint: None,
|
||||
trigger: 99999, // Non-existent trigger
|
||||
trigger_ref: "invalid.trigger".to_string(),
|
||||
enabled: true,
|
||||
@@ -332,6 +336,7 @@ async fn test_create_sensor_invalid_runtime_fails() {
|
||||
entrypoint: "sensors/invalid.py".to_string(),
|
||||
runtime: 99999, // Non-existent runtime
|
||||
runtime_ref: "invalid.runtime".to_string(),
|
||||
runtime_version_constraint: None,
|
||||
trigger: trigger.id,
|
||||
trigger_ref: trigger.r#ref.clone(),
|
||||
enabled: true,
|
||||
|
||||
Reference in New Issue
Block a user