ignition_core/client/mode.rs
1//! Deployment modes and the gateway's active `deploymentMode` (20-05,
2//! DAPI-05).
3//!
4//! Wire facts (8.3.6, `20-LIVE-CAPTURES.md`, cited by block id):
5//!
6//! - `GET /data/api/v1/mode` answers the `{items, metadata}` envelope; an
7//! item is `{name, title, description, resourceCount}` (C-mode "GET /mode
8//! (before)" and "(after)").
9//! - `POST /data/api/v1/mode` with `{name, title, description}` answers 200
10//! with the created `ModeRecord` (D6). A duplicate is a 409 and an illegal
11//! name a 400, both Jetty HTML (D6). Creating a mode only DEFINES it: the
12//! gateway's active mode is set by a JVM argument at start, which this
13//! library never touches.
14//! - `GET /data/api/v1/gateway-info` carries `deploymentMode`: `""` on a
15//! gateway with no mode and `"production"` on one started with
16//! `-Dignition.config.mode=production` (C-null (no mode), C-null (mode
17//! production), D5). `null` and an absent key were not observed on 8.3.6.
18//!
19//! `deployment_mode()` returns `Option<String>` (research A6, held by A6 in
20//! the capture table) rather than the `""` sentinel milestone SUMMARY
21//! Conflict 10 names. Both carry the same intent: no mode is the absence of
22//! a name. [`normalize_deployment_mode`] reads `null`, `""`, an absent key
23//! and a whitespace-only string all as no mode, so a gateway version that
24//! sends any of them behaves the same.
25//!
26//! The v1.0 `gateway_info_contract` golden body carries
27//! `"deploymentMode": "STANDARD"`. That value was never confirmed by a
28//! capture (02-RESEARCH records the key without a value); D5 showed `""`
29//! with no mode. `STANDARD` is therefore treated as a mode name like any
30//! other string: nothing here special-cases it.
31
32use std::collections::BTreeMap;
33
34use serde::{Deserialize, Serialize};
35
36/// `GET` (list) and `POST` (create) path of the mode collection (C-mode).
37pub const MODE_PATH: &str = "/data/api/v1/mode";
38
39/// One deployment mode as the gateway lists it (C-mode). Lenient: a key
40/// the captures do not show rides in `extra`.
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
42pub struct ModeRecord {
43 /// The mode name, e.g. `production`.
44 #[serde(default)]
45 pub name: String,
46 /// Display title.
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub title: Option<String>,
49 /// Free-text description.
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub description: Option<String>,
52 /// Number of resources the mode's collection holds (0 on the capture).
53 #[serde(
54 rename = "resourceCount",
55 alias = "resource_count",
56 default,
57 skip_serializing_if = "Option::is_none"
58 )]
59 pub resource_count: Option<i64>,
60 /// Keys this client does not model.
61 #[serde(flatten)]
62 pub extra: BTreeMap<String, serde_json::Value>,
63}
64
65/// POST body of a mode create (C-mode "POST /mode production"). Field order
66/// is the wire order, so the recorded request body is deterministic.
67#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
68pub struct ModeCreate {
69 /// The mode name; validated before any request.
70 pub name: String,
71 /// Display title.
72 pub title: String,
73 /// Free-text description.
74 pub description: String,
75}
76
77/// The one gateway-info key `deployment_mode()` reads. A private probe
78/// struct, so [`crate::client::version::GatewayInfo`] and its `--json`
79/// output stay frozen. Every other gateway-info key is ignored.
80#[derive(Debug, Clone, Default, Deserialize)]
81pub(crate) struct GatewayModeProbe {
82 /// `deploymentMode`: `""` with no mode, the mode name otherwise (D5).
83 #[serde(rename = "deploymentMode", default)]
84 pub(crate) deployment_mode: Option<String>,
85}
86
87/// Read a raw `deploymentMode` as an optional mode name.
88///
89/// `None` for an absent key, `null`, an empty string and a whitespace-only
90/// string (D5: the 8.3.6 no-mode value is `""`). Any other string is
91/// `Some(value)` verbatim, never trimmed or case-folded: a mode name is
92/// compared exactly. D5 recorded no distinct no-mode sentinel string.
93pub fn normalize_deployment_mode(raw: Option<String>) -> Option<String> {
94 raw.filter(|value| !value.trim().is_empty())
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100
101 #[test]
102 fn normalize_table() {
103 assert_eq!(normalize_deployment_mode(None), None);
104 assert_eq!(normalize_deployment_mode(Some(String::new())), None);
105 assert_eq!(normalize_deployment_mode(Some(" ".to_string())), None);
106 assert_eq!(normalize_deployment_mode(Some("\t\n".to_string())), None);
107 assert_eq!(
108 normalize_deployment_mode(Some("production".to_string())),
109 Some("production".to_string())
110 );
111 // Verbatim: no trimming or case folding of a real name.
112 assert_eq!(
113 normalize_deployment_mode(Some(" Production ".to_string())),
114 Some(" Production ".to_string())
115 );
116 }
117
118 #[test]
119 fn probe_reads_null_absent_and_string() {
120 let null: GatewayModeProbe =
121 serde_json::from_str(r#"{"deploymentMode":null,"name":"x"}"#).expect("null");
122 assert_eq!(null.deployment_mode, None);
123 let absent: GatewayModeProbe = serde_json::from_str(r#"{"name":"x"}"#).expect("absent");
124 assert_eq!(absent.deployment_mode, None);
125 let empty: GatewayModeProbe =
126 serde_json::from_str(r#"{"deploymentMode":""}"#).expect("empty");
127 assert_eq!(empty.deployment_mode.as_deref(), Some(""));
128 }
129
130 #[test]
131 fn captured_mode_body_parses() {
132 // C-mode "POST /mode production", verbatim.
133 let record: ModeRecord = serde_json::from_str(
134 "{\n \"name\": \"production\",\n \"title\": \"Production\",\n \"description\": \"ign-deploy-rig\",\n \"resourceCount\": 0\n}",
135 )
136 .expect("C-mode parses");
137 assert_eq!(record.name, "production");
138 assert_eq!(record.title.as_deref(), Some("Production"));
139 assert_eq!(record.resource_count, Some(0));
140 assert!(record.extra.is_empty());
141 }
142
143 #[test]
144 fn create_body_field_order_is_the_wire_order() {
145 let body = serde_json::to_string(&ModeCreate {
146 name: "production".to_string(),
147 title: "Production".to_string(),
148 description: "x".to_string(),
149 })
150 .expect("serializes");
151 assert_eq!(
152 body,
153 r#"{"name":"production","title":"Production","description":"x"}"#
154 );
155 }
156}