use serde::Serialize;
use crate::client::GatewayApi;
use crate::client::version::{GatewayInfo, MIN_GATEWAY, below_minimum};
use crate::error::CoreError;
#[derive(Debug, Serialize)]
pub struct VersionResult {
pub cli_version: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub gateway: Option<GatewayInfo>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}
pub async fn version(
api: Option<&dyn GatewayApi>,
cli_version: &'static str,
) -> Result<VersionResult, CoreError> {
let mut result = VersionResult {
cli_version,
gateway: None,
warnings: Vec::new(),
};
let Some(api) = api else {
return Ok(result);
};
match api.gateway_info().await {
Ok(info) => {
if below_minimum(&info.ignition_version) {
return Err(CoreError::GatewayTooOld {
found: info.ignition_version.clone(),
minimum: MIN_GATEWAY.to_string(),
endpoint: info.endpoint.clone(),
});
}
result.gateway = Some(info);
}
Err(CoreError::Network { url, .. }) => {
result.warnings.push(format!("gateway unreachable: {url}"));
}
Err(err) => return Err(err),
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::version;
use crate::client::GatewayApi;
use crate::client::version::GatewayInfo;
use crate::error::CoreError;
enum FakeOutcome {
Ok(GatewayInfo),
TooOld(String),
Unreachable(String),
}
struct FakeApi(FakeOutcome);
#[async_trait::async_trait]
impl GatewayApi for FakeApi {
async fn bundle_generate(
&self,
) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
unreachable!("not part of this action")
}
async fn bundle_status(
&self,
) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
unreachable!("not part of this action")
}
async fn bundle_download(
&self,
_out: &std::path::Path,
) -> Result<crate::client::projects::ExportMeta, CoreError> {
unreachable!("not part of this action")
}
async fn tag_provider_list(
&self,
_query: &crate::client::query::ListQuery,
) -> Result<
crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
CoreError,
> {
unreachable!("not part of this action")
}
async fn tag_provider_find(
&self,
_name: &str,
) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
unreachable!("not part of this action")
}
async fn tag_provider_create(
&self,
_body: &[crate::client::tags::TagProviderCreate],
) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn tag_provider_delete(
&self,
_name: &str,
_signature: &str,
) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
unreachable!("not part of this action")
}
async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
unreachable!("not part of this action")
}
async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
unreachable!("not part of this action")
}
async fn backup_download(
&self,
_out: &std::path::Path,
_backup_type: crate::client::backup::BackupType,
) -> Result<crate::client::projects::ExportMeta, CoreError> {
unreachable!("not part of this action")
}
async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_history(
&self,
_limit: Option<u32>,
_search: Option<&str>,
) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
{
unreachable!("not part of this action")
}
async fn eam_task_definitions(
&self,
) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
{
unreachable!("not part of this action")
}
async fn eam_task_find(
&self,
_name: &str,
) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn eam_tasks_scheduled(
&self,
_running: bool,
) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_modify(
&self,
_definition: &serde_json::Value,
) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_delete(
&self,
_name: &str,
_signature: &str,
_confirm: bool,
) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
unreachable!("not part of this action")
}
async fn api_call(
&self,
_call: &crate::client::apicall::ApiCallRequest,
) -> Result<crate::client::apicall::ApiCallData, CoreError> {
unreachable!("not part of this action")
}
async fn license_status(
&self,
) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
unreachable!("not part of this action")
}
async fn redundancy_status(
&self,
) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
unreachable!("not part of this action")
}
async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
unreachable!("not part of this action")
}
async fn gateway_info(&self) -> Result<GatewayInfo, CoreError> {
match &self.0 {
FakeOutcome::Ok(info) => Ok(info.clone()),
FakeOutcome::TooOld(found) => Err(CoreError::GatewayTooOld {
found: found.clone(),
minimum: "8.3.1".into(),
endpoint: Some("http://gw.example.com/data/api/v1/gateway-info".into()),
}),
FakeOutcome::Unreachable(url) => Err(CoreError::Network {
url: url.clone(),
source: Some(
reqwest::get("http://127.0.0.1:1")
.await
.expect_err("dead port refuses"),
),
observation: None,
}),
}
}
async fn modules(
&self,
_quarantined: bool,
_query: &crate::client::query::ListQuery,
) -> Result<crate::client::query::ListEnvelope<crate::client::status::ModuleInfo>, CoreError>
{
unimplemented!("version FakeApi only serves gateway_info")
}
async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
unimplemented!("version FakeApi only serves gateway_info")
}
async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
unimplemented!("version FakeApi only serves gateway_info")
}
async fn metrics_current(
&self,
) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
unimplemented!("version FakeApi only serves gateway_info")
}
async fn metrics_historic(
&self,
) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
unimplemented!("version FakeApi only serves gateway_info")
}
async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
unimplemented!("version FakeApi only serves gateway_info")
}
async fn designers(
&self,
_query: &crate::client::query::ListQuery,
) -> Result<
crate::client::query::ListEnvelope<crate::client::sessions::DesignerInfo>,
CoreError,
> {
unimplemented!("version FakeApi only serves gateway_info")
}
async fn perspective_sessions(
&self,
_query: &crate::client::query::ListQuery,
) -> Result<
crate::client::query::ListEnvelope<crate::client::sessions::PerspectiveSession>,
CoreError,
> {
unimplemented!("version FakeApi only serves gateway_info")
}
async fn vision_clients(
&self,
_query: &crate::client::query::ListQuery,
) -> Result<
crate::client::query::ListEnvelope<crate::client::sessions::VisionClient>,
CoreError,
> {
unimplemented!("version FakeApi only serves gateway_info")
}
async fn terminate_perspective_session(
&self,
_id: &str,
_message: Option<&str>,
) -> Result<(), CoreError> {
unimplemented!("version FakeApi only serves gateway_info")
}
async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
unimplemented!("version FakeApi only serves gateway_info")
}
async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
unimplemented!("version FakeApi only serves gateway_info")
}
async fn database_connections(
&self,
) -> Result<
crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
CoreError,
> {
unimplemented!("version FakeApi only serves gateway_info")
}
async fn opc_connections(
&self,
) -> Result<
crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
CoreError,
> {
unimplemented!("version FakeApi only serves gateway_info")
}
async fn logs(
&self,
_filter: &crate::client::logs::LogQuery,
) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LogEntry>, CoreError>
{
unreachable!("not part of this double's actions")
}
async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
unreachable!("not part of this double's actions")
}
async fn loggers(
&self,
_query: &crate::client::query::ListQuery,
) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LoggerInfo>, CoreError>
{
unreachable!("not part of this double's actions")
}
async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
unreachable!("not part of this double's actions")
}
async fn reset_logger_levels(&self) -> Result<(), CoreError> {
unreachable!("not part of this double's actions")
}
async fn restart(&self) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn scan_projects(&self) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn security_properties(
&self,
) -> Result<crate::client::restart::SecurityProperties, CoreError> {
unreachable!("not part of this action")
}
async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
unreachable!("not part of this action")
}
async fn webdev_route_call(
&self,
_project: &str,
_route: &str,
_body: &serde_json::Value,
_extra_headers: &[(&str, &str)],
) -> Result<serde_json::Value, CoreError> {
unreachable!("not part of this action")
}
async fn webdev_route_probe(
&self,
_project: &str,
_route: &str,
_extra_headers: &[(&str, &str)],
) -> Result<crate::client::webdev::RouteProbe, CoreError> {
unreachable!("not part of this action")
}
async fn projects(
&self,
_query: &crate::client::query::ListQuery,
) -> Result<
crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
CoreError,
> {
unreachable!("not part of this action")
}
async fn project_find(
&self,
_name: &str,
) -> Result<crate::client::projects::ProjectRecord, CoreError> {
unreachable!("not part of this action")
}
async fn project_create(
&self,
_body: &crate::client::projects::ProjectCreate,
) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn project_modify(
&self,
_name: &str,
_body: &crate::client::projects::ProjectModify,
) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn project_export_to_file(
&self,
_name: &str,
_out: &std::path::Path,
) -> Result<crate::client::projects::ExportMeta, CoreError> {
unreachable!("not part of this action")
}
async fn project_import(
&self,
_name: &str,
_zip: Vec<u8>,
_overwrite: bool,
) -> Result<crate::client::projects::ImportOutcome, CoreError> {
unreachable!("not part of this action")
}
}
fn info(version: &str) -> GatewayInfo {
GatewayInfo {
name: None,
redundancy_role: None,
edition: Some("standard".into()),
ignition_version: version.into(),
jvm_version: None,
license: None,
endpoint: None,
}
}
#[tokio::test]
async fn no_client_reports_cli_version_only() {
let result = version(None, "1.2.3").await.expect("always Ok");
assert_eq!(result.cli_version, "1.2.3");
assert_eq!(result.gateway, None);
assert!(result.warnings.is_empty());
}
#[tokio::test]
async fn reachable_modern_gateway_reported() {
let api = FakeApi(FakeOutcome::Ok(info("8.3.2")));
let result = version(Some(&api), "1.2.3").await.expect("exit 0");
assert_eq!(
result.gateway.as_ref().expect("gateway").ignition_version,
"8.3.2"
);
assert!(result.warnings.is_empty());
}
#[tokio::test]
async fn too_old_gateway_refuses_exit_6() {
let api = FakeApi(FakeOutcome::TooOld("8.1.14".into()));
let err = version(Some(&api), "1.2.3").await.expect_err("refuse");
match &err {
CoreError::GatewayTooOld {
found,
minimum,
endpoint,
} => {
assert_eq!(found, "8.1.14");
assert_eq!(minimum, "8.3.1");
assert!(endpoint.is_some(), "CORE-05 endpoint populated");
}
other => panic!("wrong error class: {other}"),
}
assert_eq!(err.exit_code(), 6);
assert!(err.hint().expect("hint").contains("8.3.1"));
}
#[tokio::test]
async fn unreachable_gateway_degrades_to_warning() {
let api = FakeApi(FakeOutcome::Unreachable(
"http://127.0.0.1:1/data/api/v1/gateway-info".into(),
));
let result = version(Some(&api), "1.2.3")
.await
.expect("exit 0, never a hard fail");
assert_eq!(result.gateway, None);
assert_eq!(result.warnings.len(), 1);
assert!(
result.warnings[0].contains("gateway unreachable"),
"warning names the problem: {}",
result.warnings[0]
);
}
}