193 lines
5.8 KiB
Rust
193 lines
5.8 KiB
Rust
|
|
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)
|
||
|
|
}
|