1use serde::Serialize;
18
19use crate::client::GatewayApi;
20use crate::client::version::{GatewayInfo, MIN_GATEWAY, below_minimum};
21use crate::error::CoreError;
22
23#[derive(Debug, Serialize)]
27pub struct VersionResult {
28 pub cli_version: &'static str,
30 #[serde(skip_serializing_if = "Option::is_none")]
34 pub gateway: Option<GatewayInfo>,
35 #[serde(skip_serializing_if = "Vec::is_empty")]
37 pub warnings: Vec<String>,
38}
39
40pub async fn version(
47 api: Option<&dyn GatewayApi>,
48 cli_version: &'static str,
49) -> Result<VersionResult, CoreError> {
50 let mut result = VersionResult {
51 cli_version,
52 gateway: None,
53 warnings: Vec::new(),
54 };
55 let Some(api) = api else {
56 return Ok(result);
57 };
58 match api.gateway_info().await {
59 Ok(info) => {
60 if below_minimum(&info.ignition_version) {
61 return Err(CoreError::GatewayTooOld {
64 found: info.ignition_version.clone(),
65 minimum: MIN_GATEWAY.to_string(),
66 endpoint: info.endpoint.clone(),
67 });
68 }
69 result.gateway = Some(info);
70 }
71 Err(CoreError::Network { url, .. }) => {
74 result.warnings.push(format!("gateway unreachable: {url}"));
75 }
76 Err(err) => return Err(err),
77 }
78 Ok(result)
79}
80
81#[cfg(test)]
82mod tests {
83 use super::version;
84 use crate::client::GatewayApi;
85 use crate::client::version::GatewayInfo;
86 use crate::error::CoreError;
87
88 enum FakeOutcome {
91 Ok(GatewayInfo),
92 TooOld(String),
93 Unreachable(String),
94 }
95
96 struct FakeApi(FakeOutcome);
97
98 #[async_trait::async_trait]
99 impl GatewayApi for FakeApi {
100 async fn uninstall_module(
101 &self,
102 _gateway_module_id: &str,
103 _module_id: &str,
104 ) -> Result<(), CoreError> {
105 unreachable!("not part of this action")
106 }
107 async fn bundle_generate(
108 &self,
109 ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
110 unreachable!("not part of this action")
111 }
112 async fn bundle_status(
113 &self,
114 ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
115 unreachable!("not part of this action")
116 }
117 async fn bundle_download(
118 &self,
119 _out: &std::path::Path,
120 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
121 unreachable!("not part of this action")
122 }
123 async fn tag_provider_list(
124 &self,
125 _query: &crate::client::query::ListQuery,
126 ) -> Result<
127 crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
128 CoreError,
129 > {
130 unreachable!("not part of this action")
131 }
132 async fn tag_provider_find(
133 &self,
134 _name: &str,
135 ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
136 unreachable!("not part of this action")
137 }
138 async fn tag_provider_create(
139 &self,
140 _body: &[crate::client::tags::TagProviderCreate],
141 ) -> Result<(), CoreError> {
142 unreachable!("not part of this action")
143 }
144 async fn tag_provider_delete(
145 &self,
146 _name: &str,
147 _signature: &str,
148 ) -> Result<(), CoreError> {
149 unreachable!("not part of this action")
150 }
151 async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
152 unreachable!("not part of this action")
153 }
154 async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
155 unreachable!("not part of this action")
156 }
157 async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
158 unreachable!("not part of this action")
159 }
160 async fn backup_download(
161 &self,
162 _out: &std::path::Path,
163 _backup_type: crate::client::backup::BackupType,
164 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
165 unreachable!("not part of this action")
166 }
167 async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
168 unreachable!("not part of this action")
169 }
170 async fn eam_task_history(
171 &self,
172 _limit: Option<u32>,
173 _search: Option<&str>,
174 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
175 {
176 unreachable!("not part of this action")
177 }
178 async fn eam_task_definitions(
179 &self,
180 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
181 {
182 unreachable!("not part of this action")
183 }
184 async fn eam_task_find(
185 &self,
186 _name: &str,
187 ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
188 unreachable!("not part of this action")
189 }
190 async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
191 unreachable!("not part of this action")
192 }
193 async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
194 unreachable!("not part of this action")
195 }
196 async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
197 unreachable!("not part of this action")
198 }
199 async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
200 unreachable!("not part of this action")
201 }
202 async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
203 unreachable!("not part of this action")
204 }
205 async fn eam_tasks_scheduled(
206 &self,
207 _running: bool,
208 ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
209 unreachable!("not part of this action")
210 }
211 async fn eam_task_modify(
212 &self,
213 _definition: &serde_json::Value,
214 ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
215 unreachable!("not part of this action")
216 }
217 async fn eam_task_delete(
218 &self,
219 _name: &str,
220 _signature: &str,
221 _confirm: bool,
222 ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
223 unreachable!("not part of this action")
224 }
225 async fn api_call(
226 &self,
227 _call: &crate::client::apicall::ApiCallRequest,
228 ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
229 unreachable!("not part of this action")
230 }
231 async fn license_status(
232 &self,
233 ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
234 unreachable!("not part of this action")
235 }
236 async fn redundancy_status(
237 &self,
238 ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
239 unreachable!("not part of this action")
240 }
241 async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
242 unreachable!("not part of this action")
243 }
244 async fn gateway_info(&self) -> Result<GatewayInfo, CoreError> {
245 match &self.0 {
246 FakeOutcome::Ok(info) => Ok(info.clone()),
247 FakeOutcome::TooOld(found) => Err(CoreError::GatewayTooOld {
248 found: found.clone(),
249 minimum: "8.3.1".into(),
250 endpoint: Some("http://gw.example.com/data/api/v1/gateway-info".into()),
251 }),
252 FakeOutcome::Unreachable(url) => Err(CoreError::Network {
253 url: url.clone(),
254 source: Some(
257 reqwest::get("http://127.0.0.1:1")
258 .await
259 .expect_err("dead port refuses"),
260 ),
261 observation: None,
262 }),
263 }
264 }
265
266 async fn modules(
267 &self,
268 _quarantined: bool,
269 _query: &crate::client::query::ListQuery,
270 ) -> Result<crate::client::query::ListEnvelope<crate::client::status::ModuleInfo>, CoreError>
271 {
272 unimplemented!("version FakeApi only serves gateway_info")
273 }
274 async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
275 unimplemented!("version FakeApi only serves gateway_info")
276 }
277
278 async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
282 unimplemented!("version FakeApi only serves gateway_info")
283 }
284 async fn metrics_current(
285 &self,
286 ) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
287 unimplemented!("version FakeApi only serves gateway_info")
288 }
289 async fn metrics_historic(
290 &self,
291 ) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
292 unimplemented!("version FakeApi only serves gateway_info")
293 }
294 async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
295 unimplemented!("version FakeApi only serves gateway_info")
296 }
297 async fn designers(
298 &self,
299 _query: &crate::client::query::ListQuery,
300 ) -> Result<
301 crate::client::query::ListEnvelope<crate::client::sessions::DesignerInfo>,
302 CoreError,
303 > {
304 unimplemented!("version FakeApi only serves gateway_info")
305 }
306 async fn perspective_sessions(
307 &self,
308 _query: &crate::client::query::ListQuery,
309 ) -> Result<
310 crate::client::query::ListEnvelope<crate::client::sessions::PerspectiveSession>,
311 CoreError,
312 > {
313 unimplemented!("version FakeApi only serves gateway_info")
314 }
315 async fn vision_clients(
316 &self,
317 _query: &crate::client::query::ListQuery,
318 ) -> Result<
319 crate::client::query::ListEnvelope<crate::client::sessions::VisionClient>,
320 CoreError,
321 > {
322 unimplemented!("version FakeApi only serves gateway_info")
323 }
324 async fn terminate_perspective_session(
325 &self,
326 _id: &str,
327 _message: Option<&str>,
328 ) -> Result<(), CoreError> {
329 unimplemented!("version FakeApi only serves gateway_info")
330 }
331 async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
332 unimplemented!("version FakeApi only serves gateway_info")
333 }
334 async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
335 unimplemented!("version FakeApi only serves gateway_info")
336 }
337 async fn database_connections(
338 &self,
339 ) -> Result<
340 crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
341 CoreError,
342 > {
343 unimplemented!("version FakeApi only serves gateway_info")
344 }
345 async fn opc_connections(
346 &self,
347 ) -> Result<
348 crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
349 CoreError,
350 > {
351 unimplemented!("version FakeApi only serves gateway_info")
352 }
353
354 async fn logs(
355 &self,
356 _filter: &crate::client::logs::LogQuery,
357 ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LogEntry>, CoreError>
358 {
359 unreachable!("not part of this double's actions")
360 }
361 async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
362 unreachable!("not part of this double's actions")
363 }
364 async fn loggers(
365 &self,
366 _query: &crate::client::query::ListQuery,
367 ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LoggerInfo>, CoreError>
368 {
369 unreachable!("not part of this double's actions")
370 }
371 async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
372 unreachable!("not part of this double's actions")
373 }
374 async fn reset_logger_levels(&self) -> Result<(), CoreError> {
375 unreachable!("not part of this double's actions")
376 }
377 async fn restart(&self) -> Result<(), CoreError> {
378 unreachable!("not part of this action")
379 }
380 async fn scan_projects(&self) -> Result<(), CoreError> {
381 unreachable!("not part of this action")
382 }
383 async fn security_properties(
384 &self,
385 ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
386 unreachable!("not part of this action")
387 }
388 async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
389 unreachable!("not part of this action")
390 }
391 async fn webdev_route_call(
392 &self,
393 _project: &str,
394 _route: &str,
395 _body: &serde_json::Value,
396 _extra_headers: &[(&str, &str)],
397 ) -> Result<serde_json::Value, CoreError> {
398 unreachable!("not part of this action")
399 }
400 async fn webdev_route_probe(
401 &self,
402 _project: &str,
403 _route: &str,
404 _extra_headers: &[(&str, &str)],
405 ) -> Result<crate::client::webdev::RouteProbe, CoreError> {
406 unreachable!("not part of this action")
407 }
408 async fn projects(
409 &self,
410 _query: &crate::client::query::ListQuery,
411 ) -> Result<
412 crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
413 CoreError,
414 > {
415 unreachable!("not part of this action")
416 }
417 async fn project_find(
418 &self,
419 _name: &str,
420 ) -> Result<crate::client::projects::ProjectRecord, CoreError> {
421 unreachable!("not part of this action")
422 }
423 async fn project_create(
424 &self,
425 _body: &crate::client::projects::ProjectCreate,
426 ) -> Result<(), CoreError> {
427 unreachable!("not part of this action")
428 }
429 async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
430 unreachable!("not part of this action")
431 }
432 async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
433 unreachable!("not part of this action")
434 }
435 async fn project_modify(
436 &self,
437 _name: &str,
438 _body: &crate::client::projects::ProjectModify,
439 ) -> Result<(), CoreError> {
440 unreachable!("not part of this action")
441 }
442 async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
443 unreachable!("not part of this action")
444 }
445 async fn project_export_to_file(
446 &self,
447 _name: &str,
448 _out: &std::path::Path,
449 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
450 unreachable!("not part of this action")
451 }
452 async fn project_import(
453 &self,
454 _name: &str,
455 _zip: Vec<u8>,
456 _overwrite: bool,
457 ) -> Result<crate::client::projects::ImportOutcome, CoreError> {
458 unreachable!("not part of this action")
459 }
460 }
461
462 fn info(version: &str) -> GatewayInfo {
463 GatewayInfo {
464 name: None,
465 redundancy_role: None,
466 edition: Some("standard".into()),
467 ignition_version: version.into(),
468 jvm_version: None,
469 license: None,
470 endpoint: None,
471 }
472 }
473
474 #[tokio::test]
476 async fn no_client_reports_cli_version_only() {
477 let result = version(None, "1.2.3").await.expect("always Ok");
478 assert_eq!(result.cli_version, "1.2.3");
479 assert_eq!(result.gateway, None);
480 assert!(result.warnings.is_empty());
481 }
482
483 #[tokio::test]
485 async fn reachable_modern_gateway_reported() {
486 let api = FakeApi(FakeOutcome::Ok(info("8.3.2")));
487 let result = version(Some(&api), "1.2.3").await.expect("exit 0");
488 assert_eq!(
489 result.gateway.as_ref().expect("gateway").ignition_version,
490 "8.3.2"
491 );
492 assert!(result.warnings.is_empty());
493 }
494
495 #[tokio::test]
498 async fn too_old_gateway_refuses_exit_6() {
499 let api = FakeApi(FakeOutcome::TooOld("8.1.14".into()));
500 let err = version(Some(&api), "1.2.3").await.expect_err("refuse");
501 match &err {
502 CoreError::GatewayTooOld {
503 found,
504 minimum,
505 endpoint,
506 } => {
507 assert_eq!(found, "8.1.14");
508 assert_eq!(minimum, "8.3.1");
509 assert!(endpoint.is_some(), "CORE-05 endpoint populated");
510 }
511 other => panic!("wrong error class: {other}"),
512 }
513 assert_eq!(err.exit_code(), 6);
514 assert!(err.hint().expect("hint").contains("8.3.1"));
515 }
516
517 #[tokio::test]
519 async fn unreachable_gateway_degrades_to_warning() {
520 let api = FakeApi(FakeOutcome::Unreachable(
521 "http://127.0.0.1:1/data/api/v1/gateway-info".into(),
522 ));
523 let result = version(Some(&api), "1.2.3")
524 .await
525 .expect("exit 0, never a hard fail");
526 assert_eq!(result.gateway, None);
527 assert_eq!(result.warnings.len(), 1);
528 assert!(
529 result.warnings[0].contains("gateway unreachable"),
530 "warning names the problem: {}",
531 result.warnings[0]
532 );
533 }
534}