use crate::cli::GlobalFlags;
use crate::formatter::{self, GtmplWithJson, OutputFormat, value_from_serde_json};
use boxlite::{BoxInfo, BoxStateInfo, NetworkInfo, NetworkMode};
use clap::Args;
use serde::Serialize;
#[derive(Args, Debug)]
pub struct InspectArgs {
#[arg(
value_name = "BOX",
required_unless_present = "latest",
num_args = 1..,
conflicts_with = "latest"
)]
pub boxes: Vec<String>,
#[arg(short, long)]
pub latest: bool,
#[arg(short, long, default_value = "json")]
pub format: String,
}
#[derive(Debug, Serialize)]
struct InspectPresenter {
#[serde(rename = "Id")]
id: String,
#[serde(rename = "Name")]
name: String,
#[serde(rename = "Image")]
image: String,
#[serde(rename = "Created")]
created: String,
#[serde(rename = "Status")]
status: String,
#[serde(rename = "State")]
state: InspectStatePresenter,
#[serde(rename = "Cpus")]
cpus: u8,
#[serde(rename = "Memory")]
memory: u64,
#[serde(rename = "Network")]
network: Option<InspectNetworkPresenter>,
}
#[derive(Debug, Serialize)]
struct InspectNetworkPresenter {
#[serde(rename = "Outbound")]
outbound: InspectNetworkDirectionPresenter,
#[serde(rename = "Inbound")]
inbound: InspectNetworkDirectionPresenter,
}
#[derive(Debug, Serialize)]
struct InspectNetworkDirectionPresenter {
#[serde(rename = "Mode")]
mode: NetworkMode,
#[serde(rename = "AllowNet")]
allow_net: Vec<String>,
}
impl From<&NetworkInfo> for InspectNetworkPresenter {
fn from(network: &NetworkInfo) -> Self {
Self {
outbound: InspectNetworkDirectionPresenter {
mode: network.outbound.mode.clone(),
allow_net: network.outbound.allow_net.clone(),
},
inbound: InspectNetworkDirectionPresenter {
mode: network.inbound.mode.clone(),
allow_net: network.inbound.allow_net.clone(),
},
}
}
}
#[derive(Debug, Serialize)]
struct InspectStatePresenter {
#[serde(rename = "Status")]
status: String,
#[serde(rename = "Running")]
running: bool,
#[serde(rename = "Pid")]
pid: u32,
#[serde(rename = "ExitCode")]
exit_code: i32,
#[serde(rename = "StartedAt")]
started_at: Option<String>,
#[serde(rename = "LastActivityAt")]
last_activity_at: Option<String>,
}
impl From<&BoxInfo> for InspectPresenter {
fn from(info: &BoxInfo) -> Self {
let state = BoxStateInfo::from(info);
Self {
id: info.id.to_string(),
name: info.name.as_deref().unwrap_or("").to_string(),
image: info.image.clone(),
created: info.created_at.to_rfc3339(),
status: info.status.as_str().to_string(),
state: InspectStatePresenter {
status: state.status.as_str().to_string(),
running: state.running,
pid: state.pid.unwrap_or(0),
exit_code: state.exit_code.unwrap_or(0),
started_at: info.started_at.as_ref().map(|at| at.to_rfc3339()),
last_activity_at: info.last_activity_at.as_ref().map(|at| at.to_rfc3339()),
},
cpus: info.cpus,
memory: info.memory_mib as u64 * 1024 * 1024,
network: info.network.as_ref().map(InspectNetworkPresenter::from),
}
}
}
pub async fn execute(args: InspectArgs, global: &GlobalFlags) -> anyhow::Result<()> {
if !args.latest && args.boxes.is_empty() {
return Err(anyhow::anyhow!("no names or ids specified"));
}
if args.latest && !args.boxes.is_empty() {
return Err(anyhow::anyhow!(
"--latest and arguments cannot be used together"
));
}
let rt = global.create_runtime()?;
let (infos, errs) = resolve_inspect_infos(&rt, &args).await?;
if infos.is_empty() {
println!("[]");
return Err(errs.into_iter().next().unwrap());
}
let presenters: Vec<InspectPresenter> = infos.iter().map(InspectPresenter::from).collect();
let mut stdout = std::io::stdout().lock();
write_inspect_output(&presenters, &args.format, &mut stdout)?;
if !errs.is_empty() {
for e in &errs {
eprintln!("Error: {}", e);
}
return Err(errs.into_iter().next().unwrap());
}
Ok(())
}
fn looks_like_template(s: &str) -> bool {
s.contains("{{") && s.contains("}}")
}
fn parse_single_path_template(s: &str) -> Option<String> {
let t = s.trim();
let inner = t.strip_prefix("{{")?.trim().strip_suffix("}}")?.trim();
let path = inner.strip_prefix('.')?.trim();
if path.is_empty() || path.contains("{{") || path.contains("}}") {
return None;
}
if path
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_')
{
Some(path.to_string())
} else {
None
}
}
fn json_value_at_path<'a>(
root: &'a serde_json::Value,
path: &str,
) -> Option<&'a serde_json::Value> {
let mut current = root;
for segment in path.split('.') {
current = current.get(segment)?;
}
Some(current)
}
fn normalize_inspect_format(s: &str) -> String {
let s = s.replace(".ImageID", ".Image");
s.replace(".ID", ".Id")
}
async fn resolve_inspect_infos(
rt: &boxlite::BoxliteRuntime,
args: &InspectArgs,
) -> anyhow::Result<(Vec<boxlite::BoxInfo>, Vec<anyhow::Error>)> {
if args.latest {
let mut list = rt.list_info().await?;
list.sort_by_key(|b| std::cmp::Reverse(b.created_at));
match list.into_iter().next() {
Some(info) => Ok((vec![info], Vec::new())),
None => Err(anyhow::anyhow!("no boxes to inspect")),
}
} else {
let mut infos = Vec::new();
let mut errs = Vec::new();
for name_or_id in &args.boxes {
match rt.get_info(name_or_id).await? {
Some(i) => infos.push(i),
None => errs.push(anyhow::anyhow!("no such box: {}", name_or_id)),
}
}
Ok((infos, errs))
}
}
fn write_inspect_output<W: std::io::Write>(
presenters: &Vec<InspectPresenter>,
format_str: &str,
writer: &mut W,
) -> anyhow::Result<()> {
let format_parse = OutputFormat::from_str(format_str);
match format_parse {
Ok(OutputFormat::Table) => {
return Err(anyhow::anyhow!("inspect does not support table format"));
}
Ok(fmt @ (OutputFormat::Json | OutputFormat::Yaml)) => {
formatter::print_output(writer, presenters, fmt, |_, _| Ok(()))?;
}
Err(format_err) => {
if looks_like_template(format_str) {
let format = normalize_inspect_format(format_str);
let gtmpl = GtmplWithJson::parse(&format)
.map_err(|e| anyhow::anyhow!("template: {}", e))?;
for p in presenters {
let json_val = serde_json::to_value(p)
.map_err(|e| anyhow::anyhow!("inspect serialization: {}", e))?;
let out = if let Some(path) = parse_single_path_template(&format) {
if let Some(v) = json_value_at_path(&json_val, &path) {
if v.is_object() {
formatter::format_go_style_value(v)
} else {
let ctx = value_from_serde_json(&json_val);
gtmpl.render(ctx)?
}
} else {
let ctx = value_from_serde_json(&json_val);
gtmpl.render(ctx)?
}
} else {
let ctx = value_from_serde_json(&json_val);
gtmpl.render(ctx)?
};
writeln!(writer, "{}", out)?;
}
} else {
return Err(format_err);
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use boxlite::{BoxID, BoxStatus, HealthStatus};
use std::collections::HashMap;
fn inspect_info(started_at: Option<chrono::DateTime<chrono::Utc>>) -> BoxInfo {
let now = chrono::Utc::now();
BoxInfo {
id: BoxID::parse("inspect-capabilities").unwrap(),
name: Some("cap-box".into()),
status: BoxStatus::Configured,
created_at: now,
last_updated: now,
pid: None,
image: "alpine:latest".into(),
cpus: 1,
memory_mib: 512,
network: None,
labels: HashMap::new(),
auto_stop: 0,
auto_delete: 0,
auto_resume: true,
health_status: HealthStatus::new(),
exit_code: None,
started_at,
last_activity_at: None,
resolved_image: None,
}
}
#[test]
fn inspect_omits_advanced_capability_metadata() {
let info = inspect_info(None);
let value = serde_json::to_value(InspectPresenter::from(&info)).unwrap();
assert!(value.get("Advanced").is_none());
}
#[test]
fn inspect_exposes_started_at() {
let started_at = chrono::DateTime::from_timestamp(1, 0).unwrap();
let info = inspect_info(Some(started_at));
let value = serde_json::to_value(InspectPresenter::from(&info)).unwrap();
assert_eq!(value["State"]["StartedAt"], started_at.to_rfc3339());
let value = serde_json::to_value(InspectPresenter::from(&inspect_info(None))).unwrap();
assert!(value["State"]["StartedAt"].is_null());
}
#[test]
fn inspect_exposes_both_network_directions() {
use boxlite::{InboundNetworkInfo, NetworkInfo, NetworkMode, OutboundNetworkInfo};
let mut info = inspect_info(None);
info.network = Some(NetworkInfo::new(
OutboundNetworkInfo {
mode: NetworkMode::Enabled,
allow_net: vec!["api.openai.com".into()],
},
InboundNetworkInfo {
mode: NetworkMode::Enabled,
allow_net: Vec::new(),
},
None,
));
let value = serde_json::to_value(InspectPresenter::from(&info)).unwrap();
assert_eq!(
value["Network"],
serde_json::json!({
"Outbound": { "Mode": "enabled", "AllowNet": ["api.openai.com"] },
"Inbound": { "Mode": "enabled", "AllowNet": [] },
})
);
let value = serde_json::to_value(InspectPresenter::from(&inspect_info(None))).unwrap();
assert!(
value["Network"].is_null(),
"unknown policy must read as null, not as a default"
);
}
#[test]
fn inspect_exposes_last_activity_at() {
let last_activity_at = chrono::DateTime::from_timestamp(2, 0).unwrap();
let mut info = inspect_info(None);
info.last_activity_at = Some(last_activity_at);
let value = serde_json::to_value(InspectPresenter::from(&info)).unwrap();
assert_eq!(
value["State"]["LastActivityAt"],
last_activity_at.to_rfc3339()
);
let value = serde_json::to_value(InspectPresenter::from(&inspect_info(None))).unwrap();
assert!(value["State"]["LastActivityAt"].is_null());
}
}