//! bascule-tail — governed log streaming for the Substrate governance fabric. //! //! A standalone command module invoked by bascule-shell via exec(). //! Implements the LogFilter stdio protocol via bascule-filter-core. //! //! Future: capability manifest declaration //! declare_filter_capabilities! { //! filter_id: "bascule-tail", //! network: true, // gRPC to gateway //! file_open: false, //! syscalls: [read, write, connect], //! } mod auth; mod fanout; mod filter; mod output; mod stream; use bascule_filter_core::{FilterResult, LogFilter}; use clap::Parser; use filter::FilterChain; use output::OutputFormatter; /// CLI args for bascule-tail. /// These are forwarded by bascule-shell when it execs this binary. #[derive(Parser, Debug)] #[command(name = "bascule-tail", about = "Stream governed pod logs")] struct Args { /// Session ID (forwarded by bascule-shell) #[arg(long)] session_id: String, /// Bearer token (forwarded by bascule-shell) #[arg(long)] token: String, /// bascule-gateway address #[arg(long)] gateway: String, /// Pod targets: pod/{name} or pod/{namespace}/{name} #[arg(required = true)] targets: Vec, /// Regex filter patterns (AND semantics). #[arg(long = "filter", short = 'f')] filters: Vec, /// Initial lines to show per pod. #[arg(long, default_value = "50")] lines: u32, /// Stop after initial lines. #[arg(long = "no-follow")] no_follow: bool, } #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() .with_writer(std::io::stderr) .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("bascule_tail=info")), ) .init(); let args = Args::parse(); let chain = FilterChain::from_patterns(&args.filters)?; // Parse all targets let targets: Vec = args .targets .iter() .map(|t| { parse_pod_target(t, "default").map(|(pod, ns)| fanout::PodTarget { pod, namespace: ns, }) }) .collect::>>()?; // Fan-out: N concurrent streams, one merged channel let mut rx = fanout::fanout( targets, args.session_id.clone(), args.token.clone(), args.gateway.clone(), args.lines, !args.no_follow, ) .await; let mut fmt = OutputFormatter::auto(); while let Some(line) = rx.recv().await { match chain.filter(line) { FilterResult::Pass(l) => fmt.print(&l), FilterResult::Drop => {} } } Ok(()) } /// Parse "pod/{name}" or "pod/{ns}/{name}" into (pod_name, namespace). fn parse_pod_target(target: &str, default_ns: &str) -> anyhow::Result<(String, String)> { let stripped = target.strip_prefix("pod/").unwrap_or(target); let parts: Vec<&str> = stripped.splitn(2, '/').collect(); match parts.as_slice() { [name] => Ok((name.to_string(), default_ns.to_string())), [ns, name] => Ok((name.to_string(), ns.to_string())), _ => anyhow::bail!( "Invalid pod target: '{}'. Expected: pod/{{name}} or pod/{{namespace}}/{{name}}", target ), } } #[cfg(test)] mod tests { use super::*; #[test] fn parse_pod_name_only() { let (pod, ns) = parse_pod_target("api-svc", "default").unwrap(); assert_eq!(pod, "api-svc"); assert_eq!(ns, "default"); } #[test] fn parse_pod_with_namespace() { let (pod, ns) = parse_pod_target("pod/production/api-svc", "default").unwrap(); assert_eq!(pod, "api-svc"); assert_eq!(ns, "production"); } #[test] fn parse_pod_strips_prefix() { let (pod, ns) = parse_pod_target("pod/api-svc", "default").unwrap(); assert_eq!(pod, "api-svc"); assert_eq!(ns, "default"); } }