Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/boxlite/src/runtime/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,19 @@ impl BoxliteRuntime {
)),
}
}

/// True iff this runtime is REST-backed (vs. a local VM backend).
///
/// CLI commands that mix capabilities only meaningful on one side
/// (e.g. `info` showing host home_dir + virtualization vs. a remote
/// URL, or `logs` reading a host file that doesn't exist over REST)
/// need to render different output by backend kind. Keying on
/// `auth_backend` is stable across the planned image-over-REST
/// expansion — REST is identified by "has a remote identity," not
/// by "lacks image ops."
pub fn is_rest(&self) -> bool {
self.auth_backend.is_some()
}
}

// ============================================================================
Expand Down
132 changes: 132 additions & 0 deletions src/cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,22 @@ impl GlobalFlags {
.to_string()
}

/// REST URL the runtime would talk to, applying the same precedence as
/// `create_runtime`: `--url` / `BOXLITE_REST_URL` > stored profile's
/// URL > none. Returns `None` when no REST destination is configured
/// (the local runtime case). Used by commands like `info` to render
/// "where am I pointing at" without rebuilding the precedence ladder.
pub fn resolved_url(&self) -> Option<String> {
if let Some(u) = self.url.as_deref().filter(|s| !s.is_empty()) {
return Some(u.to_string());
}
crate::credentials::load_named(&self.resolved_profile())
.ok()
.flatten()
.map(|p| p.url)
.filter(|u| !u.is_empty())
}

/// Resolve runtime options from config file and CLI overrides (--home, --registry).
pub fn resolve_runtime_options(&self) -> anyhow::Result<BoxliteOptions> {
let mut options = if let Some(config_path) = &self.config {
Expand Down Expand Up @@ -1142,4 +1158,120 @@ mod tests {
};
assert!(login.api_key_stdin);
}

/// Lay down a credentials file readable by `credentials::load_named`
/// rooted at `home`, and point `BOXLITE_HOME` at it for the duration of
/// the test. Returns a guard that restores the previous `BOXLITE_HOME`
/// (and releases the cross-test lock) on drop.
fn install_creds(home: &std::path::Path, body: &str) -> EnvGuard {
std::fs::create_dir_all(home).unwrap();
std::fs::write(home.join("credentials.toml"), body).unwrap();
EnvGuard::set("BOXLITE_HOME", home.to_str().unwrap())
}

/// All tests that mutate `BOXLITE_HOME` must serialize through this
/// mutex — `cargo test` runs tests in the same process by default and
/// env vars are process-global, so a parallel test would see another
/// test's override and pick up the wrong credentials.toml.
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

struct EnvGuard {
key: String,
prev: Option<std::ffi::OsString>,
_lock: std::sync::MutexGuard<'static, ()>,
}
impl EnvGuard {
fn set(key: &str, val: &str) -> Self {
let lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let prev = std::env::var_os(key);
// SAFETY: the mutex above guarantees no other test in this
// module is touching env vars; the lock outlives the set/remove
// calls below via `_lock` held in this struct.
unsafe { std::env::set_var(key, val) };
Self {
key: key.to_string(),
prev,
_lock: lock,
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
match &self.prev {
Some(v) => std::env::set_var(&self.key, v),
None => std::env::remove_var(&self.key),
}
}
}
}

fn flags_with_profile(profile: Option<&str>, url: Option<&str>) -> GlobalFlags {
GlobalFlags {
debug: false,
home: None,
registry: vec![],
config: None,
url: url.map(str::to_string),
profile: profile.map(str::to_string),
path_prefix: None,
}
}

/// `--url` / `BOXLITE_REST_URL` beats whatever the profile carries —
/// same precedence as `create_runtime`, exercised at the helper boundary
/// so `info` / `logs` can render "where am I pointing at" without
/// re-deriving the ladder.
#[test]
fn resolved_url_prefers_explicit_url_over_profile() {
let tmp = TempDir::new().unwrap();
let _g = install_creds(
tmp.path(),
"[profiles.p1]\nurl = \"http://from-profile:1\"\nauth_method = \"api_key\"\n",
);
let flags = flags_with_profile(Some("p1"), Some("http://from-flag:2"));
assert_eq!(flags.resolved_url().as_deref(), Some("http://from-flag:2"));
}

/// With no `--url`, the stored profile's URL wins — the path that POL-30
/// / POL-31 broke for `info` and `logs` while every other command
/// already honored it.
#[test]
fn resolved_url_falls_back_to_profile_url() {
let tmp = TempDir::new().unwrap();
let _g = install_creds(
tmp.path(),
"[profiles.p1]\nurl = \"http://from-profile:1\"\nauth_method = \"api_key\"\n",
);
let flags = flags_with_profile(Some("p1"), None);
assert_eq!(
flags.resolved_url().as_deref(),
Some("http://from-profile:1")
);
}

/// No `--url`, no matching profile → `None`. Caller (e.g. `info`)
/// then knows it's looking at a local runtime and can render local
/// fields (`home_dir`, virtualization probe).
#[test]
fn resolved_url_is_none_when_profile_missing() {
let tmp = TempDir::new().unwrap();
let _g = install_creds(tmp.path(), ""); // empty credentials file
let flags = flags_with_profile(Some("missing"), None);
assert!(flags.resolved_url().is_none());
}

/// An explicitly empty URL in the profile is treated as "no URL" —
/// otherwise `info --profile p1` would silently produce an
/// unreachable connect on a malformed profile.
#[test]
fn resolved_url_treats_empty_string_as_none() {
let tmp = TempDir::new().unwrap();
let _g = install_creds(
tmp.path(),
"[profiles.p1]\nurl = \"\"\nauth_method = \"api_key\"\n",
);
let flags = flags_with_profile(Some("p1"), None);
assert!(flags.resolved_url().is_none());
}
}
46 changes: 37 additions & 9 deletions src/cli/src/commands/info.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
use crate::cli::GlobalFlags;
use crate::formatter;
use boxlite::BoxStatus;
use boxlite::{BoxStatus, BoxliteError};
use clap::Args;
use clap::ValueEnum;
use serde::Serialize;

