diff --git a/src/cli.rs b/src/cli.rs index 8f1f4e6..d0e9c42 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -267,8 +267,8 @@ pub enum UserAction { SetPassword { /// Email or identity ID. target: String, - /// New password. - password: String, + /// New password. If omitted, reads from stdin. + password: Option, }, /// Onboard new user (create + welcome email). Onboard { @@ -427,7 +427,19 @@ mod tests { match cli.verb { Some(Verb::User { action: Some(UserAction::SetPassword { target, password }) }) => { assert_eq!(target, "admin@example.com"); - assert_eq!(password, "hunter2"); + assert_eq!(password, Some("hunter2".to_string())); + } + _ => panic!("expected User SetPassword"), + } + } + + #[test] + fn test_user_set_password_no_password() { + let cli = parse(&["sunbeam", "user", "set-password", "admin@example.com"]); + match cli.verb { + Some(Verb::User { action: Some(UserAction::SetPassword { target, password }) }) => { + assert_eq!(target, "admin@example.com"); + assert!(password.is_none()); } _ => panic!("expected User SetPassword"), } @@ -871,7 +883,16 @@ pub async fn dispatch() -> Result<()> { crate::users::cmd_user_enable(&target).await } Some(UserAction::SetPassword { target, password }) => { - crate::users::cmd_user_set_password(&target, &password).await + let pw = match password { + Some(p) => p, + None => { + eprint!("Password: "); + let mut pw = String::new(); + std::io::stdin().read_line(&mut pw)?; + pw.trim().to_string() + } + }; + crate::users::cmd_user_set_password(&target, &pw).await } Some(UserAction::Onboard { email, diff --git a/src/secrets.rs b/src/secrets.rs index 47904f6..c206184 100644 --- a/src/secrets.rs +++ b/src/secrets.rs @@ -132,18 +132,42 @@ async fn port_forward( .port(); let pod_name = pod_name.to_string(); + let ns = namespace.to_string(); let task = tokio::spawn(async move { + let mut current_pod = pod_name; loop { let (mut client_stream, _) = match listener.accept().await { Ok(s) => s, Err(_) => break, }; - let mut pf = match pods.portforward(&pod_name, &[remote_port]).await { + let pf_result = pods.portforward(¤t_pod, &[remote_port]).await; + let mut pf = match pf_result { Ok(pf) => pf, Err(e) => { - eprintln!("port-forward error: {e}"); - continue; + tracing::warn!("Port-forward failed, re-resolving pod: {e}"); + // Re-resolve the pod in case it restarted with a new name + if let Ok(new_client) = k::get_client().await { + let new_pods: Api = Api::namespaced(new_client.clone(), &ns); + let lp = ListParams::default(); + if let Ok(pod_list) = new_pods.list(&lp).await { + if let Some(name) = pod_list + .items + .iter() + .find(|p| { + p.metadata + .name + .as_deref() + .map(|n| n.starts_with(current_pod.split('-').next().unwrap_or(""))) + .unwrap_or(false) + }) + .and_then(|p| p.metadata.name.clone()) + { + current_pod = name; + } + } + } + continue; // next accept() iteration will retry } }; @@ -928,7 +952,76 @@ async fn seed_kratos_admin_identity(bao: &BaoClient) -> (String, String) { // ── cmd_seed — main entry point ───────────────────────────────────────────── /// Seed OpenBao KV with crypto-random credentials, then mirror to K8s Secrets. +/// File-based advisory lock for `cmd_seed` to prevent concurrent runs. +struct SeedLock { + path: std::path::PathBuf, +} + +impl SeedLock { + fn acquire() -> Result { + let lock_path = dirs::data_dir() + .unwrap_or_else(|| dirs::home_dir().unwrap().join(".local/share")) + .join("sunbeam") + .join("seed.lock"); + std::fs::create_dir_all(lock_path.parent().unwrap())?; + + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&lock_path) + { + Ok(mut f) => { + use std::io::Write; + write!(f, "{}", std::process::id())?; + Ok(SeedLock { path: lock_path }) + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + // Check if the PID in the file is still alive + if let Ok(pid_str) = std::fs::read_to_string(&lock_path) { + if let Ok(pid) = pid_str.trim().parse::() { + // kill(pid, 0) checks if process exists without sending a signal + let alive = is_pid_alive(pid); + if alive { + return Err(SunbeamError::secrets( + "Another sunbeam seed is already running. Wait for it to finish.", + )); + } + } + } + // Stale lock, remove and retry + std::fs::remove_file(&lock_path)?; + let mut f = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&lock_path)?; + use std::io::Write; + write!(f, "{}", std::process::id())?; + Ok(SeedLock { path: lock_path }) + } + Err(e) => Err(e.into()), + } + } +} + +impl Drop for SeedLock { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +/// Check if a process with the given PID is still alive. +fn is_pid_alive(pid: i32) -> bool { + std::process::Command::new("kill") + .args(["-0", &pid.to_string()]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + pub async fn cmd_seed() -> Result<()> { + let _lock = SeedLock::acquire()?; step("Seeding secrets..."); let seed_result = seed_openbao().await?;