Skip to main content

phala_tee_deploy_rs/
client.rs

1use reqwest::Client;
2use serde_json::json;
3use std::collections::HashMap;
4use std::time::Duration;
5
6use crate::{
7    config::DeploymentConfig,
8    crypto::Encryptor,
9    error::Error,
10    types::{
11        AttestationResponse, ComposeResponse, CvmInfo, CvmStateResponse, DeploymentResponse,
12        NetworkInfoResponse, SystemStatsResponse, VmConfig,
13    },
14    PubkeyResponse, TeePodDiscoveryResponse,
15};
16
17/// Client for interacting with the Phala TEE Cloud API.
18///
19/// `TeeClient` provides low-level access to the Phala Cloud API for deploying
20/// and managing containerized applications in a Trusted Execution Environment (TEE).
21/// This client handles authentication, API requests, and encryption of sensitive data.
22///
23/// For most use cases, consider using the higher-level `TeeDeployer` API instead,
24/// which provides a more ergonomic interface built on top of this client.
25///
26/// # Features
27///
28/// * Direct API access to the Phala TEE Cloud
29/// * Secure environment variable encryption
30/// * TEEPod discovery and selection
31/// * Application deployment and management
32pub struct TeeClient {
33    client: Client,
34    config: DeploymentConfig,
35}
36
37impl TeeClient {
38    /// Creates a new `TeeClient` with the given configuration.
39    ///
40    /// # Parameters
41    ///
42    /// * `config` - The deployment configuration including API credentials and default settings
43    ///
44    /// # Returns
45    ///
46    /// A new `TeeClient` instance if successful
47    ///
48    /// # Errors
49    ///
50    /// Returns an error if the HTTP client cannot be created
51    pub fn new(config: DeploymentConfig) -> Result<Self, Error> {
52        let client = Client::builder()
53            .timeout(Duration::from_secs(30))
54            .build()
55            .map_err(Error::HttpClient)?;
56
57        Ok(Self { client, config })
58    }
59
60    /// Deploys a container to the TEE environment using the client's configuration.
61    ///
62    /// This method uses the configuration set during client creation to deploy
63    /// an application. It handles VM configuration, encryption, and API communication.
64    ///
65    /// # Returns
66    ///
67    /// A `DeploymentResponse` containing the deployment details if successful
68    ///
69    /// # Errors
70    ///
71    /// Returns an error if:
72    /// * The API request fails
73    /// * Environment variables cannot be encrypted
74    /// * The API returns an error response
75    pub async fn deploy(&self) -> Result<DeploymentResponse, Error> {
76        // Get or create VM configuration
77        let vm_config = self.config.vm_config.clone().unwrap_or_else(|| VmConfig {
78            name: format!("tee-deploy-{}", uuid::Uuid::new_v4()),
79            compose_manifest: crate::types::ComposeManifest {
80                name: "tee-deployment".to_string(),
81                features: vec!["kms".to_string(), "tproxy-net".to_string()],
82                docker_compose_file: self.config.docker_compose.clone(),
83            },
84            vcpu: 2,
85            memory: 8192,
86            disk_size: 40,
87            teepod_id: self.config.teepod_id,
88            image: self.config.image.clone(),
89            advanced_features: crate::types::AdvancedFeatures {
90                tproxy: true,
91                kms: true,
92                public_sys_info: true,
93                public_logs: true,
94                docker_config: crate::types::DockerConfig {
95                    username: String::new(),
96                    password: String::new(),
97                    registry: None,
98                },
99                listed: false,
100            },
101        });
102
103        // Get encryption public key
104        let pubkey_response = self.get_pubkey(&vm_config).await?;
105
106        // Encrypt environment variables
107        let env_vars: Vec<_> = self
108            .config
109            .env_vars
110            .iter()
111            .map(|(k, v)| (k.clone(), v.clone()))
112            .collect();
113
114        let encrypted_env =
115            Encryptor::encrypt_env_vars(&env_vars, &pubkey_response.app_env_encrypt_pubkey)?;
116
117        // Create a mutable request body from vm_config
118        let mut request_body = serde_json::to_value(&vm_config)
119            .unwrap()
120            .as_object()
121            .cloned()
122            .unwrap_or_default();
123
124        // Add the additional fields
125        request_body.insert(
126            "encrypted_env".to_string(),
127            serde_json::Value::String(encrypted_env),
128        );
129        request_body.insert(
130            "app_env_encrypt_pubkey".to_string(),
131            serde_json::Value::String(pubkey_response.app_env_encrypt_pubkey.clone()),
132        );
133
134        // Create deployment
135        let response = self
136            .client
137            .post(format!(
138                "{}/cvms/from_cvm_configuration",
139                self.config.api_url
140            ))
141            .header("Content-Type", "application/json")
142            .header("x-api-key", &self.config.api_key)
143            .json(&request_body)
144            .send()
145            .await?;
146
147        if !response.status().is_success() {
148            return Err(Error::Api {
149                status_code: response.status().as_u16(),
150                message: response.text().await?,
151            });
152        }
153
154        response
155            .json::<DeploymentResponse>()
156            .await
157            .map_err(Error::HttpClient)
158    }
159
160    /// Retrieves the encryption public key for a given VM configuration.
161    ///
162    /// This is a helper method used internally to get the public key needed
163    /// for securely encrypting environment variables.
164    ///
165    /// # Parameters
166    ///
167    /// * `vm_config` - The VM configuration to get a public key for
168    ///
169    /// # Returns
170    ///
171    /// A JSON value containing the public key and salt if successful
172    ///
173    /// # Errors
174    ///
175    /// Returns an error if the API request fails or returns an error
176    async fn get_pubkey(&self, vm_config: &VmConfig) -> Result<PubkeyResponse, Error> {
177        let response = self
178            .client
179            .post(format!(
180                "{}/cvms/pubkey/from_cvm_configuration",
181                self.config.api_url
182            ))
183            .header("Content-Type", "application/json")
184            .header("x-api-key", &self.config.api_key)
185            .json(&vm_config)
186            .send()
187            .await?;
188
189        if !response.status().is_success() {
190            return Err(Error::Api {
191                status_code: response.status().as_u16(),
192                message: response.text().await?,
193            });
194        }
195
196        response
197            .json::<PubkeyResponse>()
198            .await
199            .map_err(Error::HttpClient)
200    }
201
202    /// Retrieves the current Docker Compose configuration for an application.
203    ///
204    /// # Parameters
205    ///
206    /// * `app_id` - The ID of the application to get the configuration for
207    ///
208    /// # Returns
209    ///
210    /// A `ComposeResponse` containing the compose file and encryption public key
211    ///
212    /// # Errors
213    ///
214    /// Returns an error if the API request fails or the application is not found
215    pub async fn get_compose(&self, app_id: &str) -> Result<ComposeResponse, Error> {
216        let response = self
217            .client
218            .get(format!("{}/cvms/{}/compose", self.config.api_url, app_id))
219            .header("Content-Type", "application/json")
220            .header("x-api-key", &self.config.api_key)
221            .send()
222            .await?;
223
224        if !response.status().is_success() {
225            return Err(Error::Api {
226                status_code: response.status().as_u16(),
227                message: response.text().await?,
228            });
229        }
230
231        response
232            .json::<ComposeResponse>()
233            .await
234            .map_err(Error::HttpClient)
235    }
236
237    /// Updates the Docker Compose configuration for an existing application.
238    ///
239    /// This method can update both the application configuration and its
240    /// environment variables.
241    ///
242    /// # Parameters
243    ///
244    /// * `app_id` - The ID of the application to update
245    /// * `compose_file` - The new Docker Compose configuration
246    /// * `env_vars` - Optional new environment variables
247    /// * `env_pubkey` - The public key for encrypting environment variables
248    ///
249    /// # Returns
250    ///
251    /// A JSON value containing the update operation result
252    ///
253    /// # Errors
254    ///
255    /// Returns an error if:
256    /// * The API request fails
257    /// * The application is not found
258    /// * Environment variables cannot be encrypted
259    pub async fn update_compose(
260        &self,
261        app_id: &str,
262        compose_file: serde_json::Value,
263        env_vars: Option<HashMap<String, String>>,
264        env_pubkey: String,
265    ) -> Result<serde_json::Value, Error> {
266        let mut body = json!({
267            "compose_manifest": compose_file
268        });
269
270        // Encrypt environment variables if provided
271        if let Some(vars) = env_vars {
272            let env_vars: Vec<_> = vars.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
273            let encrypted_env = Encryptor::encrypt_env_vars(&env_vars, &env_pubkey)?;
274            body["encrypted_env"] = json!(encrypted_env);
275        }
276
277        let response = self
278            .client
279            .put(format!("{}/cvms/{}/compose", self.config.api_url, app_id))
280            .header("Content-Type", "application/json")
281            .header("x-api-key", &self.config.api_key)
282            .json(&body)
283            .send()
284            .await?;
285
286        if !response.status().is_success() {
287            return Err(Error::Api {
288                status_code: response.status().as_u16(),
289                message: response.text().await?,
290            });
291        }
292
293        response.json().await.map_err(Error::HttpClient)
294    }
295
296    /// Retrieves a list of available TEEPods from the Phala Cloud API.
297    ///
298    /// This method queries the API for TEEPods that are available for deployment,
299    /// providing detailed diagnostics for any connection issues.
300    ///
301    /// # Returns
302    ///
303    /// A JSON value containing the list of available TEEPods if successful
304    ///
305    /// # Errors
306    ///
307    /// Returns an error if:
308    /// * The network request fails (timeout, connection issues, etc.)
309    /// * The API returns an error response
310    /// * The response cannot be parsed as valid JSON
311    pub async fn get_available_teepods(&self) -> Result<TeePodDiscoveryResponse, Error> {
312        let response = self
313            .client
314            .get(format!("{}/teepods/available", self.config.api_url))
315            .header("Content-Type", "application/json")
316            .header("x-api-key", &self.config.api_key)
317            .timeout(std::time::Duration::from_secs(15))
318            .send()
319            .await?;
320
321        if !response.status().is_success() {
322            return Err(Error::Api {
323                status_code: response.status().as_u16(),
324                message: response.text().await?,
325            });
326        }
327
328        response.json().await.map_err(Error::HttpClient)
329    }
330
331    /// Retrieves the encryption public key for a custom VM configuration.
332    ///
333    /// # Parameters
334    ///
335    /// * `vm_config` - The VM configuration as a JSON value
336    ///
337    /// # Returns
338    ///
339    /// A JSON value containing the public key and salt for encryption
340    ///
341    /// # Errors
342    ///
343    /// Returns an error if the API request fails or returns an error
344    pub async fn get_pubkey_for_config(
345        &self,
346        vm_config: &serde_json::Value,
347    ) -> Result<PubkeyResponse, Error> {
348        let response = self
349            .client
350            .post(format!(
351                "{}/cvms/pubkey/from_cvm_configuration",
352                self.config.api_url
353            ))
354            .header("Content-Type", "application/json")
355            .header("x-api-key", &self.config.api_key)
356            .json(&vm_config)
357            .send()
358            .await?;
359
360        if !response.status().is_success() {
361            return Err(Error::Api {
362                status_code: response.status().as_u16(),
363                message: response.text().await?,
364            });
365        }
366
367        response.json().await.map_err(Error::HttpClient)
368    }
369
370    /// Deploys a container with a custom VM configuration and encrypts environment variables.
371    ///
372    /// This method handles the encryption of environment variables and then calls
373    /// `deploy_with_config_encrypted_env` to perform the actual deployment.
374    ///
375    /// # Parameters
376    ///
377    /// * `vm_config` - The VM configuration as a JSON value
378    /// * `env_vars` - Environment variables to encrypt and include in the deployment
379    /// * `app_env_encrypt_pubkey` - The public key for encrypting environment variables
380    /// * `app_id_salt` - The salt value for encryption
381    ///
382    /// # Returns
383    ///
384    /// A `DeploymentResponse` containing the deployment details if successful
385    ///
386    /// # Errors
387    ///
388    /// Returns an error if:
389    /// * Environment variable encryption fails
390    /// * The API request fails
391    /// * The API returns an error response
392    pub async fn deploy_with_config_do_encrypt(
393        &self,
394        vm_config: serde_json::Value,
395        env_vars: &[(String, String)],
396        app_env_encrypt_pubkey: &str,
397        app_id_salt: &str,
398    ) -> Result<DeploymentResponse, Error> {
399        // Encrypt environment variables
400        let encrypted_env = Encryptor::encrypt_env_vars(env_vars, app_env_encrypt_pubkey)?;
401
402        self.deploy_with_config_encrypted_env(
403            vm_config,
404            encrypted_env,
405            app_env_encrypt_pubkey,
406            app_id_salt,
407        )
408        .await
409    }
410
411    /// Deploys a container with a custom VM configuration and pre-encrypted environment variables.
412    ///
413    /// This method is the final step in the deployment process, sending the VM configuration
414    /// and encrypted environment variables to the API.
415    ///
416    /// # Parameters
417    ///
418    /// * `vm_config` - The VM configuration as a JSON value
419    /// * `encrypted_env` - Pre-encrypted environment variables as a string
420    /// * `app_env_encrypt_pubkey` - The public key used for encryption
421    /// * `app_id_salt` - The salt value used for encryption
422    ///
423    /// # Returns
424    ///
425    /// A `DeploymentResponse` containing the deployment details if successful
426    ///
427    /// # Errors
428    ///
429    /// Returns an error if the API request fails or returns an error
430    pub async fn deploy_with_config_encrypted_env(
431        &self,
432        vm_config: serde_json::Value,
433        encrypted_env: String,
434        app_env_encrypt_pubkey: &str,
435        app_id_salt: &str,
436    ) -> Result<DeploymentResponse, Error> {
437        // Create a mutable request body
438        let mut request_body = vm_config.as_object().cloned().unwrap_or_default();
439
440        // Add the additional fields
441        request_body.insert(
442            "encrypted_env".to_string(),
443            serde_json::Value::String(encrypted_env),
444        );
445        request_body.insert(
446            "app_env_encrypt_pubkey".to_string(),
447            serde_json::Value::String(app_env_encrypt_pubkey.to_string()),
448        );
449        request_body.insert(
450            "app_id_salt".to_string(),
451            serde_json::Value::String(app_id_salt.to_string()),
452        );
453
454        // Create deployment
455        let response = self
456            .client
457            .post(format!(
458                "{}/cvms/from_cvm_configuration",
459                self.config.api_url
460            ))
461            .header("Content-Type", "application/json")
462            .header("x-api-key", &self.config.api_key)
463            .json(&request_body)
464            .send()
465            .await?;
466
467        if !response.status().is_success() {
468            return Err(Error::Api {
469                status_code: response.status().as_u16(),
470                message: response.text().await?,
471            });
472        }
473
474        response
475            .json::<DeploymentResponse>()
476            .await
477            .map_err(Error::HttpClient)
478    }
479
480    /// Provisions a new ELIZA chatbot deployment.
481    ///
482    /// This method initiates the ELIZA deployment process by requesting an app_id
483    /// and encryption key from the API. This is the first step in the two-step
484    /// deployment process.
485    ///
486    /// # Parameters
487    ///
488    /// * `name` - Name for the ELIZA deployment
489    /// * `character_file` - Character configuration file content
490    /// * `env_keys` - List of environment variable keys to include
491    /// * `image` - Docker image to use for the deployment
492    ///
493    /// # Returns
494    ///
495    /// A tuple containing:
496    /// * `app_id` - The ID of the provisioned application
497    /// * `app_env_encrypt_pubkey` - The public key for encrypting environment variables
498    ///
499    /// # Errors
500    ///
501    /// Returns an error if:
502    /// * The API request fails
503    /// * Invalid configuration is provided
504    /// * The response cannot be parsed
505    pub async fn provision_eliza(
506        &self,
507        name: String,
508        character_file: String,
509        env_keys: Vec<String>,
510        image: String,
511    ) -> Result<(String, String), Error> {
512        let request_body = serde_json::json!({
513            "name": name,
514            "characterfile": character_file,
515            "env_keys": env_keys,
516            "teepod_id": self.config.teepod_id,
517            "image": image
518        });
519
520        let response = self
521            .client
522            .post(format!("{}/cvms/provision/eliza", self.config.api_url))
523            .header("Content-Type", "application/json")
524            .header("x-api-key", &self.config.api_key)
525            .json(&request_body)
526            .send()
527            .await?;
528
529        if !response.status().is_success() {
530            return Err(Error::Api {
531                status_code: response.status().as_u16(),
532                message: response.text().await?,
533            });
534        }
535
536        // Get the response as JSON Value to extract necessary fields
537        let provision_response = response.json::<serde_json::Value>().await?;
538
539        // Extract required values
540        let app_id = match provision_response.get("app_id") {
541            Some(id) => id.as_str().ok_or_else(|| {
542                Error::Configuration("Missing app_id in provision response".to_string())
543            })?,
544            None => {
545                return Err(Error::Configuration(
546                    "Missing app_id in provision response".to_string(),
547                ))
548            }
549        };
550
551        let app_env_encrypt_pubkey = match provision_response.get("app_env_encrypt_pubkey") {
552            Some(key) => key.as_str().ok_or_else(|| {
553                Error::Configuration("Missing encryption key in provision response".to_string())
554            })?,
555            None => {
556                return Err(Error::Configuration(
557                    "Missing encryption key in provision response".to_string(),
558                ))
559            }
560        };
561
562        Ok((app_id.to_string(), app_env_encrypt_pubkey.to_string()))
563    }
564
565    /// Creates a VM for an ELIZA deployment with encrypted environment variables.
566    ///
567    /// This method is the second step in the ELIZA deployment process, creating
568    /// the actual VM with the provided encrypted environment variables.
569    ///
570    /// # Parameters
571    ///
572    /// * `app_id` - The ID of the provisioned application
573    /// * `encrypted_env` - Pre-encrypted environment variables
574    ///
575    /// # Returns
576    ///
577    /// A `DeploymentResponse` containing the deployment details and status
578    ///
579    /// # Errors
580    ///
581    /// Returns an error if:
582    /// * The API request fails
583    /// * The deployment cannot be created
584    /// * The response cannot be parsed
585    pub async fn create_eliza_vm(
586        &self,
587        app_id: &str,
588        encrypted_env: &str,
589    ) -> Result<DeploymentResponse, Error> {
590        // Create the VM
591        let create_body = serde_json::json!({
592            "app_id": app_id,
593            "encrypted_env": encrypted_env
594        });
595
596        let create_response = self
597            .client
598            .post(format!("{}/cvms", self.config.api_url))
599            .header("Content-Type", "application/json")
600            .header("x-api-key", &self.config.api_key)
601            .json(&create_body)
602            .send()
603            .await?;
604
605        if !create_response.status().is_success() {
606            return Err(Error::Api {
607                status_code: create_response.status().as_u16(),
608                message: create_response.text().await?,
609            });
610        }
611
612        // Parse final response into DeploymentResponse
613        let response_text = create_response.text().await?;
614
615        match serde_json::from_str::<DeploymentResponse>(&response_text) {
616            Ok(deployment_response) => Ok(deployment_response),
617            Err(e) => {
618                // Try to extract ID from app_id
619                let mut response_json: serde_json::Value = serde_json::from_str(&response_text)
620                    .map_err(|_| {
621                        Error::Configuration(format!("Failed to parse response: {}", e))
622                    })?;
623
624                // If response already has app_id, use it to build DeploymentResponse
625                if response_json.get("app_id").is_some() {
626                    // Remove app_ prefix if necessary
627                    let id_str = app_id.trim_start_matches("app_");
628
629                    // Try to parse as number
630                    let id = id_str.parse::<u64>().unwrap_or(0);
631
632                    // Create a details map with all available information
633                    let mut details = HashMap::new();
634                    if let Some(obj) = response_json.as_object_mut() {
635                        for (k, v) in obj {
636                            details.insert(k.clone(), v.clone());
637                        }
638                    }
639
640                    Ok(DeploymentResponse {
641                        id,
642                        status: "pending".to_string(),
643                        details: Some(details),
644                    })
645                } else {
646                    Err(Error::Configuration(format!(
647                        "Failed to parse deployment response: {}",
648                        e
649                    )))
650                }
651            }
652        }
653    }
654
655    /// Retrieves network information for a deployed application.
656    ///
657    /// This method fetches network connectivity details, status, and public URLs
658    /// for accessing the deployed application.
659    ///
660    /// # Parameters
661    ///
662    /// * `app_id` - The ID of the application to get network information for
663    ///
664    /// # Returns
665    ///
666    /// A `NetworkInfoResponse` containing network details including status and URLs
667    ///
668    /// # Errors
669    ///
670    /// Returns an error if:
671    /// * The API request fails
672    /// * The application is not found
673    /// * The network information cannot be retrieved
674    pub async fn get_network_info(&self, app_id: &str) -> Result<NetworkInfoResponse, Error> {
675        let response = self
676            .client
677            .get(format!("{}/cvms/{}/network", self.config.api_url, app_id))
678            .header("Content-Type", "application/json")
679            .header("x-api-key", &self.config.api_key)
680            .send()
681            .await?;
682
683        if !response.status().is_success() {
684            return Err(Error::Api {
685                status_code: response.status().as_u16(),
686                message: response.text().await?,
687            });
688        }
689
690        response
691            .json::<NetworkInfoResponse>()
692            .await
693            .map_err(Error::HttpClient)
694    }
695
696    /// Retrieves system statistics for a deployed application.
697    ///
698    /// This method fetches detailed system information including OS details,
699    /// CPU, memory, disk usage, and load averages for a deployed containerized application.
700    ///
701    /// # Parameters
702    ///
703    /// * `app_id` - The ID of the application to get system statistics for
704    ///
705    /// # Returns
706    ///
707    /// A `SystemStatsResponse` containing system information if successful
708    ///
709    /// # Errors
710    ///
711    /// Returns an error if:
712    /// * The API request fails
713    /// * The application is not found
714    /// * The system statistics cannot be retrieved
715    pub async fn get_system_stats(&self, app_id: &str) -> Result<SystemStatsResponse, Error> {
716        let response = self
717            .client
718            .get(format!("{}/cvms/{}/stats", self.config.api_url, app_id))
719            .header("Content-Type", "application/json")
720            .header("x-api-key", &self.config.api_key)
721            .send()
722            .await?;
723
724        if !response.status().is_success() {
725            return Err(Error::Api {
726                status_code: response.status().as_u16(),
727                message: response.text().await?,
728            });
729        }
730
731        response
732            .json::<SystemStatsResponse>()
733            .await
734            .map_err(Error::HttpClient)
735    }
736
737    // ─────────────────────────────────────────────────────────────────────
738    // CVM lifecycle
739    // ─────────────────────────────────────────────────────────────────────
740
741    /// Get CVM details including status.
742    /// `GET /api/v1/cvms/{cvm_id}`
743    pub async fn get_cvm(&self, cvm_id: &str) -> Result<CvmInfo, Error> {
744        let response = self
745            .client
746            .get(format!("{}/cvms/{}", self.config.api_url, cvm_id))
747            .header("Content-Type", "application/json")
748            .header("x-api-key", &self.config.api_key)
749            .send()
750            .await?;
751
752        if !response.status().is_success() {
753            return Err(Error::Api {
754                status_code: response.status().as_u16(),
755                message: response.text().await?,
756            });
757        }
758
759        response.json::<CvmInfo>().await.map_err(Error::HttpClient)
760    }
761
762    /// Get CVM state (running, stopped, etc.).
763    /// `GET /api/v1/cvms/{cvm_id}/state`
764    pub async fn get_state(&self, cvm_id: &str) -> Result<CvmStateResponse, Error> {
765        let response = self
766            .client
767            .get(format!("{}/cvms/{}/state", self.config.api_url, cvm_id))
768            .header("Content-Type", "application/json")
769            .header("x-api-key", &self.config.api_key)
770            .send()
771            .await?;
772
773        if !response.status().is_success() {
774            return Err(Error::Api {
775                status_code: response.status().as_u16(),
776                message: response.text().await?,
777            });
778        }
779
780        response
781            .json::<CvmStateResponse>()
782            .await
783            .map_err(Error::HttpClient)
784    }
785
786    /// Start a stopped CVM.
787    /// `POST /api/v1/cvms/{cvm_id}/start`
788    pub async fn start_cvm(&self, cvm_id: &str) -> Result<CvmInfo, Error> {
789        let response = self
790            .client
791            .post(format!("{}/cvms/{}/start", self.config.api_url, cvm_id))
792            .header("Content-Type", "application/json")
793            .header("x-api-key", &self.config.api_key)
794            .send()
795            .await?;
796
797        if !response.status().is_success() {
798            return Err(Error::Api {
799                status_code: response.status().as_u16(),
800                message: response.text().await?,
801            });
802        }
803
804        response.json::<CvmInfo>().await.map_err(Error::HttpClient)
805    }
806
807    /// Graceful shutdown (SIGTERM, then SIGKILL after timeout).
808    /// `POST /api/v1/cvms/{cvm_id}/shutdown`
809    pub async fn shutdown_cvm(&self, cvm_id: &str) -> Result<CvmInfo, Error> {
810        let response = self
811            .client
812            .post(format!(
813                "{}/cvms/{}/shutdown",
814                self.config.api_url, cvm_id
815            ))
816            .header("Content-Type", "application/json")
817            .header("x-api-key", &self.config.api_key)
818            .send()
819            .await?;
820
821        if !response.status().is_success() {
822            return Err(Error::Api {
823                status_code: response.status().as_u16(),
824                message: response.text().await?,
825            });
826        }
827
828        response.json::<CvmInfo>().await.map_err(Error::HttpClient)
829    }
830
831    /// Force stop (immediate, like power loss).
832    /// `POST /api/v1/cvms/{cvm_id}/stop`
833    pub async fn stop_cvm(&self, cvm_id: &str) -> Result<CvmInfo, Error> {
834        let response = self
835            .client
836            .post(format!("{}/cvms/{}/stop", self.config.api_url, cvm_id))
837            .header("Content-Type", "application/json")
838            .header("x-api-key", &self.config.api_key)
839            .send()
840            .await?;
841
842        if !response.status().is_success() {
843            return Err(Error::Api {
844                status_code: response.status().as_u16(),
845                message: response.text().await?,
846            });
847        }
848
849        response.json::<CvmInfo>().await.map_err(Error::HttpClient)
850    }
851
852    /// Permanently delete a stopped CVM (irreversible).
853    /// `DELETE /api/v1/cvms/{cvm_id}`
854    pub async fn delete_cvm(&self, cvm_id: &str) -> Result<(), Error> {
855        let response = self
856            .client
857            .delete(format!("{}/cvms/{}", self.config.api_url, cvm_id))
858            .header("Content-Type", "application/json")
859            .header("x-api-key", &self.config.api_key)
860            .send()
861            .await?;
862
863        if !response.status().is_success() {
864            return Err(Error::Api {
865                status_code: response.status().as_u16(),
866                message: response.text().await?,
867            });
868        }
869
870        Ok(())
871    }
872
873    /// Get TEE attestation data.
874    /// `GET /api/v1/cvms/{cvm_id}/attestation`
875    pub async fn get_attestation(&self, cvm_id: &str) -> Result<AttestationResponse, Error> {
876        let response = self
877            .client
878            .get(format!(
879                "{}/cvms/{}/attestation",
880                self.config.api_url, cvm_id
881            ))
882            .header("Content-Type", "application/json")
883            .header("x-api-key", &self.config.api_key)
884            .send()
885            .await?;
886
887        if !response.status().is_success() {
888            return Err(Error::Api {
889                status_code: response.status().as_u16(),
890                message: response.text().await?,
891            });
892        }
893
894        response
895            .json::<AttestationResponse>()
896            .await
897            .map_err(Error::HttpClient)
898    }
899}