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