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