this is all of the changes now

This commit is contained in:
2026-02-18 18:43:42 -06:00
parent 77cf18c02f
commit a1b9b8d2b1
22 changed files with 619 additions and 535 deletions

View File

@@ -234,7 +234,7 @@ attune rule create \
--pack core \
--trigger core.webhook \
--action core.notify \
--criteria '{"trigger.payload.severity": "critical"}'
--criteria '{"event.payload.severity": "critical"}'
```
### Delete Rule

View File

@@ -17,12 +17,14 @@ pub mod pack_registry;
pub mod repositories;
pub mod runtime_detection;
pub mod schema;
pub mod template_resolver;
pub mod test_executor;
pub mod utils;
pub mod workflow;
// Re-export commonly used types
pub use error::{Error, Result};
pub use template_resolver::{resolve_templates, TemplateContext};
/// Library version
pub const VERSION: &str = env!("CARGO_PKG_VERSION");

View File

@@ -1,27 +1,44 @@
//! Template Resolver
//!
//! Resolves template variables in rule action parameters using context from
//! trigger payloads, pack configuration, and system variables.
//! event payloads, pack configuration, and system variables.
//!
//! Supports template syntax: `{{ source.path.to.value }}`
//!
//! Example:
//! ## Available Template Sources
//!
//! - `event.payload.*` — Fields from the event payload
//! - `event.id` — The event's database ID
//! - `event.trigger` — The trigger ref that generated the event
//! - `event.created` — The event's creation timestamp
//! - `pack.config.*` — Pack configuration values
//! - `system.*` — System-provided variables (timestamp, rule info, etc.)
//!
//! ## Example
//!
//! ```rust
//! use serde_json::json;
//! use attune_sensor::template_resolver::{TemplateContext, resolve_templates};
//! use attune_common::template_resolver::{TemplateContext, resolve_templates};
//!
//! let context = TemplateContext::new(
//! json!({"service": "api-gateway"}),
//! json!({}),
//! json!({}),
//! )
//! .with_event_id(42)
//! .with_event_trigger("core.webhook")
//! .with_event_created("2026-02-05T10:00:00Z");
//!
//! let params = json!({
//! "message": "Error in {{ trigger.payload.service }}"
//! "message": "Error in {{ event.payload.service }}",
//! "trigger": "{{ event.trigger }}",
//! "event_id": "{{ event.id }}"
//! });
//!
//! let context = TemplateContext {
//! trigger_payload: json!({"service": "api-gateway"}),
//! pack_config: json!({}),
//! system_vars: json!({}),
//! };
//!
//! let resolved = resolve_templates(&params, &context).unwrap();
//! assert_eq!(resolved["message"], "Error in api-gateway");
//! assert_eq!(resolved["trigger"], "core.webhook");
//! assert_eq!(resolved["event_id"], 42);
//! ```
use anyhow::Result;
@@ -30,33 +47,71 @@ use serde_json::Value as JsonValue;
use std::sync::LazyLock;
use tracing::{debug, warn};
/// Template context containing all available data sources
/// Template context containing all available data sources for template resolution.
///
/// The context is structured around three namespaces:
/// - `event` — Event data including payload, id, trigger ref, and created timestamp
/// - `pack.config` — Pack configuration values
/// - `system` — System-provided variables
#[derive(Debug, Clone)]
pub struct TemplateContext {
/// Event/trigger payload data
pub trigger_payload: JsonValue,
/// Pack configuration
/// Event data (payload, id, trigger, created) — accessed as `event.*`
pub event: JsonValue,
/// Pack configuration — accessed as `pack.config.*`
pub pack_config: JsonValue,
/// System-provided variables
/// System-provided variables — accessed as `system.*`
pub system_vars: JsonValue,
}
impl TemplateContext {
/// Create a new template context
pub fn new(trigger_payload: JsonValue, pack_config: JsonValue, system_vars: JsonValue) -> Self {
/// Create a new template context with an event payload.
///
/// The payload is nested under `event.payload`. Use builder methods
/// to add event metadata (`with_event_id`, `with_event_trigger`, `with_event_created`).
pub fn new(event_payload: JsonValue, pack_config: JsonValue, system_vars: JsonValue) -> Self {
let event = serde_json::json!({
"payload": event_payload,
});
Self {
trigger_payload,
event,
pack_config,
system_vars,
}
}
/// Get a value from the context using a dotted path
/// Set the event ID in the context (accessible as `{{ event.id }}`).
pub fn with_event_id(mut self, id: i64) -> Self {
if let Some(obj) = self.event.as_object_mut() {
obj.insert("id".to_string(), serde_json::json!(id));
}
self
}
/// Set the trigger ref in the context (accessible as `{{ event.trigger }}`).
pub fn with_event_trigger(mut self, trigger_ref: &str) -> Self {
if let Some(obj) = self.event.as_object_mut() {
obj.insert("trigger".to_string(), serde_json::json!(trigger_ref));
}
self
}
/// Set the event created timestamp in the context (accessible as `{{ event.created }}`).
pub fn with_event_created(mut self, created: &str) -> Self {
if let Some(obj) = self.event.as_object_mut() {
obj.insert("created".to_string(), serde_json::json!(created));
}
self
}
/// Get a value from the context using a dotted path.
///
/// Supports paths like:
/// - `trigger.payload.field`
/// - `pack.config.setting`
/// - `system.timestamp`
/// - `event.payload.field` — event payload data
/// - `event.id` — event ID
/// - `event.trigger` — trigger ref
/// - `event.created` — creation timestamp
/// - `pack.config.setting` — pack configuration
/// - `system.timestamp` — system variables
pub fn get_value(&self, path: &str) -> Option<JsonValue> {
let parts: Vec<&str> = path.split('.').collect();
@@ -64,18 +119,12 @@ impl TemplateContext {
return None;
}
// Determine the root source
let root = match parts[0] {
"trigger" => {
// trigger.payload.* paths
if parts.len() < 2 || parts[1] != "payload" {
warn!(
"Invalid trigger path: {}, expected 'trigger.payload.*'",
path
);
return None;
}
&self.trigger_payload
// Determine the root source and how many path segments to skip
let (root, skip_count) = match parts[0] {
"event" => {
// event.* paths navigate directly into the event JSON object
// e.g. event.id, event.trigger, event.created, event.payload.field
(&self.event, 1)
}
"pack" => {
// pack.config.* paths
@@ -83,22 +132,15 @@ impl TemplateContext {
warn!("Invalid pack path: {}, expected 'pack.config.*'", path);
return None;
}
&self.pack_config
(&self.pack_config, 2)
}
"system" => &self.system_vars,
"system" => (&self.system_vars, 1),
_ => {
warn!("Unknown template source: {}", parts[0]);
return None;
}
};
// Navigate the path (skip the first 2 parts for trigger/pack, 1 for system)
let skip_count = match parts[0] {
"trigger" | "pack" => 2,
"system" => 1,
_ => return None,
};
extract_nested_value(root, &parts[skip_count..])
}
}
@@ -108,7 +150,7 @@ static TEMPLATE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\{\{\s*([^}]+?)\s*\}\}").expect("Failed to compile template regex")
});
/// Resolve all template variables in a JSON value
/// Resolve all template variables in a JSON value.
///
/// Recursively processes objects and arrays, replacing template strings
/// with values from the context.
@@ -132,7 +174,7 @@ pub fn resolve_templates(value: &JsonValue, context: &TemplateContext) -> Result
}
}
/// Resolve templates in a string value
/// Resolve templates in a string value.
///
/// If the string contains a single template that matches the entire string,
/// returns the value with its original type (preserving numbers, booleans, etc).
@@ -192,7 +234,7 @@ fn resolve_string_template(s: &str, context: &TemplateContext) -> Result<JsonVal
Ok(JsonValue::String(result))
}
/// Extract a nested value from JSON using a path
/// Extract a nested value from JSON using a path.
fn extract_nested_value(root: &JsonValue, path: &[&str]) -> Option<JsonValue> {
if path.is_empty() {
return Some(root.clone());
@@ -220,7 +262,7 @@ fn extract_nested_value(root: &JsonValue, path: &[&str]) -> Option<JsonValue> {
Some(current.clone())
}
/// Convert a JSON value to a string for interpolation
/// Convert a JSON value to a string for interpolation.
fn value_to_string(value: &JsonValue) -> String {
match value {
JsonValue::String(s) => s.clone(),
@@ -229,7 +271,7 @@ fn value_to_string(value: &JsonValue) -> String {
JsonValue::Null => String::new(),
JsonValue::Array(_) | JsonValue::Object(_) => {
// For complex types, serialize as JSON
serde_json::to_string(value).unwrap_or_else(|_| String::new())
serde_json::to_string(value).unwrap_or_default()
}
}
}
@@ -240,8 +282,8 @@ mod tests {
use serde_json::json;
fn create_test_context() -> TemplateContext {
TemplateContext {
trigger_payload: json!({
TemplateContext::new(
json!({
"service": "api-gateway",
"message": "Connection timeout",
"severity": "critical",
@@ -253,29 +295,29 @@ mod tests {
},
"tags": ["production", "backend"]
}),
pack_config: json!({
json!({
"api_token": "secret123",
"alert_channel": "#incidents",
"timeout": 30
}),
system_vars: json!({
json!({
"timestamp": "2026-01-17T15:30:00Z",
"rule": {
"id": 42,
"ref": "test.rule"
},
"event": {
"id": 123
}
}),
}
)
.with_event_id(123)
.with_event_trigger("core.error_event")
.with_event_created("2026-01-17T15:30:00Z")
}
#[test]
fn test_simple_string_substitution() {
let context = create_test_context();
let template = json!({
"message": "Hello {{ trigger.payload.service }}"
"message": "Hello {{ event.payload.service }}"
});
let result = resolve_templates(&template, &context).unwrap();
@@ -287,12 +329,12 @@ mod tests {
let context = create_test_context();
// Number
let template = json!({"count": "{{ trigger.payload.count }}"});
let template = json!({"count": "{{ event.payload.count }}"});
let result = resolve_templates(&template, &context).unwrap();
assert_eq!(result["count"], 42);
// Boolean
let template = json!({"enabled": "{{ trigger.payload.enabled }}"});
let template = json!({"enabled": "{{ event.payload.enabled }}"});
let result = resolve_templates(&template, &context).unwrap();
assert_eq!(result["enabled"], true);
}
@@ -301,8 +343,8 @@ mod tests {
fn test_nested_object_access() {
let context = create_test_context();
let template = json!({
"host": "{{ trigger.payload.metadata.host }}",
"port": "{{ trigger.payload.metadata.port }}"
"host": "{{ event.payload.metadata.host }}",
"port": "{{ event.payload.metadata.port }}"
});
let result = resolve_templates(&template, &context).unwrap();
@@ -314,8 +356,8 @@ mod tests {
fn test_array_access() {
let context = create_test_context();
let template = json!({
"first_tag": "{{ trigger.payload.tags.0 }}",
"second_tag": "{{ trigger.payload.tags.1 }}"
"first_tag": "{{ event.payload.tags.0 }}",
"second_tag": "{{ event.payload.tags.1 }}"
});
let result = resolve_templates(&template, &context).unwrap();
@@ -342,20 +384,65 @@ mod tests {
let template = json!({
"timestamp": "{{ system.timestamp }}",
"rule_id": "{{ system.rule.id }}",
"event_id": "{{ system.event.id }}"
});
let result = resolve_templates(&template, &context).unwrap();
assert_eq!(result["timestamp"], "2026-01-17T15:30:00Z");
assert_eq!(result["rule_id"], 42);
}
#[test]
fn test_event_metadata_id() {
let context = create_test_context();
let template = json!({
"event_id": "{{ event.id }}"
});
let result = resolve_templates(&template, &context).unwrap();
assert_eq!(result["event_id"], 123);
}
#[test]
fn test_event_metadata_trigger() {
let context = create_test_context();
let template = json!({
"trigger_ref": "{{ event.trigger }}"
});
let result = resolve_templates(&template, &context).unwrap();
assert_eq!(result["trigger_ref"], "core.error_event");
}
#[test]
fn test_event_metadata_created() {
let context = create_test_context();
let template = json!({
"created_at": "{{ event.created }}"
});
let result = resolve_templates(&template, &context).unwrap();
assert_eq!(result["created_at"], "2026-01-17T15:30:00Z");
}
#[test]
fn test_event_metadata_in_interpolation() {
let context = create_test_context();
let template = json!({
"summary": "Event {{ event.id }} from {{ event.trigger }} at {{ event.created }}"
});
let result = resolve_templates(&template, &context).unwrap();
assert_eq!(
result["summary"],
"Event 123 from core.error_event at 2026-01-17T15:30:00Z"
);
}
#[test]
fn test_missing_value_returns_null() {
let context = create_test_context();
let template = json!({
"missing": "{{ trigger.payload.nonexistent }}"
"missing": "{{ event.payload.nonexistent }}"
});
let result = resolve_templates(&template, &context).unwrap();
@@ -366,7 +453,7 @@ mod tests {
fn test_multiple_templates_in_string() {
let context = create_test_context();
let template = json!({
"message": "Error in {{ trigger.payload.service }}: {{ trigger.payload.message }}"
"message": "Error in {{ event.payload.service }}: {{ event.payload.message }}"
});
let result = resolve_templates(&template, &context).unwrap();
@@ -396,11 +483,11 @@ mod tests {
let context = create_test_context();
let template = json!({
"nested": {
"field1": "{{ trigger.payload.service }}",
"field1": "{{ event.payload.service }}",
"field2": "{{ pack.config.timeout }}"
},
"array": [
"{{ trigger.payload.severity }}",
"{{ event.payload.severity }}",
"static value"
]
});
@@ -414,14 +501,10 @@ mod tests {
#[test]
fn test_empty_template_context() {
let context = TemplateContext {
trigger_payload: json!({}),
pack_config: json!({}),
system_vars: json!({}),
};
let context = TemplateContext::new(json!({}), json!({}), json!({}));
let template = json!({
"message": "{{ trigger.payload.missing }}"
"message": "{{ event.payload.missing }}"
});
let result = resolve_templates(&template, &context).unwrap();
@@ -432,7 +515,7 @@ mod tests {
fn test_whitespace_in_templates() {
let context = create_test_context();
let template = json!({
"message": "{{ trigger.payload.service }}"
"message": "{{ event.payload.service }}"
});
let result = resolve_templates(&template, &context).unwrap();
@@ -444,12 +527,14 @@ mod tests {
let context = create_test_context();
let template = json!({
"channel": "{{ pack.config.alert_channel }}",
"message": "🚨 Error in {{ trigger.payload.service }}: {{ trigger.payload.message }}",
"severity": "{{ trigger.payload.severity }}",
"message": "🚨 Error in {{ event.payload.service }}: {{ event.payload.message }}",
"severity": "{{ event.payload.severity }}",
"details": {
"host": "{{ trigger.payload.metadata.host }}",
"count": "{{ trigger.payload.count }}",
"tags": "{{ trigger.payload.tags }}"
"host": "{{ event.payload.metadata.host }}",
"count": "{{ event.payload.count }}",
"tags": "{{ event.payload.tags }}",
"event_id": "{{ event.id }}",
"trigger": "{{ event.trigger }}"
},
"timestamp": "{{ system.timestamp }}"
});
@@ -463,6 +548,52 @@ mod tests {
assert_eq!(result["severity"], "critical");
assert_eq!(result["details"]["host"], "web-01");
assert_eq!(result["details"]["count"], 42);
assert_eq!(result["details"]["event_id"], 123);
assert_eq!(result["details"]["trigger"], "core.error_event");
assert_eq!(result["timestamp"], "2026-01-17T15:30:00Z");
}
#[test]
fn test_context_without_event_metadata() {
// Context with only a payload — no id, trigger, or created
let context = TemplateContext::new(
json!({"service": "test"}),
json!({}),
json!({}),
);
let template = json!({
"service": "{{ event.payload.service }}",
"id": "{{ event.id }}",
"trigger": "{{ event.trigger }}"
});
let result = resolve_templates(&template, &context).unwrap();
assert_eq!(result["service"], "test");
// Missing metadata returns null
assert!(result["id"].is_null());
assert!(result["trigger"].is_null());
}
#[test]
fn test_unknown_source() {
let context = create_test_context();
let template = json!({
"value": "{{ unknown.field }}"
});
let result = resolve_templates(&template, &context).unwrap();
assert!(result["value"].is_null());
}
#[test]
fn test_invalid_pack_path() {
let context = create_test_context();
let template = json!({
"value": "{{ pack.invalid.field }}"
});
let result = resolve_templates(&template, &context).unwrap();
assert!(result["value"].is_null());
}
}

View File

@@ -20,6 +20,7 @@ use attune_common::{
rule::RuleRepository,
Create, FindById, List,
},
template_resolver::{resolve_templates, TemplateContext},
};
/// Event processor that handles event-to-rule matching
@@ -189,8 +190,8 @@ impl EventProcessor {
.cloned()
.unwrap_or_else(|| serde_json::Map::new());
// Resolve action parameters (simplified - full template resolution would go here)
let resolved_params = Self::resolve_action_params(&rule.action_params, &payload)?;
// Resolve action parameters using the template resolver
let resolved_params = Self::resolve_action_params(rule, event, &payload)?;
let create_input = CreateEnforcementInput {
rule: Some(rule.id),
@@ -240,10 +241,7 @@ impl EventProcessor {
let payload = match &event.payload {
Some(p) => p,
None => {
debug!(
"Event {} has no payload, matching by default",
event.id
);
debug!("Event {} has no payload, matching by default", event.id);
return Ok(true);
}
};
@@ -351,14 +349,43 @@ impl EventProcessor {
Ok(current)
}
/// Resolve action parameters (simplified - full template resolution would go here)
/// Resolve action parameters by applying template variable substitution.
///
/// 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(
action_params: &serde_json::Value,
_payload: &serde_json::Value,
rule: &Rule,
event: &Event,
event_payload: &serde_json::Value,
) -> Result<serde_json::Map<String, serde_json::Value>> {
// For now, just convert to map if it's an object
// Full implementation would do template resolution
if let Some(obj) = action_params.as_object() {
let action_params = &rule.action_params;
// If there are no action params, return empty
if action_params.is_null() || action_params.as_object().map_or(true, |o| o.is_empty()) {
return Ok(serde_json::Map::new());
}
// 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!({}),
serde_json::json!({
"timestamp": chrono::Utc::now().to_rfc3339(),
"rule": {
"id": rule.id,
"ref": rule.r#ref,
},
}),
)
.with_event_id(event.id)
.with_event_trigger(&event.trigger_ref)
.with_event_created(&event.created.to_rfc3339());
let resolved = resolve_templates(action_params, &context)?;
if let Some(obj) = resolved.as_object() {
Ok(obj.clone())
} else {
Ok(serde_json::Map::new())

View File

@@ -8,7 +8,11 @@ pub mod rule_lifecycle_listener;
pub mod sensor_manager;
pub mod sensor_worker_registration;
pub mod service;
pub mod template_resolver;
// Re-export template resolver from common crate
pub mod template_resolver {
pub use attune_common::template_resolver::*;
}
// Re-export commonly used types
pub use rule_lifecycle_listener::RuleLifecycleListener;