1use 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 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}