1use serde::{Deserialize, Serialize};
3
4use oct_config::Config;
5
6pub struct Client {
8 public_ip: String,
9 port: u16,
10}
11
12#[derive(Debug, Serialize, Deserialize)]
13struct ApplyRequest {
14 config: Config,
15}
16
17impl Client {
18 const DEFAULT_PORT: u16 = 31888;
19
20 pub fn new(public_ip: String) -> Self {
21 Self {
22 public_ip,
23 port: Self::DEFAULT_PORT,
24 }
25 }
26
27 pub fn public_ip(&self) -> &str {
28 &self.public_ip
29 }
30
31 pub async fn apply(&self, config: Config) -> Result<(), Box<dyn std::error::Error>> {
32 let () = self.check_host_health().await?;
33
34 let client = reqwest::Client::new();
35
36 let request = ApplyRequest { config };
37
38 let response = client
39 .post(format!("http://{}:{}/apply", self.public_ip, self.port))
40 .header("Accept", "application/json")
41 .json(&request)
42 .send()
43 .await?;
44
45 match response.error_for_status() {
46 Ok(_) => Ok(()),
47 Err(e) => Err(Box::new(e)),
48 }
49 }
50
51 pub async fn destroy(&self) -> Result<(), Box<dyn std::error::Error>> {
52 let () = self.check_host_health().await?;
53
54 let client = reqwest::Client::new();
55
56 let response = client
57 .post(format!("http://{}:{}/destroy", self.public_ip, self.port))
58 .header("Accept", "application/json")
59 .send()
60 .await?;
61
62 match response.error_for_status() {
63 Ok(_) => Ok(()),
64 Err(e) => Err(Box::new(e)),
65 }
66 }
67
68 async fn check_host_health(&self) -> Result<(), Box<dyn std::error::Error>> {
69 let max_tries = 24;
70 let sleep_duration_s = 5;
71
72 log::info!("Waiting for host '{}' to be ready", self.public_ip);
73
74 let mut is_healthy = false;
75 for _ in 0..max_tries {
76 is_healthy = match self.health_check().await {
77 Ok(()) => {
78 log::info!("Host '{}' is ready", self.public_ip);
79
80 true
81 }
82 Err(err) => {
83 log::info!("Host '{}' responded with error: {}", self.public_ip, err);
84
85 false
86 }
87 };
88
89 if is_healthy {
90 break;
91 }
92
93 log::info!("Retrying in {sleep_duration_s} sec...");
94
95 tokio::time::sleep(std::time::Duration::from_secs(sleep_duration_s)).await;
96 }
97
98 if is_healthy {
99 Ok(())
100 } else {
101 Err(format!(
102 "Host '{}' failed to become ready after max retries",
103 self.public_ip
104 )
105 .into())
106 }
107 }
108
109 async fn health_check(&self) -> Result<(), Box<dyn std::error::Error>> {
110 let client = reqwest::Client::new();
111
112 let response = client
113 .get(format!(
114 "http://{}:{}/health-check",
115 self.public_ip, self.port
116 ))
117 .timeout(std::time::Duration::from_secs(5))
118 .send()
119 .await?;
120
121 match response.error_for_status() {
122 Ok(_) => Ok(()),
123 Err(e) => Err(Box::new(e)),
124 }
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use oct_config::{Project, StateBackend};
132
133 async fn setup_server() -> (String, u16, mockito::ServerGuard) {
134 let server = mockito::Server::new_async().await;
135
136 let addr = server.socket_address();
137
138 (addr.ip().to_string(), addr.port(), server)
139 }
140
141 #[tokio::test]
142 async fn test_apply_success() {
143 let (ip, port, mut server) = setup_server().await;
145
146 let health_check_mock = server
147 .mock("GET", "/health-check")
148 .with_status(200)
149 .create();
150
151 let apply_mock = server
152 .mock("POST", "/apply")
153 .with_status(201)
154 .match_header("Content-Type", "application/json")
155 .match_header("Accept", "application/json")
156 .create();
157
158 let client = Client {
159 public_ip: ip,
160 port,
161 };
162
163 let config = Config {
164 project: Project {
165 name: "test".to_string(),
166 state_backend: StateBackend::Local {
167 path: "state.json".to_string(),
168 },
169 user_state_backend: StateBackend::Local {
170 path: "user_state.json".to_string(),
171 },
172 services: Vec::new(),
173 domain: None,
174 },
175 };
176
177 let response = client.apply(config).await;
179
180 assert!(response.is_ok());
182
183 health_check_mock.assert();
184 apply_mock.assert();
185 }
186
187 #[tokio::test]
188 async fn test_destroy_success() {
189 let (ip, port, mut server) = setup_server().await;
191
192 let health_check_mock = server
193 .mock("GET", "/health-check")
194 .with_status(200)
195 .create();
196
197 let destroy_mock = server
198 .mock("POST", "/destroy")
199 .with_status(200)
200 .match_header("Accept", "application/json")
201 .create();
202
203 let client = Client {
204 public_ip: ip,
205 port,
206 };
207
208 let response = client.destroy().await;
210
211 assert!(response.is_ok());
213
214 health_check_mock.assert();
215 destroy_mock.assert();
216 }
217}