Skip to main content

ignition_core/actions/
version.rs

1//! `version` action — CLI version always, gateway check when a client was
2//! injected, implementing the LOCKED behavior matrix (research Pattern 6):
3//!
4//! | situation                        | output                          | exit |
5//! |----------------------------------|---------------------------------|------|
6//! | no client (no profile resolved)  | `cli_version` only              | 0    |
7//! | reachable, ≥ 8.3.1               | `cli_version` + `gateway`       | 0    |
8//! | answered, < 8.3.1 / unparseable  | `GatewayTooOld` envelope        | 6    |
9//! | unreachable                      | `cli_version` + `warnings`      | 0    |
10//!
11//! LOCKED: unreachable degrades to a warning INSIDE `data` (never a
12//! top-level envelope field — the LOCKED envelope never grows fields)
13//! because version is a local-info command; hard-failing scripts on a
14//! sleeping rig is hostile. The refusal contract applies only when the
15//! gateway ANSWERED.
16
17use serde::Serialize;
18
19use crate::client::GatewayApi;
20use crate::client::version::{GatewayInfo, MIN_GATEWAY, below_minimum};
21use crate::error::CoreError;
22
23/// `version` output model (declaration order = golden field order).
24/// `gateway`/`warnings` are omitted when absent/empty, so a fresh install
25/// keeps the bare `{"cli_version": …}` shape it has always had.
26#[derive(Debug, Serialize)]
27pub struct VersionResult {
28    /// The CLI's own version.
29    pub cli_version: &'static str,
30    /// Gateway info when a profile resolved AND the gateway answered within
31    /// the minimum. JSON-null-equivalent: the field is simply absent when
32    /// there is nothing to report (`Value["gateway"]` is null either way).
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub gateway: Option<GatewayInfo>,
35    /// Non-fatal degradation notes (e.g. gateway unreachable).
36    #[serde(skip_serializing_if = "Vec::is_empty")]
37    pub warnings: Vec<String>,
38}
39
40/// The version action. The CLI resolves the profile + credential and
41/// constructs the client first (the env overlay precedence belongs to the
42/// dispatch site); `api: None` is the fresh-install / no-profile path.
43/// Credential exhaustion for this *check* is degraded to header-less at
44/// the dispatch site (version must not demand a secret) — every other
45/// credential error already propagated before we got here.
46pub 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                // CORE-08: the gateway ANSWERED, so the refusal contract
62                // applies — refuse cleanly with the upgrade hint.
63                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        // LOCKED: only unreachable degrades to a warning; every other
72        // class (auth, internal) propagates through the envelope.
73        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    /// Test double over the seam: constructs its outcome lazily so no
89    /// `CoreError` ever needs `Clone` (Network carries a `reqwest::Error`).
90    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                    // A real transport error via instant loopback refusal —
248                    // reqwest::Error has no public constructor.
249                    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        // The version matrix only exercises gateway_info — the Phase-2
272        // capabilities are unimplemented in THIS double (inspect.rs's
273        // fakes serve them).
274        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    /// Matrix row 1: no client → cli_version only (fresh install shape).
468    #[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    /// Matrix row 2: reachable + ≥ minimum → gateway attached.
477    #[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    /// Matrix row 3: answered but below minimum → GatewayTooOld (exit 6)
489    /// with endpoint + hint naming the minimum.
490    #[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    /// Matrix row 4 (LOCKED): unreachable → exit-0 warning inside data.
511    #[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}