prvng_platform/crates/ops-keeper/bin/keeper_cli.rs

193 lines
5.8 KiB
Rust
Raw Permalink Normal View History

use std::path::PathBuf;
use clap::{Parser, Subcommand};
use ops_keeper::{
config::KeeperConfig,
nats_client::{KeeperNats, PeekedMessage},
pending::parse_pending,
policy::{load_policy, PolicyMatcher},
signer::Signer,
PendingOp,
};
use tracing::warn;
#[derive(Parser)]
#[command(name = "keeper-cli", about = "Interactive keeper operations CLI (ADR-037)")]
struct Cli {
#[arg(short, long, default_value = "keeper-daemon.toml")]
config: PathBuf,
#[command(subcommand)]
command: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// List pending ops waiting for a keeper signature
List {
#[arg(short, long, default_value_t = 20)]
max: usize,
},
/// Show details of a specific pending op by jti
Describe {
/// JTI or subject prefix to match
jti: String,
},
/// Sign a pending op (loads private key from config)
Sign {
/// op_type:target to match (e.g., "deploy:staging-vapora")
target: String,
},
/// Show the compiled policy decision for a hypothetical op
Simulate {
op_type: String,
target: String,
},
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let cli = Cli::parse();
let cfg = KeeperConfig::from_toml(&cli.config)?;
match cli.command {
Cmd::List { max } => {
let nats = KeeperNats::connect(&cfg).await?;
let peeked = nats.peek_pending(max).await?;
if peeked.is_empty() {
println!("No pending ops in queue.");
return Ok(());
}
for msg in &peeked {
print_list_row(msg);
}
println!("{} pending op(s) shown (max {})", peeked.len(), max);
}
Cmd::Describe { jti } => {
let nats = KeeperNats::connect(&cfg).await?;
let peeked = nats.peek_pending(100).await?;
let mut found = false;
for msg in &peeked {
found |= describe_msg(msg, &jti);
}
if !found {
eprintln!("No pending op matching '{jti}' found.");
std::process::exit(1);
}
}
Cmd::Sign { target } => {
let private_pem = std::fs::read(&cfg.private_key_path)?;
let public_pem = std::fs::read(&cfg.public_key_path)?;
let signer = Signer::from_pem_files(
&private_pem,
&public_pem,
cfg.issuer_id.clone(),
cfg.workspace.clone(),
cfg.token_validity_secs,
)?;
let nats = KeeperNats::connect(&cfg).await?;
let peeked = nats.peek_pending(100).await?;
let (op_type_filter, target_filter) = target
.split_once(':')
.map(|(a, b)| (a.to_string(), b.to_string()))
.unwrap_or_else(|| ("*".to_string(), target.clone()));
let mut signed = 0usize;
for msg in &peeked {
let did_sign = sign_msg(msg, &signer, &nats, &op_type_filter, &target_filter).await;
match did_sign {
Ok(true) => signed += 1,
Ok(false) => {}
Err(e) => return Err(e),
}
}
println!("{signed} op(s) signed.");
}
Cmd::Simulate { op_type, target } => {
let policy = load_policy(&cfg.policy_path)?;
let matcher = PolicyMatcher::from_policy(&policy)?;
let op = PendingOp {
op_type: op_type.clone(),
target: target.clone(),
sub: "simulate".to_string(),
expected_state_version: "v0".to_string(),
image: String::new(),
params: serde_json::Value::Null,
};
let decision = matcher.decide(&op);
println!("simulate op_type={op_type} target={target}");
println!("decision: {}", decision.as_str());
}
}
Ok(())
}
fn print_list_row(msg: &PeekedMessage) {
match parse_pending(&msg.payload) {
Ok(op) => println!(
"{:<30} op_type={:<15} target={:<25} sub={}",
msg.subject, op.op_type, op.target, op.sub
),
Err(_) => println!("{:<30} <unparseable payload>", msg.subject),
}
}
fn describe_msg(msg: &PeekedMessage, jti: &str) -> bool {
if !msg.subject.contains(jti) {
return false;
}
match parse_pending(&msg.payload) {
Ok(op) => {
println!("subject: {}", msg.subject);
println!("op_type: {}", op.op_type);
println!("target: {}", op.target);
println!("sub: {}", op.sub);
println!("version: {}", op.expected_state_version);
println!("image: {}", op.image);
println!("params: {}", op.params);
true
}
Err(e) => {
eprintln!("parse error for {}: {e}", msg.subject);
false
}
}
}
async fn sign_msg(
msg: &PeekedMessage,
signer: &Signer,
nats: &KeeperNats,
op_type_filter: &str,
target_filter: &str,
) -> Result<bool, Box<dyn std::error::Error>> {
let op = match parse_pending(&msg.payload) {
Ok(op) => op,
Err(e) => {
warn!(error = %e, "skipping unparseable message");
return Ok(false);
}
};
let op_match = op_type_filter == "*" || op_type_filter == op.op_type;
let target_match = target_filter == "*" || op.target.contains(target_filter);
if !op_match || !target_match {
return Ok(false);
}
let jwt = signer.sign_op(&op)?;
nats.publish_signed(&op.op_type, &jwt).await?;
println!("signed: {}{}", msg.subject, op.target);
Ok(true)
}