Skip to main content

wowlab_sentinel/
latitude.rs

1//! Thin REST client for the Latitude.sh bare metal API.
2
3use std::{sync::Arc, time::Duration};
4
5use serde::{Deserialize, Serialize};
6
7use crate::config::ExposeSecret;
8
9#[cfg(test)]
10mod tests;
11
12const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
13
14#[derive(Debug, thiserror::Error)]
15#[non_exhaustive]
16pub(crate) enum LatitudeError {
17    #[error("http: {0}")]
18    Http(#[from] reqwest::Error),
19    #[error("api {status}: {body}")]
20    Api { status: u16, body: String },
21    #[error(
22        "server {server_id} was created but tagging failed: {source}; cleanup also failed: {cleanup}"
23    )]
24    ProvisionCleanup {
25        server_id: String,
26        #[source]
27        source: Box<LatitudeError>,
28        cleanup: Box<LatitudeError>,
29    },
30}
31
32impl LatitudeError {
33    pub(crate) fn stranded_server_id(&self) -> Option<&str> {
34        match self {
35            Self::ProvisionCleanup { server_id, .. } => Some(server_id),
36            _ => None,
37        }
38    }
39}
40
41#[derive(Clone, Debug)]
42pub(crate) struct LatitudeClient {
43    inner: Arc<Inner>,
44}
45
46struct Inner {
47    http: reqwest::Client,
48    base_url: String,
49    api_key: secrecy::SecretString,
50}
51
52impl std::fmt::Debug for Inner {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        f.debug_struct("Inner")
55            .field("base_url", &self.base_url)
56            .field("api_key", &"<redacted>")
57            .finish_non_exhaustive()
58    }
59}
60
61#[derive(Clone, Debug, Deserialize)]
62pub(crate) struct ProvisionedServer {
63    pub id: String,
64}
65
66#[derive(Clone, Debug)]
67pub(crate) struct ProvisionSpec<'a> {
68    pub project: &'a str,
69    pub plan: &'a str,
70    pub site: &'a str,
71    pub operating_system: &'a str,
72    pub hostname: &'a str,
73    pub user_data_b64: &'a str,
74    /// Empty skips the follow-up tagging PATCH.
75    pub tag_ids: &'a [&'a str],
76}
77
78#[derive(Debug, Serialize)]
79struct Envelope<T> {
80    data: EnvelopeBody<T>,
81}
82
83#[derive(Debug, Serialize)]
84struct EnvelopeBody<T> {
85    #[serde(rename = "type")]
86    type_: &'static str,
87    attributes: T,
88}
89
90#[derive(Debug, Serialize)]
91struct CreateServerAttrs<'a> {
92    project: &'a str,
93    plan: &'a str,
94    site: &'a str,
95    operating_system: &'a str,
96    hostname: &'a str,
97    user_data: &'a str,
98}
99
100#[derive(Debug, Serialize)]
101struct UpdateTagsAttrs<'a> {
102    tags: &'a [&'a str],
103}
104
105#[derive(Debug, Deserialize)]
106struct CreateResponse {
107    data: CreateData,
108}
109
110#[derive(Debug, Deserialize)]
111struct CreateData {
112    id: String,
113}
114
115impl LatitudeClient {
116    pub(crate) fn new(api_key: secrecy::SecretString, base_url: String) -> Self {
117        let http = reqwest::Client::builder()
118            .timeout(REQUEST_TIMEOUT)
119            .build()
120            .expect("reqwest client build");
121
122        Self {
123            inner: Arc::new(Inner {
124                http,
125                base_url,
126                api_key,
127            }),
128        }
129    }
130
131    pub(crate) async fn provision(
132        &self,
133        spec: &ProvisionSpec<'_>,
134    ) -> Result<ProvisionedServer, LatitudeError> {
135        let url = format!("{}/servers", self.inner.base_url);
136
137        tracing::debug!(%url, "Latitude POST /servers");
138
139        let body = Envelope {
140            data: EnvelopeBody {
141                type_: "servers",
142                attributes: CreateServerAttrs {
143                    project: spec.project,
144                    plan: spec.plan,
145                    site: spec.site,
146                    operating_system: spec.operating_system,
147                    hostname: spec.hostname,
148                    user_data: spec.user_data_b64,
149                },
150            },
151        };
152
153        let resp = self
154            .inner
155            .http
156            .post(&url)
157            .bearer_auth(self.inner.api_key.expose_secret())
158            .header("Accept", "application/vnd.api+json")
159            .header("Content-Type", "application/vnd.api+json")
160            .json(&body)
161            .send()
162            .await?;
163
164        let status = resp.status();
165
166        if !status.is_success() {
167            let body = resp.text().await.unwrap_or_default();
168
169            tracing::warn!(status = status.as_u16(), body = %body, "Latitude provision failed");
170
171            return Err(LatitudeError::Api {
172                status: status.as_u16(),
173                body,
174            });
175        }
176
177        let parsed: CreateResponse = resp.json().await?;
178        let server = ProvisionedServer { id: parsed.data.id };
179
180        if !spec.tag_ids.is_empty() {
181            if let Err(source) = self.apply_tags(&server.id, spec.tag_ids).await {
182                if let Err(cleanup) = self.deprovision(&server.id).await {
183                    return Err(LatitudeError::ProvisionCleanup {
184                        server_id: server.id,
185                        source: Box::new(source),
186                        cleanup: Box::new(cleanup),
187                    });
188                }
189
190                return Err(source);
191            }
192        }
193
194        Ok(server)
195    }
196
197    pub(crate) async fn deprovision(&self, server_id: &str) -> Result<(), LatitudeError> {
198        let url = format!("{}/servers/{}", self.inner.base_url, server_id);
199
200        tracing::debug!(%url, "Latitude DELETE /servers/{{id}}");
201
202        let resp = self
203            .inner
204            .http
205            .delete(&url)
206            .bearer_auth(self.inner.api_key.expose_secret())
207            .header("Accept", "application/vnd.api+json")
208            .send()
209            .await?;
210
211        let status = resp.status();
212
213        if !status.is_success() {
214            let body = resp.text().await.unwrap_or_default();
215
216            tracing::warn!(status = status.as_u16(), body = %body, "Latitude deprovision failed");
217
218            return Err(LatitudeError::Api {
219                status: status.as_u16(),
220                body,
221            });
222        }
223
224        Ok(())
225    }
226
227    async fn apply_tags(&self, server_id: &str, tag_ids: &[&str]) -> Result<(), LatitudeError> {
228        let url = format!("{}/servers/{}", self.inner.base_url, server_id);
229
230        tracing::debug!(%url, "Latitude PATCH /servers/{{id}} tags");
231
232        let body = Envelope {
233            data: EnvelopeBody {
234                type_: "servers",
235                attributes: UpdateTagsAttrs { tags: tag_ids },
236            },
237        };
238
239        let resp = self
240            .inner
241            .http
242            .patch(&url)
243            .bearer_auth(self.inner.api_key.expose_secret())
244            .header("Accept", "application/vnd.api+json")
245            .header("Content-Type", "application/vnd.api+json")
246            .json(&body)
247            .send()
248            .await?;
249
250        let status = resp.status();
251
252        if !status.is_success() {
253            let body = resp.text().await.unwrap_or_default();
254
255            tracing::warn!(status = status.as_u16(), body = %body, "Latitude tagging failed");
256
257            return Err(LatitudeError::Api {
258                status: status.as_u16(),
259                body,
260            });
261        }
262
263        Ok(())
264    }
265}