Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4866c4e
tritonadm: ship bash completion via SMF setup service
nshalman May 7, 2026
80ab426
tritonadm: add post-setup admin-profile
nshalman May 7, 2026
206c6d4
tritonadm: move admin-profile from post-setup to dev
nshalman May 7, 2026
195f6d0
tritonadm image fetch-nocloud: clean up artifacts after upload
nshalman May 7, 2026
a590db0
tritonadm: suppress two intentional AL003 warnings with reasons
nshalman May 7, 2026
0592102
tritonadm: implement channel list/get/set/unset
nshalman May 7, 2026
378e80c
tritonadm dev admin-profile: install rustls crypto provider before pr…
nshalman May 7, 2026
4737396
tritonadm dev admin-profile: probe with the same trust chain triton uses
nshalman May 7, 2026
c2bbc60
gateway: tolerate cloudapi-shape errors on the proxy path
nshalman May 7, 2026
d073597
triton-cli: render API errors as <status> <reason>: <code>: <message>
nshalman May 7, 2026
0f40dac
tritonadm channel list: tighten column widths to match sdcadm
nshalman May 7, 2026
05a0000
tritonadm: hide URL override flags from --help
nshalman May 7, 2026
6fc9724
tritonadm image fetch-nocloud: add --list-releases for non-rolling ve…
nshalman May 8, 2026
0df58d3
tritonadm dev admin-profile: fix stale `post-setup` references
nshalman May 12, 2026
8abecd2
tritonadm channel unset: replace dead defensive code with let-else
nshalman May 12, 2026
4cfa63d
tritonadm image fetch-nocloud: don't claim cleanup success after a wa…
nshalman May 12, 2026
71d7338
tritonadm image freebsd: dedupe find_latest/list via shared fetch
nshalman May 12, 2026
56b39f0
triton-tls: introduce TlsTrust enum, replace bool across public API
nshalman May 12, 2026
3f13726
tritonadm dev admin-profile: replace string-matched TLS detection wit…
nshalman May 12, 2026
20bdd4a
CLAUDE.md: add five new advisories to known audit exceptions
nshalman May 12, 2026
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Trait-based OpenAPI-driven migration of Node.js services to Rust. API traits (Dr
4. `make audit` (check for vulnerabilities)
5. Commit only files related to this change — one commit = one logical change

**Known audit exceptions** (pre-existing, do not block commits): RUSTSEC-2023-0071 (rsa), RUSTSEC-2026-0009 (time), RUSTSEC-2024-0436 (paste), RUSTSEC-2025-0134 (rustls-pemfile).
**Known audit exceptions** (pre-existing, do not block commits): RUSTSEC-2023-0071 (rsa), RUSTSEC-2026-0009 (time), RUSTSEC-2024-0436 (paste), RUSTSEC-2025-0134 (rustls-pemfile), RUSTSEC-2026-0049 (rustls-webpki), RUSTSEC-2026-0098 (rustls-webpki), RUSTSEC-2026-0099 (rustls-webpki), RUSTSEC-2026-0104 (rustls-webpki), RUSTSEC-2026-0097 (rand).

## Common Make Targets

Expand Down
3 changes: 2 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

130 changes: 130 additions & 0 deletions cli/triton-cli/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@
//
// Copyright 2026 Edgecast Cloud LLC.

use std::error::Error as StdError;

// `triton_gateway_client::Error` is the re-export of
// `progenitor_client::Error`; the gateway client crate is the only
// progenitor-generated dep triton-cli pulls in directly.
use triton_gateway_client::Error as ProgError;
use triton_gateway_client::types::Error as ApiError;

/// Error type for resource-not-found conditions.
///
/// Commands that fail because a named resource (instance, image, package, etc.)
Expand All @@ -13,9 +21,89 @@
#[error("{0}")]
pub struct ResourceNotFoundError(pub String);

/// Try to render a `progenitor_client::Error<gateway::Error>` somewhere in
/// `e`'s chain as a short, user-readable string. Returns `None` if no API
/// error is found, in which case the caller should fall back to
/// `format!("{e:#}")`.
///
/// Without this, progenitor's stock `Display` produces output like
/// `"Error Response: status: 405 ...; value: Error { code: Some(\"...\"),
/// error_code: None, message: Some(\"...\"), request_id: None }"` —
/// `{:?}` of the body struct leaks Rust internals into operator output.
/// We pull the typed body apart and reconstruct a node-triton-shaped
/// message instead.
pub fn render_api_error(e: &anyhow::Error) -> Option<String> {
// anyhow's Error chain is iter<&dyn StdError + 'static>. The
// Error<E> we care about is usually at the head (commands `?` the
// result of `.send().await` directly, no `.context(...)`), but if
// somebody wraps it later we still find it by walking.
if let Some(api) = e.downcast_ref::<ProgError<ApiError>>() {
return Some(format_progenitor(api));
}
for source in e.chain() {
if let Some(api) = (source as &dyn StdError).downcast_ref::<ProgError<ApiError>>() {
return Some(format_progenitor(api));
}
}
None
}

fn format_progenitor(err: &ProgError<ApiError>) -> String {
match err {
ProgError::ErrorResponse(rv) => {
let status = rv.status();
let reason = status.canonical_reason().unwrap_or("");
let body = rv.as_ref();
let code = body
.code
.as_deref()
.or(body.error_code.as_deref())
.unwrap_or("");
let message = body.message.as_deref().unwrap_or("");
match (code.is_empty(), message.is_empty()) {
(false, false) => format!("{} {reason}: {code}: {message}", status.as_u16()),
(false, true) => format!("{} {reason}: {code}", status.as_u16()),
(true, false) => format!("{} {reason}: {message}", status.as_u16()),
(true, true) => format!("{} {reason}", status.as_u16()),
}
}
// Server returned a body that didn't deserialize against the
// generated `Error` schema. Show the raw bytes — they're more
// useful than serde's "missing field" complaint, and the typed
// path is now permissive enough that this should be rare.
ProgError::InvalidResponsePayload(bytes, parse_err) => {
let body = String::from_utf8_lossy(bytes);
format!("invalid response payload ({parse_err}): {body}")
}
// Other variants (InvalidRequest, CommunicationError,
// InvalidUpgrade, ResponseBodyError, UnexpectedResponse) have
// adequate Display impls upstream — defer to those.
other => other.to_string(),
}
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use reqwest::StatusCode;
use reqwest::header::HeaderMap;
use triton_gateway_client::ResponseValue;

fn build_err_response(
status: StatusCode,
code: Option<&str>,
error_code: Option<&str>,
message: Option<&str>,
) -> ProgError<ApiError> {
let body = ApiError {
code: code.map(str::to_string),
error_code: error_code.map(str::to_string),
message: message.map(str::to_string),
request_id: None,
};
ProgError::ErrorResponse(ResponseValue::new(body, status, HeaderMap::new()))
}

#[test]
fn resource_not_found_downcast() {
Expand All @@ -30,4 +118,46 @@ mod tests {
let err = anyhow::anyhow!("connection refused");
assert!(err.downcast_ref::<ResourceNotFoundError>().is_none());
}

#[test]
fn render_cloudapi_shape_405() {
// Reproduces `triton volume list` against a headnode where
// VOLAPI isn't installed: cloudapi answers with 405 +
// {"code": "MethodNotAllowedError", "message": "GET is not allowed"}.
let err = build_err_response(
StatusCode::METHOD_NOT_ALLOWED,
Some("MethodNotAllowedError"),
None,
Some("GET is not allowed"),
);
let any: anyhow::Error = err.into();
assert_eq!(
render_api_error(&any).unwrap(),
"405 Method Not Allowed: MethodNotAllowedError: GET is not allowed"
);
}

#[test]
fn render_dropshot_shape_uses_error_code() {
// tritonapi (Dropshot-native) emits {error_code, message,
// request_id}; the renderer should use error_code when code
// is absent.
let err = build_err_response(
StatusCode::BAD_REQUEST,
None,
Some("InvalidArgument"),
Some("foo is required"),
);
let any: anyhow::Error = err.into();
assert_eq!(
render_api_error(&any).unwrap(),
"400 Bad Request: InvalidArgument: foo is required"
);
}

#[test]
fn render_returns_none_for_non_api_error() {
let err = anyhow::anyhow!("connection refused");
assert!(render_api_error(&err).is_none());
}
}
9 changes: 6 additions & 3 deletions cli/triton-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ fn env_fallbacks(explicit_profile: bool, triton_var: &str, sdc_var: &str) -> Vec
/// Build a reqwest HTTP client with CA cert fallback for platforms where
/// the default certificate store isn't found (e.g., SmartOS/illumos).
async fn build_http_client(insecure: bool) -> Result<reqwest::Client> {
triton_tls::build_http_client(insecure)
triton_tls::build_http_client(insecure.into())
.await
.map_err(|e| anyhow::anyhow!("failed to build HTTP client: {e}"))
}
Expand Down Expand Up @@ -534,8 +534,11 @@ impl Cli {
#[tokio::main]
async fn main() {
if let Err(e) = try_main().await {
// Format with alternate display to include the full error chain.
let msg = format!("{e:#}");
// Prefer a renderer that knows the gateway-client Error shape;
// fall back to anyhow's alternate display (which walks the
// full cause chain) for non-API errors.
let api_msg = errors::render_api_error(&e);
let msg = api_msg.clone().unwrap_or_else(|| format!("{e:#}"));

// Emit-payload mode uses a sentinel error to abort the request
// after printing the payload. Treat it as a successful exit.
Expand Down
1 change: 1 addition & 0 deletions cli/tritonadm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ bytes = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true }
clap_complete = { workspace = true }
dirs = { workspace = true }
futures-util = { workspace = true }
imgapi-api = { workspace = true }
imgapi-client = { workspace = true }
Expand Down
Loading
Loading