use std::{io, panic};
use color_eyre::eyre::Result;
use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use tracing_subscriber::{layer::SubscriberExt, reload, util::SubscriberInitExt, EnvFilter};
use ebman::{app::App, aws, config, control, font_probe, splash, util, LogReloadHandle, Tui};
#[tokio::main]
async fn main() -> Result<()> {
let mut read_only = false;
let mut demo = false;
let mut control_socket: Option<std::path::PathBuf> = None;
let args: Vec<String> = std::env::args().skip(1).collect();
if let Some(first) = args.first() {
match first.as_str() {
"envs" => return run_envs_cli(&args).await,
"action" => return run_action_cli(&args).await,
"ctl" => return run_ctl_cli(&args).await,
"lint" => return run_lint_cli(&args).await,
"drift" => return run_drift_cli(&args).await,
"audit" => return run_audit_cli(&args).await,
"explain" => return run_explain_cli(&args).await,
_ => {}
}
}
let mut iter = args.iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"--version" | "-V" => {
println!(
"ebman {}\nby Tom Baldwin · Polymorphism Ltd · https://polymorphism.co.uk",
env!("CARGO_PKG_VERSION")
);
return Ok(());
}
"--help" | "-h" => {
print_help();
return Ok(());
}
"--read-only" => read_only = true,
"--demo" => demo = true,
"--control-socket" => {
control_socket = iter.next().map(std::path::PathBuf::from);
if control_socket.is_none() {
eprintln!("ebman: --control-socket requires a path argument");
std::process::exit(2);
}
}
other if other.starts_with('-') => {
eprintln!("ebman: unknown flag {other}\n");
print_help();
std::process::exit(2);
}
_ => {}
}
}
color_eyre::install()?;
let log_handle = init_logging()?;
install_panic_hook();
let mut cfg = config::load();
cfg.icons = font_probe::resolve_icons_setting(&cfg.icons);
let splash_icons = cfg.icons.clone();
let mut terminal = enter_tui()?;
const SPLASH_MIN_DURATION: std::time::Duration = std::time::Duration::from_secs(3);
let mut app_inst = if demo {
let app = App::new_demo(cfg);
let splash_started = std::time::Instant::now();
let mut splash_frame: u64 = 0;
let mut interval = tokio::time::interval(std::time::Duration::from_millis(30));
while splash_started.elapsed() < SPLASH_MIN_DURATION {
interval.tick().await;
draw_splash(&mut terminal, splash_frame, &splash_icons)?;
splash_frame = splash_frame.wrapping_add(1);
}
app
} else {
let splash_started = std::time::Instant::now();
let mut splash_frame: u64 = 0;
let mut interval = tokio::time::interval(std::time::Duration::from_millis(30));
let mut new_app_fut = Box::pin(App::new(cfg));
let mut app_ready: Option<App> = None;
loop {
tokio::select! {
biased;
res = &mut new_app_fut, if app_ready.is_none() => {
app_ready = Some(res?);
}
_ = interval.tick() => {
draw_splash(&mut terminal, splash_frame, &splash_icons)?;
splash_frame = splash_frame.wrapping_add(1);
if app_ready.is_some() && splash_started.elapsed() >= SPLASH_MIN_DURATION {
break app_ready
.take()
.expect("app_ready was Some, just checked above");
}
}
}
}
};
app_inst.read_only = read_only;
app_inst.log_reload = Some(log_handle);
let control_rx = control_socket.map(|path| {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
control::spawn_listener(path, tx);
rx
});
let result = app_inst.run(&mut terminal, control_rx).await;
app_inst.persist_state();
leave_tui(&mut terminal)?;
if result.is_ok() && app_inst.reload_requested {
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let exe = std::env::current_exe()?;
let argv: Vec<String> = std::env::args().skip(1).collect();
let err = std::process::Command::new(exe).args(argv).exec();
return Err(color_eyre::eyre::eyre!("reload exec failed: {err}"));
}
#[cfg(not(unix))]
{
eprintln!("ebman: reload is unix-only");
}
}
result
}
fn print_help() {
let version = env!("CARGO_PKG_VERSION");
println!(
"\
ebman {version}
k9s-style TUI for AWS Elastic Beanstalk.
USAGE:
ebman [FLAGS]
FLAGS:
-V, --version Print version and exit.
-h, --help Print this help and exit.
--read-only Start with destructive actions disabled (also toggleable with :readonly).
--demo Run with a hand-crafted synthetic fleet (no AWS calls, no disk reads).
Use for screenshots / VHS recordings / talk demos that shouldn't show
real account data. Drill-into-other-tabs may show stub errors — main
table + Detail/Health is the supported surface.
--control-socket P Open a Unix socket at P for remote control (off by default).
Pair with `ebman ctl <op>` to drive the running session.
SUBCOMMANDS:
envs [--json] List environments in current profile / region.
lint [--env NAME] [--regions r1,r2,r3] [--json] [--severity LVL] [--rules ID1,ID2] [--quiet]
[--fix (--yes | --dry-run)]
Run the diagnostic rule engine against one env
(or every env in the context) and emit findings
as text or JSON. Non-zero exit when issues found.
Useful for git hooks, CI gates, monitoring loops.
Exit codes: 0 clean, 1 aws err, 2 usage, 3 issues.
Operator disables via `lint.disable` in
config.toml and project-local .ebman/ebman.toml.
--regions fans out across regions; rows are
prefixed with the region.
--fix dispatches each rule's auto-remediation
(DeploymentPolicy → Rolling for EBL001, etc.).
Requires --yes to write; --dry-run prints the
plan without dispatching. Per-rule opt-out via
`lint.fix_disable`. Manual fixes printed as
instructions when the right answer is operator-
context-dependent.
drift [--env NAME] [--regions r1,r2,r3] [--tfstate PATH] [--tfdir PATH] [--json] [--quiet]
Terraform drift report. Discovers tfstate via
walk-up from cwd (or --tfdir / --tfstate
overrides). Compares tf-declared option settings
+ version_label against live EB state.
Exit codes: 0 no drift, 1 aws err, 2 usage,
3 drift detected. CI-friendly default exit code.
--regions fans out across regions against a
single tfstate (multi-region tf projects).
action ACTION --env NAME [--yes] Run an action (rebuild|restart|terminate|deploy|rollout) on an env.
Terminate requires --yes to confirm.
Deploy requires --version LABEL; supports
--wait-for-green Nm and --auto-rollback Nm.
Rollout: --version LABEL --regions r1,r2,r3 --env NAME
--yes [--wait-for-green Nm] [--json] [--profile P].
Sequential cross-region deploy with pre-flight
validation. Stops on first failure. Single
rollout_id correlation across audit lines.
Exit codes: 0 ok, 1 aws err, 2 usage, 3 partial
failure, 4 wait-timeout, 5 rolled-back.
ctl <screen|key|cmd|state|reload> [args] Talk to a running ebman via --control-socket.
`reload` re-execs the binary (rebuild first via
`cargo build --release`). Use --socket PATH to
override the default location.
audit [--tail] [--since DUR] [--env NAME] [--rule ID] [--action NAME] [--json]
Read ~/.cache/ebman/audit.log — surface the local
audit trail for scripting / Slack-bot routing /
CI gating. Default text mode renders columns
(TS / REGION / STAGE / ACTION / TARGET / OUTCOME);
--json emits JSONL one entry per line. --tail
polls 1s for new entries (until Ctrl-C). --since
filters to entries within a duration (5m/1h/2d).
Exit codes: 0 ok, 1 io err, 2 usage.
explain EBL### [--env NAME] [--json] [--dry-run] [--no-cache]
LLM-backed explanation of a lint issue. Routes to
the configured Provider (Anthropic API or local
Ollama) and prints an operator-readable summary
of why the issue matters and what to do next.
Requires `[explain] enabled = true` in
config.toml + an exported ANTHROPIC_API_KEY
(Anthropic) or a running Ollama server. Responses
cached to ~/.cache/ebman/explain/; --no-cache
forces a fresh call. --dry-run prints the prompt
without sending. Exit codes: 0 ok, 1 provider err,
2 usage, 3 issue not found.
CONFIG:
~/.config/ebman/config.toml user configuration (see README)
~/.config/ebman/state.toml persisted session state (managed by the app)
~/.cache/ebman/ebman.log log output (filter with RUST_LOG)
KEYS:
Once running, press '?' for the in-app help screen."
);
}
async fn run_drift_cli(args: &[String]) -> Result<()> {
let mut env_name: Option<String> = None;
let mut regions_csv: Option<String> = None;
let mut tfstate_path: Option<std::path::PathBuf> = None;
let mut tfdir: Option<std::path::PathBuf> = None;
let mut json = false;
let mut quiet = false;
let mut iter = args.iter().skip(1);
while let Some(arg) = iter.next() {
match arg.as_str() {
"--env" => env_name = iter.next().cloned(),
"--regions" => regions_csv = iter.next().cloned(),
"--tfstate" => tfstate_path = iter.next().map(std::path::PathBuf::from),
"--tfdir" => tfdir = iter.next().map(std::path::PathBuf::from),
"--json" => json = true,
"--quiet" => quiet = true,
other => {
eprintln!("ebman drift: unknown flag '{other}'");
std::process::exit(2);
}
}
}
let (tf_state, used_path) = if let Some(path) = tfstate_path.as_ref() {
let Some(state) = ebman::terraform::load_from_path(path) else {
eprintln!(
"ebman drift: could not read or parse tfstate at {}",
path.display()
);
std::process::exit(2);
};
(state, Some(path.clone()))
} else {
let start = tfdir
.as_deref()
.unwrap_or(std::path::Path::new("."))
.to_path_buf();
let abs = start.canonicalize().unwrap_or(start);
let Some(found) = ebman::terraform::find_tfstate(&abs) else {
if !quiet {
if json {
println!("{{\"tfstate\":null,\"envs\":[]}}");
} else {
eprintln!(
"ebman drift: no terraform.tfstate found under {}",
abs.display()
);
}
}
return Ok(());
};
let Some(state) = ebman::terraform::load_from_path(&found) else {
eprintln!(
"ebman drift: could not parse tfstate at {}",
found.display()
);
std::process::exit(2);
};
(state, Some(found))
};
let regions: Vec<Option<String>> = match regions_csv {
Some(csv) => {
let parsed: Vec<String> = csv
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if parsed.is_empty() {
eprintln!("ebman drift: --regions list is empty");
std::process::exit(2);
}
parsed.into_iter().map(Some).collect()
}
None => vec![None],
};
let multi_region = regions.len() > 1;
let mut reports: Vec<(
Option<String>,
String,
bool,
Vec<ebman::terraform::DriftField>,
)> = Vec::new();
let mut any_drift = false;
for region_opt in ®ions {
let aws = match aws::AwsClient::with(None, region_opt.clone()).await {
Ok(c) => c,
Err(e) => {
if !quiet {
let region_label = region_opt.as_deref().unwrap_or("default");
eprintln!("warning: skipping region '{region_label}' — AwsClient::with: {e}");
}
continue;
}
};
let live_envs = match aws.list_environments().await {
Ok(envs) => envs,
Err(e) => {
if !quiet {
let region_label = region_opt.as_deref().unwrap_or("default");
eprintln!("warning: skipping region '{region_label}' — list_environments: {e}");
}
continue;
}
};
let targets: Vec<&ebman::aws::Environment> = match env_name.as_deref() {
Some(name) => match live_envs.iter().find(|e| e.name == name) {
Some(env) => vec![env],
None => {
if multi_region && !quiet {
let region_label = region_opt.as_deref().unwrap_or("default");
eprintln!(
"warning: env '{name}' not in region '{region_label}' — skipping"
);
} else if !multi_region {
eprintln!("ebman drift: env '{name}' not found in current context");
std::process::exit(2);
}
continue;
}
},
None => live_envs
.iter()
.filter(|e| tf_state.env_by_name(&e.name).is_some())
.collect(),
};
for env in targets {
let tf_env = tf_state.env_by_name(&env.name);
let tf_managed = tf_env.is_some();
let drift = if let Some(tf) = tf_env {
match aws
.fetch_env_option_settings(&env.application, &env.name)
.await
{
Ok(opts) => ebman::terraform::compute_drift(tf, env, &opts),
Err(e) => {
if !quiet {
eprintln!(
"warning: skipping {} — fetch_env_option_settings: {e}",
env.name
);
}
Vec::new()
}
}
} else {
Vec::new()
};
if !drift.is_empty() {
any_drift = true;
}
reports.push((region_opt.clone(), env.name.clone(), tf_managed, drift));
}
}
if !quiet {
if json {
let shaped: Vec<(String, bool, Vec<ebman::terraform::DriftField>)> = reports
.iter()
.map(|(region, env, managed, drift)| {
let name = if multi_region {
if let Some(r) = region {
format!("{r}/{env}")
} else {
env.clone()
}
} else {
env.clone()
};
(name, *managed, drift.clone())
})
.collect();
println!(
"{}",
ebman::terraform::render_drift_json(used_path.as_deref(), &shaped)
);
} else {
for (region, env, managed, drift) in &reports {
let prefix = if multi_region {
let r = region.as_deref().unwrap_or("default");
format!("{r}\t")
} else {
String::new()
};
if drift.is_empty() {
if *managed {
println!("{prefix}{env}\t✓ no drift");
}
continue;
}
for d in drift {
let target = match (d.namespace.as_deref(), d.name.as_deref()) {
(Some(ns), Some(n)) => format!("{ns}/{n}"),
(_, Some(n)) => n.to_string(),
_ => d.kind.clone(),
};
println!(
"{prefix}{env}\t{}\t{target}\ttf={}\tlive={}",
d.kind, d.tf_value, d.live_value
);
}
}
}
}
if any_drift {
std::process::exit(3);
}
Ok(())
}
async fn run_lint_cli(args: &[String]) -> Result<()> {
let mut env_name: Option<String> = None;
let mut regions_csv: Option<String> = None;
let mut json = false;
let mut quiet = false;
let mut severity_filter: Option<ebman::lint::Severity> = None;
let mut rule_filter: Vec<String> = Vec::new();
let mut fix = false;
let mut dry_run = false;
let mut yes = false;
let mut iter = args.iter().skip(1);
while let Some(arg) = iter.next() {
match arg.as_str() {
"--env" => env_name = iter.next().cloned(),
"--regions" => regions_csv = iter.next().cloned(),
"--json" => json = true,
"--quiet" => quiet = true,
"--fix" => fix = true,
"--dry-run" => dry_run = true,
"--yes" => yes = true,
"--severity" => {
let Some(v) = iter.next() else {
eprintln!("ebman lint: --severity expects a value (info / warn / error)");
std::process::exit(2);
};
let Some(sev) = ebman::lint::Severity::parse(v) else {
eprintln!("ebman lint: unknown severity '{v}' (info / warn / error)");
std::process::exit(2);
};
severity_filter = Some(sev);
}
"--rules" => {
let Some(v) = iter.next() else {
eprintln!("ebman lint: --rules expects a comma-separated rule id list");
std::process::exit(2);
};
rule_filter = v
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
other => {
eprintln!("ebman lint: unknown flag '{other}'");
std::process::exit(2);
}
}
}
let mut disabled: Vec<String> = config::load_lint_disables();
disabled.extend(ebman::project::load_lint_disables_from_cwd());
let rules = ebman::lint::default_rules(&disabled);
let mut fix_disabled: Vec<String> = config::load_lint_fix_disables();
fix_disabled.extend(ebman::project::load_lint_fix_disables_from_cwd());
if fix && !yes && !dry_run {
eprintln!("ebman lint --fix: requires --yes to dispatch writes (or --dry-run to preview)");
std::process::exit(2);
}
if fix && yes && dry_run {
eprintln!("ebman lint --fix: --yes and --dry-run are mutually exclusive");
std::process::exit(2);
}
let regions: Vec<Option<String>> = match regions_csv {
Some(csv) => {
let parsed: Vec<String> = csv
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if parsed.is_empty() {
eprintln!("ebman lint: --regions list is empty");
std::process::exit(2);
}
parsed.into_iter().map(Some).collect()
}
None => vec![None],
};
let multi_region = regions.len() > 1;
let mut all_issues: Vec<ebman::lint::Issue> = Vec::new();
for region_opt in ®ions {
let aws = match aws::AwsClient::with(None, region_opt.clone()).await {
Ok(c) => c,
Err(e) => {
if !quiet {
let region_label = region_opt.as_deref().unwrap_or("default");
eprintln!("warning: skipping region '{region_label}' — AwsClient::with: {e}");
}
continue;
}
};
let envs = match aws.list_environments().await {
Ok(envs) => envs,
Err(e) => {
if !quiet {
let region_label = region_opt.as_deref().unwrap_or("default");
eprintln!("warning: skipping region '{region_label}' — list_environments: {e}");
}
continue;
}
};
let targets: Vec<&ebman::aws::Environment> = match env_name.as_deref() {
Some(name) => match envs.iter().find(|e| e.name == name) {
Some(env) => vec![env],
None => {
if multi_region && !quiet {
let region_label = region_opt.as_deref().unwrap_or("default");
eprintln!(
"warning: env '{name}' not in region '{region_label}' — skipping"
);
} else if !multi_region {
eprintln!("ebman lint: env '{name}' not found in current context");
std::process::exit(2);
}
continue;
}
},
None => envs.iter().collect(),
};
for env in targets {
let opts = match aws
.fetch_env_option_settings(&env.application, &env.name)
.await
{
Ok(opts) => opts,
Err(e) => {
if !quiet {
eprintln!(
"warning: skipping {} — fetch_env_option_settings: {e}",
env.name
);
}
continue;
}
};
let ctx = ebman::lint::LintContext {
env,
options: &opts,
events: &[],
cost_usd_per_month: None,
latest_stack_version: None,
};
let mut issues = ebman::lint::run_rules(&rules, &ctx);
if let Some(min) = severity_filter {
issues.retain(|i| i.severity >= min);
}
if !rule_filter.is_empty() {
issues.retain(|i| rule_filter.contains(&i.rule_id));
}
if let Some(region) = region_opt {
for issue in &mut issues {
issue.fields.insert("region".into(), region.clone());
}
}
if fix && !issues.is_empty() {
let region_label = region_opt.as_deref().unwrap_or("default").to_string();
let mut to_set: Vec<(String, String, String)> = Vec::new();
let mut planned: Vec<(String, ebman::lint::FixAction)> = Vec::new();
let mut planned_set_indices: Vec<usize> = Vec::new();
for issue in &issues {
if fix_disabled.contains(&issue.rule_id) {
if !quiet {
println!("skip {} ({}): in lint.fix_disable", issue.rule_id, env.name);
}
continue;
}
let Some(rule) = rules.iter().find(|r| r.id() == issue.rule_id) else {
continue;
};
let Some(action) = rule.fix(&ctx) else {
if !quiet {
println!(
"no-fix {} ({}): rule has no auto-remediation",
issue.rule_id, env.name
);
}
continue;
};
if let ebman::lint::FixAction::SetOption {
namespace,
name,
value,
..
} = &action
{
planned_set_indices.push(planned.len());
to_set.push((namespace.clone(), name.clone(), value.clone()));
}
planned.push((issue.rule_id.clone(), action));
}
for (rule_id, action) in &planned {
match action {
ebman::lint::FixAction::SetOption { description, .. } => {
println!("fix {rule_id} ({}): {description}", env.name);
}
ebman::lint::FixAction::Manual { instructions } => {
println!(
"fix {rule_id} ({}) MANUAL — operator action required:\n {instructions}",
env.name
);
}
}
}
if !to_set.is_empty() && yes {
match aws
.update_env_option_settings(&env.name, &to_set, &[])
.await
{
Ok(()) => {
for &idx in &planned_set_indices {
let (rule_id, action) = &planned[idx];
if let ebman::lint::FixAction::SetOption {
namespace,
name,
value,
..
} = action
{
write_lint_fix_audit_line(
®ion_label,
&env.name,
rule_id,
namespace,
name,
value,
None,
);
}
}
if !quiet {
println!(
"ok ({}): applied {} fix(es)",
env.name,
planned_set_indices.len()
);
}
}
Err(e) => {
eprintln!(
"ebman lint --fix: dispatch failed for {} in {region_label}: {e}",
env.name
);
let err_str = e.to_string();
for &idx in &planned_set_indices {
let (rule_id, action) = &planned[idx];
if let ebman::lint::FixAction::SetOption {
namespace,
name,
value,
..
} = action
{
write_lint_fix_audit_line(
®ion_label,
&env.name,
rule_id,
namespace,
name,
value,
Some(&err_str),
);
}
}
FIX_DISPATCH_FAILED.store(true, std::sync::atomic::Ordering::Relaxed);
}
}
}
}
all_issues.extend(issues);
}
}
if !quiet {
if json {
println!("{}", ebman::lint::render_issues_json(&all_issues));
} else if all_issues.is_empty() {
println!("✓ No issues found");
} else {
for issue in &all_issues {
let sev = issue.severity.as_str();
let env_str = issue.env_name.as_deref().unwrap_or("-");
if multi_region {
let region = issue
.fields
.get("region")
.map(String::as_str)
.unwrap_or("-");
println!(
"{region}\t{sev}\t{}\t{env_str}\t{}",
issue.rule_id, issue.title
);
} else {
println!("{sev}\t{}\t{env_str}\t{}", issue.rule_id, issue.title);
}
if let Some(s) = &issue.suggestion {
println!("\t→ {s}");
}
}
}
}
if fix {
if FIX_DISPATCH_FAILED.load(std::sync::atomic::Ordering::Relaxed) {
std::process::exit(1);
}
Ok(())
} else if all_issues.is_empty() {
Ok(())
} else {
std::process::exit(3);
}
}
static FIX_DISPATCH_FAILED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
fn write_lint_fix_audit_line(
region: &str,
env: &str,
rule_id: &str,
namespace: &str,
name: &str,
value: &str,
err: Option<&str>,
) {
let dir = ebman::util::cache_dir();
if std::fs::create_dir_all(&dir).is_err() {
return;
}
let path = dir.join("audit.log");
let when = chrono::Utc::now().to_rfc3339();
let q_value = value.replace('"', "'");
let line = match err {
None => format!(
"{when}\tregion={region}\tstage=fix action=SetOption target={env} rule_id={rule_id} namespace={namespace} name={name} value=\"{q_value}\" outcome=ok\n"
),
Some(e) => format!(
"{when}\tregion={region}\tstage=fix action=SetOption target={env} rule_id={rule_id} namespace={namespace} name={name} value=\"{q_value}\" outcome=err err=\"{}\"\n",
e.replace('"', "'")
),
};
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
let _ = f.write_all(line.as_bytes());
}
}
async fn run_audit_cli(args: &[String]) -> Result<()> {
let mut tail = false;
let mut since_str: Option<String> = None;
let mut env_filter: Option<String> = None;
let mut rule_filter: Option<String> = None;
let mut action_filter: Option<String> = None;
let mut json = false;
let mut iter = args.iter().skip(1);
while let Some(arg) = iter.next() {
match arg.as_str() {
"--tail" => tail = true,
"--since" => since_str = iter.next().cloned(),
"--env" => env_filter = iter.next().cloned(),
"--rule" => rule_filter = iter.next().cloned(),
"--action" => action_filter = iter.next().cloned(),
"--json" => json = true,
other => {
eprintln!("ebman audit: unknown flag '{other}'");
std::process::exit(2);
}
}
}
let since_dt: Option<chrono::DateTime<chrono::Utc>> = match since_str.as_deref() {
None => None,
Some(s) => match aws::parse_window_ms(s) {
Some(ms) => Some(chrono::Utc::now() - chrono::Duration::milliseconds(ms)),
None => {
eprintln!(
"ebman audit: --since expects a duration like `5m` / `30m` / `1h` / `2d`"
);
std::process::exit(2);
}
},
};
let filter = ebman::audit::AuditFilter {
since: since_dt,
env: env_filter.as_deref(),
rule: rule_filter.as_deref(),
action: action_filter.as_deref(),
};
let path = ebman::util::cache_dir().join("audit.log");
if !path.exists() {
if !json {
println!("(no audit entries — log not yet created)");
}
return Ok(());
}
let bytes = std::fs::read(&path)
.map_err(|e| color_eyre::eyre::eyre!("read {}: {e}", path.display()))?;
let initial_offset = bytes.len() as u64;
let text = String::from_utf8_lossy(&bytes);
let entries: Vec<ebman::audit::AuditEntry> = text
.lines()
.filter_map(ebman::audit::parse_audit_line)
.filter(|e| filter.matches(e))
.collect();
if json {
print!("{}", ebman::audit::render_audit_entries_json(&entries));
} else {
print!("{}", ebman::audit::render_audit_entries_text(&entries));
}
use std::io::Write;
let _ = std::io::stdout().flush();
if tail {
let mut offset = initial_offset;
loop {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
let meta = match std::fs::metadata(&path) {
Ok(m) => m,
Err(_) => continue, };
let len = meta.len();
if len < offset {
offset = 0;
}
if len == offset {
continue;
}
use std::io::{Read, Seek, SeekFrom};
let mut f = match std::fs::File::open(&path) {
Ok(f) => f,
Err(_) => continue,
};
if f.seek(SeekFrom::Start(offset)).is_err() {
continue;
}
let mut buf = Vec::with_capacity((len - offset) as usize);
if f.read_to_end(&mut buf).is_err() {
continue;
}
offset = len;
let chunk = String::from_utf8_lossy(&buf);
let new_entries: Vec<ebman::audit::AuditEntry> = chunk
.lines()
.filter_map(ebman::audit::parse_audit_line)
.filter(|e| filter.matches(e))
.collect();
if new_entries.is_empty() {
continue;
}
if json {
print!("{}", ebman::audit::render_audit_entries_json(&new_entries));
} else {
for e in &new_entries {
let outcome = match (e.outcome.as_deref(), e.err.as_deref()) {
(_, Some(err)) => format!("err=\"{err}\""),
(Some("ok"), _) => "ok".into(),
(Some(s), _) => s.into(),
_ => "-".into(),
};
println!(
"{}\t{}\t{}\t{}\t{}\t{}",
e.when,
e.region.as_deref().unwrap_or("-"),
e.stage.as_deref().unwrap_or("-"),
e.action.as_deref().unwrap_or("-"),
e.target.as_deref().unwrap_or("-"),
outcome,
);
}
}
let _ = std::io::stdout().flush();
}
}
Ok(())
}
async fn run_explain_cli(args: &[String]) -> Result<()> {
let mut issue_id: Option<String> = None;
let mut env_name: Option<String> = None;
let mut json = false;
let mut dry_run = false;
let mut no_cache = false;
let mut iter = args.iter().skip(1);
while let Some(arg) = iter.next() {
match arg.as_str() {
"--env" => env_name = iter.next().cloned(),
"--json" => json = true,
"--dry-run" => dry_run = true,
"--no-cache" => no_cache = true,
other if other.starts_with("--") => {
eprintln!("ebman explain: unknown flag '{other}'");
std::process::exit(2);
}
other => {
issue_id = Some(other.to_string());
}
}
}
let Some(issue_id) = issue_id else {
eprintln!("usage: ebman explain EBL### [--env NAME] [--json] [--dry-run] [--no-cache]");
std::process::exit(2);
};
if !issue_id.starts_with("EBL") {
eprintln!("ebman explain: ISSUE_ID must be an EBL### rule id (e.g. EBL001)");
std::process::exit(2);
}
let cfg = config::load();
let settings = ebman::llm::Settings::from_config(&cfg);
let mut disabled: Vec<String> = config::load_lint_disables();
disabled.extend(ebman::project::load_lint_disables_from_cwd());
let rules = ebman::lint::default_rules(&disabled);
let aws_client = aws::AwsClient::with(None, None).await?;
let envs = aws_client
.list_environments()
.await
.map_err(|e| color_eyre::eyre::eyre!("list_environments: {e}"))?;
let targets: Vec<&ebman::aws::Environment> = match env_name.as_deref() {
Some(name) => match envs.iter().find(|e| e.name == name) {
Some(env) => vec![env],
None => {
eprintln!("ebman explain: env '{name}' not found in current context");
std::process::exit(2);
}
},
None => envs.iter().collect(),
};
let mut matched: Vec<ebman::lint::Issue> = Vec::new();
for env in targets {
let opts = match aws_client
.fetch_env_option_settings(&env.application, &env.name)
.await
{
Ok(o) => o,
Err(e) => {
eprintln!(
"warning: skipping {} — fetch_env_option_settings: {e}",
env.name
);
continue;
}
};
let ctx = ebman::lint::LintContext {
env,
options: &opts,
events: &[],
cost_usd_per_month: None,
latest_stack_version: None,
};
let issues = ebman::lint::run_rules(&rules, &ctx);
for i in issues {
if i.rule_id == issue_id {
matched.push(i);
}
}
}
if matched.is_empty() {
eprintln!("ebman explain: no env in scope has issue '{issue_id}' — nothing to explain");
std::process::exit(3);
}
let mut json_blocks: Vec<String> = Vec::new();
for issue in &matched {
let prompt = ebman::llm::build_prompt(issue);
if dry_run {
if json {
json_blocks.push(format!(
"{{\"rule_id\":{},\"env\":{},\"dry_run\":true,\"prompt\":{}}}",
json_string(&issue.rule_id),
json_string(issue.env_name.as_deref().unwrap_or("")),
json_string(&prompt),
));
} else {
println!(
"── {} ({}) — DRY RUN ──",
issue.rule_id,
issue.env_name.as_deref().unwrap_or("-")
);
println!("{prompt}\n");
}
continue;
}
let cached = if no_cache {
None
} else {
ebman::llm::read_cache(issue)
};
let response = match cached {
Some(c) => c,
None => match ebman::llm::dispatch(&settings, &prompt).await {
Ok(r) => {
if !no_cache {
ebman::llm::write_cache(issue, &r);
}
r
}
Err(e) => {
eprintln!("ebman explain: {e}");
std::process::exit(1);
}
},
};
if json {
json_blocks.push(format!(
"{{\"rule_id\":{},\"env\":{},\"response\":{}}}",
json_string(&issue.rule_id),
json_string(issue.env_name.as_deref().unwrap_or("")),
json_string(&response),
));
} else {
println!(
"── {} ({}) ──",
issue.rule_id,
issue.env_name.as_deref().unwrap_or("-")
);
println!("{}\n", response.trim());
}
}
if json {
if json_blocks.len() == 1 {
println!("{}", json_blocks[0]);
} else {
println!("[{}]", json_blocks.join(","));
}
}
Ok(())
}
fn json_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
async fn run_envs_cli(args: &[String]) -> Result<()> {
let json = args.iter().any(|a| a == "--json");
let aws = aws::AwsClient::with(None, None).await?;
let envs = aws
.list_environments()
.await
.map_err(|e| color_eyre::eyre::eyre!("list_environments: {e}"))?;
if json {
let entries: Vec<String> = envs
.iter()
.map(|e| {
format!(
"{{\"name\":\"{}\",\"application\":\"{}\",\"status\":\"{}\",\"health\":\"{}\",\"platform\":\"{}\",\"cname\":\"{}\",\"version_label\":\"{}\"}}",
cli_esc(&e.name),
cli_esc(&e.application),
cli_esc(&e.status),
cli_esc(&e.health),
cli_esc(&e.platform),
cli_esc(&e.cname),
cli_esc(&e.version_label),
)
})
.collect();
println!("[{}]", entries.join(","));
} else {
println!("NAME\tAPPLICATION\tSTATUS\tHEALTH\tPLATFORM\tCNAME\tVERSION");
for e in &envs {
println!(
"{}\t{}\t{}\t{}\t{}\t{}\t{}",
e.name, e.application, e.status, e.health, e.platform, e.cname, e.version_label
);
}
}
Ok(())
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum PollDecision {
KeepPolling,
Success,
WaitForGreenTimeout,
DispatchRollback,
}
pub(crate) fn decide_poll(
status: &str,
health: &str,
elapsed_secs: u64,
wait_for_green_secs: Option<u64>,
auto_rollback_secs: Option<u64>,
wait_for_green_timeout_emitted: bool,
) -> PollDecision {
if ebman::app::deploy_settled_green(status, health) {
return PollDecision::Success;
}
if let Some(d) = auto_rollback_secs {
if elapsed_secs >= d {
return PollDecision::DispatchRollback;
}
}
if let Some(d) = wait_for_green_secs {
if elapsed_secs >= d && !wait_for_green_timeout_emitted {
return PollDecision::WaitForGreenTimeout;
}
}
PollDecision::KeepPolling
}
async fn run_action_cli(args: &[String]) -> Result<()> {
let action_name = args.get(1).map(|s| s.as_str()).unwrap_or("");
if action_name.is_empty() || action_name.starts_with('-') {
eprintln!(
"usage: ebman action <rebuild|restart|terminate|deploy|rollout> --env NAME [--version LABEL] [--regions r1,r2,r3] [--yes] [--wait-for-green Nm] [--auto-rollback Nm]"
);
std::process::exit(2);
}
if action_name == "rollout" {
return run_action_rollout(args).await;
}
let mut env_name: Option<String> = None;
let mut version: Option<String> = None;
let mut wait_for_green: Option<String> = None;
let mut auto_rollback: Option<String> = None;
let mut yes = false;
let mut iter = args.iter().skip(2);
while let Some(arg) = iter.next() {
match arg.as_str() {
"--env" => env_name = iter.next().cloned(),
"--version" => version = iter.next().cloned(),
"--wait-for-green" => wait_for_green = iter.next().cloned(),
"--auto-rollback" => auto_rollback = iter.next().cloned(),
"--yes" => yes = true,
other => {
eprintln!("ebman action: unknown flag '{other}'");
std::process::exit(2);
}
}
}
let Some(env) = env_name else {
eprintln!("ebman action: --env NAME is required");
std::process::exit(2);
};
let destructive = matches!(action_name, "terminate");
if destructive && !yes {
eprintln!("ebman action: '{action_name}' is destructive; re-run with --yes to confirm");
std::process::exit(3);
}
let aws = aws::AwsClient::with(None, None).await?;
if action_name == "deploy" {
return run_action_deploy(&aws, &env, version, wait_for_green, auto_rollback).await;
}
let result = match action_name {
"rebuild" => aws.rebuild_env(&env).await,
"restart" => aws.restart_app_server(&env).await,
"terminate" => aws.terminate_env(&env).await,
other => {
eprintln!("ebman action: unknown action '{other}'");
std::process::exit(2);
}
};
match result {
Ok(()) => {
println!("ok: {action_name} on {env} dispatched");
Ok(())
}
Err(e) => {
eprintln!("err: {e}");
std::process::exit(1);
}
}
}
async fn run_action_rollout(args: &[String]) -> Result<()> {
let mut env_name: Option<String> = None;
let mut version: Option<String> = None;
let mut regions_csv: Option<String> = None;
let mut wait_for_green: Option<String> = None;
let mut profile: Option<String> = None;
let mut yes = false;
let mut json = false;
let mut quiet = false;
let mut iter = args.iter().skip(2);
while let Some(arg) = iter.next() {
match arg.as_str() {
"--env" => env_name = iter.next().cloned(),
"--version" => version = iter.next().cloned(),
"--regions" => regions_csv = iter.next().cloned(),
"--wait-for-green" => wait_for_green = iter.next().cloned(),
"--profile" => profile = iter.next().cloned(),
"--yes" => yes = true,
"--json" => json = true,
"--quiet" => quiet = true,
other => {
eprintln!("ebman action rollout: unknown flag '{other}'");
std::process::exit(2);
}
}
}
let Some(env) = env_name else {
eprintln!("ebman action rollout: --env NAME is required");
std::process::exit(2);
};
let Some(version) = version else {
eprintln!("ebman action rollout: --version LABEL is required");
std::process::exit(2);
};
let Some(regions_csv) = regions_csv else {
eprintln!(
"ebman action rollout: --regions r1,r2,r3 is required (comma-separated, no spaces)"
);
std::process::exit(2);
};
let regions: Vec<String> = regions_csv
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if regions.is_empty() {
eprintln!("ebman action rollout: --regions list is empty");
std::process::exit(2);
}
let wait_for_green_secs = match wait_for_green.as_deref() {
Some(s) => match aws::parse_window_ms(s) {
Some(ms) => Some((ms / 1000) as u64),
None => {
eprintln!(
"ebman action rollout: --wait-for-green expects a duration like `5m` / `30m` / `1h`"
);
std::process::exit(2);
}
},
None => None,
};
if !quiet {
eprintln!(
"rollout: pre-flighting {} region(s) for env '{env}' version '{version}'",
regions.len()
);
}
let mut per_region: Vec<(String, aws::AwsClient)> = Vec::with_capacity(regions.len());
for region in ®ions {
let client = match aws::AwsClient::with(profile.clone(), Some(region.clone())).await {
Ok(c) => c,
Err(e) => {
eprintln!(
"ebman action rollout: failed to construct client for region '{region}': {e}"
);
std::process::exit(1);
}
};
let envs = match client.list_environments().await {
Ok(envs) => envs,
Err(e) => {
eprintln!("ebman action rollout: list_environments in '{region}' failed: {e}");
std::process::exit(1);
}
};
if !envs.iter().any(|e| e.name == env) {
eprintln!(
"ebman action rollout: env '{env}' not found in region '{region}' — rollout halted before dispatching"
);
std::process::exit(2);
}
per_region.push((region.clone(), client));
}
if !yes {
eprintln!(
"ebman action rollout: would dispatch to {} region(s); re-run with --yes to confirm",
regions.len()
);
std::process::exit(2);
}
let rollout_id = format!("rollout-{}", chrono::Utc::now().format("%Y%m%dT%H%M%SZ"));
let mut outcomes: Vec<(String, Result<(), String>)> = Vec::new();
for (region, client) in &per_region {
if !quiet {
eprintln!("rollout: dispatching to {region} (env={env}, version={version})");
}
match client.deploy_version(&env, &version).await {
Ok(()) => {
outcomes.push((region.clone(), Ok(())));
if let Some(secs) = wait_for_green_secs {
let start = tokio::time::Instant::now();
let wait_timeout_emitted = false;
loop {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let envs = match client.list_environments().await {
Ok(envs) => envs,
Err(e) => {
eprintln!("rollout[{region}]: list_environments during poll: {e}");
outcomes.last_mut().unwrap().1 = Err(format!("poll: {e}"));
break;
}
};
let (status, health) = envs
.iter()
.find(|e| e.name == env)
.map(|e| (e.status.clone(), e.health.clone()))
.unwrap_or_default();
let elapsed = start.elapsed().as_secs();
match decide_poll(
&status,
&health,
elapsed,
Some(secs),
None,
wait_timeout_emitted,
) {
PollDecision::KeepPolling => {
if !quiet {
eprintln!(
"rollout[{region}]: t={elapsed}s status={status} health={health}"
);
}
}
PollDecision::Success => {
if !quiet {
eprintln!("rollout[{region}]: reached Green at t={elapsed}s");
}
break;
}
PollDecision::WaitForGreenTimeout => {
let msg = format!(
"did not reach Green within {secs}s (status={status}, health={health})"
);
eprintln!("rollout[{region}]: {msg}");
outcomes.last_mut().unwrap().1 = Err(msg);
break;
}
PollDecision::DispatchRollback => {
break;
}
}
}
if matches!(outcomes.last(), Some((_, Err(_)))) {
break;
}
}
}
Err(e) => {
let msg = format!("deploy_version: {e}");
outcomes.push((region.clone(), Err(msg.clone())));
eprintln!("rollout[{region}]: {msg}");
break;
}
}
write_rollout_audit_line(&rollout_id, region, &env, &version, "dispatched", None);
}
let any_failure = outcomes.iter().any(|(_, r)| r.is_err());
if !quiet {
if json {
let mut out = String::from("{");
out.push_str(&format!(
"\"rollout_id\":\"{}\",\"env\":\"{}\",\"version\":\"{}\",\"regions\":[",
cli_esc(&rollout_id),
cli_esc(&env),
cli_esc(&version),
));
for (i, (region, result)) in outcomes.iter().enumerate() {
if i > 0 {
out.push(',');
}
match result {
Ok(()) => {
out.push_str(&format!(
"{{\"region\":\"{}\",\"ok\":true}}",
cli_esc(region)
));
}
Err(e) => {
out.push_str(&format!(
"{{\"region\":\"{}\",\"ok\":false,\"err\":\"{}\"}}",
cli_esc(region),
cli_esc(e),
));
}
}
}
let attempted: std::collections::HashSet<&str> =
outcomes.iter().map(|(r, _)| r.as_str()).collect();
for region in ®ions {
if !attempted.contains(region.as_str()) {
out.push_str(&format!(
",{{\"region\":\"{}\",\"ok\":false,\"err\":\"skipped (rollout halted)\"}}",
cli_esc(region)
));
}
}
out.push_str("]}");
println!("{}", out);
} else {
println!("rollout_id={rollout_id}");
for (region, result) in &outcomes {
match result {
Ok(()) => println!("{region}\tok"),
Err(e) => println!("{region}\terr\t{e}"),
}
}
let attempted: std::collections::HashSet<&str> =
outcomes.iter().map(|(r, _)| r.as_str()).collect();
for region in ®ions {
if !attempted.contains(region.as_str()) {
println!("{region}\tskipped (rollout halted)");
}
}
}
}
if any_failure {
std::process::exit(3);
}
Ok(())
}
fn write_rollout_audit_line(
rollout_id: &str,
region: &str,
env: &str,
version: &str,
stage: &str,
err: Option<&str>,
) {
let dir = ebman::util::cache_dir();
if std::fs::create_dir_all(&dir).is_err() {
return;
}
let path = dir.join("audit.log");
let when = chrono::Utc::now().to_rfc3339();
let line = match err {
None => format!(
"{when}\trollout_id={rollout_id}\tregion={region}\tstage={stage} action=Rollout target={env} version={version}\n"
),
Some(e) => format!(
"{when}\trollout_id={rollout_id}\tregion={region}\tstage={stage} action=Rollout target={env} version={version} err=\"{}\"\n",
e.replace('"', "'")
),
};
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
let _ = f.write_all(line.as_bytes());
}
}
async fn run_action_deploy(
aws: &aws::AwsClient,
env: &str,
version: Option<String>,
wait_for_green: Option<String>,
auto_rollback: Option<String>,
) -> Result<()> {
let Some(version) = version else {
eprintln!("ebman action deploy: --version LABEL is required");
std::process::exit(2);
};
let wait_for_green_secs = match wait_for_green {
Some(ref s) => match aws::parse_window_ms(s) {
Some(ms) => Some((ms / 1000) as u64),
None => {
eprintln!(
"ebman action deploy: --wait-for-green expects a duration like `5m` / `30m` / `1h`"
);
std::process::exit(2);
}
},
None => None,
};
let auto_rollback_secs = match auto_rollback {
Some(ref s) => match aws::parse_window_ms(s) {
Some(ms) => Some((ms / 1000) as u64),
None => {
eprintln!(
"ebman action deploy: --auto-rollback expects a duration like `5m` / `30m` / `1h`"
);
std::process::exit(2);
}
},
None => None,
};
let envs = aws
.list_environments()
.await
.map_err(|e| color_eyre::eyre::eyre!("list_environments: {e}"))?;
let snapshot = envs
.iter()
.find(|e| e.name == env)
.map(|e| e.version_label.clone());
let Some(snapshot_label) = snapshot else {
eprintln!("ebman action deploy: env '{env}' not found");
std::process::exit(2);
};
if auto_rollback_secs.is_some() && snapshot_label.is_empty() {
eprintln!(
"ebman action deploy: --auto-rollback requested but env '{env}' has no prior version to roll back to"
);
std::process::exit(2);
}
println!("dispatching deploy: env={env} version={version}");
if let Err(e) = aws.deploy_version(env, &version).await {
eprintln!("err: deploy_version: {e}");
std::process::exit(1);
}
if wait_for_green_secs.is_none() && auto_rollback_secs.is_none() {
println!("ok: deploy on {env} dispatched (version={version})");
return Ok(());
}
let start = tokio::time::Instant::now();
let poll_interval = std::time::Duration::from_secs(5);
let mut wait_for_green_timeout_emitted = false;
println!(
"polling {env} every {}s for Green{}{}",
poll_interval.as_secs(),
wait_for_green_secs
.map(|s| format!(", wait-for-green={s}s"))
.unwrap_or_default(),
auto_rollback_secs
.map(|s| format!(", auto-rollback={s}s"))
.unwrap_or_default(),
);
loop {
tokio::time::sleep(poll_interval).await;
let envs = match aws.list_environments().await {
Ok(envs) => envs,
Err(e) => {
eprintln!("err: list_environments during poll: {e}");
std::process::exit(1);
}
};
let (status, health) = envs
.iter()
.find(|e| e.name == env)
.map(|e| (e.status.clone(), e.health.clone()))
.unwrap_or_default();
let elapsed = start.elapsed().as_secs();
match decide_poll(
&status,
&health,
elapsed,
wait_for_green_secs,
auto_rollback_secs,
wait_for_green_timeout_emitted,
) {
PollDecision::KeepPolling => {
println!("poll t={elapsed}s status={status} health={health}");
}
PollDecision::Success => {
println!("ok: deploy on {env} reached Green at t={elapsed}s (version={version})");
return Ok(());
}
PollDecision::WaitForGreenTimeout => {
wait_for_green_timeout_emitted = true;
if auto_rollback_secs.is_none() {
eprintln!(
"timeout: deploy on {env} did not reach Green within {}s (status={status}, health={health}, version={version})",
wait_for_green_secs.unwrap_or(0)
);
std::process::exit(4);
}
let remaining = auto_rollback_secs.unwrap_or(0).saturating_sub(elapsed);
println!(
"wait-for-green timeout at t={elapsed}s (status={status}, health={health}); continuing under auto-rollback ({remaining}s remaining)"
);
}
PollDecision::DispatchRollback => {
eprintln!(
"auto-rollback firing on {env}: env still status={status} health={health} at t={elapsed}s; redeploying snapshot version={snapshot_label}"
);
if let Err(e) = aws.deploy_version(env, &snapshot_label).await {
eprintln!("err: rollback deploy_version: {e}");
std::process::exit(1);
}
println!("ok: rollback dispatched on {env} (version={snapshot_label})");
std::process::exit(5);
}
}
}
}
async fn run_ctl_cli(args: &[String]) -> Result<()> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UnixStream;
let mut socket_path = control::default_socket_path();
let mut rest: Vec<&str> = Vec::new();
let mut iter = args.iter().skip(1);
while let Some(arg) = iter.next() {
if arg == "--socket" {
if let Some(p) = iter.next() {
socket_path = std::path::PathBuf::from(p);
} else {
eprintln!("ebman ctl: --socket requires a path");
std::process::exit(2);
}
} else {
rest.push(arg.as_str());
}
}
if rest.is_empty() {
eprintln!(
"usage: ebman ctl <screen|key|cmd|state> [args] [--socket PATH]\n\
examples:\n ebman ctl screen\n ebman ctl key Down\n ebman ctl key Ctrl+R\n \
ebman ctl cmd region eu-west-2\n ebman ctl state"
);
std::process::exit(2);
}
let head = rest[0].to_ascii_uppercase();
let body = rest[1..].join(" ");
let request = if body.is_empty() {
head
} else {
format!("{head} {body}")
};
let mut stream = UnixStream::connect(&socket_path).await.map_err(|e| {
color_eyre::eyre::eyre!(
"ebman ctl: connect to {} failed: {e}\n hint: start ebman with `--control-socket {}`",
socket_path.display(),
socket_path.display()
)
})?;
stream.write_all(request.as_bytes()).await?;
stream.write_all(b"\n").await?;
let mut response = String::new();
stream.read_to_string(&mut response).await?;
print!("{response}");
if !response.ends_with('\n') {
println!();
}
if response.starts_with("ERR ") {
std::process::exit(1);
}
Ok(())
}
fn cli_esc(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
const SPLASH_SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
fn draw_splash(terminal: &mut Tui, frame: u64, icons: &str) -> Result<()> {
use ratatui::layout::{Alignment, Constraint, Direction, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
terminal.draw(|f| {
let area = f.area();
let powerline = icons == "powerline";
let show_scene = splash::splash_shows_scene(area.width, area.height);
let (card_w, card_h): (u16, u16) = if show_scene { (46, 30) } else { (52, 9) };
let v = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0),
Constraint::Length(card_h),
Constraint::Min(0),
])
.split(area);
let h = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(0),
Constraint::Length(card_w),
Constraint::Min(0),
])
.split(v[1]);
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(""));
if show_scene {
lines.extend(splash::splash_scene_lines(frame));
lines.push(Line::from(""));
}
if powerline {
let tag_bg = Color::Rgb(35, 45, 60);
let tag_fg = Color::Rgb(170, 180, 200);
let cloud = "\u{f0c2}"; lines.push(
Line::from(vec![
Span::styled("\u{e0b6}", Style::default().fg(tag_bg)),
Span::styled(
format!(" {cloud} k9s-style TUI for AWS Elastic Beanstalk "),
Style::default().fg(tag_fg).bg(tag_bg),
),
Span::styled("\u{e0b4}", Style::default().fg(tag_bg)),
])
.alignment(Alignment::Center),
);
let by_bg = Color::Rgb(50, 40, 75);
let by_fg = Color::Rgb(220, 195, 245);
lines.push(
Line::from(vec![
Span::styled("\u{e0b6}", Style::default().fg(by_bg)),
Span::styled(
" by Tom Baldwin · Polymorphism Ltd ",
Style::default().fg(by_fg).bg(by_bg),
),
Span::styled("\u{e0b4}", Style::default().fg(by_bg)),
])
.alignment(Alignment::Center),
);
} else {
lines.push(
Line::from(Span::styled(
"k9s-style TUI for AWS Elastic Beanstalk",
Style::default().fg(Color::Rgb(150, 155, 170)),
))
.alignment(Alignment::Center),
);
lines.push(
Line::from(Span::styled(
"by Tom Baldwin · Polymorphism Ltd",
Style::default().fg(Color::Rgb(180, 140, 230)),
))
.alignment(Alignment::Center),
);
}
lines.push(Line::from(""));
let spinner = SPLASH_SPINNER[(frame as usize / 3) % SPLASH_SPINNER.len()];
let dots = ".".repeat((frame as usize / 8) % 4);
lines.push(
Line::from(Span::styled(
format!("{spinner} connecting to AWS{dots}"),
Style::default()
.fg(Color::Rgb(255, 200, 120))
.add_modifier(Modifier::BOLD),
))
.alignment(Alignment::Center),
);
const BORDER_SPOTLIGHT_FRAMES: f64 = 33.0;
let border_phase = (frame as f64 / BORDER_SPOTLIGHT_FRAMES).clamp(0.0, 1.0);
let border_glow = if border_phase < 0.5 {
border_phase * 2.0
} else {
(1.0 - border_phase) * 2.0
};
let border_hue = 180.0 + border_glow * 120.0;
let (br, bg, bb) = hsl_to_rgb(border_hue, 0.60, 0.65);
let mut block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Rgb(br, bg, bb)));
if powerline {
let tab_bg = Color::Rgb(60, 50, 80);
let tab_fg = Color::Rgb(220, 200, 250);
let version = format!(" v{} ", env!("CARGO_PKG_VERSION"));
let title = Line::from(vec![
Span::styled("\u{e0b6}", Style::default().fg(tab_bg)),
Span::styled(
version,
Style::default()
.fg(tab_fg)
.bg(tab_bg)
.add_modifier(Modifier::BOLD),
),
Span::styled("\u{e0b4}", Style::default().fg(tab_bg)),
]);
block = block.title(title).title_alignment(Alignment::Center);
}
f.render_widget(Paragraph::new(lines).block(block), h[1]);
})?;
Ok(())
}
fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (u8, u8, u8) {
let h = h.rem_euclid(360.0);
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let h_prime = h / 60.0;
let x = c * (1.0 - (h_prime.rem_euclid(2.0) - 1.0).abs());
let (r1, g1, b1) = if h_prime < 1.0 {
(c, x, 0.0)
} else if h_prime < 2.0 {
(x, c, 0.0)
} else if h_prime < 3.0 {
(0.0, c, x)
} else if h_prime < 4.0 {
(0.0, x, c)
} else if h_prime < 5.0 {
(x, 0.0, c)
} else {
(c, 0.0, x)
};
let m = l - c / 2.0;
let to_u8 = |v: f64| ((v + m) * 255.0).round().clamp(0.0, 255.0) as u8;
(to_u8(r1), to_u8(g1), to_u8(b1))
}
fn enter_tui() -> Result<Tui> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
execute!(stdout, crossterm::terminal::SetTitle("ebman"))?;
Ok(Terminal::new(CrosstermBackend::new(stdout))?)
}
fn leave_tui(terminal: &mut Tui) -> Result<()> {
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
Ok(())
}
fn install_panic_hook() {
let original = panic::take_hook();
panic::set_hook(Box::new(move |info| {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
write_crash_report(info);
original(info);
}));
}
fn write_crash_report(info: &panic::PanicHookInfo<'_>) {
let dir = util::cache_dir();
if std::fs::create_dir_all(&dir).is_err() {
return;
}
prune_old_crash_reports(&dir, MAX_CRASH_REPORTS);
let ts = chrono::Utc::now().format("%Y%m%dT%H%M%SZ");
let path = dir.join(format!("crash-{ts}.log"));
let location = info
.location()
.map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
.unwrap_or_else(|| "unknown".into());
let payload = info
.payload()
.downcast_ref::<&str>()
.map(|s| (*s).to_string())
.or_else(|| info.payload().downcast_ref::<String>().cloned())
.unwrap_or_else(|| "<non-string panic payload>".into());
let backtrace = std::backtrace::Backtrace::force_capture();
let report = format!(
"ebman {} crashed at {ts}\n\
location: {location}\n\
payload: {payload}\n\
\n--- backtrace ---\n{backtrace}\n",
env!("CARGO_PKG_VERSION")
);
let _ = std::fs::write(&path, report);
eprintln!("ebman: crash report written to {}", path.display());
}
const MAX_CRASH_REPORTS: usize = 10;
const CRASH_REPORT_MAX_AGE_DAYS: u64 = 30;
fn prune_old_crash_reports(dir: &std::path::Path, keep: usize) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let mut crashes: Vec<std::path::PathBuf> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with("crash-") && n.ends_with(".log"))
.unwrap_or(false)
})
.collect();
let age_cutoff = std::time::SystemTime::now()
.checked_sub(std::time::Duration::from_secs(
CRASH_REPORT_MAX_AGE_DAYS * 24 * 3600,
))
.unwrap_or(std::time::UNIX_EPOCH);
crashes.retain(|p| {
let too_old = std::fs::metadata(p)
.and_then(|m| m.modified())
.map(|t| t < age_cutoff)
.unwrap_or(false);
if too_old {
let _ = std::fs::remove_file(p);
}
!too_old
});
if crashes.len() < keep {
return;
}
crashes.sort();
let drop_count = crashes.len().saturating_sub(keep - 1);
for p in crashes.into_iter().take(drop_count) {
let _ = std::fs::remove_file(p);
}
}
fn init_logging() -> Result<LogReloadHandle> {
let log_dir = dirs_log_dir();
std::fs::create_dir_all(&log_dir).ok();
let file_appender = tracing_appender::rolling::never(log_dir, "ebman.log");
let env_filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info,aws=warn,hyper=warn"));
let (filter_layer, handle) = reload::Layer::new(env_filter);
let fmt_layer = tracing_subscriber::fmt::layer()
.with_writer(file_appender)
.with_ansi(false);
tracing_subscriber::registry()
.with(filter_layer)
.with(fmt_layer)
.init();
Ok(handle)
}
fn dirs_log_dir() -> std::path::PathBuf {
util::cache_dir()
}
#[cfg(test)]
mod tests {
use super::{cli_esc, decide_poll, hsl_to_rgb, prune_old_crash_reports, PollDecision};
#[test]
fn decide_poll_green_plus_ready_returns_success_regardless_of_deadlines() {
assert_eq!(
decide_poll("Ready", "Green", 600, Some(300), Some(600), false),
PollDecision::Success
);
assert_eq!(
decide_poll("Ready", "Ok", 0, None, None, false),
PollDecision::Success
);
assert_eq!(
decide_poll("ready", "ok", 0, Some(300), None, false),
PollDecision::Success
);
}
#[test]
fn decide_poll_green_during_updating_is_not_success() {
assert_eq!(
decide_poll("Updating", "Green", 5, Some(300), Some(600), false),
PollDecision::KeepPolling
);
assert_eq!(
decide_poll("Launching", "Green", 5, Some(300), None, false),
PollDecision::KeepPolling
);
}
#[test]
fn decide_poll_keep_polling_before_any_deadline() {
assert_eq!(
decide_poll("Ready", "Red", 30, Some(300), Some(600), false),
PollDecision::KeepPolling
);
assert_eq!(
decide_poll("Updating", "Yellow", 60, None, None, false),
PollDecision::KeepPolling
);
}
#[test]
fn decide_poll_wait_for_green_only_emits_timeout_once() {
assert_eq!(
decide_poll("Ready", "Red", 301, Some(300), None, false),
PollDecision::WaitForGreenTimeout
);
assert_eq!(
decide_poll("Ready", "Red", 350, Some(300), None, true),
PollDecision::KeepPolling
);
}
#[test]
fn decide_poll_rollback_wins_when_both_deadlines_passed() {
assert_eq!(
decide_poll("Ready", "Red", 700, Some(300), Some(600), false),
PollDecision::DispatchRollback
);
}
#[test]
fn decide_poll_wait_then_rollback_sequence() {
assert_eq!(
decide_poll("Updating", "Yellow", 200, Some(300), Some(600), false),
PollDecision::KeepPolling
);
assert_eq!(
decide_poll("Ready", "Red", 350, Some(300), Some(600), false),
PollDecision::WaitForGreenTimeout
);
assert_eq!(
decide_poll("Ready", "Red", 500, Some(300), Some(600), true),
PollDecision::KeepPolling
);
assert_eq!(
decide_poll("Ready", "Red", 601, Some(300), Some(600), true),
PollDecision::DispatchRollback
);
}
#[test]
fn decide_poll_rollback_only_no_intermediate_emission() {
assert_eq!(
decide_poll("Updating", "Yellow", 100, None, Some(300), false),
PollDecision::KeepPolling
);
assert_eq!(
decide_poll("Ready", "Red", 301, None, Some(300), false),
PollDecision::DispatchRollback
);
}
#[test]
fn cli_esc_escapes_quotes_and_backslashes() {
assert_eq!(cli_esc("hello"), "hello");
assert_eq!(cli_esc("a\"b"), "a\\\"b");
assert_eq!(cli_esc("a\\b"), "a\\\\b");
}
#[test]
fn hsl_to_rgb_red() {
let (r, g, b) = hsl_to_rgb(0.0, 1.0, 0.5);
assert_eq!((r, g, b), (255, 0, 0));
}
#[test]
fn hsl_to_rgb_cyan_and_magenta() {
let (r, g, b) = hsl_to_rgb(180.0, 1.0, 0.5);
assert_eq!((r, g, b), (0, 255, 255));
let (r, g, b) = hsl_to_rgb(300.0, 1.0, 0.5);
assert_eq!((r, g, b), (255, 0, 255));
}
#[test]
fn prune_old_crash_reports_keeps_newest() {
let dir = std::env::temp_dir().join(format!("ebman-prune-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let names = [
"crash-20260101T000000Z.log",
"crash-20260102T000000Z.log",
"crash-20260103T000000Z.log",
"crash-20260104T000000Z.log",
"crash-20260105T000000Z.log",
];
for n in names {
std::fs::write(dir.join(n), b"x").unwrap();
}
std::fs::write(dir.join("not-a-crash.log"), b"y").unwrap();
prune_old_crash_reports(&dir, 3);
assert!(!dir.join(names[0]).exists());
assert!(!dir.join(names[1]).exists());
assert!(!dir.join(names[2]).exists());
assert!(dir.join(names[3]).exists());
assert!(dir.join(names[4]).exists());
assert!(dir.join("not-a-crash.log").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn prune_old_crash_reports_under_limit_is_noop() {
let dir = std::env::temp_dir().join(format!("ebman-prune-under-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("crash-2026.log"), b"x").unwrap();
prune_old_crash_reports(&dir, 5);
assert!(dir.join("crash-2026.log").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn prune_old_crash_reports_drops_files_past_ttl() {
let dir = std::env::temp_dir().join(format!("ebman-prune-ttl-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let fresh = dir.join("crash-fresh.log");
let stale = dir.join("crash-stale.log");
std::fs::write(&fresh, b"x").unwrap();
std::fs::write(&stale, b"x").unwrap();
let past = std::time::SystemTime::now() - std::time::Duration::from_secs(60 * 24 * 3600);
let file = std::fs::File::open(&stale).unwrap();
file.set_modified(past).unwrap();
drop(file);
prune_old_crash_reports(&dir, 10);
assert!(fresh.exists(), "fresh file should survive");
assert!(!stale.exists(), "stale file should be deleted by TTL");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn hsl_to_rgb_clamps_to_valid_range() {
for h in [-30.0, 0.0, 90.0, 180.0, 270.0, 360.0, 720.0] {
let (r, g, b) = hsl_to_rgb(h, 0.7, 0.65);
let max = r.max(g).max(b);
let min = r.min(g).min(b);
assert!(max > min, "hue {h} collapsed to greyscale");
}
assert_eq!(hsl_to_rgb(-30.0, 0.7, 0.65), hsl_to_rgb(330.0, 0.7, 0.65));
assert_eq!(hsl_to_rgb(0.0, 0.7, 0.65), hsl_to_rgb(360.0, 0.7, 0.65));
let (r, g, b) = hsl_to_rgb(123.0, 0.0, 0.5);
assert_eq!(r, g);
assert_eq!(g, b);
}
}