working on sensors and rules
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -79,3 +79,5 @@ tests/pids/*
|
||||
.env.docker
|
||||
docker-compose.override.yml
|
||||
*.pid
|
||||
|
||||
packs.examples/
|
||||
|
||||
@@ -222,7 +222,9 @@ Enforcement created → Execution scheduled → Worker executes Action
|
||||
- **Action Script Resolution**: Worker constructs file paths as `{packs_base_dir}/{pack_ref}/actions/{entrypoint}`
|
||||
- **Runtime YAML Loading**: Pack registration reads `runtimes/*.yaml` files and inserts them into the `runtime` table. Runtime refs use format `{pack_ref}.{name}` (e.g., `core.python`, `core.shell`).
|
||||
- **Runtime Selection**: Determined by action's runtime field (e.g., "Shell", "Python") - compared case-insensitively; when an explicit `runtime_name` is set in execution context, it is authoritative (no fallback to extension matching)
|
||||
- **Worker Runtime Loading**: Worker loads all runtimes from DB that have a non-empty `execution_config` (i.e., runtimes with an interpreter configured). Builtin runtimes (e.g., sensor runtime with empty config) are automatically skipped.
|
||||
- **Worker Runtime Loading**: Worker loads all runtimes from DB that have a non-empty `execution_config` (i.e., runtimes with an interpreter configured). Native runtimes (e.g., `core.native` with empty config) are automatically skipped since they execute binaries directly.
|
||||
- **Native Runtime Detection**: Runtime detection is purely data-driven via `execution_config` in the runtime table. A runtime with empty `execution_config` (or empty `interpreter.binary`) is native — the entrypoint is executed directly without an interpreter. There is no special "builtin" runtime concept.
|
||||
- **Sensor Runtime Assignment**: Sensors declare their `runner_type` in YAML (e.g., `python`, `native`). The pack loader resolves this to the correct runtime from the database. Default is `native` (compiled binary, no interpreter). Legacy values `standalone` and `builtin` map to `core.native`.
|
||||
- **Runtime Environment Setup**: Worker creates isolated environments (virtualenvs, node_modules) on-demand at `{runtime_envs_dir}/{pack_ref}/{runtime_name}` before first execution; setup is idempotent
|
||||
- **Parameter Delivery**: Actions receive parameters via stdin as JSON (never environment variables)
|
||||
- **Output Format**: Actions declare output format (text/json/yaml) - json/yaml are parsed into execution.result JSONB
|
||||
|
||||
@@ -14,9 +14,11 @@ pub fn create_cors_layer(allowed_origins: Vec<String>) -> CorsLayer {
|
||||
// Default development origins
|
||||
vec![
|
||||
"http://localhost:3000".to_string(),
|
||||
"http://localhost:3001".to_string(),
|
||||
"http://localhost:5173".to_string(),
|
||||
"http://localhost:8080".to_string(),
|
||||
"http://127.0.0.1:3000".to_string(),
|
||||
"http://127.0.0.1:3001".to_string(),
|
||||
"http://127.0.0.1:5173".to_string(),
|
||||
"http://127.0.0.1:8080".to_string(),
|
||||
]
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! Parameter validation module
|
||||
//!
|
||||
//! Validates trigger and action parameters against their declared JSON schemas.
|
||||
//! Template-aware: values containing `{{ }}` template expressions are replaced
|
||||
//! with schema-appropriate placeholders before validation, so template expressions
|
||||
//! pass type checks while literal values are still validated normally.
|
||||
|
||||
use attune_common::models::{action::Action, trigger::Trigger};
|
||||
use jsonschema::Validator;
|
||||
@@ -8,15 +11,167 @@ use serde_json::Value;
|
||||
|
||||
use crate::middleware::ApiError;
|
||||
|
||||
/// Validate trigger parameters against the trigger's parameter schema
|
||||
/// Check if a JSON value is (or contains) a template expression.
|
||||
fn is_template_expression(value: &Value) -> bool {
|
||||
match value {
|
||||
Value::String(s) => s.contains("{{") && s.contains("}}"),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a JSON Schema property definition, produce a placeholder value that
|
||||
/// satisfies the schema's type constraint. This is used to replace template
|
||||
/// expressions so that JSON Schema validation passes for the remaining
|
||||
/// (non-template) parts of the parameters.
|
||||
fn placeholder_for_schema(property_schema: &Value) -> Value {
|
||||
// Handle anyOf / oneOf by picking the first variant
|
||||
if let Some(any_of) = property_schema.get("anyOf").and_then(|v| v.as_array()) {
|
||||
if let Some(first) = any_of.first() {
|
||||
return placeholder_for_schema(first);
|
||||
}
|
||||
}
|
||||
if let Some(one_of) = property_schema.get("oneOf").and_then(|v| v.as_array()) {
|
||||
if let Some(first) = one_of.first() {
|
||||
return placeholder_for_schema(first);
|
||||
}
|
||||
}
|
||||
|
||||
let type_value = property_schema.get("type").and_then(|t| t.as_str());
|
||||
|
||||
match type_value {
|
||||
Some("integer") => {
|
||||
// Use minimum if set, else default if set, else 0
|
||||
if let Some(default) = property_schema.get("default") {
|
||||
return default.clone();
|
||||
}
|
||||
if let Some(min) = property_schema.get("minimum").and_then(|v| v.as_i64()) {
|
||||
return Value::Number(min.into());
|
||||
}
|
||||
Value::Number(0.into())
|
||||
}
|
||||
Some("number") => {
|
||||
if let Some(default) = property_schema.get("default") {
|
||||
return default.clone();
|
||||
}
|
||||
if let Some(min) = property_schema.get("minimum").and_then(|v| v.as_f64()) {
|
||||
return serde_json::Number::from_f64(min)
|
||||
.map(Value::Number)
|
||||
.unwrap_or(Value::Number(0.into()));
|
||||
}
|
||||
serde_json::Number::from_f64(0.0)
|
||||
.map(Value::Number)
|
||||
.unwrap_or(Value::Number(0.into()))
|
||||
}
|
||||
Some("boolean") => {
|
||||
if let Some(default) = property_schema.get("default") {
|
||||
return default.clone();
|
||||
}
|
||||
Value::Bool(true)
|
||||
}
|
||||
Some("array") => {
|
||||
if let Some(default) = property_schema.get("default") {
|
||||
return default.clone();
|
||||
}
|
||||
Value::Array(vec![])
|
||||
}
|
||||
Some("object") => {
|
||||
if let Some(default) = property_schema.get("default") {
|
||||
return default.clone();
|
||||
}
|
||||
Value::Object(serde_json::Map::new())
|
||||
}
|
||||
Some("string") | None => {
|
||||
// For enum fields, use the first valid value so enum validation passes
|
||||
if let Some(enum_values) = property_schema.get("enum").and_then(|v| v.as_array()) {
|
||||
if let Some(first) = enum_values.first() {
|
||||
return first.clone();
|
||||
}
|
||||
}
|
||||
if let Some(default) = property_schema.get("default") {
|
||||
return default.clone();
|
||||
}
|
||||
Value::String("__template_placeholder__".to_string())
|
||||
}
|
||||
Some(_) => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk a parameters object and replace any template expression values with
|
||||
/// schema-appropriate placeholders. Only replaces leaf values that match
|
||||
/// `{{ ... }}`; non-template values are left untouched for normal validation.
|
||||
///
|
||||
/// `schema` should be the full JSON Schema object (with `properties`, `type`, etc).
|
||||
fn replace_templates_with_placeholders(params: &Value, schema: &Value) -> Value {
|
||||
match params {
|
||||
Value::Object(map) => {
|
||||
let properties = schema.get("properties").and_then(|p| p.as_object());
|
||||
|
||||
let mut result = serde_json::Map::new();
|
||||
for (key, value) in map {
|
||||
let prop_schema = properties.and_then(|p| p.get(key));
|
||||
|
||||
if is_template_expression(value) {
|
||||
// Replace with a type-appropriate placeholder
|
||||
if let Some(ps) = prop_schema {
|
||||
result.insert(key.clone(), placeholder_for_schema(ps));
|
||||
} else {
|
||||
// No schema for this property — keep as string placeholder
|
||||
result.insert(
|
||||
key.clone(),
|
||||
Value::String("__template_placeholder__".to_string()),
|
||||
);
|
||||
}
|
||||
} else if value.is_object() {
|
||||
// Recurse into nested objects
|
||||
let empty_schema = Value::Object(serde_json::Map::new());
|
||||
let nested_schema = prop_schema.unwrap_or(&empty_schema);
|
||||
result.insert(
|
||||
key.clone(),
|
||||
replace_templates_with_placeholders(value, nested_schema),
|
||||
);
|
||||
} else if value.is_array() {
|
||||
// Recurse into arrays — check each element
|
||||
if let Some(arr) = value.as_array() {
|
||||
let empty_items_schema = Value::Object(serde_json::Map::new());
|
||||
let item_schema = prop_schema
|
||||
.and_then(|ps| ps.get("items"))
|
||||
.unwrap_or(&empty_items_schema);
|
||||
let new_arr: Vec<Value> = arr
|
||||
.iter()
|
||||
.map(|item| {
|
||||
if is_template_expression(item) {
|
||||
placeholder_for_schema(item_schema)
|
||||
} else if item.is_object() || item.is_array() {
|
||||
replace_templates_with_placeholders(item, item_schema)
|
||||
} else {
|
||||
item.clone()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
result.insert(key.clone(), Value::Array(new_arr));
|
||||
} else {
|
||||
result.insert(key.clone(), value.clone());
|
||||
}
|
||||
} else {
|
||||
result.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
Value::Object(result)
|
||||
}
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate trigger parameters against the trigger's parameter schema.
|
||||
/// Template expressions (`{{ ... }}`) are accepted for any field type.
|
||||
pub fn validate_trigger_params(trigger: &Trigger, params: &Value) -> Result<(), ApiError> {
|
||||
// If no schema is defined, accept any parameters
|
||||
let Some(schema) = &trigger.param_schema else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// If parameters are empty object and schema exists, validate against schema
|
||||
// (schema might allow empty object or have defaults)
|
||||
// Replace template expressions with schema-appropriate placeholders
|
||||
let sanitized = replace_templates_with_placeholders(params, schema);
|
||||
|
||||
// Compile the JSON schema
|
||||
let compiled_schema = Validator::new(schema).map_err(|e| {
|
||||
@@ -26,9 +181,9 @@ pub fn validate_trigger_params(trigger: &Trigger, params: &Value) -> Result<(),
|
||||
))
|
||||
})?;
|
||||
|
||||
// Validate the parameters
|
||||
// Validate the sanitized parameters
|
||||
let errors: Vec<String> = compiled_schema
|
||||
.iter_errors(params)
|
||||
.iter_errors(&sanitized)
|
||||
.map(|e| {
|
||||
let path = e.instance_path().to_string();
|
||||
if path.is_empty() {
|
||||
@@ -50,13 +205,17 @@ pub fn validate_trigger_params(trigger: &Trigger, params: &Value) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate action parameters against the action's parameter schema
|
||||
/// Validate action parameters against the action's parameter schema.
|
||||
/// Template expressions (`{{ ... }}`) are accepted for any field type.
|
||||
pub fn validate_action_params(action: &Action, params: &Value) -> Result<(), ApiError> {
|
||||
// If no schema is defined, accept any parameters
|
||||
let Some(schema) = &action.param_schema else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Replace template expressions with schema-appropriate placeholders
|
||||
let sanitized = replace_templates_with_placeholders(params, schema);
|
||||
|
||||
// Compile the JSON schema
|
||||
let compiled_schema = Validator::new(schema).map_err(|e| {
|
||||
ApiError::InternalServerError(format!(
|
||||
@@ -65,9 +224,9 @@ pub fn validate_action_params(action: &Action, params: &Value) -> Result<(), Api
|
||||
))
|
||||
})?;
|
||||
|
||||
// Validate the parameters
|
||||
// Validate the sanitized parameters
|
||||
let errors: Vec<String> = compiled_schema
|
||||
.iter_errors(params)
|
||||
.iter_errors(&sanitized)
|
||||
.map(|e| {
|
||||
let path = e.instance_path().to_string();
|
||||
if path.is_empty() {
|
||||
@@ -94,9 +253,10 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_validate_trigger_params_with_no_schema() {
|
||||
let trigger = Trigger {
|
||||
// ── Helper builders ──────────────────────────────────────────────
|
||||
|
||||
fn make_trigger(schema: Option<Value>) -> Trigger {
|
||||
Trigger {
|
||||
id: 1,
|
||||
r#ref: "test.trigger".to_string(),
|
||||
pack: Some(1),
|
||||
@@ -104,7 +264,7 @@ mod tests {
|
||||
label: "Test Trigger".to_string(),
|
||||
description: None,
|
||||
enabled: true,
|
||||
param_schema: None,
|
||||
param_schema: schema,
|
||||
out_schema: None,
|
||||
webhook_enabled: false,
|
||||
webhook_key: None,
|
||||
@@ -112,12 +272,43 @@ mod tests {
|
||||
is_adhoc: false,
|
||||
created: chrono::Utc::now(),
|
||||
updated: chrono::Utc::now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn make_action(schema: Option<Value>) -> Action {
|
||||
Action {
|
||||
id: 1,
|
||||
r#ref: "test.action".to_string(),
|
||||
pack: 1,
|
||||
pack_ref: "test".to_string(),
|
||||
label: "Test Action".to_string(),
|
||||
description: "Test action".to_string(),
|
||||
entrypoint: "test.sh".to_string(),
|
||||
runtime: Some(1),
|
||||
param_schema: schema,
|
||||
out_schema: None,
|
||||
is_workflow: false,
|
||||
workflow_def: None,
|
||||
is_adhoc: false,
|
||||
parameter_delivery: attune_common::models::ParameterDelivery::default(),
|
||||
parameter_format: attune_common::models::ParameterFormat::default(),
|
||||
output_format: attune_common::models::OutputFormat::default(),
|
||||
created: chrono::Utc::now(),
|
||||
updated: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── No schema ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_validate_trigger_params_with_no_schema() {
|
||||
let trigger = make_trigger(None);
|
||||
let params = json!({ "any": "value" });
|
||||
assert!(validate_trigger_params(&trigger, ¶ms).is_ok());
|
||||
}
|
||||
|
||||
// ── Basic trigger validation (no templates) ──────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_validate_trigger_params_with_valid_params() {
|
||||
let schema = json!({
|
||||
@@ -129,24 +320,7 @@ mod tests {
|
||||
"required": ["unit", "delta"]
|
||||
});
|
||||
|
||||
let trigger = Trigger {
|
||||
id: 1,
|
||||
r#ref: "test.trigger".to_string(),
|
||||
pack: Some(1),
|
||||
pack_ref: Some("test".to_string()),
|
||||
label: "Test Trigger".to_string(),
|
||||
description: None,
|
||||
enabled: true,
|
||||
param_schema: Some(schema),
|
||||
out_schema: None,
|
||||
webhook_enabled: false,
|
||||
webhook_key: None,
|
||||
webhook_config: None,
|
||||
is_adhoc: false,
|
||||
created: chrono::Utc::now(),
|
||||
updated: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let trigger = make_trigger(Some(schema));
|
||||
let params = json!({ "unit": "seconds", "delta": 10 });
|
||||
assert!(validate_trigger_params(&trigger, ¶ms).is_ok());
|
||||
}
|
||||
@@ -162,23 +336,7 @@ mod tests {
|
||||
"required": ["unit", "delta"]
|
||||
});
|
||||
|
||||
let trigger = Trigger {
|
||||
id: 1,
|
||||
r#ref: "test.trigger".to_string(),
|
||||
pack: Some(1),
|
||||
pack_ref: Some("test".to_string()),
|
||||
label: "Test Trigger".to_string(),
|
||||
description: None,
|
||||
enabled: true,
|
||||
param_schema: Some(schema),
|
||||
out_schema: None,
|
||||
webhook_enabled: false,
|
||||
webhook_key: None,
|
||||
webhook_config: None,
|
||||
is_adhoc: false,
|
||||
created: chrono::Utc::now(),
|
||||
updated: chrono::Utc::now(),
|
||||
};
|
||||
let trigger = make_trigger(Some(schema));
|
||||
|
||||
// Missing required field 'delta'
|
||||
let params = json!({ "unit": "seconds" });
|
||||
@@ -193,6 +351,8 @@ mod tests {
|
||||
assert!(validate_trigger_params(&trigger, ¶ms).is_err());
|
||||
}
|
||||
|
||||
// ── Basic action validation (no templates) ───────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_validate_action_params_with_valid_params() {
|
||||
let schema = json!({
|
||||
@@ -203,27 +363,7 @@ mod tests {
|
||||
"required": ["message"]
|
||||
});
|
||||
|
||||
let action = Action {
|
||||
id: 1,
|
||||
r#ref: "test.action".to_string(),
|
||||
pack: 1,
|
||||
pack_ref: "test".to_string(),
|
||||
label: "Test Action".to_string(),
|
||||
description: "Test action".to_string(),
|
||||
entrypoint: "test.sh".to_string(),
|
||||
runtime: Some(1),
|
||||
param_schema: Some(schema),
|
||||
out_schema: None,
|
||||
is_workflow: false,
|
||||
workflow_def: None,
|
||||
is_adhoc: false,
|
||||
parameter_delivery: attune_common::models::ParameterDelivery::default(),
|
||||
parameter_format: attune_common::models::ParameterFormat::default(),
|
||||
output_format: attune_common::models::OutputFormat::default(),
|
||||
created: chrono::Utc::now(),
|
||||
updated: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let action = make_action(Some(schema));
|
||||
let params = json!({ "message": "Hello, world!" });
|
||||
assert!(validate_action_params(&action, ¶ms).is_ok());
|
||||
}
|
||||
@@ -238,28 +378,327 @@ mod tests {
|
||||
"required": ["message"]
|
||||
});
|
||||
|
||||
let action = Action {
|
||||
id: 2,
|
||||
r#ref: "test.action".to_string(),
|
||||
pack: 1,
|
||||
pack_ref: "test".to_string(),
|
||||
label: "Test Action".to_string(),
|
||||
description: "Test action".to_string(),
|
||||
entrypoint: "test.sh".to_string(),
|
||||
runtime: Some(1),
|
||||
param_schema: Some(schema),
|
||||
out_schema: None,
|
||||
is_workflow: false,
|
||||
workflow_def: None,
|
||||
is_adhoc: false,
|
||||
parameter_delivery: attune_common::models::ParameterDelivery::default(),
|
||||
parameter_format: attune_common::models::ParameterFormat::default(),
|
||||
output_format: attune_common::models::OutputFormat::default(),
|
||||
created: chrono::Utc::now(),
|
||||
updated: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let action = make_action(Some(schema));
|
||||
let params = json!({});
|
||||
assert!(validate_action_params(&action, ¶ms).is_err());
|
||||
}
|
||||
|
||||
// ── Template-aware validation ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_template_in_integer_field_passes() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"counter": { "type": "integer" }
|
||||
},
|
||||
"required": ["counter"]
|
||||
});
|
||||
|
||||
let action = make_action(Some(schema));
|
||||
let params = json!({ "counter": "{{ event.payload.counter }}" });
|
||||
assert!(validate_action_params(&action, ¶ms).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_in_boolean_field_passes() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"verbose": { "type": "boolean" }
|
||||
},
|
||||
"required": ["verbose"]
|
||||
});
|
||||
|
||||
let action = make_action(Some(schema));
|
||||
let params = json!({ "verbose": "{{ event.payload.debug }}" });
|
||||
assert!(validate_action_params(&action, ¶ms).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_in_number_field_passes() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"threshold": { "type": "number", "minimum": 0.0 }
|
||||
},
|
||||
"required": ["threshold"]
|
||||
});
|
||||
|
||||
let action = make_action(Some(schema));
|
||||
let params = json!({ "threshold": "{{ event.payload.threshold }}" });
|
||||
assert!(validate_action_params(&action, ¶ms).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_in_enum_field_passes() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level": { "type": "string", "enum": ["info", "warn", "error"] }
|
||||
},
|
||||
"required": ["level"]
|
||||
});
|
||||
|
||||
let action = make_action(Some(schema));
|
||||
let params = json!({ "level": "{{ event.payload.severity }}" });
|
||||
assert!(validate_action_params(&action, ¶ms).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_in_array_field_passes() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"recipients": { "type": "array", "items": { "type": "string" } }
|
||||
},
|
||||
"required": ["recipients"]
|
||||
});
|
||||
|
||||
let action = make_action(Some(schema));
|
||||
let params = json!({ "recipients": "{{ event.payload.emails }}" });
|
||||
assert!(validate_action_params(&action, ¶ms).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_in_object_field_passes() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"metadata": { "type": "object" }
|
||||
},
|
||||
"required": ["metadata"]
|
||||
});
|
||||
|
||||
let action = make_action(Some(schema));
|
||||
let params = json!({ "metadata": "{{ event.payload.meta }}" });
|
||||
assert!(validate_action_params(&action, ¶ms).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mixed_template_and_literal_values() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": { "type": "string" },
|
||||
"count": { "type": "integer" },
|
||||
"verbose": { "type": "boolean" }
|
||||
},
|
||||
"required": ["message", "count", "verbose"]
|
||||
});
|
||||
|
||||
let action = make_action(Some(schema));
|
||||
|
||||
// Mix of literal and template values
|
||||
let params = json!({
|
||||
"message": "Hello",
|
||||
"count": "{{ event.payload.count }}",
|
||||
"verbose": true
|
||||
});
|
||||
assert!(validate_action_params(&action, ¶ms).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_literal_values_still_validated() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": { "type": "string" },
|
||||
"count": { "type": "integer" }
|
||||
},
|
||||
"required": ["message", "count"]
|
||||
});
|
||||
|
||||
let action = make_action(Some(schema));
|
||||
|
||||
// Template for message is fine, but literal "not_a_number" for integer is not
|
||||
let params = json!({
|
||||
"message": "{{ event.payload.msg }}",
|
||||
"count": "not_a_number"
|
||||
});
|
||||
assert!(validate_action_params(&action, ¶ms).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_required_field_still_enforced_with_templates() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": { "type": "string" },
|
||||
"count": { "type": "integer" }
|
||||
},
|
||||
"required": ["message", "count"]
|
||||
});
|
||||
|
||||
let action = make_action(Some(schema));
|
||||
|
||||
// Only message provided (even as template), count is missing
|
||||
let params = json!({ "message": "{{ event.payload.msg }}" });
|
||||
assert!(validate_action_params(&action, ¶ms).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pack_config_template_passes() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"api_key": { "type": "string" },
|
||||
"timeout": { "type": "integer" }
|
||||
},
|
||||
"required": ["api_key", "timeout"]
|
||||
});
|
||||
|
||||
let action = make_action(Some(schema));
|
||||
let params = json!({
|
||||
"api_key": "{{ pack.config.api_key }}",
|
||||
"timeout": "{{ pack.config.default_timeout }}"
|
||||
});
|
||||
assert!(validate_action_params(&action, ¶ms).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_template_passes() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"timestamp": { "type": "string" },
|
||||
"rule_id": { "type": "integer" }
|
||||
},
|
||||
"required": ["timestamp", "rule_id"]
|
||||
});
|
||||
|
||||
let action = make_action(Some(schema));
|
||||
let params = json!({
|
||||
"timestamp": "{{ system.timestamp }}",
|
||||
"rule_id": "{{ system.rule.id }}"
|
||||
});
|
||||
assert!(validate_action_params(&action, ¶ms).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trigger_params_template_aware() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"unit": { "type": "string", "enum": ["seconds", "minutes", "hours"] },
|
||||
"delta": { "type": "integer", "minimum": 1 }
|
||||
},
|
||||
"required": ["unit", "delta"]
|
||||
});
|
||||
|
||||
let trigger = make_trigger(Some(schema));
|
||||
|
||||
// Both fields as templates
|
||||
let params = json!({
|
||||
"unit": "{{ pack.config.timer_unit }}",
|
||||
"delta": "{{ pack.config.timer_delta }}"
|
||||
});
|
||||
assert!(validate_trigger_params(&trigger, ¶ms).is_ok());
|
||||
}
|
||||
|
||||
// ── Placeholder generation ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_is_template_expression() {
|
||||
assert!(is_template_expression(&json!("{{ event.payload.x }}")));
|
||||
assert!(is_template_expression(&json!("{{ pack.config.key }}")));
|
||||
assert!(is_template_expression(&json!(
|
||||
"prefix {{ system.ts }} suffix"
|
||||
)));
|
||||
assert!(!is_template_expression(&json!("no braces here")));
|
||||
assert!(!is_template_expression(&json!(42)));
|
||||
assert!(!is_template_expression(&json!(true)));
|
||||
assert!(!is_template_expression(&json!("{ single braces }")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_placeholder_for_schema_types() {
|
||||
assert_eq!(
|
||||
placeholder_for_schema(&json!({"type": "integer"})),
|
||||
json!(0)
|
||||
);
|
||||
assert_eq!(
|
||||
placeholder_for_schema(&json!({"type": "number"})),
|
||||
json!(0.0)
|
||||
);
|
||||
assert_eq!(
|
||||
placeholder_for_schema(&json!({"type": "boolean"})),
|
||||
json!(true)
|
||||
);
|
||||
assert_eq!(placeholder_for_schema(&json!({"type": "array"})), json!([]));
|
||||
assert_eq!(
|
||||
placeholder_for_schema(&json!({"type": "object"})),
|
||||
json!({})
|
||||
);
|
||||
assert_eq!(
|
||||
placeholder_for_schema(&json!({"type": "string"})),
|
||||
json!("__template_placeholder__")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_placeholder_respects_enum() {
|
||||
let schema = json!({"type": "string", "enum": ["a", "b", "c"]});
|
||||
assert_eq!(placeholder_for_schema(&schema), json!("a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_placeholder_respects_default() {
|
||||
let schema = json!({"type": "integer", "default": 42});
|
||||
assert_eq!(placeholder_for_schema(&schema), json!(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_placeholder_respects_minimum() {
|
||||
let schema = json!({"type": "integer", "minimum": 5});
|
||||
assert_eq!(placeholder_for_schema(&schema), json!(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nested_object_template_replacement() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"outer": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"inner_count": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let params = json!({
|
||||
"outer": {
|
||||
"inner_count": "{{ event.payload.count }}"
|
||||
}
|
||||
});
|
||||
|
||||
let sanitized = replace_templates_with_placeholders(¶ms, &schema);
|
||||
// The inner template should be replaced with an integer placeholder
|
||||
assert!(sanitized["outer"]["inner_count"].is_number());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_array_element_template_replacement() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let params = json!({
|
||||
"tags": ["literal", "{{ event.payload.tag }}"]
|
||||
});
|
||||
|
||||
let sanitized = replace_templates_with_placeholders(¶ms, &schema);
|
||||
let tags = sanitized["tags"].as_array().unwrap();
|
||||
assert_eq!(tags[0], "literal");
|
||||
assert!(tags[1].is_string());
|
||||
assert_ne!(tags[1], "{{ event.payload.tag }}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,7 +464,7 @@ pub mod runtime {
|
||||
}
|
||||
|
||||
fn default_interpreter_binary() -> String {
|
||||
"/bin/sh".to_string()
|
||||
String::new()
|
||||
}
|
||||
|
||||
impl Default for InterpreterConfig {
|
||||
|
||||
@@ -144,10 +144,7 @@ impl<'a> PackComponentLoader<'a> {
|
||||
let runtime_ref = match data.get("ref").and_then(|v| v.as_str()) {
|
||||
Some(r) => r.to_string(),
|
||||
None => {
|
||||
let msg = format!(
|
||||
"Runtime YAML {} missing 'ref' field, skipping",
|
||||
filename
|
||||
);
|
||||
let msg = format!("Runtime YAML {} missing 'ref' field, skipping", filename);
|
||||
warn!("{}", msg);
|
||||
result.warnings.push(msg);
|
||||
continue;
|
||||
@@ -155,9 +152,7 @@ impl<'a> PackComponentLoader<'a> {
|
||||
};
|
||||
|
||||
// Check if runtime already exists
|
||||
if let Some(existing) =
|
||||
RuntimeRepository::find_by_ref(self.pool, &runtime_ref).await?
|
||||
{
|
||||
if let Some(existing) = RuntimeRepository::find_by_ref(self.pool, &runtime_ref).await? {
|
||||
info!(
|
||||
"Runtime '{}' already exists (ID: {}), skipping",
|
||||
runtime_ref, existing.id
|
||||
@@ -204,10 +199,7 @@ impl<'a> PackComponentLoader<'a> {
|
||||
|
||||
match RuntimeRepository::create(self.pool, input).await {
|
||||
Ok(rt) => {
|
||||
info!(
|
||||
"Created runtime '{}' (ID: {})",
|
||||
runtime_ref, rt.id
|
||||
);
|
||||
info!("Created runtime '{}' (ID: {})", runtime_ref, rt.id);
|
||||
result.runtimes_loaded += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -509,15 +501,19 @@ impl<'a> PackComponentLoader<'a> {
|
||||
self.pack_ref
|
||||
);
|
||||
|
||||
// Resolve sensor runtime
|
||||
let sensor_runtime_id = self.resolve_runtime_id("builtin").await?;
|
||||
let sensor_runtime_ref = "core.builtin".to_string();
|
||||
|
||||
for (filename, content) in &yaml_files {
|
||||
let data: serde_yaml_ng::Value = serde_yaml_ng::from_str(content).map_err(|e| {
|
||||
Error::validation(format!("Failed to parse sensor YAML {}: {}", filename, e))
|
||||
})?;
|
||||
|
||||
// Resolve sensor runtime from YAML runner_type field.
|
||||
// Defaults to "native" if not specified (compiled binary, no interpreter).
|
||||
let runner_type = data
|
||||
.get("runner_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("native");
|
||||
let (sensor_runtime_id, sensor_runtime_ref) = self.resolve_runtime(runner_type).await?;
|
||||
|
||||
let sensor_ref = match data.get("ref").and_then(|v| v.as_str()) {
|
||||
Some(r) => r.to_string(),
|
||||
None => {
|
||||
@@ -581,7 +577,7 @@ impl<'a> PackComponentLoader<'a> {
|
||||
label,
|
||||
description,
|
||||
entrypoint,
|
||||
runtime: sensor_runtime_id.unwrap_or(0),
|
||||
runtime: sensor_runtime_id,
|
||||
runtime_ref: sensor_runtime_ref.clone(),
|
||||
trigger: trigger_id.unwrap_or(0),
|
||||
trigger_ref: trigger_ref.unwrap_or_default(),
|
||||
@@ -606,7 +602,7 @@ impl<'a> PackComponentLoader<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve a runtime ID from a runner type string (e.g., "shell", "python", "builtin").
|
||||
/// Resolve a runtime ID from a runner type string (e.g., "shell", "python", "native").
|
||||
///
|
||||
/// Looks up the runtime in the database by `core.{name}` ref pattern,
|
||||
/// then falls back to name-based lookup (case-insensitive).
|
||||
@@ -614,8 +610,20 @@ impl<'a> PackComponentLoader<'a> {
|
||||
/// - "shell" -> "core.shell"
|
||||
/// - "python" -> "core.python"
|
||||
/// - "node" -> "core.nodejs"
|
||||
/// - "builtin" -> "core.builtin"
|
||||
/// - "native" -> "core.native"
|
||||
async fn resolve_runtime_id(&self, runner_type: &str) -> Result<Option<Id>> {
|
||||
let (id, _ref) = self.resolve_runtime(runner_type).await?;
|
||||
if id == 0 {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a runner_type string to a (runtime_id, runtime_ref) pair.
|
||||
///
|
||||
/// Returns `(0, "unknown")` when no matching runtime is found.
|
||||
async fn resolve_runtime(&self, runner_type: &str) -> Result<(Id, String)> {
|
||||
let runner_lower = runner_type.to_lowercase();
|
||||
|
||||
// Runtime refs use the format `{pack_ref}.{name}` (e.g., "core.python").
|
||||
@@ -623,28 +631,27 @@ impl<'a> PackComponentLoader<'a> {
|
||||
"shell" | "bash" | "sh" => vec!["core.shell"],
|
||||
"python" | "python3" => vec!["core.python"],
|
||||
"node" | "nodejs" | "node.js" => vec!["core.nodejs"],
|
||||
"native" => vec!["core.native"],
|
||||
"builtin" => vec!["core.builtin"],
|
||||
"native" | "builtin" | "standalone" => vec!["core.native"],
|
||||
other => vec![other],
|
||||
};
|
||||
|
||||
for runtime_ref in &refs_to_try {
|
||||
if let Some(runtime) = RuntimeRepository::find_by_ref(self.pool, runtime_ref).await? {
|
||||
return Ok(Some(runtime.id));
|
||||
return Ok((runtime.id, runtime.r#ref));
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to name-based lookup (case-insensitive)
|
||||
use crate::repositories::runtime::RuntimeRepository as RR;
|
||||
if let Some(runtime) = RR::find_by_name(self.pool, &runner_lower).await? {
|
||||
return Ok(Some(runtime.id));
|
||||
return Ok((runtime.id, runtime.r#ref));
|
||||
}
|
||||
|
||||
warn!(
|
||||
"Could not find runtime for runner_type '{}', action will have no runtime",
|
||||
"Could not find runtime for runner_type '{}', component will have no runtime",
|
||||
runner_type
|
||||
);
|
||||
Ok(None)
|
||||
Ok((0, "unknown".to_string()))
|
||||
}
|
||||
|
||||
/// Resolve the trigger reference and ID for a sensor.
|
||||
|
||||
@@ -146,7 +146,7 @@ impl RuntimeDetector {
|
||||
|
||||
/// Verify if a runtime is available on this system
|
||||
pub async fn verify_runtime_available(runtime: &Runtime) -> bool {
|
||||
// Check if runtime is always available (e.g., shell, native, builtin)
|
||||
// Check if runtime is always available (e.g., shell, native)
|
||||
if let Some(verification) = runtime.distributions.get("verification") {
|
||||
if let Some(always_available) = verification.get("always_available") {
|
||||
if always_available.as_bool() == Some(true) {
|
||||
|
||||
@@ -264,7 +264,7 @@ mod tests {
|
||||
assert!(RefValidator::validate_runtime_ref("core.python").is_ok());
|
||||
assert!(RefValidator::validate_runtime_ref("core.shell").is_ok());
|
||||
assert!(RefValidator::validate_runtime_ref("mypack.nodejs").is_ok());
|
||||
assert!(RefValidator::validate_runtime_ref("core.builtin").is_ok());
|
||||
assert!(RefValidator::validate_runtime_ref("core.native").is_ok());
|
||||
|
||||
// Invalid formats
|
||||
assert!(RefValidator::validate_runtime_ref("core.action.webhook").is_err()); // 3-part no longer valid
|
||||
|
||||
@@ -17,6 +17,7 @@ use attune_common::{
|
||||
},
|
||||
repositories::{
|
||||
event::{CreateEnforcementInput, EnforcementRepository, EventRepository},
|
||||
pack::PackRepository,
|
||||
rule::RuleRepository,
|
||||
Create, FindById, List,
|
||||
},
|
||||
@@ -191,7 +192,7 @@ impl EventProcessor {
|
||||
.unwrap_or_else(|| serde_json::Map::new());
|
||||
|
||||
// Resolve action parameters using the template resolver
|
||||
let resolved_params = Self::resolve_action_params(rule, event, &payload)?;
|
||||
let resolved_params = Self::resolve_action_params(pool, rule, event, &payload).await?;
|
||||
|
||||
let create_input = CreateEnforcementInput {
|
||||
rule: Some(rule.id),
|
||||
@@ -354,7 +355,8 @@ impl EventProcessor {
|
||||
/// Replaces `{{ event.payload.* }}`, `{{ event.id }}`, `{{ event.trigger }}`,
|
||||
/// `{{ event.created }}`, `{{ pack.config.* }}`, and `{{ system.* }}` references
|
||||
/// in the rule's `action_params` with values from the event and context.
|
||||
fn resolve_action_params(
|
||||
async fn resolve_action_params(
|
||||
pool: &PgPool,
|
||||
rule: &Rule,
|
||||
event: &Event,
|
||||
event_payload: &serde_json::Value,
|
||||
@@ -366,11 +368,26 @@ impl EventProcessor {
|
||||
return Ok(serde_json::Map::new());
|
||||
}
|
||||
|
||||
// Load pack config from database for pack.config.* resolution
|
||||
let pack_config = match PackRepository::find_by_id(pool, rule.pack).await {
|
||||
Ok(Some(pack)) => pack.config,
|
||||
Ok(None) => {
|
||||
warn!(
|
||||
"Pack {} not found for rule {} — pack.config.* templates will resolve to null",
|
||||
rule.pack, rule.r#ref
|
||||
);
|
||||
serde_json::json!({})
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to load pack {} for rule {}: {} — pack.config.* templates will resolve to null", rule.pack, rule.r#ref, e);
|
||||
serde_json::json!({})
|
||||
}
|
||||
};
|
||||
|
||||
// Build template context from the event
|
||||
let context = TemplateContext::new(
|
||||
event_payload.clone(),
|
||||
// TODO: Load pack config from database for pack.config.* resolution
|
||||
serde_json::json!({}),
|
||||
pack_config,
|
||||
serde_json::json!({
|
||||
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||
"rule": {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use attune_common::models::{Id, Sensor, Trigger};
|
||||
use attune_common::repositories::{FindById, List};
|
||||
use attune_common::repositories::{FindById, List, RuntimeRepository};
|
||||
|
||||
use sqlx::{PgPool, Row};
|
||||
use std::collections::HashMap;
|
||||
@@ -38,6 +38,7 @@ struct SensorManagerInner {
|
||||
sensors: Arc<RwLock<HashMap<Id, SensorInstance>>>,
|
||||
running: Arc<RwLock<bool>>,
|
||||
packs_base_dir: String,
|
||||
runtime_envs_dir: String,
|
||||
api_client: ApiClient,
|
||||
api_url: String,
|
||||
mq_url: String,
|
||||
@@ -58,6 +59,10 @@ impl SensorManager {
|
||||
let mq_url = std::env::var("ATTUNE_MQ_URL")
|
||||
.unwrap_or_else(|_| "amqp://guest:guest@localhost:5672".to_string());
|
||||
|
||||
let runtime_envs_dir = std::env::var("ATTUNE_RUNTIME_ENVS_DIR")
|
||||
.or_else(|_| std::env::var("ATTUNE__RUNTIME_ENVS_DIR"))
|
||||
.unwrap_or_else(|_| "/opt/attune/runtime_envs".to_string());
|
||||
|
||||
// Create API client for token provisioning (no admin token - uses internal endpoint)
|
||||
let api_client = ApiClient::new(api_url.clone(), None);
|
||||
|
||||
@@ -67,6 +72,7 @@ impl SensorManager {
|
||||
sensors: Arc::new(RwLock::new(HashMap::new())),
|
||||
running: Arc::new(RwLock::new(false)),
|
||||
packs_base_dir,
|
||||
runtime_envs_dir,
|
||||
api_client,
|
||||
api_url,
|
||||
mq_url,
|
||||
@@ -212,9 +218,45 @@ impl SensorManager {
|
||||
self.inner.packs_base_dir, pack_ref, sensor.entrypoint
|
||||
);
|
||||
|
||||
info!(
|
||||
"TRACE: Before fetching trigger instances for sensor {}",
|
||||
// Load the runtime to determine how to execute the sensor
|
||||
let runtime = RuntimeRepository::find_by_id(&self.inner.db, sensor.runtime)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"Runtime {} not found for sensor {}",
|
||||
sensor.runtime,
|
||||
sensor.r#ref
|
||||
)
|
||||
})?;
|
||||
|
||||
let exec_config = runtime.parsed_execution_config();
|
||||
let rt_name = runtime.name.to_lowercase();
|
||||
|
||||
// Resolve the interpreter: check for a virtualenv/node_modules first,
|
||||
// then fall back to the system interpreter.
|
||||
let pack_dir = std::path::PathBuf::from(&self.inner.packs_base_dir).join(pack_ref);
|
||||
let env_dir = std::path::PathBuf::from(&self.inner.runtime_envs_dir)
|
||||
.join(pack_ref)
|
||||
.join(&rt_name);
|
||||
let env_dir_opt = if env_dir.exists() {
|
||||
Some(env_dir.as_path())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Determine whether we need an interpreter or can execute directly.
|
||||
// Determine native vs interpreted purely from the runtime's execution_config.
|
||||
// A native runtime (e.g., core.native) has no interpreter configured —
|
||||
// its binary field is empty. Interpreted runtimes (Python, Node, etc.)
|
||||
// declare their interpreter binary explicitly in execution_config.
|
||||
let interpreter_binary = &exec_config.interpreter.binary;
|
||||
let is_native = interpreter_binary.is_empty()
|
||||
|| interpreter_binary == "native"
|
||||
|| interpreter_binary == "none";
|
||||
|
||||
info!(
|
||||
"Sensor {} runtime={} interpreter={} native={}",
|
||||
sensor.r#ref, rt_name, interpreter_binary, is_native
|
||||
);
|
||||
info!("Starting standalone sensor process: {}", sensor_script);
|
||||
|
||||
@@ -245,9 +287,30 @@ impl SensorManager {
|
||||
.map_err(|e| anyhow!("Failed to serialize trigger instances: {}", e))?;
|
||||
info!("Trigger instances JSON: {}", trigger_instances_json);
|
||||
|
||||
// Build the command: use the interpreter for non-native runtimes,
|
||||
// execute the script directly for native binaries.
|
||||
let mut cmd = if is_native {
|
||||
Command::new(&sensor_script)
|
||||
} else {
|
||||
let resolved_interpreter =
|
||||
exec_config.resolve_interpreter_with_env(&pack_dir, env_dir_opt);
|
||||
info!(
|
||||
"Using interpreter {} for sensor {}",
|
||||
resolved_interpreter.display(),
|
||||
sensor.r#ref
|
||||
);
|
||||
let mut c = Command::new(resolved_interpreter);
|
||||
// Pass any extra interpreter args (e.g., -u for unbuffered Python)
|
||||
for arg in &exec_config.interpreter.args {
|
||||
c.arg(arg);
|
||||
}
|
||||
c.arg(&sensor_script);
|
||||
c
|
||||
};
|
||||
|
||||
// Start the standalone sensor with token and configuration
|
||||
// Pass sensor ref (e.g., "core.interval_timer_sensor") for proper identification
|
||||
let mut child = Command::new(&sensor_script)
|
||||
let mut child = cmd
|
||||
.env("ATTUNE_API_URL", &self.inner.api_url)
|
||||
.env("ATTUNE_API_TOKEN", &token_response.token)
|
||||
.env("ATTUNE_SENSOR_ID", &sensor.id.to_string())
|
||||
|
||||
@@ -434,12 +434,18 @@ async fn process_runtime_for_pack(
|
||||
///
|
||||
/// Returns `None` if the variable is not set (meaning all runtimes are accepted).
|
||||
pub fn runtime_filter_from_env() -> Option<Vec<String>> {
|
||||
std::env::var("ATTUNE_WORKER_RUNTIMES").ok().map(|val| {
|
||||
std::env::var("ATTUNE_WORKER_RUNTIMES")
|
||||
.ok()
|
||||
.map(|val| parse_runtime_filter(&val))
|
||||
}
|
||||
|
||||
/// Parse a comma-separated runtime filter string into a list of lowercase runtime names.
|
||||
/// Empty entries are filtered out.
|
||||
fn parse_runtime_filter(val: &str) -> Vec<String> {
|
||||
val.split(',')
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -447,26 +453,21 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_runtime_filter_from_env_not_set() {
|
||||
// When ATTUNE_WORKER_RUNTIMES is not set, filter should be None
|
||||
std::env::remove_var("ATTUNE_WORKER_RUNTIMES");
|
||||
assert!(runtime_filter_from_env().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_runtime_filter_from_env_set() {
|
||||
std::env::set_var("ATTUNE_WORKER_RUNTIMES", "shell,Python, Node");
|
||||
let filter = runtime_filter_from_env().unwrap();
|
||||
fn test_parse_runtime_filter_values() {
|
||||
let filter = parse_runtime_filter("shell,Python, Node");
|
||||
assert_eq!(filter, vec!["shell", "python", "node"]);
|
||||
std::env::remove_var("ATTUNE_WORKER_RUNTIMES");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_runtime_filter_from_env_empty() {
|
||||
std::env::set_var("ATTUNE_WORKER_RUNTIMES", "");
|
||||
let filter = runtime_filter_from_env().unwrap();
|
||||
fn test_parse_runtime_filter_empty() {
|
||||
let filter = parse_runtime_filter("");
|
||||
assert!(filter.is_empty());
|
||||
std::env::remove_var("ATTUNE_WORKER_RUNTIMES");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_runtime_filter_whitespace() {
|
||||
let filter = parse_runtime_filter(" shell , , python ");
|
||||
assert_eq!(filter, vec!["shell", "python"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -55,14 +55,22 @@ pub async fn execute_streaming(
|
||||
let stdin_write_error = if let Some(mut stdin) = child.stdin.take() {
|
||||
let mut error = None;
|
||||
|
||||
// Write parameters first if using stdin delivery
|
||||
// Write parameters first if using stdin delivery.
|
||||
// Skip empty/trivial content ("{}","","[]") to avoid polluting stdin
|
||||
// before secrets — scripts that read secrets via readline() expect
|
||||
// the secrets JSON as the first line.
|
||||
let has_real_params = parameters_stdin
|
||||
.map(|s| !matches!(s.trim(), "" | "{}" | "[]"))
|
||||
.unwrap_or(false);
|
||||
if let Some(params_data) = parameters_stdin {
|
||||
if has_real_params {
|
||||
if let Err(e) = stdin.write_all(params_data.as_bytes()).await {
|
||||
error = Some(format!("Failed to write parameters to stdin: {}", e));
|
||||
} else if let Err(e) = stdin.write_all(b"\n---ATTUNE_PARAMS_END---\n").await {
|
||||
error = Some(format!("Failed to write parameter delimiter: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write secrets as JSON (always, for backward compatibility)
|
||||
if error.is_none() && !secrets.is_empty() {
|
||||
|
||||
@@ -8,6 +8,7 @@ use super::{
|
||||
RuntimeResult,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use std::time::Instant;
|
||||
@@ -16,6 +17,15 @@ use tokio::process::Command;
|
||||
use tokio::time::timeout;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Escape a string for embedding inside a bash single-quoted string.
|
||||
///
|
||||
/// In single-quoted strings the only problematic character is `'` itself.
|
||||
/// We close the current single-quote, insert an escaped single-quote, and
|
||||
/// reopen: `'foo'\''bar'` → `foo'bar`.
|
||||
fn bash_single_quote_escape(s: &str) -> String {
|
||||
s.replace('\'', "'\\''")
|
||||
}
|
||||
|
||||
/// Shell runtime for executing shell scripts and commands
|
||||
pub struct ShellRuntime {
|
||||
/// Shell interpreter path (bash, sh, zsh, etc.)
|
||||
@@ -75,14 +85,22 @@ impl ShellRuntime {
|
||||
let stdin_write_error = if let Some(mut stdin) = child.stdin.take() {
|
||||
let mut error = None;
|
||||
|
||||
// Write parameters first if using stdin delivery
|
||||
// Write parameters first if using stdin delivery.
|
||||
// Skip empty/trivial content ("{}","","[]") to avoid polluting stdin
|
||||
// before secrets — scripts that read secrets via readline() expect
|
||||
// the secrets JSON as the first line.
|
||||
let has_real_params = parameters_stdin
|
||||
.map(|s| !matches!(s.trim(), "" | "{}" | "[]"))
|
||||
.unwrap_or(false);
|
||||
if let Some(params_data) = parameters_stdin {
|
||||
if has_real_params {
|
||||
if let Err(e) = stdin.write_all(params_data.as_bytes()).await {
|
||||
error = Some(format!("Failed to write parameters to stdin: {}", e));
|
||||
} else if let Err(e) = stdin.write_all(b"\n---ATTUNE_PARAMS_END---\n").await {
|
||||
error = Some(format!("Failed to write parameter delimiter: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write secrets as JSON (always, for backward compatibility)
|
||||
if error.is_none() && !secrets.is_empty() {
|
||||
@@ -300,7 +318,12 @@ impl ShellRuntime {
|
||||
})
|
||||
}
|
||||
|
||||
/// Generate shell wrapper script that injects parameters as environment variables
|
||||
/// Generate shell wrapper script that injects parameters and secrets directly.
|
||||
///
|
||||
/// Secrets are embedded as bash associative-array entries at generation time
|
||||
/// so the wrapper has **zero external runtime dependencies** (no Python, jq,
|
||||
/// etc.). The generated script is written to a temp file by the caller so
|
||||
/// that secrets never appear in `/proc/<pid>/cmdline`.
|
||||
fn generate_wrapper_script(&self, context: &ExecutionContext) -> RuntimeResult<String> {
|
||||
let mut script = String::new();
|
||||
|
||||
@@ -308,25 +331,19 @@ impl ShellRuntime {
|
||||
script.push_str("#!/bin/bash\n");
|
||||
script.push_str("set -e\n\n"); // Exit on error
|
||||
|
||||
// Read secrets from stdin and store in associative array
|
||||
script.push_str("# Read secrets from stdin (passed securely, not via environment)\n");
|
||||
// Populate secrets associative array directly from Rust — no stdin
|
||||
// reading, no JSON parsing, no external interpreters.
|
||||
script.push_str("# Secrets (injected at generation time, not via environment)\n");
|
||||
script.push_str("declare -A ATTUNE_SECRETS\n");
|
||||
script.push_str("read -r ATTUNE_SECRETS_JSON\n");
|
||||
script.push_str("if [ -n \"$ATTUNE_SECRETS_JSON\" ]; then\n");
|
||||
script.push_str(" # Parse JSON secrets using Python (always available)\n");
|
||||
script.push_str(" eval \"$(echo \"$ATTUNE_SECRETS_JSON\" | python3 -c \"\n");
|
||||
script.push_str("import sys, json\n");
|
||||
script.push_str("try:\n");
|
||||
script.push_str(" secrets = json.load(sys.stdin)\n");
|
||||
script.push_str(" for key, value in secrets.items():\n");
|
||||
script.push_str(" # Escape single quotes in value\n");
|
||||
script.push_str(
|
||||
" safe_value = value.replace(\\\"'\\\", \\\"'\\\\\\\\\\\\\\\\'\\\") \n",
|
||||
);
|
||||
script.push_str(" print(f\\\"ATTUNE_SECRETS['{key}']='{safe_value}'\\\")\n");
|
||||
script.push_str("except: pass\n");
|
||||
script.push_str("\")\"\n");
|
||||
script.push_str("fi\n\n");
|
||||
for (key, value) in &context.secrets {
|
||||
let escaped_key = bash_single_quote_escape(key);
|
||||
let escaped_val = bash_single_quote_escape(value);
|
||||
script.push_str(&format!(
|
||||
"ATTUNE_SECRETS['{}']='{}'\n",
|
||||
escaped_key, escaped_val
|
||||
));
|
||||
}
|
||||
script.push('\n');
|
||||
|
||||
// Helper function to get secrets
|
||||
script.push_str("# Helper function to access secrets\n");
|
||||
@@ -344,16 +361,17 @@ impl ShellRuntime {
|
||||
serde_json::Value::Bool(b) => b.to_string(),
|
||||
_ => serde_json::to_string(value)?,
|
||||
};
|
||||
let escaped = bash_single_quote_escape(&value_str);
|
||||
// Export with PARAM_ prefix for consistency
|
||||
script.push_str(&format!(
|
||||
"export PARAM_{}='{}'\n",
|
||||
key.to_uppercase(),
|
||||
value_str
|
||||
escaped
|
||||
));
|
||||
// Also export without prefix for easier shell script writing
|
||||
script.push_str(&format!("export {}='{}'\n", key, value_str));
|
||||
script.push_str(&format!("export {}='{}'\n", key, escaped));
|
||||
}
|
||||
script.push_str("\n");
|
||||
script.push('\n');
|
||||
|
||||
// Add the action code
|
||||
script.push_str("# Action code\n");
|
||||
@@ -364,44 +382,6 @@ impl ShellRuntime {
|
||||
Ok(script)
|
||||
}
|
||||
|
||||
/// Execute shell script directly
|
||||
async fn execute_shell_code(
|
||||
&self,
|
||||
code: String,
|
||||
secrets: &std::collections::HashMap<String, String>,
|
||||
env: &std::collections::HashMap<String, String>,
|
||||
parameters_stdin: Option<&str>,
|
||||
timeout_secs: Option<u64>,
|
||||
max_stdout_bytes: usize,
|
||||
max_stderr_bytes: usize,
|
||||
output_format: OutputFormat,
|
||||
) -> RuntimeResult<ExecutionResult> {
|
||||
debug!(
|
||||
"Executing shell script with {} secrets (passed via stdin)",
|
||||
secrets.len()
|
||||
);
|
||||
|
||||
// Build command
|
||||
let mut cmd = Command::new(&self.shell_path);
|
||||
cmd.arg("-c").arg(&code);
|
||||
|
||||
// Add environment variables
|
||||
for (key, value) in env {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
|
||||
self.execute_with_streaming(
|
||||
cmd,
|
||||
secrets,
|
||||
parameters_stdin,
|
||||
timeout_secs,
|
||||
max_stdout_bytes,
|
||||
max_stderr_bytes,
|
||||
output_format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Execute shell script from file
|
||||
async fn execute_shell_file(
|
||||
&self,
|
||||
@@ -520,19 +500,42 @@ impl Runtime for ShellRuntime {
|
||||
.await;
|
||||
}
|
||||
|
||||
// Otherwise, generate wrapper script and execute
|
||||
// Otherwise, generate wrapper script and execute.
|
||||
// Secrets and parameters are embedded directly in the wrapper script
|
||||
// by generate_wrapper_script(), so we write it to a temp file (to keep
|
||||
// secrets out of /proc/cmdline) and pass no secrets/params via stdin.
|
||||
let script = self.generate_wrapper_script(&context)?;
|
||||
self.execute_shell_code(
|
||||
script,
|
||||
&context.secrets,
|
||||
|
||||
// Write wrapper to a temp file so secrets are not exposed in the
|
||||
// process command line (which would happen with `bash -c "..."`).
|
||||
let wrapper_dir = self.work_dir.join("wrappers");
|
||||
tokio::fs::create_dir_all(&wrapper_dir).await.map_err(|e| {
|
||||
RuntimeError::ExecutionFailed(format!("Failed to create wrapper directory: {}", e))
|
||||
})?;
|
||||
let wrapper_path = wrapper_dir.join(format!("wrapper_{}.sh", context.execution_id));
|
||||
tokio::fs::write(&wrapper_path, &script)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
RuntimeError::ExecutionFailed(format!("Failed to write wrapper script: {}", e))
|
||||
})?;
|
||||
|
||||
let result = self
|
||||
.execute_shell_file(
|
||||
wrapper_path.clone(),
|
||||
&HashMap::new(), // secrets are in the script, not stdin
|
||||
&env,
|
||||
parameters_stdin,
|
||||
None,
|
||||
context.timeout,
|
||||
context.max_stdout_bytes,
|
||||
context.max_stderr_bytes,
|
||||
context.output_format,
|
||||
)
|
||||
.await
|
||||
.await;
|
||||
|
||||
// Clean up wrapper file (best-effort)
|
||||
let _ = tokio::fs::remove_file(&wrapper_path).await;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn setup(&self) -> RuntimeResult<()> {
|
||||
@@ -716,7 +719,6 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Pre-existing failure - secrets not being passed correctly"]
|
||||
async fn test_shell_runtime_with_secrets() {
|
||||
let runtime = ShellRuntime::new();
|
||||
|
||||
|
||||
@@ -157,8 +157,8 @@ impl WorkerService {
|
||||
// Load runtimes from the database and create ProcessRuntime instances.
|
||||
// Each runtime row's `execution_config` JSONB drives how the ProcessRuntime
|
||||
// invokes interpreters, manages environments, and installs dependencies.
|
||||
// We skip runtimes with empty execution_config (e.g., the built-in sensor
|
||||
// runtime) since they have no interpreter and cannot execute as a process.
|
||||
// We skip runtimes with empty execution_config (e.g., core.native) since
|
||||
// they execute binaries directly and don't need a ProcessRuntime wrapper.
|
||||
match RuntimeRepository::list(&pool).await {
|
||||
Ok(db_runtimes) => {
|
||||
let executable_runtimes: Vec<_> = db_runtimes
|
||||
|
||||
@@ -90,7 +90,7 @@ services:
|
||||
# Initialize builtin packs
|
||||
# Copies pack files to shared volume and loads them into database
|
||||
init-packs:
|
||||
image: python:3.11-alpine
|
||||
image: python:3.11-slim
|
||||
container_name: attune-init-packs
|
||||
volumes:
|
||||
- ./packs:/source/packs:ro
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/bin/sh
|
||||
# Initialize builtin packs for Attune
|
||||
# This script copies pack files to the shared volume and registers them in the database
|
||||
# Designed to run on python:3.11-slim (Debian-based) image
|
||||
|
||||
set -e
|
||||
|
||||
@@ -32,20 +33,9 @@ echo -e "${BLUE}║ Attune Builtin Packs Initialization ║${NC}"
|
||||
echo -e "${BLUE}╚════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
# Install system dependencies
|
||||
echo -e "${YELLOW}→${NC} Installing system dependencies..."
|
||||
apk add --no-cache postgresql-client > /dev/null 2>&1
|
||||
if [ $? -eq 0 ]; then
|
||||
echo -e "${GREEN}✓${NC} System dependencies installed"
|
||||
else
|
||||
echo -e "${RED}✗${NC} Failed to install system dependencies"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install Python dependencies
|
||||
echo -e "${YELLOW}→${NC} Installing Python dependencies..."
|
||||
pip install --quiet --no-cache-dir psycopg2-binary pyyaml 2>/dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
if pip install --quiet --no-cache-dir psycopg2-binary pyyaml; then
|
||||
echo -e "${GREEN}✓${NC} Python dependencies installed"
|
||||
else
|
||||
echo -e "${RED}✗${NC} Failed to install Python dependencies"
|
||||
@@ -53,10 +43,17 @@ else
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Wait for database to be ready
|
||||
# Wait for database to be ready (using Python instead of psql to avoid needing postgresql-client)
|
||||
echo -e "${YELLOW}→${NC} Waiting for database to be ready..."
|
||||
export PGPASSWORD="$DB_PASSWORD"
|
||||
until psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c '\q' 2>/dev/null; do
|
||||
until python3 -c "
|
||||
import psycopg2, sys
|
||||
try:
|
||||
conn = psycopg2.connect(host='$DB_HOST', port=$DB_PORT, user='$DB_USER', password='$DB_PASSWORD', dbname='$DB_NAME', connect_timeout=3)
|
||||
conn.close()
|
||||
sys.exit(0)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
" 2>/dev/null; do
|
||||
echo -e "${YELLOW} ...${NC} Database is unavailable - sleeping"
|
||||
sleep 2
|
||||
done
|
||||
@@ -111,8 +108,7 @@ for pack_dir in "$SOURCE_PACKS_DIR"/*; do
|
||||
if [ -d "$target_pack_dir" ]; then
|
||||
# Pack exists, update files to ensure we have latest (especially binaries)
|
||||
echo -e "${YELLOW} ⟳${NC} Pack exists at: $target_pack_dir, updating files..."
|
||||
cp -rf "$pack_dir"/* "$target_pack_dir"/
|
||||
if [ $? -eq 0 ]; then
|
||||
if cp -rf "$pack_dir"/* "$target_pack_dir"/; then
|
||||
echo -e "${GREEN} ✓${NC} Updated pack files at: $target_pack_dir"
|
||||
else
|
||||
echo -e "${RED} ✗${NC} Failed to update pack"
|
||||
@@ -121,9 +117,7 @@ for pack_dir in "$SOURCE_PACKS_DIR"/*; do
|
||||
else
|
||||
# Copy pack to target directory
|
||||
echo -e "${YELLOW} →${NC} Copying pack files..."
|
||||
cp -r "$pack_dir" "$target_pack_dir"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
if cp -r "$pack_dir" "$target_pack_dir"; then
|
||||
COPIED_COUNT=$((COPIED_COUNT + 1))
|
||||
echo -e "${GREEN} ✓${NC} Copied to: $target_pack_dir"
|
||||
else
|
||||
|
||||
@@ -18,8 +18,7 @@ Each runtime YAML file contains only the fields that are stored in the database:
|
||||
- **python.yaml** - Python 3 runtime for actions and sensors
|
||||
- **nodejs.yaml** - Node.js runtime for JavaScript-based actions and sensors
|
||||
- **shell.yaml** - Shell (bash/sh) runtime - always available
|
||||
- **native.yaml** - Native compiled runtime (Rust, Go, C, etc.) - always available
|
||||
- **sensor_builtin.yaml** - Built-in sensor runtime for native Attune sensors
|
||||
- **native.yaml** - Native compiled runtime (Rust, Go, C, etc.) - executes binaries directly without an interpreter
|
||||
|
||||
## Loading
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
ref: core.native
|
||||
pack_ref: core
|
||||
name: Native
|
||||
description: Native compiled runtime (Rust, Go, C, etc.) - always available
|
||||
description: Native compiled runtime (Rust, Go, C, etc.) - executes binaries directly without an interpreter
|
||||
|
||||
distributions:
|
||||
verification:
|
||||
@@ -17,9 +17,4 @@ installation:
|
||||
build_required: false
|
||||
system_native: true
|
||||
|
||||
execution_config:
|
||||
interpreter:
|
||||
binary: "/bin/sh"
|
||||
args:
|
||||
- "-c"
|
||||
file_extension: null
|
||||
execution_config: {}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
ref: core.builtin
|
||||
pack_ref: core
|
||||
name: Builtin
|
||||
description: Built-in sensor runtime for native Attune sensors (timers, webhooks, etc.)
|
||||
|
||||
distributions:
|
||||
verification:
|
||||
always_available: true
|
||||
check_required: false
|
||||
type: builtin
|
||||
|
||||
installation:
|
||||
method: builtin
|
||||
included_with_service: true
|
||||
@@ -7,7 +7,7 @@ description: "Built-in sensor that monitors time and fires timer triggers (inter
|
||||
enabled: true
|
||||
|
||||
# Sensor runner type
|
||||
runner_type: standalone
|
||||
runner_type: native
|
||||
|
||||
# Entry point for sensor execution
|
||||
entry_point: attune-core-timer-sensor
|
||||
|
||||
@@ -442,12 +442,20 @@ class PackLoader:
|
||||
sensor_ids = {}
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
# Look up sensor runtime from already-loaded runtimes
|
||||
sensor_runtime_id = runtime_ids.get("builtin") or runtime_ids.get(
|
||||
"core.builtin"
|
||||
)
|
||||
if not sensor_runtime_id:
|
||||
print(" ⚠ No sensor runtime found, sensors will have no runtime")
|
||||
# Runtime name mapping: runner_type values to core runtime refs
|
||||
runner_type_to_ref = {
|
||||
"native": "core.native",
|
||||
"standalone": "core.native",
|
||||
"builtin": "core.native",
|
||||
"shell": "core.shell",
|
||||
"bash": "core.shell",
|
||||
"sh": "core.shell",
|
||||
"python": "core.python",
|
||||
"python3": "core.python",
|
||||
"node": "core.nodejs",
|
||||
"nodejs": "core.nodejs",
|
||||
"node.js": "core.nodejs",
|
||||
}
|
||||
|
||||
for yaml_file in sorted(sensors_dir.glob("*.yaml")):
|
||||
sensor_data = self.load_yaml(yaml_file)
|
||||
@@ -483,6 +491,20 @@ class PackLoader:
|
||||
trigger_ref = f"{self.pack_ref}.{first_trigger}"
|
||||
trigger_id = trigger_ids.get(trigger_ref)
|
||||
|
||||
# Resolve sensor runtime from YAML runner_type field
|
||||
# Defaults to "native" (compiled binary, no interpreter)
|
||||
runner_type = sensor_data.get("runner_type", "native").lower()
|
||||
runtime_ref = runner_type_to_ref.get(runner_type, runner_type)
|
||||
# Look up runtime ID: try the mapped ref, then the raw runner_type
|
||||
sensor_runtime_id = runtime_ids.get(runtime_ref)
|
||||
if not sensor_runtime_id:
|
||||
# Try looking up by the short name (e.g., "python" key in runtime_ids)
|
||||
sensor_runtime_id = runtime_ids.get(runner_type)
|
||||
if not sensor_runtime_id:
|
||||
print(
|
||||
f" ⚠ No runtime found for runner_type '{runner_type}' (ref: {runtime_ref}), sensor will have no runtime"
|
||||
)
|
||||
|
||||
# Determine entrypoint
|
||||
entry_point = sensor_data.get("entry_point", "")
|
||||
if not entry_point:
|
||||
@@ -521,7 +543,7 @@ class PackLoader:
|
||||
description,
|
||||
entry_point,
|
||||
sensor_runtime_id,
|
||||
"core.builtin",
|
||||
runtime_ref,
|
||||
trigger_id,
|
||||
trigger_ref,
|
||||
enabled,
|
||||
|
||||
@@ -48,21 +48,29 @@ BEGIN
|
||||
updated = NOW()
|
||||
RETURNING id INTO v_action_runtime_id;
|
||||
|
||||
-- Create built-in runtime for sensors (no execution_config = not executable by worker)
|
||||
INSERT INTO attune.runtime (ref, pack, pack_ref, name, description, distributions)
|
||||
-- Use the native runtime for sensors that are compiled binaries
|
||||
SELECT id INTO v_sensor_runtime_id
|
||||
FROM attune.runtime
|
||||
WHERE ref = 'core.native';
|
||||
|
||||
-- If core.native doesn't exist yet (shouldn't happen), create it
|
||||
IF v_sensor_runtime_id IS NULL THEN
|
||||
INSERT INTO attune.runtime (ref, pack, pack_ref, name, description, distributions, execution_config)
|
||||
VALUES (
|
||||
'core.builtin',
|
||||
'core.native',
|
||||
v_pack_id,
|
||||
'core',
|
||||
'Builtin',
|
||||
'Built-in sensor runtime for native Attune sensors (timers, webhooks, etc.)',
|
||||
'{"verification": {"always_available": true, "check_required": false}, "type": "builtin"}'::jsonb
|
||||
'Native',
|
||||
'Native compiled runtime (Rust, Go, C, etc.) - executes binaries directly without an interpreter',
|
||||
'{"verification": {"always_available": true, "check_required": false}}'::jsonb,
|
||||
'{}'::jsonb
|
||||
)
|
||||
ON CONFLICT (ref) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
description = EXCLUDED.description,
|
||||
updated = NOW()
|
||||
RETURNING id INTO v_sensor_runtime_id;
|
||||
END IF;
|
||||
|
||||
-- Create generic timer triggers (these define trigger types, not instances)
|
||||
|
||||
@@ -366,9 +374,9 @@ BEGIN
|
||||
'core',
|
||||
'10 Second Timer Sensor',
|
||||
'Timer sensor that fires every 10 seconds',
|
||||
'builtin:interval_timer',
|
||||
'attune-core-timer-sensor',
|
||||
v_sensor_runtime_id,
|
||||
'core.builtin',
|
||||
'core.native',
|
||||
v_intervaltimer_id,
|
||||
'core.intervaltimer',
|
||||
true,
|
||||
|
||||
14
systemctl status postgresql.service
Normal file
14
systemctl status postgresql.service
Normal file
@@ -0,0 +1,14 @@
|
||||
○ valkey.service - Advanced key-value store
|
||||
Loaded: loaded (]8;;file://hp-probook-cachy/usr/lib/systemd/system/valkey.service\/usr/lib/systemd/system/valkey.service]8;;\; [0;1;38:5:185mdisabled[0m; preset: [0;1;38:5:185mdisabled[0m)
|
||||
Active: inactive (dead)
|
||||
|
||||
Feb 10 20:42:44 hp-probook-cachy systemd[1]: Started Advanced key-value store.
|
||||
Feb 19 13:50:06 hp-probook-cachy valkey-server[1154]: 1154:signal-handler (1771530606) Received SIGTERM scheduling shutdown...
|
||||
Feb 19 13:50:06 hp-probook-cachy systemd[1]: Stopping Advanced key-value store...
|
||||
Feb 19 13:50:06 hp-probook-cachy valkey-server[1154]: 1154:M 19 Feb 2026 13:50:06.871 * User requested shutdown...
|
||||
Feb 19 13:50:06 hp-probook-cachy valkey-server[1154]: 1154:M 19 Feb 2026 13:50:06.871 * Saving the final RDB snapshot before exiting.
|
||||
Feb 19 13:50:06 hp-probook-cachy valkey-server[1154]: 1154:M 19 Feb 2026 13:50:06.874 * DB saved on disk
|
||||
Feb 19 13:50:06 hp-probook-cachy valkey-server[1154]: 1154:M 19 Feb 2026 13:50:06.874 # Valkey is now ready to exit, bye bye...
|
||||
Feb 19 13:50:06 hp-probook-cachy systemd[1]: valkey.service: Deactivated successfully.
|
||||
Feb 19 13:50:06 hp-probook-cachy systemd[1]: Stopped Advanced key-value store.
|
||||
Feb 19 13:50:06 hp-probook-cachy systemd[1]: valkey.service: Consumed 3min 58.539s CPU time over 1d 15h 35min 51.539s wall clock time, 13.2M memory peak.
|
||||
@@ -32,12 +32,76 @@ interface ParamSchemaFormProps {
|
||||
errors?: Record<string, string>;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
/**
|
||||
* When true, all inputs render as text fields that accept template expressions
|
||||
* like {{ event.payload.field }}, {{ pack.config.key }}, {{ system.timestamp }}.
|
||||
* Used in rule configuration where parameters may be dynamically resolved
|
||||
* at enforcement time rather than set to literal values.
|
||||
*/
|
||||
allowTemplates?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string value contains a template expression ({{ ... }})
|
||||
*/
|
||||
function isTemplateExpression(value: any): boolean {
|
||||
return typeof value === "string" && /\{\{.*\}\}/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a value for display in a text input.
|
||||
* Non-string values (booleans, numbers, objects, arrays) are JSON-stringified
|
||||
* so the user can edit them as text.
|
||||
*/
|
||||
function valueToString(value: any): string {
|
||||
if (value === undefined || value === null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to parse a text input value back to the appropriate JS type.
|
||||
* Template expressions are always kept as strings.
|
||||
* Plain values are coerced to the schema type when possible.
|
||||
*/
|
||||
function parseTemplateValue(raw: string, type: string): any {
|
||||
if (raw === "") return "";
|
||||
// Template expressions stay as strings - resolved server-side
|
||||
if (isTemplateExpression(raw)) return raw;
|
||||
|
||||
switch (type) {
|
||||
case "boolean":
|
||||
if (raw === "true") return true;
|
||||
if (raw === "false") return false;
|
||||
return raw; // keep as string if not a recognised literal
|
||||
case "number":
|
||||
if (!isNaN(Number(raw))) return parseFloat(raw);
|
||||
return raw;
|
||||
case "integer":
|
||||
if (!isNaN(Number(raw)) && Number.isInteger(Number(raw)))
|
||||
return parseInt(raw, 10);
|
||||
return raw;
|
||||
case "array":
|
||||
case "object":
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
default:
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic form component that renders inputs based on a parameter schema.
|
||||
* Supports standard JSON Schema format with properties and required array.
|
||||
* Supports string, number, integer, boolean, array, object, and enum types.
|
||||
*
|
||||
* When `allowTemplates` is enabled, every field renders as a text input that
|
||||
* accepts Jinja2-style template expressions (e.g. {{ event.payload.x }}).
|
||||
* This is essential for rule configuration, where parameter values may reference
|
||||
* event payloads, pack configs, keys, or system variables.
|
||||
*/
|
||||
export default function ParamSchemaForm({
|
||||
schema,
|
||||
@@ -46,6 +110,7 @@ export default function ParamSchemaForm({
|
||||
errors = {},
|
||||
disabled = false,
|
||||
className = "",
|
||||
allowTemplates = false,
|
||||
}: ParamSchemaFormProps) {
|
||||
const [localErrors, setLocalErrors] = useState<Record<string, string>>({});
|
||||
|
||||
@@ -98,7 +163,71 @@ export default function ParamSchemaForm({
|
||||
};
|
||||
|
||||
/**
|
||||
* Render input field based on parameter type
|
||||
* Get a placeholder hint for template-mode inputs
|
||||
*/
|
||||
const getTemplatePlaceholder = (key: string, param: any): string => {
|
||||
const type = param?.type || "string";
|
||||
switch (type) {
|
||||
case "boolean":
|
||||
return `true, false, or {{ event.payload.${key} }}`;
|
||||
case "number":
|
||||
case "integer":
|
||||
return `${type} value or {{ event.payload.${key} }}`;
|
||||
case "array":
|
||||
return `["a","b"] or {{ event.payload.${key} }}`;
|
||||
case "object":
|
||||
return `{"k":"v"} or {{ event.payload.${key} }}`;
|
||||
default:
|
||||
if (param?.enum && param.enum.length > 0) {
|
||||
const options = param.enum.slice(0, 3).join(", ");
|
||||
const suffix = param.enum.length > 3 ? ", ..." : "";
|
||||
return `${options}${suffix} or {{ event.payload.${key} }}`;
|
||||
}
|
||||
return param?.description || `{{ event.payload.${key} }}`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Render a template-mode text input for any parameter type
|
||||
*/
|
||||
const renderTemplateInput = (key: string, param: any) => {
|
||||
const type = param?.type || "string";
|
||||
const rawValue = values[key] ?? param?.default ?? "";
|
||||
const isDisabled = disabled;
|
||||
const displayValue = valueToString(rawValue);
|
||||
|
||||
// Use a textarea for complex types (array/object) to give more room
|
||||
if (type === "array" || type === "object") {
|
||||
return (
|
||||
<textarea
|
||||
value={displayValue}
|
||||
onChange={(e) =>
|
||||
handleInputChange(key, parseTemplateValue(e.target.value, type))
|
||||
}
|
||||
disabled={isDisabled}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
placeholder={getTemplatePlaceholder(key, param)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={displayValue}
|
||||
onChange={(e) =>
|
||||
handleInputChange(key, parseTemplateValue(e.target.value, type))
|
||||
}
|
||||
disabled={isDisabled}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
placeholder={getTemplatePlaceholder(key, param)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Render input field based on parameter type (standard mode)
|
||||
*/
|
||||
const renderInput = (key: string, param: any) => {
|
||||
const type = param?.type || "string";
|
||||
@@ -249,6 +378,38 @@ export default function ParamSchemaForm({
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Render type hint badge and additional context for template-mode fields
|
||||
*/
|
||||
const renderTemplateHints = (_key: string, param: any) => {
|
||||
const type = param?.type || "string";
|
||||
const hints: string[] = [];
|
||||
|
||||
if (type === "boolean") {
|
||||
hints.push("Accepts: true, false, or a template expression");
|
||||
} else if (type === "number" || type === "integer") {
|
||||
const parts = [`Accepts: ${type} value`];
|
||||
if (param?.minimum !== undefined) parts.push(`min: ${param.minimum}`);
|
||||
if (param?.maximum !== undefined) parts.push(`max: ${param.maximum}`);
|
||||
hints.push(parts.join(", ") + ", or a template expression");
|
||||
} else if (param?.enum && param.enum.length > 0) {
|
||||
hints.push(`Options: ${param.enum.join(", ")}`);
|
||||
hints.push("Also accepts a template expression");
|
||||
}
|
||||
|
||||
if (hints.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{hints.map((hint, i) => (
|
||||
<p key={i} className="text-xs text-gray-500">
|
||||
{hint}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const paramEntries = Object.entries(properties);
|
||||
|
||||
if (paramEntries.length === 0) {
|
||||
@@ -261,6 +422,26 @@ export default function ParamSchemaForm({
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{allowTemplates && (
|
||||
<div className="px-3 py-2 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<p className="text-xs text-amber-800">
|
||||
<span className="font-semibold">Template expressions</span> are
|
||||
supported. Use{" "}
|
||||
<code className="px-1 py-0.5 bg-amber-100 rounded text-[11px]">
|
||||
{"{{ event.payload.field }}"}
|
||||
</code>
|
||||
,{" "}
|
||||
<code className="px-1 py-0.5 bg-amber-100 rounded text-[11px]">
|
||||
{"{{ pack.config.key }}"}
|
||||
</code>
|
||||
, or{" "}
|
||||
<code className="px-1 py-0.5 bg-amber-100 rounded text-[11px]">
|
||||
{"{{ system.timestamp }}"}
|
||||
</code>{" "}
|
||||
to dynamically resolve values when the rule fires.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{paramEntries.map(([key, param]) => (
|
||||
<div key={key}>
|
||||
<label className="block mb-2">
|
||||
@@ -283,8 +464,19 @@ export default function ParamSchemaForm({
|
||||
{param?.description && param?.type !== "boolean" && (
|
||||
<p className="text-xs text-gray-600 mb-2">{param.description}</p>
|
||||
)}
|
||||
{/* For boolean in template mode, show description since there's no checkbox label */}
|
||||
{param?.description &&
|
||||
param?.type === "boolean" &&
|
||||
allowTemplates && (
|
||||
<p className="text-xs text-gray-600 mb-2">
|
||||
{param.description}
|
||||
</p>
|
||||
)}
|
||||
</label>
|
||||
{renderInput(key, param)}
|
||||
{allowTemplates
|
||||
? renderTemplateInput(key, param)
|
||||
: renderInput(key, param)}
|
||||
{allowTemplates && renderTemplateHints(key, param)}
|
||||
{allErrors[key] && (
|
||||
<p className="text-xs text-red-600 mt-1">{allErrors[key]}</p>
|
||||
)}
|
||||
@@ -302,12 +494,16 @@ export default function ParamSchemaForm({
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to validate parameter values against a schema
|
||||
* Supports standard JSON Schema format
|
||||
* Utility function to validate parameter values against a schema.
|
||||
* Supports standard JSON Schema format.
|
||||
*
|
||||
* When `allowTemplates` is true, template expressions ({{ ... }}) are
|
||||
* accepted for any field type and skip type-specific validation.
|
||||
*/
|
||||
export function validateParamSchema(
|
||||
schema: ParamSchema,
|
||||
values: Record<string, any>,
|
||||
allowTemplates: boolean = false,
|
||||
): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
const properties = schema.properties || {};
|
||||
@@ -333,12 +529,23 @@ export function validateParamSchema(
|
||||
return;
|
||||
}
|
||||
|
||||
// Template expressions are always valid in template mode
|
||||
if (allowTemplates && isTemplateExpression(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const type = param?.type || "string";
|
||||
|
||||
switch (type) {
|
||||
case "number":
|
||||
case "integer":
|
||||
if (typeof value !== "number" && isNaN(Number(value))) {
|
||||
if (allowTemplates) {
|
||||
// In template mode, non-numeric strings that aren't templates
|
||||
// are still allowed — the user might be mid-edit or using a
|
||||
// non-standard expression format. Only warn on submission.
|
||||
break;
|
||||
}
|
||||
errors[key] = `Must be a valid ${type}`;
|
||||
} else {
|
||||
const numValue = typeof value === "number" ? value : Number(value);
|
||||
@@ -351,8 +558,23 @@ export function validateParamSchema(
|
||||
}
|
||||
break;
|
||||
|
||||
case "boolean":
|
||||
// In template mode, string values like "true"/"false" are fine
|
||||
if (
|
||||
allowTemplates &&
|
||||
typeof value === "string" &&
|
||||
(value === "true" || value === "false")
|
||||
) {
|
||||
break;
|
||||
}
|
||||
break;
|
||||
|
||||
case "array":
|
||||
if (!Array.isArray(value)) {
|
||||
if (allowTemplates && typeof value === "string") {
|
||||
// In template mode, strings are acceptable (could be template or JSON)
|
||||
break;
|
||||
}
|
||||
try {
|
||||
JSON.parse(value);
|
||||
} catch {
|
||||
@@ -363,6 +585,9 @@ export function validateParamSchema(
|
||||
|
||||
case "object":
|
||||
if (typeof value !== "object" || Array.isArray(value)) {
|
||||
if (allowTemplates && typeof value === "string") {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
@@ -392,8 +617,9 @@ export function validateParamSchema(
|
||||
break;
|
||||
}
|
||||
|
||||
// Enum validation
|
||||
if (param?.enum && param.enum.length > 0) {
|
||||
// Enum validation — skip in template mode (value may be a template expression
|
||||
// or a string that will be resolved at runtime)
|
||||
if (!allowTemplates && param?.enum && param.enum.length > 0) {
|
||||
if (!param.enum.includes(value)) {
|
||||
errors[key] = `Must be one of: ${param.enum.join(", ")}`;
|
||||
}
|
||||
|
||||
@@ -144,17 +144,19 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate trigger parameters
|
||||
// Validate trigger parameters (allow templates in rule context)
|
||||
const triggerErrors = validateParamSchema(
|
||||
triggerParamSchema,
|
||||
triggerParameters,
|
||||
true,
|
||||
);
|
||||
setTriggerParamErrors(triggerErrors);
|
||||
|
||||
// Validate action parameters
|
||||
// Validate action parameters (allow templates in rule context)
|
||||
const actionErrors = validateParamSchema(
|
||||
actionParamSchema,
|
||||
actionParameters,
|
||||
true,
|
||||
);
|
||||
setActionParamErrors(actionErrors);
|
||||
|
||||
@@ -428,6 +430,7 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
|
||||
values={triggerParameters}
|
||||
onChange={setTriggerParameters}
|
||||
errors={triggerParamErrors}
|
||||
allowTemplates
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -517,6 +520,7 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
|
||||
values={actionParameters}
|
||||
onChange={setActionParameters}
|
||||
errors={actionParamErrors}
|
||||
allowTemplates
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,51 +1,5 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
// Temporary types until API client is regenerated
|
||||
interface PackTestResult {
|
||||
pack_ref: string;
|
||||
pack_version: string;
|
||||
execution_time: string;
|
||||
status: string;
|
||||
total_tests: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
pass_rate: number;
|
||||
duration_ms: number;
|
||||
test_suites: any[];
|
||||
}
|
||||
|
||||
interface PackTestExecution {
|
||||
id: number;
|
||||
pack_id: number;
|
||||
pack_version: string;
|
||||
execution_time: string;
|
||||
trigger_reason: string;
|
||||
total_tests: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
pass_rate: number;
|
||||
duration_ms: number;
|
||||
result: PackTestResult;
|
||||
created: string;
|
||||
}
|
||||
|
||||
interface PackTestHistoryResponse {
|
||||
data: {
|
||||
items: PackTestExecution[];
|
||||
meta: {
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_items: number;
|
||||
total_pages: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface PackTestLatestResponse {
|
||||
data: PackTestExecution | null;
|
||||
}
|
||||
import { PacksService, ApiError } from "@/api";
|
||||
|
||||
// Fetch test history for a pack
|
||||
export function usePackTestHistory(
|
||||
@@ -54,27 +8,12 @@ export function usePackTestHistory(
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["pack-tests", packRef, params],
|
||||
queryFn: async (): Promise<PackTestHistoryResponse> => {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.page) queryParams.append("page", params.page.toString());
|
||||
if (params?.pageSize)
|
||||
queryParams.append("page_size", params.pageSize.toString());
|
||||
|
||||
const token = localStorage.getItem("access_token");
|
||||
const response = await fetch(
|
||||
`http://localhost:8080/api/v1/packs/${packRef}/tests?${queryParams}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch test history: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
queryFn: async () => {
|
||||
return PacksService.getPackTestHistory({
|
||||
ref: packRef,
|
||||
page: params?.page,
|
||||
pageSize: params?.pageSize,
|
||||
});
|
||||
},
|
||||
enabled: !!packRef,
|
||||
staleTime: 30000, // 30 seconds
|
||||
@@ -85,25 +24,15 @@ export function usePackTestHistory(
|
||||
export function usePackLatestTest(packRef: string) {
|
||||
return useQuery({
|
||||
queryKey: ["pack-tests", packRef, "latest"],
|
||||
queryFn: async (): Promise<PackTestLatestResponse> => {
|
||||
const token = localStorage.getItem("access_token");
|
||||
const response = await fetch(
|
||||
`http://localhost:8080/api/v1/packs/${packRef}/tests/latest`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await PacksService.getPackLatestTest({ ref: packRef });
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
return { data: null };
|
||||
}
|
||||
throw new Error(`Failed to fetch latest test: ${response.statusText}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
enabled: !!packRef,
|
||||
staleTime: 30000,
|
||||
@@ -115,27 +44,8 @@ export function useExecutePackTests() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (packRef: string): Promise<{ data: PackTestResult }> => {
|
||||
const token = localStorage.getItem("access_token");
|
||||
const response = await fetch(
|
||||
`http://localhost:8080/api/v1/packs/${packRef}/test`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
error.error || `Failed to execute tests: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
mutationFn: async (packRef: string) => {
|
||||
return PacksService.testPack({ ref: packRef });
|
||||
},
|
||||
onSuccess: (_, packRef) => {
|
||||
// Invalidate test history and latest test queries
|
||||
@@ -157,38 +67,14 @@ export function useRegisterPack() {
|
||||
path: string;
|
||||
force?: boolean;
|
||||
skipTests?: boolean;
|
||||
}): Promise<{
|
||||
data: {
|
||||
pack: any;
|
||||
test_result: PackTestResult | null;
|
||||
tests_skipped: boolean;
|
||||
};
|
||||
}> => {
|
||||
const token = localStorage.getItem("access_token");
|
||||
const response = await fetch(
|
||||
"http://localhost:8080/api/v1/packs/register",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
}) => {
|
||||
return PacksService.registerPack({
|
||||
requestBody: {
|
||||
path,
|
||||
force,
|
||||
skip_tests: skipTests,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
error.error || `Failed to register pack: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
// Invalidate packs list and test queries
|
||||
@@ -219,40 +105,16 @@ export function useInstallPack() {
|
||||
force?: boolean;
|
||||
skipTests?: boolean;
|
||||
skipDeps?: boolean;
|
||||
}): Promise<{
|
||||
data: {
|
||||
pack: any;
|
||||
test_result: PackTestResult | null;
|
||||
tests_skipped: boolean;
|
||||
};
|
||||
}> => {
|
||||
const token = localStorage.getItem("access_token");
|
||||
const response = await fetch(
|
||||
"http://localhost:8080/api/v1/packs/install",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
}) => {
|
||||
return PacksService.installPack({
|
||||
requestBody: {
|
||||
source,
|
||||
ref_spec: refSpec,
|
||||
force,
|
||||
skip_tests: skipTests,
|
||||
skip_deps: skipDeps,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
error.error || `Failed to install pack: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
// Invalidate packs list and test queries
|
||||
|
||||
@@ -6,6 +6,30 @@ import axios, {
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "";
|
||||
|
||||
// A bare axios instance with NO interceptors, used exclusively for token refresh
|
||||
// requests. This prevents infinite loops when the refresh endpoint returns 401.
|
||||
const refreshClient = axios.create({
|
||||
baseURL: API_BASE_URL || undefined,
|
||||
timeout: 10000,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
function getRefreshUrl(): string {
|
||||
return API_BASE_URL ? `${API_BASE_URL}/auth/refresh` : "/auth/refresh";
|
||||
}
|
||||
|
||||
// Clear auth state and redirect to the login page.
|
||||
function clearSessionAndRedirect(): void {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
|
||||
const currentPath = window.location.pathname;
|
||||
if (currentPath !== "/login") {
|
||||
sessionStorage.setItem("redirect_after_login", currentPath);
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
|
||||
// Create axios instance
|
||||
export const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
@@ -37,7 +61,7 @@ apiClient.interceptors.response.use(
|
||||
_retry?: boolean;
|
||||
};
|
||||
|
||||
// Handle 401 Unauthorized - token expired or invalid
|
||||
// Handle 401 Unauthorized — token expired or invalid
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
@@ -48,11 +72,8 @@ apiClient.interceptors.response.use(
|
||||
throw new Error("No refresh token available");
|
||||
}
|
||||
|
||||
// Attempt token refresh
|
||||
const refreshUrl = API_BASE_URL
|
||||
? `${API_BASE_URL}/auth/refresh`
|
||||
: "/auth/refresh";
|
||||
const response = await axios.post(refreshUrl, {
|
||||
// Use the bare refreshClient (no interceptors) to avoid infinite loops
|
||||
const response = await refreshClient.post(getRefreshUrl(), {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
@@ -75,23 +96,13 @@ apiClient.interceptors.response.use(
|
||||
console.error(
|
||||
"Token refresh failed, clearing session and redirecting to login",
|
||||
);
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
|
||||
// Store the current path so we can redirect back after login
|
||||
const currentPath = window.location.pathname;
|
||||
if (currentPath !== "/login") {
|
||||
sessionStorage.setItem("redirect_after_login", currentPath);
|
||||
}
|
||||
|
||||
window.location.href = "/login";
|
||||
clearSessionAndRedirect();
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle 403 Forbidden - valid token but insufficient permissions
|
||||
if (error.response?.status === 403) {
|
||||
// Enhance error message to distinguish from 401
|
||||
const enhancedError = error as AxiosError & {
|
||||
isAuthorizationError?: boolean;
|
||||
};
|
||||
|
||||
@@ -13,8 +13,39 @@ import axios from "axios";
|
||||
* Strategy:
|
||||
* Since the generated API client creates its own axios instances, we configure
|
||||
* axios defaults globally and ensure the OpenAPI client uses our configured instance.
|
||||
*
|
||||
* IMPORTANT: All refresh calls use `refreshClient` — a bare axios instance with
|
||||
* NO interceptors — to prevent infinite 401 retry loops when the refresh token
|
||||
* itself is expired or invalid.
|
||||
*/
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "";
|
||||
|
||||
// A bare axios instance with NO interceptors, used exclusively for token refresh
|
||||
// requests. This prevents infinite loops when the refresh endpoint returns 401.
|
||||
const refreshClient = axios.create({
|
||||
baseURL: API_BASE_URL || undefined,
|
||||
timeout: 10000,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
function getRefreshUrl(): string {
|
||||
return API_BASE_URL ? `${API_BASE_URL}/auth/refresh` : "/auth/refresh";
|
||||
}
|
||||
|
||||
// Clear auth state and redirect to the login page.
|
||||
// Safe to call multiple times — only the first redirect takes effect.
|
||||
function clearSessionAndRedirect(): void {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
|
||||
const currentPath = window.location.pathname;
|
||||
if (currentPath !== "/login") {
|
||||
sessionStorage.setItem("redirect_after_login", currentPath);
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to decode JWT and check if it's expired or about to expire
|
||||
export function isTokenExpiringSoon(
|
||||
token: string,
|
||||
@@ -59,6 +90,39 @@ export function isTokenExpired(token: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt to refresh the access token using the refresh token.
|
||||
// Returns true on success, false on failure.
|
||||
// On failure, clears session and redirects to login.
|
||||
async function attemptTokenRefresh(): Promise<boolean> {
|
||||
const currentRefreshToken = localStorage.getItem("refresh_token");
|
||||
if (!currentRefreshToken) {
|
||||
console.warn("No refresh token available, redirecting to login");
|
||||
clearSessionAndRedirect();
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await refreshClient.post(getRefreshUrl(), {
|
||||
refresh_token: currentRefreshToken,
|
||||
});
|
||||
|
||||
const { access_token, refresh_token: newRefreshToken } = response.data.data;
|
||||
|
||||
localStorage.setItem("access_token", access_token);
|
||||
if (newRefreshToken) {
|
||||
localStorage.setItem("refresh_token", newRefreshToken);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Token refresh failed, clearing session and redirecting to login",
|
||||
);
|
||||
clearSessionAndRedirect();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to proactively refresh token if needed
|
||||
export async function ensureValidToken(): Promise<void> {
|
||||
const token = localStorage.getItem("access_token");
|
||||
@@ -70,30 +134,7 @@ export async function ensureValidToken(): Promise<void> {
|
||||
|
||||
// Check if token is expiring soon (within 5 minutes)
|
||||
if (isTokenExpiringSoon(token, 300)) {
|
||||
try {
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "";
|
||||
const refreshUrl = API_BASE_URL
|
||||
? `${API_BASE_URL}/auth/refresh`
|
||||
: "/auth/refresh";
|
||||
|
||||
// Use base axios to avoid circular refresh attempts
|
||||
const response = await axios.post(refreshUrl, {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
const { access_token, refresh_token: newRefreshToken } =
|
||||
response.data.data;
|
||||
|
||||
localStorage.setItem("access_token", access_token);
|
||||
if (newRefreshToken) {
|
||||
localStorage.setItem("refresh_token", newRefreshToken);
|
||||
}
|
||||
|
||||
// Token proactively refreshed
|
||||
} catch (error) {
|
||||
console.error("Proactive token refresh failed:", error);
|
||||
// Don't throw - let the interceptor handle it on the next request
|
||||
}
|
||||
await attemptTokenRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,8 +146,6 @@ export function startTokenRefreshMonitor(): void {
|
||||
return; // Already running
|
||||
}
|
||||
|
||||
// Starting token refresh monitor
|
||||
|
||||
// Check token every 60 seconds
|
||||
tokenCheckInterval = setInterval(async () => {
|
||||
const token = localStorage.getItem("access_token");
|
||||
@@ -121,7 +160,6 @@ export function startTokenRefreshMonitor(): void {
|
||||
|
||||
export function stopTokenRefreshMonitor(): void {
|
||||
if (tokenCheckInterval) {
|
||||
// Stopping token refresh monitor
|
||||
clearInterval(tokenCheckInterval);
|
||||
tokenCheckInterval = null;
|
||||
}
|
||||
@@ -130,7 +168,6 @@ export function stopTokenRefreshMonitor(): void {
|
||||
// Configure axios defaults to apply to all instances
|
||||
export function configureAxiosDefaults(): void {
|
||||
// Set default base URL
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "";
|
||||
if (API_BASE_URL) {
|
||||
axios.defaults.baseURL = API_BASE_URL;
|
||||
}
|
||||
@@ -138,8 +175,7 @@ export function configureAxiosDefaults(): void {
|
||||
// Set default headers
|
||||
axios.defaults.headers.common["Content-Type"] = "application/json";
|
||||
|
||||
// Copy our interceptors to the default axios instance
|
||||
// This ensures that even new axios instances inherit the behavior
|
||||
// Request interceptor — attach JWT to outgoing requests
|
||||
axios.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem("access_token");
|
||||
@@ -153,66 +189,31 @@ export function configureAxiosDefaults(): void {
|
||||
},
|
||||
);
|
||||
|
||||
// Response interceptor — handle 401 with a single refresh attempt
|
||||
axios.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config as any;
|
||||
|
||||
// Handle 401 Unauthorized - token expired or invalid
|
||||
// Handle 401 Unauthorized — token expired or invalid
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const refreshToken = localStorage.getItem("refresh_token");
|
||||
if (!refreshToken) {
|
||||
console.warn("No refresh token available, redirecting to login");
|
||||
throw new Error("No refresh token available");
|
||||
}
|
||||
|
||||
// Access token expired, attempting refresh
|
||||
|
||||
const refreshUrl = API_BASE_URL
|
||||
? `${API_BASE_URL}/auth/refresh`
|
||||
: "/auth/refresh";
|
||||
|
||||
const response = await axios.post(refreshUrl, {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
const { access_token, refresh_token: newRefreshToken } =
|
||||
response.data.data;
|
||||
|
||||
localStorage.setItem("access_token", access_token);
|
||||
if (newRefreshToken) {
|
||||
localStorage.setItem("refresh_token", newRefreshToken);
|
||||
}
|
||||
|
||||
// Token refreshed successfully
|
||||
|
||||
const refreshed = await attemptTokenRefresh();
|
||||
if (refreshed) {
|
||||
// Retry original request with new token
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${access_token}`;
|
||||
const newToken = localStorage.getItem("access_token");
|
||||
if (originalRequest.headers && newToken) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
}
|
||||
return axios(originalRequest);
|
||||
} catch (refreshError) {
|
||||
console.error(
|
||||
"Token refresh failed, clearing session and redirecting to login",
|
||||
);
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
|
||||
// Store the current path for redirect after login
|
||||
const currentPath = window.location.pathname;
|
||||
if (currentPath !== "/login") {
|
||||
sessionStorage.setItem("redirect_after_login", currentPath);
|
||||
}
|
||||
|
||||
window.location.href = "/login";
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
// attemptTokenRefresh already cleared session and redirected
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// Handle 403 Forbidden - valid token but insufficient permissions
|
||||
// Handle 403 Forbidden — valid token but insufficient permissions
|
||||
if (error.response?.status === 403) {
|
||||
const enhancedError = error as any;
|
||||
enhancedError.isAuthorizationError = true;
|
||||
@@ -226,18 +227,9 @@ export function configureAxiosDefaults(): void {
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
// Axios defaults configured with interceptors
|
||||
}
|
||||
|
||||
// Initialize the API wrapper
|
||||
export function initializeApiWrapper(): void {
|
||||
// Initializing API wrapper
|
||||
|
||||
// Configure axios defaults so all instances get the interceptors
|
||||
configureAxiosDefaults();
|
||||
|
||||
// The generated API client will now inherit these interceptors
|
||||
|
||||
// API wrapper initialized
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function PackInstallPage() {
|
||||
result.data.tests_skipped
|
||||
? "Tests were skipped."
|
||||
: result.data.test_result
|
||||
? `Tests ${result.data.test_result.status}: ${result.data.test_result.passed}/${result.data.test_result.total_tests} passed.`
|
||||
? `Tests ${result.data.test_result.status}: ${result.data.test_result.passed}/${result.data.test_result.totalTests} passed.`
|
||||
: ""
|
||||
}`,
|
||||
);
|
||||
@@ -138,8 +138,7 @@ export default function PackInstallPage() {
|
||||
any git server
|
||||
</li>
|
||||
<li>
|
||||
<strong>Archive URL</strong> - Download from .zip or .tar.gz
|
||||
URL
|
||||
<strong>Archive URL</strong> - Download from .zip or .tar.gz URL
|
||||
</li>
|
||||
<li>
|
||||
<strong>Pack Registry</strong> - Install from configured
|
||||
@@ -192,8 +191,7 @@ export default function PackInstallPage() {
|
||||
{/* Source Type Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Installation Source Type{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
Installation Source Type <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<button
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function PackRegisterPage() {
|
||||
result.data.tests_skipped
|
||||
? "Tests were skipped."
|
||||
: result.data.test_result
|
||||
? `Tests ${result.data.test_result.status}: ${result.data.test_result.passed}/${result.data.test_result.total_tests} passed.`
|
||||
? `Tests ${result.data.test_result.status}: ${result.data.test_result.passed}/${result.data.test_result.totalTests} passed.`
|
||||
: ""
|
||||
}`,
|
||||
);
|
||||
|
||||
76
work-summary/2026-02-20-native-runtime-refactor.md
Normal file
76
work-summary/2026-02-20-native-runtime-refactor.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Work Summary: Native Runtime Refactor, Shell Wrapper Cleanup & Stdin Protocol Fixes (2026-02-20)
|
||||
|
||||
## Problem
|
||||
|
||||
Python sensors (e.g., `python_example.counter_sensor`) were being executed by `/bin/sh` instead of `python3`, causing `import: not found` errors. Root cause was two-fold:
|
||||
|
||||
1. **All sensors hardcoded to `core.builtin` runtime** — `PackComponentLoader::load_sensors()` ignored the sensor YAML's `runner_type` field and assigned every sensor the `core.builtin` runtime.
|
||||
2. **`core.builtin` runtime defaulted to `/bin/sh`** — The `InterpreterConfig` default was `/bin/sh`, so runtimes with no `execution_config` (like `core.builtin`) got shell as their interpreter, causing Python scripts to be interpreted as shell.
|
||||
|
||||
Additionally, the shell wrapper script had a hard dependency on `python3` for JSON secret parsing, and pre-existing security test failures were discovered caused by a stdin protocol conflict between parameters and secrets.
|
||||
|
||||
## Changes
|
||||
|
||||
### Architecture: Remove "builtin" runtime concept
|
||||
|
||||
Replaced the separate `core.builtin` runtime with `core.native`. Runtime detection is now purely data-driven via `execution_config` in the runtime table — no special-cased runtime names.
|
||||
|
||||
- **Deleted** `packs/core/runtimes/sensor_builtin.yaml`
|
||||
- **Fixed** `packs/core/runtimes/native.yaml` — removed `/bin/sh -c` interpreter; empty `execution_config` signals direct binary execution
|
||||
- **Changed** `InterpreterConfig` default `binary` from `"/bin/sh"` to `""` (empty = native)
|
||||
- **Updated** `interval_timer_sensor.yaml` — `runner_type: native`
|
||||
|
||||
### Sensor runtime resolution
|
||||
|
||||
- **Fixed** `PackComponentLoader::load_sensors()` — reads `runner_type` from each sensor's YAML definition and resolves to the correct runtime via `resolve_runtime()`. Defaults to `native`.
|
||||
- **Added** `resolve_runtime()` method returning `(Id, String)` — both ID and ref
|
||||
- **Updated** runtime mappings — `builtin`, `standalone` → `core.native`
|
||||
- **Updated** `load_core_pack.py` — per-sensor runtime resolution from YAML instead of hardcoded `core.builtin`
|
||||
- **Updated** `seed_core_pack.sql` — references `core.native` instead of `core.builtin`
|
||||
|
||||
### Shell wrapper: remove Python dependency
|
||||
|
||||
The shell wrapper script (`generate_wrapper_script`) previously used `python3 -c` to parse JSON secrets from stdin into a bash associative array. This created a hard dependency on Python being installed, which violates the principle that core services must operate without supplemental runtimes.
|
||||
|
||||
- **Replaced** runtime JSON parsing with Rust-side secret injection — secrets are now embedded directly as `ATTUNE_SECRETS['key']='value'` entries at script generation time
|
||||
- **Added** `bash_single_quote_escape()` helper for safe bash string embedding
|
||||
- **Changed** wrapper execution from `bash -c <script>` to writing a temp file and executing it, keeping secrets out of `/proc/<pid>/cmdline`
|
||||
- **Removed** unused `execute_shell_code()` method (wrapper now uses `execute_shell_file`)
|
||||
- **Also applied** bash single-quote escaping to parameter values embedded in the wrapper
|
||||
- **Un-ignored** `test_shell_runtime_with_secrets` — it now passes
|
||||
|
||||
### Stdin protocol fixes (pre-existing bugs)
|
||||
|
||||
- **Process executor**: Skip writing empty/trivial parameter content (`{}`, `""`, `[]`) to stdin to avoid breaking scripts that read secrets via `readline()`
|
||||
- **Shell streaming executor**: Same empty-params skip applied
|
||||
- **Worker env tests**: Replaced flaky env-var-manipulating tests with pure parsing tests to eliminate parallel test interference
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `crates/common/src/models.rs` | Default interpreter binary: `"/bin/sh"` → `""` |
|
||||
| `crates/common/src/pack_registry/loader.rs` | Sensors read `runner_type` from YAML; added `resolve_runtime()`; removed `builtin` mapping |
|
||||
| `crates/common/src/runtime_detection.rs` | Comment update |
|
||||
| `crates/common/src/schema.rs` | Test: `core.builtin` → `core.native` |
|
||||
| `crates/sensor/src/sensor_manager.rs` | Updated `is_native` comments |
|
||||
| `crates/worker/src/env_setup.rs` | Fixed flaky env var tests |
|
||||
| `crates/worker/src/runtime/shell.rs` | Rewrote wrapper to embed secrets from Rust (no Python); temp file execution; removed `execute_shell_code`; un-ignored secrets test |
|
||||
| `crates/worker/src/runtime/process_executor.rs` | Skip empty params on stdin |
|
||||
| `crates/worker/src/service.rs` | Comment update |
|
||||
| `packs/core/runtimes/native.yaml` | Removed `/bin/sh` interpreter; empty execution_config |
|
||||
| `packs/core/runtimes/sensor_builtin.yaml` | **Deleted** |
|
||||
| `packs/core/runtimes/README.md` | Removed sensor_builtin reference |
|
||||
| `packs/core/sensors/interval_timer_sensor.yaml` | `runner_type: native` |
|
||||
| `scripts/load_core_pack.py` | Per-sensor runtime resolution from YAML |
|
||||
| `scripts/seed_core_pack.sql` | `core.native` references |
|
||||
| `AGENTS.md` | Updated runtime documentation |
|
||||
|
||||
## Test Results
|
||||
|
||||
- All 7 security tests pass (2 previously failing now fixed)
|
||||
- All 82 worker unit tests pass (1 previously ignored now un-ignored and passing)
|
||||
- All 17 dependency isolation tests pass
|
||||
- All 8 log truncation tests pass
|
||||
- All 145 common unit tests pass
|
||||
- Zero compiler warnings
|
||||
Reference in New Issue
Block a user