ubi_rs/commands/
kc.rs

1use crate::client::{HTTPClient, encode_param};
2use crate::errors::UbiClientError;
3use crate::{make_json_request, make_request};
4use bytes::Bytes;
5use serde::Deserialize;
6use serde::Serialize;
7use std::sync::Arc;
8
9pub struct KCClient {
10    http_client: Arc<HTTPClient>,
11}
12
13#[derive(Debug, Serialize, Deserialize, Default)]
14pub struct QueryParams {
15    start_after: Option<String>,
16    page_size: Option<u32>,
17    order_column: Option<String>,
18}
19
20#[derive(Debug, Serialize, Deserialize, Default)]
21pub struct ReqClusterCreate {
22    pub version: String,
23    pub worker_size: String,
24    pub cp_nodes: u32,
25    pub worker_nodes: u32,
26}
27
28#[derive(Debug, Serialize, Deserialize, Default)]
29pub struct ResponseListKubernetesClusters {
30    pub count: u32,
31    pub items: Vec<ClusterItem>,
32}
33
34#[derive(Debug, Serialize, Deserialize, Default)]
35
36pub struct ClusterItem {
37    pub cp_node_count: u32,
38    pub cp_vms: Option<Vec<VirtualMachine>>,
39    pub display_state: String,
40    pub id: String,
41    pub location: String,
42    pub name: String,
43    pub node_size: String,
44    pub nodepools: Option<Vec<NodePool>>,
45    pub services_load_balancer_url: Option<String>,
46    pub version: String,
47}
48
49#[derive(Debug, Serialize, Deserialize, Default)]
50pub struct NodePool {
51    pub kubernetes_cluster_id: String,
52    pub id: String,
53    pub name: String,
54    pub node_count: u32,
55    pub node_size: String,
56    pub vms: Vec<VirtualMachine>,
57}
58
59#[derive(Debug, Serialize, Deserialize, Default)]
60pub struct VirtualMachine {
61    pub id: String,
62    pub ip4: String,
63    pub ip4_enabled: bool,
64    pub ip6: String,
65    pub location: String,
66    pub name: String,
67    pub size: String,
68    pub state: String,
69    pub storage_size_gib: u32,
70    pub unix_user: String,
71}
72
73impl KCClient {
74    pub fn new(http_client: Arc<HTTPClient>) -> Self {
75        KCClient { http_client }
76    }
77
78    /// List KubernetesClusters in a specific location of a project
79    /// https://api.ubicloud.com/project/{project_id}/kubernetes-cluster
80    ///
81    /// project_id
82    /// string
83    /// required
84    pub async fn list_kubernetes_clusters(
85        &self,
86        project_id: &str,
87        query_params: Option<QueryParams>,
88    ) -> Result<ResponseListKubernetesClusters, UbiClientError> {
89        let url = &format!("project/{}/kubernetes-cluster", project_id);
90
91        let body_empty = serde_json::json!({});
92        make_json_request!(
93            self,
94            reqwest::Method::GET,
95            url,
96            body_empty,
97            query_params,
98            ResponseListKubernetesClusters
99        )
100    }
101
102    /// Create a new KubernetesCluster in a specific location of a project
103    /// https://api.ubicloud.com/project/{project_id}/location/{location}/kubernetes-cluster/{kubernetes_cluster_reference}
104    ///
105    /// # Arguments:
106    /// * `project_id` - The ID of the project.
107    /// * `location` - The location/region of the Kubernetes cluster.
108    /// * `kubernetes_cluster_reference` - The ID or name of the Kubernetes cluster.
109    /// * `payload` - The request body containing the details of the Kubernetes cluster to create.
110    ///
111    /// This function sends a POST request to create a new Kubernetes cluster with the specified parameters.
112    pub async fn create_kubernetes_cluster(
113        &self,
114        project_id: &str,
115        location: &str,
116        kubernetes_cluster_reference: &str,
117        payload: &ReqClusterCreate,
118    ) -> Result<ClusterItem, UbiClientError> {
119        let url = &format!(
120            "project/{}/location/{}/kubernetes-cluster/{}",
121            project_id, location, kubernetes_cluster_reference
122        );
123
124        let query_params = [(); 0]; // Option<QueryParams>
125        make_json_request!(
126            self,
127            reqwest::Method::POST,
128            url,
129            payload,
130            query_params,
131            ClusterItem
132        )
133    }
134
135    /// Delete a specific KubernetesCluster
136    /// https://api.ubicloud.com/project/{project_id}/location/{location}/kubernetes-cluster/{kubernetes_cluster_reference}
137    ///
138    /// # Arguments:
139    /// * `project_id` - The ID of the project.
140    /// * `location` - The location/region of the Kubernetes cluster.
141    /// * `kubernetes_cluster_reference` - The ID or name of the Kubernetes cluster.
142    ///
143    /// This function sends a DELETE request to remove the specified Kubernetes cluster.
144    ///
145    pub async fn delete_kubernetes_cluster(
146        &self,
147        project_id: &str,
148        location: &str,
149        kubernetes_cluster_reference: &str,
150    ) -> Result<(), UbiClientError> {
151        let url = &format!(
152            "project/{}/location/{}/kubernetes-cluster/{}",
153            project_id, location, kubernetes_cluster_reference
154        );
155
156        let _response = make_request!(self, reqwest::Method::DELETE, url)?;
157        Ok(())
158    }
159
160    /// Download the kubeconfig file for a specific Kubernetes cluster
161    /// https://api.ubicloud.com/project/{project_id}/location/{location}/kubernetes-cluster/{kubernetes_cluster_reference}/kubeconfig
162    /// ///
163    /// # Arguments:
164    /// * `project_id` - The ID of the project.
165    /// * `location` - The location/region of the Kubernetes cluster.
166    /// * `kubernetes_cluster_reference` - The ID or name of the Kubernetes cluster.
167    pub async fn download_kc_config(
168        &self,
169        project_id: &str,
170        location: &str,
171        kubernetes_cluster_reference: &str,
172    ) -> Result<Bytes, UbiClientError> {
173        let url = &format!(
174            "project/{}/location/{}/kubernetes-cluster/{}/kubeconfig",
175            encode_param(project_id),
176            location,
177            kubernetes_cluster_reference
178        );
179
180        let response: reqwest::Response = make_request!(self, reqwest::Method::GET, &url)?;
181        Ok(response.bytes().await?)
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use crate::client::HTTPClient;
189    use mockito::{Matcher, Server};
190    use std::sync::Arc;
191
192    fn create_test_client(server_url: &str) -> KCClient {
193        let reqwest_client = reqwest::Client::new();
194        let http_client = HTTPClient::new(server_url, reqwest_client, "v1");
195        KCClient::new(Arc::new(http_client))
196    }
197
198    #[tokio::test]
199    async fn test_list_kubernetes_clusters_success() {
200        let mut server = Server::new_async().await;
201        let mock_response = ResponseListKubernetesClusters {
202            count: 2,
203            items: vec![
204                ClusterItem {
205                    cp_node_count: 3,
206                    cp_vms: None,
207                    display_state: "running".to_string(),
208                    id: "cluster-1".to_string(),
209                    location: "us-east".to_string(),
210                    name: "test-cluster-1".to_string(),
211                    node_size: "standard-2".to_string(),
212                    nodepools: None,
213                    services_load_balancer_url: Some("http://lb.example.com".to_string()),
214                    version: "1.28.0".to_string(),
215                },
216                ClusterItem {
217                    cp_node_count: 1,
218                    cp_vms: None,
219                    display_state: "creating".to_string(),
220                    id: "cluster-2".to_string(),
221                    location: "us-west".to_string(),
222                    name: "test-cluster-2".to_string(),
223                    node_size: "standard-1".to_string(),
224                    nodepools: None,
225                    services_load_balancer_url: None,
226                    version: "1.27.0".to_string(),
227                },
228            ],
229        };
230
231        let mock = server
232            .mock("GET", "/v1/project/test-project/kubernetes-cluster")
233            .with_status(200)
234            .with_header("content-type", "application/json")
235            .with_body(serde_json::to_string(&mock_response).unwrap())
236            .create_async()
237            .await;
238
239        let client = create_test_client(&server.url());
240        let result = client
241            .list_kubernetes_clusters("test-project", None)
242            .await
243            .unwrap();
244
245        mock.assert_async().await;
246        assert_eq!(result.count, 2);
247        assert_eq!(result.items.len(), 2);
248        assert_eq!(result.items[0].name, "test-cluster-1");
249        assert_eq!(result.items[1].name, "test-cluster-2");
250    }
251
252    #[tokio::test]
253    async fn test_list_kubernetes_clusters_with_query_params() {
254        let mut server = Server::new_async().await;
255        let mock_response = ResponseListKubernetesClusters {
256            count: 1,
257            items: vec![ClusterItem {
258                cp_node_count: 3,
259                cp_vms: None,
260                display_state: "running".to_string(),
261                id: "cluster-1".to_string(),
262                location: "us-east".to_string(),
263                name: "test-cluster-1".to_string(),
264                node_size: "standard-2".to_string(),
265                nodepools: None,
266                services_load_balancer_url: Some("http://lb.example.com".to_string()),
267                version: "1.28.0".to_string(),
268            }],
269        };
270
271        let mock = server
272            .mock("GET", "/v1/project/test-project/kubernetes-cluster")
273            .match_query(Matcher::AllOf(vec![
274                Matcher::UrlEncoded("page_size".to_string(), "10".to_string()),
275                Matcher::UrlEncoded("order_column".to_string(), "name".to_string()),
276            ]))
277            .with_status(200)
278            .with_header("content-type", "application/json")
279            .with_body(serde_json::to_string(&mock_response).unwrap())
280            .create_async()
281            .await;
282
283        let client = create_test_client(&server.url());
284        let query_params = QueryParams {
285            start_after: None,
286            page_size: Some(10),
287            order_column: Some("name".to_string()),
288        };
289
290        let result = client
291            .list_kubernetes_clusters("test-project", Some(query_params))
292            .await
293            .unwrap();
294
295        mock.assert_async().await;
296        assert_eq!(result.count, 1);
297        assert_eq!(result.items.len(), 1);
298    }
299
300    #[tokio::test]
301    async fn test_create_kubernetes_cluster_success() {
302        let mut server = Server::new_async().await;
303        let mock_response = ClusterItem {
304            cp_node_count: 3,
305            cp_vms: None,
306            display_state: "creating".to_string(),
307            id: "new-cluster".to_string(),
308            location: "us-east".to_string(),
309            name: "new-test-cluster".to_string(),
310            node_size: "standard-2".to_string(),
311            nodepools: None,
312            services_load_balancer_url: None,
313            version: "1.28.0".to_string(),
314        };
315
316        let mock = server
317            .mock(
318                "POST",
319                "/v1/project/test-project/location/us-east/kubernetes-cluster/new-test-cluster",
320            )
321            .with_status(201)
322            .with_header("content-type", "application/json")
323            .with_body(serde_json::to_string(&mock_response).unwrap())
324            .create_async()
325            .await;
326
327        let client = create_test_client(&server.url());
328        let payload = ReqClusterCreate {
329            version: "1.28.0".to_string(),
330            worker_size: "standard-2".to_string(),
331            cp_nodes: 3,
332            worker_nodes: 2,
333        };
334
335        let result = client
336            .create_kubernetes_cluster("test-project", "us-east", "new-test-cluster", &payload)
337            .await
338            .unwrap();
339
340        mock.assert_async().await;
341        assert_eq!(result.name, "new-test-cluster");
342        assert_eq!(result.location, "us-east");
343        assert_eq!(result.version, "1.28.0");
344        assert_eq!(result.display_state, "creating");
345    }
346
347    #[tokio::test]
348    async fn test_delete_kubernetes_cluster_success() {
349        let mut server = Server::new_async().await;
350
351        let mock = server
352            .mock(
353                "DELETE",
354                "/v1/project/test-project/location/us-east/kubernetes-cluster/test-cluster",
355            )
356            .with_status(204)
357            .create_async()
358            .await;
359
360        let client = create_test_client(&server.url());
361        let result = client
362            .delete_kubernetes_cluster("test-project", "us-east", "test-cluster")
363            .await;
364
365        mock.assert_async().await;
366        assert!(result.is_ok());
367    }
368
369    #[tokio::test]
370    async fn test_download_kc_config_success() {
371        let mut server = Server::new_async().await;
372        let kubeconfig_content = r#"apiVersion: v1
373kind: Config
374clusters:
375- cluster:
376    server: https://test-cluster.example.com
377  name: test-cluster
378contexts:
379- context:
380    cluster: test-cluster
381    user: test-user
382  name: test-context
383current-context: test-context
384users:
385- name: test-user
386  user:
387    token: test-token"#;
388
389        let mock = server
390            .mock("GET", "/v1/project/test-project/location/us-east/kubernetes-cluster/test-cluster/kubeconfig")
391            .with_status(200)
392            .with_header("content-type", "application/yaml")
393            .with_body(kubeconfig_content)
394            .create_async()
395            .await;
396
397        let client = create_test_client(&server.url());
398        let result = client
399            .download_kc_config("test-project", "us-east", "test-cluster")
400            .await
401            .unwrap();
402
403        mock.assert_async().await;
404        let downloaded_content = String::from_utf8(result.to_vec()).unwrap();
405        assert!(downloaded_content.contains("apiVersion: v1"));
406        assert!(downloaded_content.contains("kind: Config"));
407        assert!(downloaded_content.contains("test-cluster"));
408    }
409
410    #[tokio::test]
411    async fn test_list_kubernetes_clusters_api_error() {
412        let mut server = Server::new_async().await;
413        let error_response = serde_json::json!({
414            "error": {
415                "type": "NotFound",
416                "message": "Project not found",
417                "details": "The specified project does not exist"
418            }
419        });
420
421        let mock = server
422            .mock("GET", "/v1/project/nonexistent-project/kubernetes-cluster")
423            .with_status(404)
424            .with_header("content-type", "application/json")
425            .with_body(error_response.to_string())
426            .create_async()
427            .await;
428
429        let client = create_test_client(&server.url());
430        let result = client
431            .list_kubernetes_clusters("nonexistent-project", None)
432            .await;
433
434        mock.assert_async().await;
435        assert!(result.is_err());
436
437        if let Err(UbiClientError::APIResponseError {
438            etype,
439            message,
440            details,
441        }) = result
442        {
443            assert_eq!(etype, "NotFound");
444            assert_eq!(message, "Project not found");
445            assert_eq!(
446                details,
447                Some("The specified project does not exist".to_string())
448            );
449        } else {
450            panic!("Expected APIResponseError");
451        }
452    }
453
454    #[tokio::test]
455    async fn test_create_kubernetes_cluster_validation_error() {
456        let mut server = Server::new_async().await;
457        let error_response = serde_json::json!({
458            "error": {
459                "type": "ValidationError",
460                "message": "Invalid cluster configuration",
461                "details": "Worker nodes count must be greater than 0"
462            }
463        });
464
465        let mock = server
466            .mock(
467                "POST",
468                "/v1/project/test-project/location/us-east/kubernetes-cluster/invalid-cluster",
469            )
470            .with_status(400)
471            .with_header("content-type", "application/json")
472            .with_body(error_response.to_string())
473            .create_async()
474            .await;
475
476        let client = create_test_client(&server.url());
477        let payload = ReqClusterCreate {
478            version: "1.28.0".to_string(),
479            worker_size: "standard-2".to_string(),
480            cp_nodes: 3,
481            worker_nodes: 0, // Invalid value
482        };
483
484        let result = client
485            .create_kubernetes_cluster("test-project", "us-east", "invalid-cluster", &payload)
486            .await;
487
488        mock.assert_async().await;
489        assert!(result.is_err());
490
491        if let Err(UbiClientError::APIResponseError { etype, message, .. }) = result {
492            assert_eq!(etype, "ValidationError");
493            assert_eq!(message, "Invalid cluster configuration");
494        } else {
495            panic!("Expected APIResponseError");
496        }
497    }
498
499    #[tokio::test]
500    async fn test_delete_kubernetes_cluster_not_found() {
501        let mut server = Server::new_async().await;
502        let error_response = serde_json::json!({
503            "error": {
504                "type": "NotFound",
505                "message": "Kubernetes cluster not found",
506                "details": null
507            }
508        });
509
510        let mock = server
511            .mock(
512                "DELETE",
513                "/v1/project/test-project/location/us-east/kubernetes-cluster/nonexistent-cluster",
514            )
515            .with_status(404)
516            .with_header("content-type", "application/json")
517            .with_body(error_response.to_string())
518            .create_async()
519            .await;
520
521        let client = create_test_client(&server.url());
522        let result = client
523            .delete_kubernetes_cluster("test-project", "us-east", "nonexistent-cluster")
524            .await;
525
526        mock.assert_async().await;
527        assert!(result.is_err());
528
529        if let Err(UbiClientError::APIResponseError { etype, message, .. }) = result {
530            assert_eq!(etype, "NotFound");
531            assert_eq!(message, "Kubernetes cluster not found");
532        } else {
533            panic!("Expected APIResponseError");
534        }
535    }
536
537    #[tokio::test]
538    async fn test_encode_param_in_urls() {
539        let mut server = Server::new_async().await;
540        let mock_response = ResponseListKubernetesClusters {
541            count: 0,
542            items: vec![],
543        };
544
545        // Test with normal project ID for list_kubernetes_clusters which doesn't use encode_param
546        let project_id = "test-project";
547
548        let mock = server
549            .mock(
550                "GET",
551                format!("/v1/project/{}/kubernetes-cluster", project_id).as_str(),
552            )
553            .with_status(200)
554            .with_header("content-type", "application/json")
555            .with_body(serde_json::to_string(&mock_response).unwrap())
556            .create_async()
557            .await;
558
559        let client = create_test_client(&server.url());
560        let result = client
561            .list_kubernetes_clusters(project_id, None)
562            .await
563            .unwrap();
564
565        mock.assert_async().await;
566        assert_eq!(result.count, 0);
567    }
568
569    #[tokio::test]
570    async fn test_download_kc_config_with_encoded_params() {
571        let mut server = Server::new_async().await;
572        let kubeconfig_content = "test-config";
573
574        // Test with a project ID that contains special characters that need encoding
575        let project_id_with_special_chars = "test-project@domain.com";
576
577        let mock = server
578            .mock("GET", "/v1/project/test%2Dproject%40domain%2Ecom/location/us-east/kubernetes-cluster/test-cluster/kubeconfig")
579            .with_status(200)
580            .with_header("content-type", "application/yaml")
581            .with_body(kubeconfig_content)
582            .create_async()
583            .await;
584
585        let client = create_test_client(&server.url());
586        let result = client
587            .download_kc_config(project_id_with_special_chars, "us-east", "test-cluster")
588            .await
589            .unwrap();
590
591        mock.assert_async().await;
592        let downloaded_content = String::from_utf8(result.to_vec()).unwrap();
593        assert_eq!(downloaded_content, "test-config");
594    }
595
596    #[test]
597    fn test_query_params_default() {
598        let params = QueryParams::default();
599        assert!(params.start_after.is_none());
600        assert!(params.page_size.is_none());
601        assert!(params.order_column.is_none());
602    }
603
604    #[test]
605    fn test_req_cluster_create_default() {
606        let req = ReqClusterCreate::default();
607        assert_eq!(req.version, "");
608        assert_eq!(req.worker_size, "");
609        assert_eq!(req.cp_nodes, 0);
610        assert_eq!(req.worker_nodes, 0);
611    }
612
613    #[test]
614    fn test_cluster_item_serialization() {
615        let cluster = ClusterItem {
616            cp_node_count: 3,
617            cp_vms: None,
618            display_state: "running".to_string(),
619            id: "test-cluster".to_string(),
620            location: "us-east".to_string(),
621            name: "My Test Cluster".to_string(),
622            node_size: "standard-2".to_string(),
623            nodepools: None,
624            services_load_balancer_url: Some("http://lb.example.com".to_string()),
625            version: "1.28.0".to_string(),
626        };
627
628        let serialized = serde_json::to_string(&cluster).unwrap();
629        let deserialized: ClusterItem = serde_json::from_str(&serialized).unwrap();
630
631        assert_eq!(cluster.id, deserialized.id);
632        assert_eq!(cluster.name, deserialized.name);
633        assert_eq!(cluster.location, deserialized.location);
634        assert_eq!(cluster.version, deserialized.version);
635    }
636}