bascule-workspace/bascule-tail/src/main.rs
Tyler King b1865a0627 initial: bascule v0.1.0
Bascule shell runtime workspace — governed shell access layer
for Substrate/Guildhouse FFC deployments.

Crates:
- bascule-agent: node agent with SSH server + command filtering
- bascule-core: audit, grant engine, ceremony types, session
- bascule-filter-core: log line filtering (stdio protocol)
- bascule-gateway: OIDC auth, session management, SAT validation
- bascule-node-agent: k8s DaemonSet agent (pod watcher, BPF manager)
- bascule-proto: protobuf definitions
- bascule-shell: governed SSH shell (commands, elevation, REPL)
- bascule-tail: chronicle log tail + fanout
- ceremony-engine: ceremony lifecycle (6 types + request/resolution)

172 tests passing.
Implements SBS-SPEC-0001 shell model.
Reference impl for SPEC-SHELLOPS-0001 Layer 1 (root shell).
2026-03-18 16:40:48 -04:00

148 lines
4 KiB
Rust

//! 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<String>,
/// Regex filter patterns (AND semantics).
#[arg(long = "filter", short = 'f')]
filters: Vec<String>,
/// 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<fanout::PodTarget> = args
.targets
.iter()
.map(|t| {
parse_pod_target(t, "default").map(|(pod, ns)| fanout::PodTarget {
pod,
namespace: ns,
})
})
.collect::<anyhow::Result<Vec<_>>>()?;
// 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");
}
}