1use super::process_io::{
21 read_bounded, ProcessGroup, ScriptDirectory, MAX_STDERR_BYTES, MAX_STDOUT_BYTES,
22};
23use super::types::{
24 parse_script_response, ScriptConfig, ScriptInput, ScriptLanguage, ScriptResponse,
25};
26use anyhow::{Context as AnyhowContext, Result};
27use std::process::Stdio;
28use std::time::Duration;
29use tokio::io::AsyncWriteExt;
30use tokio::process::{Child, Command};
31use tracing::{debug, error, trace, warn};
32
33pub const SCRIPT_TIMEOUT_SECS: u64 = 30;
38
39pub const DEFAULT_SCRIPT_TIMEOUT: Duration = Duration::from_secs(SCRIPT_TIMEOUT_SECS);
45
46const KILL_REAP_TIMEOUT: Duration = Duration::from_secs(5);
48
49pub async fn execute_script_async(
71 config: &ScriptConfig,
72 input: &ScriptInput,
73) -> Result<ScriptResponse> {
74 execute_script_with_timeout_async(config, input, DEFAULT_SCRIPT_TIMEOUT).await
75}
76
77pub async fn execute_script_with_timeout_async(
83 config: &ScriptConfig,
84 input: &ScriptInput,
85 timeout: Duration,
86) -> Result<ScriptResponse> {
87 tokio::time::timeout(timeout, execute_script_inner(config, input, timeout))
88 .await
89 .map_err(|_| {
90 anyhow::anyhow!(
91 "script execution timed out after {:?} (process killed)",
92 timeout
93 )
94 })?
95}
96
97async fn execute_script_inner(
98 config: &ScriptConfig,
99 input: &ScriptInput,
100 timeout: Duration,
101) -> Result<ScriptResponse> {
102 let code = config
104 .source
105 .get_code_async()
106 .await
107 .context("Failed to load script code")?;
108
109 let input_json = serde_json::to_string(&input).context("Failed to serialize input to JSON")?;
111
112 debug!(
113 "Executing {} script for context '{}' (timeout: {}s, input: {} bytes)",
114 config.language.as_str(),
115 input.event_type_id,
116 timeout.as_secs(),
117 input_json.len()
118 );
119
120 if tracing::enabled!(tracing::Level::TRACE) {
121 let input_json_pretty =
122 serde_json::to_string_pretty(&input).unwrap_or_else(|_| input_json.clone());
123 trace!("─────────────────────────────────────────────");
124 trace!("SCRIPT EXECUTION START");
125 trace!("Language: {}", config.language.as_str());
126 trace!("Context: {}", input.event_type_id);
127 trace!("Handles: {:?}", config.handles_contexts);
128 trace!("");
129 trace!("Script code:");
130 trace!("{}", code);
131 trace!("");
132 trace!("Script input (JSON):");
133 trace!("{}", input_json_pretty);
134 trace!("─────────────────────────────────────────────");
135 }
136
137 let (output, stderr) = match config.language {
139 ScriptLanguage::Python => execute_python(&code, &input_json, timeout).await?,
140 ScriptLanguage::JavaScript => execute_javascript(&code, &input_json, timeout).await?,
141 ScriptLanguage::Go => execute_go(&code, &input_json, timeout).await?,
142 ScriptLanguage::Perl => execute_perl(&code, &input_json, timeout).await?,
143 };
144
145 if tracing::enabled!(tracing::Level::TRACE) {
146 trace!("─────────────────────────────────────────────");
147 trace!("SCRIPT EXECUTION COMPLETE");
148 trace!("");
149 trace!("Script stdout:");
150 trace!("{}", output);
151 if !stderr.is_empty() {
152 trace!("");
153 trace!("Script stderr:");
154 trace!("{}", stderr);
155 }
156 trace!("─────────────────────────────────────────────");
157 }
158
159 let response = parse_script_response(&output).with_context(|| {
161 format!(
162 "Failed to parse script response as JSON object with actions array. Output was: {}",
163 output
164 )
165 })?;
166
167 debug!(
168 "Script executed successfully: {} actions",
169 response.actions.len()
170 );
171
172 Ok(response)
173}
174
175pub fn execute_script(config: &ScriptConfig, input: &ScriptInput) -> Result<ScriptResponse> {
185 execute_script_blocking_with_timeout(config, input, DEFAULT_SCRIPT_TIMEOUT)
186}
187
188pub fn execute_script_blocking_with_timeout(
192 config: &ScriptConfig,
193 input: &ScriptInput,
194 timeout: Duration,
195) -> Result<ScriptResponse> {
196 let config = config.clone();
197 let input = input.clone();
198
199 std::thread::Builder::new()
200 .name("netget-script-sync".to_string())
201 .spawn(move || -> Result<ScriptResponse> {
202 let runtime = tokio::runtime::Builder::new_current_thread()
203 .enable_all()
204 .build()
205 .context("Failed to build runtime for blocking script execution")?;
206 runtime.block_on(execute_script_with_timeout_async(&config, &input, timeout))
207 })
208 .context("Failed to spawn thread for blocking script execution")?
209 .join()
210 .map_err(|_| anyhow::anyhow!("Script execution thread panicked"))?
211}
212
213async fn execute_python(
217 code: &str,
218 input_json: &str,
219 timeout: Duration,
220) -> Result<(String, String)> {
221 execute_with_command(
222 ScriptLanguage::Python,
223 "python3",
224 &["-c"],
225 Some(code),
226 input_json,
227 timeout,
228 )
229 .await
230}
231
232async fn execute_javascript(
236 code: &str,
237 input_json: &str,
238 timeout: Duration,
239) -> Result<(String, String)> {
240 let wrapped_code = format!(
243 r#"
244const fs = require('fs');
245const inputJson = fs.readFileSync(0, 'utf-8');
246const input = JSON.parse(inputJson);
247
248// User's script begins here
249(function() {{
250{}
251}})();
252"#,
253 code
254 );
255
256 execute_with_command(
257 ScriptLanguage::JavaScript,
258 "node",
259 &["-e"],
260 Some(&wrapped_code),
261 input_json,
262 timeout,
263 )
264 .await
265}
266
267async fn execute_perl(code: &str, input_json: &str, timeout: Duration) -> Result<(String, String)> {
271 execute_with_command(
272 ScriptLanguage::Perl,
273 "perl",
274 &["-e"],
275 Some(code),
276 input_json,
277 timeout,
278 )
279 .await
280}
281
282async fn execute_go(code: &str, input_json: &str, timeout: Duration) -> Result<(String, String)> {
287 let directory = tokio::task::spawn_blocking(ScriptDirectory::new)
288 .await
289 .context("failed to create Go staging worker")??;
290 let script_path = directory.script_path();
291
292 let wrapped_code = format!(
294 r#"package main
295
296import (
297 "encoding/json"
298 "fmt"
299 "io"
300 "os"
301)
302
303func main() {{
304 // Read JSON input from stdin
305 inputBytes, err := io.ReadAll(os.Stdin)
306 if err != nil {{
307 fmt.Fprintf(os.Stderr, "Error reading stdin: %v\n", err)
308 os.Exit(1)
309 }}
310
311 var input map[string]interface{{}}
312 if err := json.Unmarshal(inputBytes, &input); err != nil {{
313 fmt.Fprintf(os.Stderr, "Error parsing JSON: %v\n", err)
314 os.Exit(1)
315 }}
316
317 // User's script begins here
318 _ = input // Make input available to user code
319 {{
320{}
321 }}
322}}
323"#,
324 code
325 );
326
327 tokio::fs::write(&script_path, wrapped_code.as_bytes())
329 .await
330 .with_context(|| format!("Failed to write Go script to {:?}", script_path))?;
331
332 let script_path_str = script_path.to_string_lossy().to_string();
334 let result = execute_with_command(
335 ScriptLanguage::Go,
336 "go",
337 &["run", &script_path_str],
338 None,
339 input_json,
340 timeout,
341 )
342 .await;
343
344 drop(directory);
346
347 result
348}
349
350async fn execute_with_command(
364 language: ScriptLanguage,
365 command: &str,
366 args: &[&str],
367 code: Option<&str>,
368 input_json: &str,
369 timeout: Duration,
370) -> Result<(String, String)> {
371 let mut builder = Command::new(command);
372 builder.args(args);
373 if let Some(code) = code {
374 builder.arg(code);
375 }
376 builder
377 .stdin(Stdio::piped())
378 .stdout(Stdio::piped())
379 .stderr(Stdio::piped())
380 .kill_on_drop(true);
383
384 ProcessGroup::configure(&mut builder);
385 let mut child = builder
386 .spawn()
387 .map_err(|e| spawn_error(language, command, e))?;
388 let group = ProcessGroup::new(&child)?;
389
390 let mut stdin = child.stdin.take();
391 let stdout = child
392 .stdout
393 .take()
394 .context("Failed to capture script stdout")?;
395 let stderr = child
396 .stderr
397 .take()
398 .context("Failed to capture script stderr")?;
399
400 let input_bytes = input_json.as_bytes().to_vec();
401
402 let write_fut = async move {
407 if let Some(mut handle) = stdin.take() {
408 handle.write_all(&input_bytes).await?;
409 handle.flush().await?;
410 drop(handle);
412 }
413 Ok::<(), std::io::Error>(())
414 };
415 let stdout_fut = read_bounded(stdout, MAX_STDOUT_BYTES);
416 let stderr_fut = read_bounded(stderr, MAX_STDERR_BYTES);
417 let write_fut = async {
420 match write_fut.await {
421 Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
422 result => result,
423 }
424 };
425 let interaction = async { tokio::try_join!(write_fut, stdout_fut, stderr_fut, child.wait()) };
426 let joined = tokio::time::timeout(timeout, interaction).await;
427 let (_, stdout_bytes, stderr_bytes, status) = match joined {
428 Ok(Ok(results)) => results,
429 failed => {
430 group.kill();
431 let _ = child.start_kill();
432 reap(&mut child).await;
433 return match failed {
434 Ok(Err(error)) => Err(error).context("script input/output failed"),
435 Err(_) => Err(anyhow::anyhow!(
436 "{} script execution timed out after {:?} (process killed)",
437 language.as_str(),
438 timeout
439 )),
440 Ok(Ok(_)) => unreachable!(),
441 };
442 }
443 };
444 drop(group);
445
446 let stdout = String::from_utf8(stdout_bytes).context("Script stdout is not valid UTF-8")?;
447 let stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
448
449 if !status.success() {
451 error!(
452 "{} script execution failed with exit code {:?}",
453 language.as_str(),
454 status.code()
455 );
456 error!("Script stderr: {}", stderr);
457 anyhow::bail!(
458 "{} script exited with non-zero status. stderr: {}",
459 language.as_str(),
460 stderr
461 );
462 }
463
464 if !stderr.is_empty() {
466 warn!("Script produced stderr output (but succeeded): {}", stderr);
467 }
468
469 Ok((stdout.trim().to_string(), stderr))
470}
471
472async fn reap(child: &mut Child) {
475 match tokio::time::timeout(KILL_REAP_TIMEOUT, child.wait()).await {
476 Ok(Ok(status)) => {
477 debug!("Killed script process reaped (status: {:?})", status);
478 }
479 Ok(Err(e)) => {
480 warn!("Failed to reap killed script process: {}", e);
481 }
482 Err(_) => {
483 warn!(
484 "Killed script process did not exit within {:?}; it may linger",
485 KILL_REAP_TIMEOUT
486 );
487 }
488 }
489}
490
491fn interpreter_requirement(language: ScriptLanguage) -> (&'static str, &'static str) {
495 match language {
496 ScriptLanguage::Python => (
497 "Python 3",
498 "install Python 3 (e.g. `brew install python3` or `apt install python3`)",
499 ),
500 ScriptLanguage::JavaScript => (
501 "Node.js",
502 "install Node.js (e.g. `brew install node` or `apt install nodejs`)",
503 ),
504 ScriptLanguage::Go => (
505 "the Go toolchain",
506 "install Go (e.g. `brew install go` or see https://go.dev/dl/)",
507 ),
508 ScriptLanguage::Perl => (
509 "Perl",
510 "install Perl (e.g. `brew install perl` or `apt install perl`)",
511 ),
512 }
513}
514
515fn spawn_error(language: ScriptLanguage, command: &str, err: std::io::Error) -> anyhow::Error {
517 let (requirement, remedy) = interpreter_requirement(language);
518
519 if err.kind() == std::io::ErrorKind::NotFound {
520 error!(
521 "Cannot run {} script: `{}` not found on PATH",
522 language.as_str(),
523 command
524 );
525 anyhow::anyhow!(
526 "Interpreter `{command}` was not found on PATH. \
527 The '{lang}' script handler requires {requirement}. \
528 To fix: {remedy}, or switch this event handler to a language whose \
529 interpreter is installed, or to an `llm`/`static` handler.",
530 command = command,
531 lang = language.as_str(),
532 requirement = requirement,
533 remedy = remedy,
534 )
535 } else {
536 anyhow::anyhow!(
537 "Failed to start interpreter `{command}` for the '{lang}' script handler \
538 (requires {requirement}): {err}",
539 command = command,
540 lang = language.as_str(),
541 requirement = requirement,
542 err = err,
543 )
544 }
545}