Skip to main content

oct_ctl_sdk/
lib.rs

1/// TODO(#147): Generate this from `oct-ctl`'s `OpenAPI` spec
2use serde::{Deserialize, Serialize};
3
4use oct_config::Config;
5
6/// HTTP client to access `oct-ctl`'s API
7pub 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        // Arrange
144        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        // Act
178        let response = client.apply(config).await;
179
180        // Assert
181        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        // Arrange
190        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        // Act
209        let response = client.destroy().await;
210
211        // Assert
212        assert!(response.is_ok());
213
214        health_check_mock.assert();
215        destroy_mock.assert();
216    }
217}