use anyhow::Result;
use serde::{Deserialize, Serialize};
use super::config::ResolvedConfig;
use super::{
OutputFormat, Reaching, authed_client, get_auth_header, get_auth_header_for_output, with_auth,
};
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LeaseSummary {
pub id: String,
pub phase: String,
#[serde(default = "default_resource_kind")]
pub resource_kind: String,
#[serde(default)]
pub capabilities: Vec<String>,
#[serde(alias = "pool")]
pub profile: String,
#[serde(default, alias = "cluster_name")]
pub cluster_name: Option<String>,
#[serde(default, alias = "expires_at")]
pub expires_at: Option<String>,
#[serde(default, alias = "queue_position")]
pub queue_position: u32,
#[serde(default)]
pub requester: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kubeconfig_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub alias: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transport: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub iroh: Option<IrohDial>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(rename_all = "camelCase")]
pub(crate) struct IrohDial {
pub node_id: String,
#[serde(default)]
pub relay: String,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct LeaseDetail {
pub id: String,
pub phase: String,
#[serde(default = "default_resource_kind")]
pub resource_kind: String,
#[serde(default)]
pub capabilities: Vec<String>,
#[serde(alias = "pool")]
pub profile: String,
#[serde(default, alias = "clusterName")]
pub cluster_name: Option<String>,
#[serde(default, alias = "expiresAt")]
pub expires_at: Option<String>,
#[serde(default, alias = "queuePosition")]
pub queue_position: u32,
#[serde(default)]
pub metadata: Option<serde_json::Value>,
#[serde(default)]
pub kubeconfig: Option<String>,
#[serde(default)]
pub transport: Option<String>,
#[serde(default)]
pub iroh: Option<IrohDial>,
}
fn default_resource_kind() -> String {
"Cluster".to_string()
}
impl LeaseSummary {
pub(crate) fn is_sandbox(&self) -> bool {
self.resource_kind.eq_ignore_ascii_case("sandbox") || self.id.starts_with("sandbox-")
}
}
pub(crate) async fn fetch_leases_path(
config: &ResolvedConfig,
path: &str,
) -> Result<Vec<LeaseSummary>> {
let endpoint = config.endpoint.as_str();
let token = get_auth_header(config, "GET", path, b"").await?;
let client = authed_client();
let response = with_auth(client.get(format!("{endpoint}{path}")), &token)
.send()
.await
.reaching(config)?;
if !response.status().is_success() {
anyhow::bail!("Failed to list leases (HTTP {})", response.status());
}
Ok(response.json().await?)
}
pub(crate) async fn fetch_all_leases(config: &ResolvedConfig) -> Result<Vec<LeaseSummary>> {
fetch_all_leases_with_output(config, OutputFormat::Text).await
}
pub(crate) async fn fetch_all_leases_with_output(
config: &ResolvedConfig,
output: OutputFormat,
) -> Result<Vec<LeaseSummary>> {
let mut leases = if output == OutputFormat::Text {
fetch_leases_path(config, "/v1/leases").await?
} else {
let path = "/v1/leases";
let endpoint = config.endpoint.as_str();
let token = get_auth_header_for_output(config, "GET", path, b"", output).await?;
let response = with_auth(authed_client().get(format!("{endpoint}{path}")), &token)
.send()
.await
.reaching(config)?;
if !response.status().is_success() {
anyhow::bail!("Failed to list leases (HTTP {})", response.status());
}
response.json().await?
};
for lease in &mut leases {
if lease.id.starts_with("sandbox-") {
lease.resource_kind = "Sandbox".to_string();
} else if lease.resource_kind.is_empty() {
lease.resource_kind = "Cluster".to_string();
}
if lease.capabilities.is_empty() {
lease.capabilities = if lease.is_sandbox() {
[
"exec",
"cancel",
"logs",
"attach",
"port-forward",
"extend",
"release",
]
.map(str::to_string)
.to_vec()
} else {
["kubeconfig", "extend", "release"]
.map(str::to_string)
.to_vec()
};
}
}
leases.sort_by(|left, right| left.id.cmp(&right.id));
Ok(leases)
}
pub(crate) fn is_terminal_phase(phase: &str) -> bool {
matches!(
phase.to_ascii_lowercase().as_str(),
"released" | "expired" | "recycling"
)
}
pub(crate) async fn find_active_lease_by_alias(
config: &ResolvedConfig,
alias: &str,
) -> Result<Option<LeaseSummary>> {
let found = fetch_all_leases(config)
.await?
.into_iter()
.find(|l| l.alias.as_deref() == Some(alias) && !is_terminal_phase(&l.phase));
Ok(found)
}
pub(crate) async fn fetch_lease(config: &ResolvedConfig, lease_id: &str) -> Result<LeaseDetail> {
let sandbox = lease_id.starts_with("sandbox-");
let path = if sandbox {
format!("/v1/sandbox-leases/{lease_id}")
} else {
format!("/v1/leases/{lease_id}")
};
let endpoint = config.endpoint.as_str();
let token = get_auth_header(config, "GET", &path, b"").await?;
let client = authed_client();
let response = with_auth(client.get(format!("{endpoint}{path}")), &token)
.send()
.await
.reaching(config)?;
if !response.status().is_success() {
anyhow::bail!(
"Failed to get lease {lease_id} (HTTP {})",
response.status()
);
}
let mut detail: LeaseDetail = response.json().await?;
if sandbox {
detail.resource_kind = "Sandbox".to_string();
detail.capabilities = vec![
"exec".to_string(),
"cancel".to_string(),
"logs".to_string(),
"attach".to_string(),
"port-forward".to_string(),
"extend".to_string(),
"release".to_string(),
];
}
Ok(detail)
}
pub(crate) fn format_relative_time(iso: &str) -> String {
let Ok(expires) = chrono::DateTime::parse_from_rfc3339(iso) else {
return iso.to_string();
};
let now = chrono::Utc::now();
let diff = expires.signed_duration_since(now);
if diff.num_seconds() < 0 {
"expired".to_string()
} else if diff.num_hours() > 0 {
format!("{}h {}m left", diff.num_hours(), diff.num_minutes() % 60)
} else if diff.num_minutes() > 0 {
format!("{}m left", diff.num_minutes())
} else {
format!("{}s left", diff.num_seconds())
}
}
pub(crate) fn lease_phase_label(lease: &LeaseSummary) -> String {
lease.phase.to_ascii_lowercase()
}
pub(crate) fn lease_cluster_label(lease: &LeaseSummary) -> &str {
lease.cluster_name.as_deref().unwrap_or("-")
}
pub(crate) fn lease_when_label(lease: &LeaseSummary) -> String {
if lease.phase.eq_ignore_ascii_case("pending") && lease.queue_position > 0 {
format!("queue #{}", lease.queue_position)
} else if let Some(expires_at) = lease.expires_at.as_deref() {
format_relative_time(expires_at)
} else {
lease_phase_label(lease)
}
}
pub(crate) fn is_status_hidden_phase(phase: &str) -> bool {
matches!(phase.to_ascii_lowercase().as_str(), "released" | "expired")
}
pub(crate) fn short_lease_id(id: &str) -> String {
const HEAD: usize = 8;
const TAIL: usize = 3;
let Some(split) = id.find('-') else {
return id.to_string();
};
let (prefix, rest) = id.split_at(split + 1);
if rest.len() <= HEAD + TAIL + 1 {
return id.to_string();
}
format!("{prefix}{}…{}", &rest[..HEAD], &rest[rest.len() - TAIL..])
}
pub(crate) fn lease_glance_label(lease: &LeaseSummary) -> String {
let phase = lease_phase_label(lease);
if is_status_hidden_phase(&lease.phase) {
return phase;
}
if lease.phase.eq_ignore_ascii_case("pending") && lease.queue_position > 0 {
return format!("{phase} queue #{}", lease.queue_position);
}
let Some(expires_at) = lease.expires_at.as_deref() else {
return phase;
};
let when = format_relative_time(expires_at);
if when == "expired" || when == phase {
return phase;
}
let remaining = when.strip_suffix(" left").unwrap_or(&when);
format!("{phase} {remaining}")
}
pub(crate) fn format_lease_status_line(lease: &LeaseSummary) -> String {
let id = short_lease_id(&lease.id);
let glance = lease_glance_label(lease);
let mut line = match lease.alias.as_deref().filter(|alias| !alias.is_empty()) {
Some(alias) => format!("{id} {alias} {} {glance}", lease.profile),
None => format!("{id} {} {glance}", lease.profile),
};
if lease.transport.as_deref() == Some("iroh") {
match lease.iroh.as_ref() {
Some(iroh) if !iroh.node_id.is_empty() => {
line.push_str(&format!(" iroh {}", short_iroh_node(&iroh.node_id)));
}
_ => line.push_str(" iroh"),
}
}
line
}
fn short_iroh_node(node_id: &str) -> String {
match node_id.char_indices().nth(8) {
Some((idx, _)) => format!("{}…", &node_id[..idx]),
None => node_id.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn lease(id: &str, phase: &str, expires_at: Option<&str>, queue: u32) -> LeaseSummary {
LeaseSummary {
id: id.into(),
phase: phase.into(),
resource_kind: "Sandbox".into(),
capabilities: Vec::new(),
profile: "agent-trusted".into(),
cluster_name: None,
expires_at: expires_at.map(str::to_string),
queue_position: queue,
requester: None,
kubeconfig_path: None,
alias: None,
metadata: None,
transport: None,
iroh: None,
}
}
#[test]
fn iroh_leases_show_transport_and_short_node_id() {
let mut lease = lease("sandbox-1", "Ready", None, 0);
lease.transport = Some("iroh".into());
lease.iroh = Some(IrohDial {
node_id: "abcdef0123456789deadbeef".into(),
relay: "public".into(),
});
let line = format_lease_status_line(&lease);
assert!(line.contains("iroh abcdef01…"), "{line}");
lease.iroh = None;
let line = format_lease_status_line(&lease);
assert!(line.ends_with("iroh"), "{line}");
}
#[test]
fn short_lease_id_keeps_prefix_and_tail() {
assert_eq!(
short_lease_id("sandbox-68c264e7eac6158b20edc7c9"),
"sandbox-68c264e7…7c9"
);
assert_eq!(short_lease_id("lease-abc"), "lease-abc");
assert_eq!(short_lease_id("lease-a1b2c3d4e5f6"), "lease-a1b2c3d4e5f6");
assert_eq!(short_lease_id("nohyphen"), "nohyphen");
}
#[test]
fn status_line_includes_alias_when_set() {
let mut named = lease("sandbox-68c264e7eac6158b20edc7c9", "Ready", None, 0);
named.alias = Some("pr-106".into());
assert_eq!(
format_lease_status_line(&named),
"sandbox-68c264e7…7c9 pr-106 agent-trusted ready"
);
let unnamed = lease("sandbox-68c264e7eac6158b20edc7c9", "Released", None, 0);
assert_eq!(
format_lease_status_line(&unnamed),
"sandbox-68c264e7…7c9 agent-trusted released"
);
}
#[test]
fn glance_label_does_not_repeat_expired() {
let expired = lease("sandbox-x", "Expired", Some("2020-01-01T00:00:00Z"), 0);
assert_eq!(lease_glance_label(&expired), "expired");
let released = lease("sandbox-x", "Released", Some("2020-01-01T00:00:00Z"), 0);
assert_eq!(lease_glance_label(&released), "released");
}
#[test]
fn glance_label_ready_shows_remaining_ttl() {
let expires = (chrono::Utc::now() + chrono::Duration::minutes(95)).to_rfc3339();
let ready = lease("sandbox-x", "Ready", Some(&expires), 0);
let label = lease_glance_label(&ready);
assert!(
label.starts_with("ready 1h "),
"expected remaining hours, got {label}"
);
}
#[test]
fn glance_label_pending_queue() {
let pending = lease("sandbox-x", "Pending", None, 3);
assert_eq!(lease_glance_label(&pending), "pending queue #3");
}
#[test]
fn a_listing_row_keeps_the_fields_the_operator_spells_snake_case() {
let row: LeaseSummary = serde_json::from_str(
r#"{
"id": "lease-abc",
"phase": "Bound",
"resourceKind": "Cluster",
"profile": "ci-small",
"cluster_name": "kobe-ci-7",
"expires_at": "2026-09-16T10:51:51Z",
"queue_position": 4
}"#,
)
.expect("a listing row parses");
assert_eq!(row.expires_at.as_deref(), Some("2026-09-16T10:51:51Z"));
assert_eq!(row.cluster_name.as_deref(), Some("kobe-ci-7"));
assert_eq!(row.queue_position, 4);
}
#[test]
fn a_listing_row_parses_the_camel_case_spelling_too() {
let row: LeaseSummary = serde_json::from_str(
r#"{
"id": "lease-abc",
"phase": "Bound",
"resourceKind": "Cluster",
"profile": "ci-small",
"clusterName": "kobe-ci-7",
"expiresAt": "2026-09-16T10:51:51Z",
"queuePosition": 4
}"#,
)
.expect("a listing row parses");
assert_eq!(row.expires_at.as_deref(), Some("2026-09-16T10:51:51Z"));
assert_eq!(row.cluster_name.as_deref(), Some("kobe-ci-7"));
assert_eq!(row.queue_position, 4);
}
#[test]
fn a_detail_body_parses_either_spelling() {
for body in [
r#"{"id":"lease-abc","phase":"Bound","profile":"ci-small",
"expires_at":"2026-09-16T10:51:51Z","queue_position":4}"#,
r#"{"id":"lease-abc","phase":"Bound","profile":"ci-small",
"expiresAt":"2026-09-16T10:51:51Z","queuePosition":4}"#,
] {
let detail: LeaseDetail = serde_json::from_str(body).expect("a detail body parses");
assert_eq!(detail.expires_at.as_deref(), Some("2026-09-16T10:51:51Z"));
assert_eq!(detail.queue_position, 4);
}
}
}