working on sensors and rules

This commit is contained in:
2026-02-19 20:37:17 -06:00
parent a1b9b8d2b1
commit f9cfcf8f40
31 changed files with 1316 additions and 586 deletions

View File

@@ -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| {
val.split(',')
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect()
})
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]

View File

@@ -55,12 +55,20 @@ 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 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));
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));
}
}
}

View File

@@ -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,12 +85,20 @@ 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 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));
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));
}
}
}
@@ -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,
&env,
parameters_stdin,
context.timeout,
context.max_stdout_bytes,
context.max_stderr_bytes,
context.output_format,
)
.await
// 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,
None,
context.timeout,
context.max_stdout_bytes,
context.max_stderr_bytes,
context.output_format,
)
.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();

View File

@@ -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