Skip to main content

cellos_ctl/cmd/
version.rs

1//! `cellctl version` — print client version, then attempt the server version.
2//!
3//! The client version is printed unconditionally on its own line so an operator
4//! always sees what they have locally even when the server is unreachable.
5//! After that, we attempt the server query and:
6//!
7//! - on success, print `server: cellos-server <ver> (<gitSha> <buildProfile>)`
8//!   and exit 0 — fields the server omits (e.g. `gitSha` when the build did
9//!   not capture one) are simply elided from the parenthetical;
10//! - on transport OR HTTP error, return a [`CtlError::api`] so the process
11//!   exits non-zero. The error formatter prints
12//!   `cellctl: api: unable to connect to the server: <reason>` to stderr,
13//!   matching the SMOKE-TEST wave-2 contract.
14//!
15//! This matches `kubectl version`, which also exits non-zero when it cannot
16//! reach the API server. See SMOKE-TEST report Finding #2 (wave 2) and
17//! E2E report SRV-001 (wave 3, server route added).
18//!
19//! ## Wire shape (server side: `GET /v1/version`)
20//!
21//! ```json
22//! { "server": { "version": "0.5.0", "gitSha": "abc1234", "buildProfile": "release" },
23//!   "api":    { "version": "v1" } }
24//! ```
25//!
26//! `gitSha` is optional. We tolerate either the nested shape (added by
27//! SRV-001) or a flat legacy shape (`{ "version": "..." }`) so a cellctl
28//! pointed at an older server still prints something useful.
29
30use serde::Deserialize;
31use serde_json::Value;
32
33use crate::client::CellosClient;
34use crate::exit::{CtlError, CtlResult};
35
36/// Nested wire shape returned by SRV-001+ servers. All inner fields are
37/// optional so a partial response (or future field additions) does not
38/// fail the deserializer.
39#[derive(Debug, Default, Deserialize)]
40struct ServerVersion {
41    #[serde(default)]
42    version: Option<String>,
43    #[serde(rename = "gitSha", default)]
44    git_sha: Option<String>,
45    #[serde(rename = "buildProfile", default)]
46    build_profile: Option<String>,
47}
48
49pub async fn run(client: &CellosClient) -> CtlResult<()> {
50    println!("client: cellctl {}", env!("CARGO_PKG_VERSION"));
51
52    match client.get_json::<Value>("/v1/version").await {
53        Ok(v) => {
54            println!("server: {}", format_server_line(&v));
55            Ok(())
56        }
57        Err(e) => {
58            // Propagate as an API error so the process exits with code 2 —
59            // scripts and CI gates that branch on $? get an honest signal
60            // instead of a false-success. `CtlError::exit` formats this as
61            // `cellctl: api: unable to connect to the server: <reason>` on
62            // stderr, matching the existing error-output contract.
63            Err(CtlError::api(format!(
64                "unable to connect to the server: {e}"
65            )))
66        }
67    }
68}
69
70/// Render the `server:` line content given the raw JSON body. Public to
71/// the module so a focused unit test can pin the exact format without
72/// spinning up a live HTTP exchange.
73fn format_server_line(v: &Value) -> String {
74    // Prefer the nested `server` object (SRV-001 shape). If that's not
75    // present we fall back to the flat `{ "version": "..." }` legacy
76    // shape so cellctl 0.5.x can still talk to a 0.5.0 server.
77    let server = v
78        .get("server")
79        .and_then(|s| serde_json::from_value::<ServerVersion>(s.clone()).ok())
80        .unwrap_or_else(|| ServerVersion {
81            version: v.get("version").and_then(|x| x.as_str()).map(str::to_owned),
82            ..Default::default()
83        });
84
85    let version = server.version.as_deref().unwrap_or("unknown");
86
87    // Build the parenthetical from whichever optional fields are
88    // populated. An older server returns neither, so we drop the
89    // parens entirely rather than printing `cellos-server 0.5.0 ()`.
90    let mut parts: Vec<&str> = Vec::with_capacity(2);
91    if let Some(s) = server.git_sha.as_deref() {
92        parts.push(s);
93    }
94    if let Some(p) = server.build_profile.as_deref() {
95        parts.push(p);
96    }
97    if parts.is_empty() {
98        format!("cellos-server {version}")
99    } else {
100        format!("cellos-server {version} ({})", parts.join(" "))
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use serde_json::json;
108
109    #[test]
110    fn renders_full_nested_shape() {
111        let v = json!({
112            "server": {
113                "version": "0.5.0",
114                "gitSha": "abc1234",
115                "buildProfile": "release",
116            },
117            "api": { "version": "v1" },
118        });
119        assert_eq!(
120            format_server_line(&v),
121            "cellos-server 0.5.0 (abc1234 release)"
122        );
123    }
124
125    #[test]
126    fn renders_nested_shape_without_git_sha() {
127        // gitSha omitted by the server (no CELLOS_GIT_SHA at build) —
128        // the buildProfile is the only parenthetical content.
129        let v = json!({
130            "server": { "version": "0.5.0", "buildProfile": "debug" },
131            "api": { "version": "v1" },
132        });
133        assert_eq!(format_server_line(&v), "cellos-server 0.5.0 (debug)");
134    }
135
136    #[test]
137    fn renders_flat_legacy_shape() {
138        // Older server (pre-SRV-001) returning a bare `{ version }`.
139        let v = json!({ "version": "0.4.0" });
140        assert_eq!(format_server_line(&v), "cellos-server 0.4.0");
141    }
142
143    #[test]
144    fn renders_unknown_when_version_missing() {
145        let v = json!({ "server": { "buildProfile": "release" } });
146        assert_eq!(format_server_line(&v), "cellos-server unknown (release)");
147    }
148}