Skip to main content

dinio_sdk/models/
health_check.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6/// A health check on one record value (`record_id` is its key: one check per value).
7/// Durations are seconds, as on the wire. It runs only while the value's
8/// [`host_health`](crate::models::RecordValue::host_health) is
9/// [`Auto`](crate::models::HostHealthMode::Auto); there is no separate on/off switch.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct HealthCheck {
12    /// The checked record value; also the check's id.
13    pub record_id: Uuid,
14    /// `http`, `tcp` or `ping`.
15    #[serde(rename = "type")]
16    pub check_type: String,
17    /// Free-form label.
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub name: Option<String>,
20    /// Type-specific settings; see [`HealthCheckConfig`] for the known shapes.
21    pub config: serde_json::Value,
22    /// Time between checks, seconds.
23    pub interval: u32,
24    /// Time one attempt may take, seconds.
25    pub timeout: u32,
26    /// Extra attempts before the value counts as unhealthy.
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub retries: Option<u32>,
29    /// Time between retries, seconds; Core waits 5 s when it is `None`.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub retry_interval: Option<u32>,
32    /// Live status: `UNKNOWN`, `HEALTHY` or `UNHEALTHY`. `UNKNOWN` while the value is not
33    /// on `Auto` and before the first run after any mode change.
34    pub last_status: String,
35    /// Unix seconds; absent while the status is `UNKNOWN`.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub last_checked_at: Option<i64>,
38    /// Why the last attempt failed.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub last_error: Option<String>,
41    /// Unix seconds.
42    pub created_at: i64,
43    /// Unix seconds.
44    pub updated_at: i64,
45}
46
47impl HealthCheck {
48    /// The config in its typed form, if the type and shape are known.
49    pub fn typed_config(&self) -> Option<HealthCheckConfig> {
50        serde_json::from_value(serde_json::json!({
51            "type": self.check_type,
52            "config": self.config,
53        }))
54        .ok()
55    }
56}
57
58/// What to check. Serialized as `"type"` + `"config"` (`../core/src/api/health_checks.ts`).
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
60#[serde(tag = "type", content = "config", rename_all = "lowercase")]
61pub enum HealthCheckConfig {
62    /// An HTTP(S) request must return an accepted status.
63    Http(HttpCheck),
64    /// A TCP connection must open.
65    Tcp(TcpCheck),
66    /// An ICMP echo must be answered.
67    Ping(PingCheck),
68}
69
70/// Settings of an `http` check.
71#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
72pub struct HttpCheck {
73    /// http(s) URL.
74    pub url: String,
75    /// `GET` (default on Core's side), `POST` or `HEAD`.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub method: Option<String>,
78    /// Accepted status codes; Core's default when absent.
79    #[serde(
80        rename = "expectedStatus",
81        default,
82        skip_serializing_if = "Option::is_none"
83    )]
84    pub expected_status: Option<Vec<u16>>,
85    /// Extra request headers.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub headers: Option<BTreeMap<String, String>>,
88    /// Request body (for `POST`).
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub body: Option<String>,
91}
92
93/// Settings of a `tcp` check.
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95pub struct TcpCheck {
96    /// Host name or IP address.
97    pub host: String,
98    /// Port, 1–65535.
99    pub port: u16,
100}
101
102/// Settings of a `ping` check.
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
104pub struct PingCheck {
105    /// Host name or IP address.
106    pub address: String,
107}
108
109/// `POST /health-checks` body. Besides the ranges below, Core requires one run to end
110/// before the next is due: `timeout × (retries + 1) + retry_interval × retries < interval`
111/// (with `retry_interval` 5 when `None`), otherwise
112/// [`ErrorCode::Validation`](crate::ErrorCode::Validation).
113#[derive(Debug, Clone, Serialize)]
114pub struct NewHealthCheck {
115    /// The record value to check.
116    pub record_id: Uuid,
117    /// What to check.
118    #[serde(flatten)]
119    pub config: HealthCheckConfig,
120    /// Free-form label, up to 255 characters.
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub name: Option<String>,
123    /// Seconds, 60–86 400.
124    pub interval: u32,
125    /// Seconds, 1–30.
126    pub timeout: u32,
127    /// 0–5; Core counts `None` as 0.
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub retries: Option<u32>,
130    /// Seconds, 5–60; Core waits 5 s when `None`.
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub retry_interval: Option<u32>,
133}
134
135/// `PATCH /health-checks/:record_id` body; `None` fields stay unchanged. `config`, when
136/// given, **replaces** the whole config and must match the existing type (the type cannot
137/// change). Core checks the run-length rule of [`NewHealthCheck`] against the patch
138/// combined with the stored values.
139#[derive(Debug, Clone, Default, Serialize)]
140pub struct HealthCheckPatch {
141    /// New label.
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub name: Option<String>,
144    /// New config object, the `config` part of [`HealthCheckConfig`] only (without
145    /// `type`).
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub config: Option<serde_json::Value>,
148    /// Seconds; same range as [`NewHealthCheck::interval`].
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub interval: Option<u32>,
151    /// Seconds; same range as [`NewHealthCheck::timeout`].
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub timeout: Option<u32>,
154    /// 0–5.
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub retries: Option<u32>,
157    /// Seconds, 5–60.
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub retry_interval: Option<u32>,
160}
161
162impl HealthCheckPatch {
163    /// Whether the patch changes nothing.
164    pub fn is_empty(&self) -> bool {
165        self.name.is_none()
166            && self.config.is_none()
167            && self.interval.is_none()
168            && self.timeout.is_none()
169            && self.retries.is_none()
170            && self.retry_interval.is_none()
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn new_check_wire_shape() {
180        let check = NewHealthCheck {
181            record_id: Uuid::nil(),
182            config: HealthCheckConfig::Tcp(TcpCheck {
183                host: "192.0.2.1".into(),
184                port: 443,
185            }),
186            name: None,
187            interval: 60,
188            timeout: 5,
189            retries: None,
190            retry_interval: None,
191        };
192        assert_eq!(
193            serde_json::to_value(&check).unwrap(),
194            serde_json::json!({
195                "record_id": "00000000-0000-0000-0000-000000000000",
196                "type": "tcp",
197                "config": { "host": "192.0.2.1", "port": 443 },
198                "interval": 60,
199                "timeout": 5,
200            })
201        );
202    }
203
204    #[test]
205    fn typed_config_from_response() {
206        let check: HealthCheck = serde_json::from_value(serde_json::json!({
207            "record_id": "00000000-0000-0000-0000-000000000000",
208            "type": "http",
209            "config": { "url": "https://example.com/health", "expectedStatus": [200, 204] },
210            "interval": 60, "timeout": 5,
211            "last_status": "UNKNOWN", "last_checked_at": null, "last_error": null,
212            "created_at": 1, "updated_at": 1
213        }))
214        .unwrap();
215        let Some(HealthCheckConfig::Http(http)) = check.typed_config() else {
216            panic!("expected http config")
217        };
218        assert_eq!(http.expected_status, Some(vec![200, 204]));
219    }
220}