use std::collections::VecDeque;
use std::process::Stdio;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use faucet_core::{FaucetError, Value};
use tokio::io::BufReader;
use tokio::process::{Child, Command};
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use crate::config::SingerSourceConfig;
use crate::message::{SingerMessage, parse_line};
const CHANNEL_CAPACITY: usize = 256;
const STDERR_TAIL_LINES: usize = 20;
const SIGTERM_GRACE: Duration = Duration::from_secs(5);
pub enum Line {
Parsed(SingerMessage),
Malformed(String),
Overlong(usize),
}
pub enum Recv {
Line(Line),
Eof,
IdleTimeout,
}
pub use faucet_common_singer::Redactor;
pub struct TapProcess {
child: Child,
pid: Option<u32>,
rx: mpsc::Receiver<Line>,
stderr_tail: Arc<Mutex<VecDeque<String>>>,
_temp_files: Vec<tempfile::NamedTempFile>,
_reader: JoinHandle<()>,
_stderr: JoinHandle<()>,
}
impl TapProcess {
pub fn spawn(
cfg: &SingerSourceConfig,
start_state: Option<&Value>,
) -> Result<Self, FaucetError> {
let (mut command, temp_files, redactor) = Self::command(cfg, start_state)?;
let child = faucet_common_singer::spawn_command(&mut command);
Self::start(cfg, child, temp_files, redactor)
}
pub async fn spawn_in(
cfg: &SingerSourceConfig,
start_state: Option<&Value>,
calls: &faucet_core::CallContext,
) -> Result<Self, FaucetError> {
let (mut command, temp_files, redactor) = Self::command(cfg, start_state)?;
let child = faucet_common_singer::spawn_command_in(&mut command, calls).await;
Self::start(cfg, child, temp_files, redactor)
}
fn command(
cfg: &SingerSourceConfig,
start_state: Option<&Value>,
) -> Result<(Command, Vec<tempfile::NamedTempFile>, Redactor), FaucetError> {
let redactor = Redactor::from_config(&cfg.tap_config);
let mut temp_files = Vec::new();
let config_path = write_temp("config", &cfg.tap_config)?;
let mut command = Command::new(&cfg.executable);
command.arg("--config").arg(config_path.path());
temp_files.push(config_path);
if let Some(catalog) = &cfg.catalog {
let catalog_path = write_temp("catalog", catalog)?;
command.arg("--catalog").arg(catalog_path.path());
temp_files.push(catalog_path);
}
if let Some(state) = start_state {
let state_path = write_temp("state", state)?;
command.arg("--state").arg(state_path.path());
temp_files.push(state_path);
}
command.args(&cfg.args);
if let Some(env) = cfg.inherit_env.from_process() {
command.env_clear().envs(env);
}
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
tracing::debug!(
executable = %cfg.executable,
stream = %cfg.stream,
"spawning singer tap"
);
Ok((command, temp_files, redactor))
}
fn start(
cfg: &SingerSourceConfig,
child: std::io::Result<Child>,
temp_files: Vec<tempfile::NamedTempFile>,
redactor: Redactor,
) -> Result<Self, FaucetError> {
let mut child = child.map_err(|e| {
FaucetError::Source(format!(
"failed to spawn tap '{}': {}",
redactor.redact(&cfg.executable),
e
))
})?;
let pid = child.id();
let stdout = child
.stdout
.take()
.ok_or_else(|| FaucetError::Source("tap stdout not captured".into()))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| FaucetError::Source("tap stderr not captured".into()))?;
let (tx, rx) = mpsc::channel::<Line>(CHANNEL_CAPACITY);
let policy_reader = {
let tx = tx.clone();
let max_line = cfg.max_line_bytes;
tokio::spawn(async move {
let mut reader = BufReader::new(stdout);
loop {
match faucet_common_singer::read_capped_line(&mut reader, max_line).await {
Ok(faucet_common_singer::CappedLine::TooLong) => {
let _ = tx.send(Line::Overlong(max_line)).await;
break;
}
Ok(faucet_common_singer::CappedLine::Line(line)) => {
let item = match parse_line(&line) {
Ok(msg) => Line::Parsed(msg),
Err(reason) => Line::Malformed(reason),
};
if tx.send(item).await.is_err() {
break; }
}
Ok(faucet_common_singer::CappedLine::Eof) => break,
Err(e) => {
let _ = tx
.send(Line::Malformed(format!("stdout read error: {e}")))
.await;
break;
}
}
}
})
};
drop(tx);
let stderr_tail = Arc::new(Mutex::new(VecDeque::with_capacity(STDERR_TAIL_LINES)));
let stderr_task = {
let stderr_tail = Arc::clone(&stderr_tail);
let redactor = redactor.clone();
tokio::spawn(async move {
let mut reader = BufReader::new(stderr);
while let Ok(Some(line)) = faucet_common_singer::read_log_line(
&mut reader,
faucet_common_singer::MAX_LOG_LINE_BYTES,
)
.await
{
let redacted = redactor.redact(&line);
tracing::debug!(target: "faucet_source_singer::tap", "{redacted}");
let mut tail = stderr_tail.lock().unwrap();
if tail.len() == STDERR_TAIL_LINES {
tail.pop_front();
}
tail.push_back(redacted);
}
})
};
Ok(Self {
child,
pid,
rx,
stderr_tail,
_temp_files: temp_files,
_reader: policy_reader,
_stderr: stderr_task,
})
}
pub async fn recv(&mut self, idle_timeout: Option<Duration>) -> Recv {
match idle_timeout {
Some(timeout) => match tokio::time::timeout(timeout, self.rx.recv()).await {
Ok(Some(line)) => Recv::Line(line),
Ok(None) => Recv::Eof,
Err(_) => Recv::IdleTimeout,
},
None => match self.rx.recv().await {
Some(line) => Recv::Line(line),
None => Recv::Eof,
},
}
}
pub fn stderr_tail(&self) -> String {
self.stderr_tail
.lock()
.unwrap()
.iter()
.cloned()
.collect::<Vec<_>>()
.join("\n")
}
pub async fn shutdown_and_check(&mut self) -> Result<(), FaucetError> {
let status = self.terminate().await;
match status {
Ok(status) if status.success() => Ok(()),
Ok(status) => Err(FaucetError::Source(format!(
"tap exited with status {}; last stderr:\n{}",
status,
self.stderr_tail()
))),
Err(e) => Err(FaucetError::Source(format!(
"failed to reap tap: {e}; last stderr:\n{}",
self.stderr_tail()
))),
}
}
pub async fn shutdown(&mut self) {
let _ = self.terminate().await;
}
async fn terminate(&mut self) -> std::io::Result<std::process::ExitStatus> {
if let Ok(Some(status)) = self.child.try_wait() {
return Ok(status); }
#[cfg(unix)]
if let Some(pid) = self.pid {
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
}
#[cfg(not(unix))]
let _ = self.pid; match tokio::time::timeout(SIGTERM_GRACE, self.child.wait()).await {
Ok(status) => status,
Err(_) => {
tracing::warn!("tap did not exit within grace period; sending SIGKILL");
let _ = self.child.start_kill();
self.child.wait().await
}
}
}
}
pub(crate) fn write_temp(
kind: &str,
value: &Value,
) -> Result<tempfile::NamedTempFile, FaucetError> {
faucet_common_singer::write_private_json(kind, value).map_err(FaucetError::Source)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn write_temp_is_private_and_valid_json() {
let f = write_temp("config", &json!({"a": 1})).unwrap();
let contents = std::fs::read_to_string(f.path()).unwrap();
assert_eq!(contents, r#"{"a":1}"#);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(f.path()).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
}
#[cfg(unix)]
fn echo_tap(dir: &std::path::Path) -> SingerSourceConfig {
use std::os::unix::fs::PermissionsExt;
let path = dir.join("tap.sh");
std::fs::write(
&path,
"#!/bin/sh\necho '{\"type\":\"RECORD\",\"stream\":\"s\",\"record\":{\"id\":1}}'\n",
)
.unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
SingerSourceConfig::new(path.to_string_lossy(), "s")
}
#[cfg(unix)]
#[tokio::test]
async fn spawn_and_spawn_in_both_start_the_tap() {
let dir = tempfile::tempdir().unwrap();
let cfg = echo_tap(dir.path());
let mut sync = TapProcess::spawn(&cfg, Some(&json!({"b": 1}))).unwrap();
assert!(matches!(sync.recv(None).await, Recv::Line(Line::Parsed(_))));
let ctx = faucet_core::CallContext::new();
let mut in_ctx = TapProcess::spawn_in(&cfg, None, &ctx).await.unwrap();
assert!(matches!(
in_ctx.recv(None).await,
Recv::Line(Line::Parsed(_))
));
}
#[tokio::test]
async fn spawn_in_reports_a_missing_executable() {
let cfg = SingerSourceConfig::new("/nonexistent/faucet-tap", "s");
let err = TapProcess::spawn_in(&cfg, None, &faucet_core::CallContext::new())
.await
.err()
.expect("spawn fails");
assert!(err.to_string().contains("failed to spawn tap"), "{err}");
}
}