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).
148 lines
4 KiB
Rust
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");
|
|
}
|
|
}
|