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