Skip to main content

uptrakit_wire/
shared_types.rs

1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4use time::UtcDateTime;
5
6/// Unix epoch timestamp in milliseconds.
7pub type Timestamp = i64;
8
9/// Returns the current time as Unix epoch milliseconds.
10pub fn now_millis() -> Timestamp {
11    let now = UtcDateTime::now();
12    now.unix_timestamp() * 1000 + i64::from(now.millisecond())
13}
14
15/// Final status of an update execution.
16///
17/// # Wire forward-compatibility
18///
19/// `Other(String)` is a catch-all for status strings received from a newer
20/// agent that this build does not yet recognise. Serde deserialization is
21/// infallible: an unknown string becomes `Other(...)` rather than a parse
22/// error, allowing older controllers to survive rolling upgrades without
23/// dropping the enclosing `UpdateResult` message.
24#[non_exhaustive]
25#[derive(Debug, Clone, PartialEq, Eq)]
26#[cfg_attr(feature = "schema", derive(strum::EnumIter))]
27pub enum UpdateFinalStatus {
28    Completed,
29    Failed,
30    /// An unknown status received from a newer peer.
31    ///
32    /// The inner string is the raw snake_case value as it appeared on the wire.
33    Other(String),
34}
35
36impl UpdateFinalStatus {
37    /// Returns the string representation.
38    ///
39    /// For [`UpdateFinalStatus::Other`], returns the inner string as-is.
40    pub fn as_str(&self) -> &str {
41        match self {
42            Self::Completed => "completed",
43            Self::Failed => "failed",
44            Self::Other(s) => s.as_str(),
45        }
46    }
47}
48
49impl fmt::Display for UpdateFinalStatus {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        f.write_str(self.as_str())
52    }
53}
54
55impl From<String> for UpdateFinalStatus {
56    fn from(s: String) -> Self {
57        match s.as_str() {
58            "completed" => Self::Completed,
59            "failed" => Self::Failed,
60            _ => Self::Other(s),
61        }
62    }
63}
64
65impl Serialize for UpdateFinalStatus {
66    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
67        serializer.serialize_str(self.as_str())
68    }
69}
70
71impl<'de> Deserialize<'de> for UpdateFinalStatus {
72    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
73        String::deserialize(deserializer).map(UpdateFinalStatus::from)
74    }
75}
76
77/// Default timeout for update execution (2 hours).
78pub const DEFAULT_UPDATE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(7200);
79
80/// Default timeout for update execution.
81pub(crate) fn default_update_timeout() -> std::time::Duration {
82    DEFAULT_UPDATE_TIMEOUT
83}
84
85/// Reason for service disconnection.
86///
87/// # Wire forward-compatibility
88///
89/// `Other(String)` is a catch-all for reason strings received from a newer
90/// peer that this build does not yet recognise. Serde deserialization is
91/// infallible: an unknown string becomes `Other(...)` rather than a parse
92/// error, allowing rolling upgrades without dropping the `Disconnecting` message.
93#[non_exhaustive]
94#[derive(Debug, Clone, PartialEq, Eq)]
95#[cfg_attr(feature = "schema", derive(strum::EnumIter))]
96pub enum DisconnectReason {
97    /// SIGTERM/SIGINT - clean exit.
98    Shutdown,
99    /// SIGHUP - will reconnect after external restart.
100    Restart,
101    /// An unknown reason received from a newer peer.
102    ///
103    /// The inner string is the raw snake_case value as it appeared on the wire.
104    Other(String),
105}
106
107impl DisconnectReason {
108    /// Returns the string representation.
109    ///
110    /// For [`DisconnectReason::Other`], returns the inner string as-is.
111    pub fn as_str(&self) -> &str {
112        match self {
113            Self::Shutdown => "shutdown",
114            Self::Restart => "restart",
115            Self::Other(s) => s.as_str(),
116        }
117    }
118}
119
120impl fmt::Display for DisconnectReason {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        f.write_str(self.as_str())
123    }
124}
125
126impl From<String> for DisconnectReason {
127    fn from(s: String) -> Self {
128        match s.as_str() {
129            "shutdown" => Self::Shutdown,
130            "restart" => Self::Restart,
131            _ => Self::Other(s),
132        }
133    }
134}
135
136impl Serialize for DisconnectReason {
137    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
138        serializer.serialize_str(self.as_str())
139    }
140}
141
142impl<'de> Deserialize<'de> for DisconnectReason {
143    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
144        String::deserialize(deserializer).map(DisconnectReason::from)
145    }
146}
147
148// ── JSON Schema impls for custom-serde enums ──────────────────────────────────
149//
150// `derive(schemars::JsonSchema)` would document the Rust variant identifiers
151// rather than the wire strings — a silent semantic bug (spec §1). These
152// hand-written impls emit an OPEN string schema: `"type": "string"` with known
153// wire strings in the description and NO `"enum"` array, because the
154// `Other(String)` catch-all makes the value space open-ended.
155//
156// Known-value lists are derived via `strum::EnumIter` from the same `as_str()`
157// the `Serialize` impl uses — a hardcoded list here would drift silently.
158
159#[cfg(feature = "schema")]
160impl schemars::JsonSchema for UpdateFinalStatus {
161    fn schema_name() -> std::borrow::Cow<'static, str> {
162        std::borrow::Cow::Borrowed("UpdateFinalStatus")
163    }
164
165    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
166        use strum::IntoEnumIterator;
167        let known: Vec<String> = UpdateFinalStatus::iter()
168            .filter(|v| !matches!(v, Self::Other(_)))
169            .map(|v| v.as_str().to_string())
170            .collect();
171        schemars::json_schema!({
172            "type": "string",
173            "description": format!(
174                "Open wire string (unknown values are forward-compatible). Known values: {}.",
175                known.join(", ")
176            ),
177        })
178    }
179}
180
181#[cfg(feature = "schema")]
182impl schemars::JsonSchema for DisconnectReason {
183    fn schema_name() -> std::borrow::Cow<'static, str> {
184        std::borrow::Cow::Borrowed("DisconnectReason")
185    }
186
187    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
188        use strum::IntoEnumIterator;
189        let known: Vec<String> = DisconnectReason::iter()
190            .filter(|v| !matches!(v, Self::Other(_)))
191            .map(|v| v.as_str().to_string())
192            .collect();
193        schemars::json_schema!({
194            "type": "string",
195            "description": format!(
196                "Open wire string (unknown values are forward-compatible). Known values: {}.",
197                known.join(", ")
198            ),
199        })
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    #[cfg(feature = "schema")]
206    mod schema_tests {
207        use super::super::*;
208
209        fn assert_open_string_schema<T: schemars::JsonSchema>(known: &[&str]) {
210            let schema = schemars::schema_for!(T);
211            let value = serde_json::to_value(&schema).expect("schema to JSON");
212            assert_eq!(value["type"], "string");
213            assert!(
214                value.get("enum").is_none(),
215                "must be an open string schema, found closed enum list: {value}"
216            );
217            let desc = value["description"].as_str().expect("description present");
218            for k in known {
219                assert!(
220                    desc.contains(k),
221                    "known value {k} missing from description: {desc}"
222                );
223            }
224        }
225
226        #[test]
227        fn update_final_status_schema_is_open_string_with_known_values() {
228            assert_open_string_schema::<UpdateFinalStatus>(&["completed", "failed"]);
229        }
230
231        #[test]
232        fn disconnect_reason_schema_is_open_string_with_known_values() {
233            assert_open_string_schema::<DisconnectReason>(&["shutdown", "restart"]);
234        }
235    }
236}