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 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                    // A real transport error via instant loopback refusal —
265                    // reqwest::Error has no public constructor.
266                    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        // The version matrix only exercises gateway_info — the Phase-2
289        // capabilities are unimplemented in THIS double (inspect.rs's
290        // fakes serve them).
291        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    /// Matrix row 1: no client → cli_version only (fresh install shape).
485    #[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    /// Matrix row 2: reachable + ≥ minimum → gateway attached.
494    #[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    /// Matrix row 3: answered but below minimum → GatewayTooOld (exit 6)
506    /// with endpoint + hint naming the minimum.
507    #[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    /// Matrix row 4 (LOCKED): unreachable → exit-0 warning inside data.
528    #[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}