/// System-wide runtime information (CLI output shape).
///
/// `homeDir` / `virtualization` are populated for the local backend; for
/// REST they become the URL string and a `"remote"` sentinel so the user
/// can see at a glance which environment the count fields describe.
/// `imagesCount` is omitted (set to `None`) when the backend doesn't
/// expose image listing — currently the REST backend, until image
/// endpoints land.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct SystemInfo {
Expand All @@ -18,7 +25,8 @@ struct SystemInfo {
boxes_running: u32,
boxes_stopped: u32,
boxes_configured: u32,
images_count: u32,
#[serde(skip_serializing_if = "Option::is_none")]
images_count: Option<u32>,
}

/// Display system-wide runtime information (default: YAML).
Expand All @@ -37,14 +45,26 @@ pub enum InfoFormat {
}

pub async fn execute(args: InfoArgs, global: &GlobalFlags) -> anyhow::Result<()> {
let options = global.resolve_runtime_options()?;
let home_dir = options.home_dir.to_string_lossy().to_string();
let rt = global.create_runtime()?;
let is_rest = rt.is_rest();

let (home_dir, virtualization) = if is_rest {
// Local-only fields don't describe the environment the box/image
// counts come from; render the URL we're talking to instead.
let url = global
.resolved_url()
.unwrap_or_else(|| "(remote)".to_string());
(url, "remote".to_string())
} else {
let options = global.resolve_runtime_options()?;
let home = options.home_dir.to_string_lossy().to_string();
let virt = boxlite::system_check::SystemCheck::run()
.map(|_| "available".to_string())
.unwrap_or_else(|e| format!("unavailable: {}", e));
(home, virt)
};

let rt = global.create_runtime_with_options(options)?;
let version = boxlite::VERSION.to_string();
let virtualization = boxlite::system_check::SystemCheck::run()
.map(|_| "available".to_string())
.unwrap_or_else(|e| format!("unavailable: {}", e));
let os = std::env::consts::OS.to_string();
let arch = std::env::consts::ARCH.to_string();

Expand All @@ -60,7 +80,15 @@ pub async fn execute(args: InfoArgs, global: &GlobalFlags) -> anyhow::Result<()>
.filter(|b| b.status == BoxStatus::Configured)
.count() as u32;

let images_count = rt.images()?.list().await?.len() as u32;
// Image listing is local-only today; surface it when the backend
// supports it and omit the field otherwise (rather than misreporting
// 0). When REST image endpoints land, removing this branch keeps the
// count visible.
let images_count = match rt.images() {
Ok(handle) => Some(handle.list().await?.len() as u32),
Err(BoxliteError::Unsupported(_)) => None,
Err(e) => return Err(e.into()),
};

let info = SystemInfo {
version,
Expand Down
19 changes: 16 additions & 3 deletions src/cli/src/commands/logs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,22 @@ pub struct LogsArgs {
}

pub async fn execute(args: LogsArgs, global: &GlobalFlags) -> anyhow::Result<()> {
let options = global.resolve_runtime_options()?;
let home_dir = options.home_dir.clone();
let rt = global.create_runtime_with_options(options)?;
let rt = global.create_runtime()?;

// `logs` reads the box's console.log file directly from the local
// host's $BOXLITE_HOME — there is no REST endpoint for log streaming
// yet. Falling back to local would silently misdirect a `--profile`
// user to the wrong environment (POL-30/31). Error cleanly instead.
if rt.is_rest() {
return Err(anyhow::anyhow!(
"`logs` is not yet supported over REST — re-run without --profile / \
BOXLITE_PROFILE / --url to read logs from the local runtime"
));
}

// Re-resolve options to learn the local home_dir. (We can't get it
// off the runtime — only the construction-time options had it.)
let home_dir = global.resolve_runtime_options()?.home_dir;

let litebox = rt
.get(&args.target)
Expand Down
Loading
Loading