use std::path::PathBuf;
use anyhow::{Context, Result};
use tokio::sync::oneshot;
use super::OutputFormat;
use super::config::{CliConfig, ResolvedConfig};
use super::keepalive::heartbeat_until;
use super::lease_create::{create_lease_request, parse_metadata_json, wait_for_usable_lease};
use super::pools::fetch_pool_for_config_with_output;
use super::release::release_lease;
pub struct WithLeaseCommand<'a> {
pub pool: Option<&'a str>,
pub ttl: &'a str,
pub metadata_json: Option<&'a str>,
pub cmd: &'a [String],
pub target_override: Option<&'a str>,
pub endpoint_override: Option<&'a str>,
pub output: OutputFormat,
}
struct TempFile(PathBuf);
impl Drop for TempFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
pub async fn with_lease(command: WithLeaseCommand<'_>) -> Result<()> {
let config = CliConfig::load()?;
let config = config.resolve(command.target_override, command.endpoint_override)?;
let verbose = command.output == OutputFormat::Text;
if verbose && let Some(endpoint_version) = super::version::fetch_endpoint_version(&config).await
{
super::version::warn_if_cli_behind_endpoint(&endpoint_version);
}
let pool_name = command
.pool
.context("with-lease requires a pool: kobe with-lease <pool> --ttl 1h -- <cmd>")?;
let pool = fetch_pool_for_config_with_output(&config, pool_name, command.output).await?;
if !pool.supports("kubeconfig") {
anyhow::bail!(
"pool {} allocates {} resources, which do not support with-lease; use `kobe run` for executable resources",
pool.name,
pool.resource_kind
);
}
if command.cmd.is_empty() {
anyhow::bail!("with-lease requires a command after `--`");
}
if verbose {
eprintln!("Leasing '{}' for the wrapped command...", pool.name);
}
let metadata = parse_metadata_json(command.metadata_json)?;
let accepted = create_lease_request(
&config,
&pool.name,
command.ttl,
None,
metadata.as_ref(),
None,
)
.await?;
let lease_id = accepted.id.clone();
let outcome = run_wrapped(
&config,
&lease_id,
accepted.effective_ttl.clone(),
command.ttl,
command.cmd,
verbose,
)
.await;
if let Err(e) = release_lease(&config, &lease_id).await {
eprintln!("Warning: failed to release lease {lease_id}: {e}");
} else if verbose {
eprintln!("Released lease {lease_id}");
}
match outcome {
Ok(0) => Ok(()),
Ok(code) => std::process::exit(code),
Err(e) => Err(e),
}
}
async fn run_wrapped(
config: &ResolvedConfig,
lease_id: &str,
effective_ttl: Option<String>,
ttl: &str,
cmd: &[String],
verbose: bool,
) -> Result<i32> {
let ready = wait_for_usable_lease(config, lease_id, effective_ttl, None).await?;
let kubeconfig = ready
.kubeconfig
.as_deref()
.ok_or_else(|| anyhow::anyhow!("Lease {lease_id} became bound without kubeconfig"))?;
let kpath = std::env::temp_dir().join(format!("kobe-{lease_id}.yaml"));
std::fs::write(&kpath, kubeconfig)
.with_context(|| format!("writing kubeconfig to {}", kpath.display()))?;
let _tmp = TempFile(kpath.clone());
if verbose {
eprintln!(
"Running `{}` with KUBECONFIG={}",
cmd.join(" "),
kpath.display()
);
}
let mut child = tokio::process::Command::new(&cmd[0])
.args(&cmd[1..])
.env("KUBECONFIG", &kpath)
.spawn()
.with_context(|| format!("failed to spawn '{}'", cmd[0]))?;
let (stop_tx, stop_rx) = oneshot::channel::<()>();
let hb = tokio::spawn({
let config = config.clone();
let lease_id = lease_id.to_string();
let ttl = ttl.to_string();
async move {
let stop = async {
let _ = stop_rx.await;
};
let _ = heartbeat_until(&config, &lease_id, &ttl, stop, verbose).await;
}
});
let code = wait_for_child_or_signal(&mut child, verbose).await;
let _ = stop_tx.send(());
let _ = hb.await;
Ok(code)
}
fn exit_code(status: std::io::Result<std::process::ExitStatus>) -> i32 {
status.ok().and_then(|s| s.code()).unwrap_or(1)
}
#[cfg(unix)]
async fn wait_for_child_or_signal(child: &mut tokio::process::Child, verbose: bool) -> i32 {
use tokio::signal::unix::{SignalKind, signal};
let signalled: Option<(&str, i32)> = match signal(SignalKind::terminate()) {
Ok(mut sigterm) => tokio::select! {
status = child.wait() => return exit_code(status),
_ = tokio::signal::ctrl_c() => Some(("SIGINT", 130)),
_ = sigterm.recv() => Some(("SIGTERM", 143)),
},
Err(_) => tokio::select! {
status = child.wait() => return exit_code(status),
_ = tokio::signal::ctrl_c() => Some(("SIGINT", 130)),
},
};
match signalled {
Some((name, code)) => {
if verbose {
eprintln!("{name} received; stopping command and releasing lease...");
}
let _ = child.start_kill();
let _ = child.wait().await;
code
}
None => 1,
}
}
#[cfg(not(unix))]
async fn wait_for_child_or_signal(child: &mut tokio::process::Child, verbose: bool) -> i32 {
tokio::select! {
status = child.wait() => return exit_code(status),
_ = tokio::signal::ctrl_c() => {}
}
if verbose {
eprintln!("Interrupted; stopping command and releasing lease...");
}
let _ = child.start_kill();
let _ = child.wait().await;
130
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_command_that_did_not_exit_cleanly_is_never_reported_as_success() {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
let signalled = std::process::ExitStatus::from_raw(9);
assert_eq!(
exit_code(Ok(signalled)),
1,
"a signal-killed child has no exit code and must not read as success"
);
}
let failed_wait = Err(std::io::Error::other("wait failed"));
assert_eq!(exit_code(failed_wait), 1);
}
#[cfg(unix)]
#[test]
fn a_clean_exit_code_is_propagated_verbatim() {
use std::os::unix::process::ExitStatusExt;
for code in [0, 1, 2, 42, 127] {
let status = std::process::ExitStatus::from_raw(code << 8);
assert_eq!(
exit_code(Ok(status)),
code,
"exit code {code} must survive the wrapper unchanged"
);
}
}
}