vim_rs/core/
client.rs

1use std::sync::Arc;
2
3use tokio::sync::RwLock;
4use super::super::types::structs;
5use log::{warn, debug, trace, log_enabled};
6use log::Level::Trace;
7
8use std::ffi::OsStr;
9use crate::mo;
10use crate::types::structs::{ManagedObjectReference, ServiceContent};
11
12const LIB_NAME: &str = env!("CARGO_PKG_NAME");
13const LIB_VERSION: &str = env!("CARGO_PKG_VERSION");
14// See build.rs for the RUSTC_VERSION
15const RUSTC_VERSION: &str = env!("RUSTC_VERSION");
16
17/// Compatible API releases i.e. current and older API releases that can be negotiated with a server
18pub const COMPATIBLE_API_RELEASES: [&str; 2] = ["8.0.2.0", "8.0.1.0"];
19
20/// The default API version found in the OpenAPI specification
21pub const API_RELEASE: &str = "8.0.2.0";
22
23/// The header key for the session key
24const AUTHN_HEADER: &str = "vmware-api-session-id";
25
26const SERVICE_INSTANCE_MOID: &str = "ServiceInstance";
27
28#[derive(Debug, thiserror::Error)]
29pub enum Error {
30    #[error("MethodFault: {0:?}")]
31    MethodFault(structs::MethodFault),
32    #[error("Reqwest error: {0}")]
33    ReqwestError(#[from] reqwest::Error),
34    #[error("Serde error: {0}")]
35    SerdeError(#[from] serde_json::Error),
36    #[error("Missing or Invalid session key")]
37    MissingOrInvalidSessionKey,
38    #[error("Invalid object type {0} expected: {1}")]
39    InvalidObjectType(String, String),
40    #[error("Cannot negotiate compatible API release. Attempted with: {0:?}")]
41    CannotNegotiateAPIRelease(Vec<String>),
42}
43
44pub type Result<T> = std::result::Result<T, Error>;
45
46pub struct ClientBuilder {
47    server_address: String,
48    compatible_api_releases: Option<Vec<String>>,
49    api_release: Option<String>,
50    http_client: Option<reqwest::Client>,
51    insecure: Option<bool>,
52    app_name: Option<String>,
53    app_version: Option<String>,
54    user_name: Option<String>,
55    password: Option<String>,
56    locale: Option<String>,
57}
58
59impl ClientBuilder {
60    /// Create a new client builder for a VI/JSON API at given FQDN or IP address
61    ///
62    /// * `server_address` - vCenter server FQDN or IP address
63    pub fn new(server_address: &str) -> Self {
64        Self {
65            server_address: server_address.to_string(),
66            compatible_api_releases: None,
67            api_release: None,
68            http_client: None,
69            insecure: None,
70            app_name: None,
71            app_version: None,
72            user_name: None,
73            password: None,
74            locale: None,
75        }
76    }
77
78    /// Set the compatible API releases. The default is set from the openapi spec. If `api_release`
79    /// is not explicitly set then this value or `COMPATIBLE_API_RELEASES` will be used to call the
80    /// vCenter [Hello System](https://developer.broadcom.com/xapis/vsphere-automation-api/latest/vcenter/api/vcenter/system__action=hello/post/index)
81    /// API to negotiate an API release.
82    /// * `compatible_api_releases` - List of compatible API releases
83    pub fn compatible_api_releases(mut self, releases: Vec<&str>) -> Self {
84        self.compatible_api_releases = Some(releases.iter().map(|s| s.to_string()).collect());
85        self
86    }
87
88    /// Set the vCenter API release version. The default value from the OpenAPI spec can be used
89    /// by setting here the `API_RELEASE` constant. If this is set then the Hello System API will
90    /// not be called to negotiate the API release.
91    /// * `api_release` - API release version
92    pub fn api_release(mut self, api_release: &str) -> Self {
93        self.api_release = Some(api_release.to_string());
94        self
95    }
96
97    /// Set the reqwest::Client instance to use for HTTP requests.
98    /// This resets the insecure flag. Use the http_client methods to set the certificate and
99    /// hostname verification behavior.
100    /// * `http_client` - preconfigured reqwest::Client instance
101    pub fn http_client(mut self, http_client: reqwest::Client) -> Self {
102        self.http_client = Some(http_client);
103        self.insecure = None;
104        self
105    }
106
107    /// Set the insecure flag to allow invalid certificates and hostnames.
108    /// This resets the http_client. A new reqwest::Client instance will be created instead.
109    /// * `insecure` - Allow invalid certificates and hostnames
110    pub fn insecure(mut self, insecure: bool) -> Self {
111        warn!("!!! WARNING !!! Insecure mode enabled. TLS certificate and hostname verification is disabled. !!! WARNING !!!");
112        self.insecure = Some(insecure);
113        self.http_client = None;
114        self
115    }
116
117    /// Set app name and version. This will be used to compose the User-Agent header. User Agent
118    /// value is seen in the vSphere UI under Monitoring for the vCenter system for troubleshooting.
119    /// The easiest is to use cargo environment variables during build time.
120    /// ```rust
121    /// const APP_NAME: &str = env!("CARGO_PKG_NAME");
122    /// const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
123    /// ```
124    /// * `app_name` - Name of the application
125    /// * `app_version` - Version of the application
126    pub fn app_details(mut self, app_name: &str, app_version: &str) -> Self {
127        self.app_name = Some(app_name.to_string());
128        self.app_version = Some(app_version.to_string());
129        self
130    }
131
132    /// Set the username and password for basic login.
133    /// * `user_name` - Username for login
134    /// * `password` - Password for login
135    pub fn basic_authn(mut self, user_name: &str, password: &str) -> Self {
136        self.user_name = Some(user_name.to_string());
137        self.password = Some(password.to_string());
138        self
139    }
140
141    /// Set the locale for the session. The default is "en".
142    /// * `locale` - Locale for the session
143    pub fn locale(mut self, locale: &str) -> Self {
144        self.locale = Some(locale.to_string());
145        self
146    }
147
148    /// Build the client instance
149    pub async fn build(self) -> Result<Arc<Client>> {
150        let http_client = match self.http_client {
151            Some(client) => client,
152            None => {
153                let mut builder = reqwest::ClientBuilder::new();
154                if let Some(insecure) = self.insecure {
155                    builder = builder.danger_accept_invalid_certs(insecure)
156                                     .danger_accept_invalid_hostnames(insecure);
157                }
158                builder.build()?
159            },
160        };
161        let session_key = Arc::new(RwLock::new(None));
162
163        let user_agent = user_agent(self.app_name.as_deref(), self.app_version.as_deref());
164
165        // Negotiate the API release if not set
166        let api_release = match self.api_release {
167            Some(release) => release,
168            None => {
169                let releases = self.compatible_api_releases
170                    .unwrap_or_else(|| COMPATIBLE_API_RELEASES.iter().map(|s| s.to_string()).collect());
171                let spec = HelloSpec {
172                    api_releases: &releases,
173                };
174                let path = format!("https://{}/api/vcenter/system?action=hello", self.server_address);
175                let req = http_client.post(&path)
176                    .header("Content-Type", "application/json")
177                    .header("User-Agent", &user_agent)
178                    .json(&spec);
179                let res = req.send().await?;
180                let res = res.error_for_status()?;
181                let result: HelloResult = res.json().await?;
182                let api_release = result.api_release;
183                // Throw error if api_release is empty string indicating no compatible API release
184                // was found.
185                if api_release.is_empty() {
186                    return Err(Error::CannotNegotiateAPIRelease(releases));
187                }
188                debug!("Negotiated API release: {}", api_release);
189                api_release
190            },
191        };
192
193        let base_url = format!("https://{}/sdk/vim25/{}", self.server_address, api_release);
194
195        let bootstrap = Arc::new(Client {
196            http_client: http_client.clone(),
197            session_key: session_key.clone(),
198            api_release: api_release.clone(),
199            base_url: base_url.clone(),
200            user_agent: user_agent.clone(),
201            service_content: None,
202        });
203
204        let service_instance = mo::ServiceInstance::new(bootstrap.clone(), SERVICE_INSTANCE_MOID);
205        let content = service_instance.content().await?;
206        debug!("ServiceInstance content obtained from: {}", content.about.full_name);
207        trace!("ServiceInstance content: {:?}", content);
208
209        let sm_id = content.session_manager.as_ref().map(|moid| moid.value.clone());
210        let client = Arc::new(Client {
211            http_client: http_client.clone(),
212            session_key: session_key.clone(),
213            api_release: api_release.clone(),
214            base_url: base_url.clone(),
215            user_agent: user_agent.clone(),
216            service_content: Some(content),
217        });
218
219
220        if let (Some(ref sm_id), Some(ref user_name), Some(ref password)) = (sm_id, self.user_name, self.password) {
221            let sm = mo::SessionManager::new(client.clone(), sm_id);
222            let session = sm.login(user_name, password, self.locale.as_deref()).await?;
223            debug!("Session created for: {:?}", session.user_name);
224        }
225        Ok(client)
226    }
227}
228
229pub struct Client {
230    http_client: reqwest::Client,
231    session_key: Arc<RwLock<Option<String>>>,
232    api_release: String,
233    base_url: String,
234    user_agent: String,
235    service_content: Option<ServiceContent>,
236}
237
238/// Client for the VI JSON API that handles basic HTTP requests and authentication headers.
239/// 
240/// The client is responsible for managing the session key header and logging out the session when
241/// the client is dropped.
242impl Client {
243
244    /// Get the service instance content
245    pub fn service_content(&self) -> &ServiceContent {
246        // Safe to unwrap as the service_content is set during construction
247        self.service_content.as_ref().unwrap()
248    }
249
250    /// Get the currently used API release. This may be lower than `API_RELEASE` and should be used
251    /// to downgrade client expectations. For example if client is using library 8.0.3.0 with
252    /// vCenter 8.0.1.0 the negotiated release will be 8.0.1.0 and the client should not call APIs
253    /// or set parameters that are only available in 8.0.3.0.
254    pub fn api_release(&self) -> String {
255        self.api_release.clone()
256    }
257
258    /// Fetch a managed object property by name into user provided type. This method can be used
259    /// with ['serde_json::Value'] to fetch the property as a dynamic JSON value. This enables
260    /// lightweight albeit unsafe approach to explore the API and extract relevant pieces of data.
261    pub async fn fetch_property<T>(&self, obj: ManagedObjectReference, property: &str) -> Result<T>
262    where
263        T: serde::de::DeserializeOwned
264    {
265        let type_name: &str = obj.r#type.into();
266        let id = &obj.value;
267        let path = format!("/{type_name}/{id}/{property}");
268        let req = self.get_request(&path);
269        self.execute(req).await
270    }
271
272    /// Prepare GET request
273    pub(crate) fn get_request(&self, path: &str) -> reqwest::RequestBuilder
274    {
275        debug!("GET request: {}", path);
276        let url = format!("{}{}", self.base_url, path);
277        self.http_client.get(&url)
278    }
279
280    /// Prepare POST request with a body
281    pub(crate) fn post_request<B>(&self, path: &str, payload: &B) -> reqwest::RequestBuilder
282    where
283        B: serde::Serialize,
284    {
285        debug!("POST request: {}", path);
286        let url = format!("{}{}", self.base_url, path);
287        let req = self.http_client.post(&url);
288        req.header("Content-Type", "application/json").json(payload)
289    }
290
291    /// Prepare POST request without a body
292    pub(crate) fn post_bare(&self, path: &str) -> reqwest::RequestBuilder
293    {
294        debug!("POST request (void): {}", path);
295        let url = format!("{}{}", self.base_url, path);
296        self.http_client.post(&url)
297    }
298
299    /// Execute a request that returns a response body
300    pub(crate) async fn execute<T>(&self, mut req: reqwest::RequestBuilder) -> Result<T>
301    where T: serde::de::DeserializeOwned 
302    {
303        req = self.prepare(req).await;
304        let res = req.send().await?;
305        let res = self.process_response(res).await?;
306        let content: T = res.json().await?;
307        Ok(content)
308    }
309
310    /// Execute a request that optionally returns a response body
311    pub(crate) async fn execute_option<T>(&self, mut req: reqwest::RequestBuilder) -> Result<Option<T>>
312    where T: serde::de::DeserializeOwned 
313    {
314        req = self.prepare(req).await;
315        let res = req.send().await?;
316        let res = self.process_response(res).await?;
317        let bytes = res.bytes().await?;
318        if log_enabled!(Trace) {
319            trace!("Response body: {}", std::str::from_utf8(&bytes).unwrap());
320        }
321        let r: serde_json::Result<T> = serde_json::from_slice(&bytes);
322        let content = match r {
323            Ok(c) => Some(c),
324            Err(e) => {
325                if e.is_eof() {
326                    None
327                } else {
328                    return Err(Error::SerdeError(e));
329                }
330            },
331        };
332        Ok(content)
333    }
334
335    /// Execute a request that does not return a response body
336    pub(crate) async fn execute_void(&self, mut req: reqwest::RequestBuilder) -> Result<()>
337    {
338        req = self.prepare(req).await;
339        let res = req.send().await?;
340        let _ = self.process_response(res).await?;
341        Ok(())
342    }
343
344    /// Add authn header to request
345    async fn prepare(&self, mut req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
346        let session_key = self.session_key.read().await;
347        if let Some(value) = session_key.as_ref() {
348            req = req.header(AUTHN_HEADER, value);
349        }
350        req = req.header("User-Agent", &self.user_agent);
351        req
352    }
353
354    /// Handle authn header update and error unmarsalling
355    async fn process_response(&self, res: reqwest::Response) -> Result<reqwest::Response> {
356        if res.status().is_success() && res.headers().contains_key(AUTHN_HEADER) {
357            let session_key = res.headers().get(AUTHN_HEADER).unwrap().to_str().map_err(|_| Error::MissingOrInvalidSessionKey)?.to_string();
358            let mut key_holder = self.session_key.write().await;
359            *key_holder = Some(session_key);
360        }
361        if !res.status().is_success() {
362            warn!("HTTP error: {}", res.status());
363            let fault: structs::MethodFault = res.json().await?;
364            return Err(Error::MethodFault(fault));
365        }
366        Ok(res)
367    }
368}
369
370
371/// Task called asynchronously during drop of the VimClient instance to logout the session if one
372/// was created.
373impl Drop for Client {
374    fn drop(&mut self) {
375        debug!("Disposing VIM client.");
376
377        let session_key = Arc::clone(&self.session_key);
378        let http_client = &self.http_client.clone();
379        let base_url = self.base_url.clone();
380
381        let sm_id = self.service_content.as_ref().and_then(|content| content.session_manager.as_ref().map(|moid| moid.value.clone()));
382        let sm_id = match sm_id {
383            Some(id) => id,
384            None => {
385                debug!("No session manager found. Skipping logout.");
386                return;
387            },
388        };
389
390        tokio::task::block_in_place(|| {
391            tokio::runtime::Handle::current().block_on(async move {
392                debug!("Terminating VIM session as needed.");
393                let key = {
394                    let session_key = session_key.read().await;
395                    session_key.clone()
396                };
397                let Some(key) = key else {
398                    debug!("No session key present. Skipping logout.");
399                    return;
400                };
401                debug!("Session is present. Sending logout request...");
402
403                let path = format!("{base_url}/SessionManager/{moId}/Logout",
404                                    base_url = base_url,
405                                    moId = sm_id);
406                let req = http_client.post(&path)
407                                        .header(AUTHN_HEADER, key);
408                match req.send().await {
409                    Ok(resp) => {
410                        let status = resp.status();
411                        if status.is_success() {
412                            debug!("Session logged out successfully");
413                        } else {
414                            resp.json::<structs::MethodFault>().await.map(|fault| {
415                                warn!("Failed to logout session(HTTP code: {}). MethodFault: {:?}", status, fault);
416                            }).unwrap_or_else(|e| {
417                                warn!("Failed to logout session(HTTP code: {}). Cannot parse MethodFault: {}", status, e);
418                            });
419                        }
420                    },
421                    Err(e) => warn!("Failed to logout session. Cannot execute logout request: {}", e),
422                }
423            });
424        });
425    }
426}
427
428fn user_agent(app_name: Option<&str>, app_version: Option<&str>) -> String {
429    let app_name: String = if app_name.is_some() {
430        app_name.unwrap().to_string()
431    } else {
432        get_executable_name().unwrap_or_else(|| "unknown".to_string())
433    };
434    let Some(appv) = app_version else {
435        return format!(
436            "{} ({}/{}; {}; {}; rustc/{})",
437            app_name,
438            LIB_NAME,
439            LIB_VERSION,
440            std::env::consts::OS,
441            std::env::consts::ARCH,
442            RUSTC_VERSION
443        );
444    };
445    format!(
446        "{}/{} ({}/{}; {}; {}; rustc/{})",
447        app_name,
448        appv,
449        LIB_NAME,
450        LIB_VERSION,
451        std::env::consts::OS,
452        std::env::consts::ARCH,
453        RUSTC_VERSION
454    )
455}
456
457fn get_executable_name() -> Option<String> {
458    std::env::current_exe()
459        .ok()
460        .as_ref()
461        .and_then(|path| path.file_name())
462        .and_then(OsStr::to_str)
463        .map(|s| s.to_owned())
464}
465
466
467/// The Hello System API request. This is not full-fledged binding but a simple request to
468/// negotiate the API release version.
469/// See [Hello System](https://developer.broadcom.com/xapis/vsphere-automation-api/latest/vcenter/api/vcenter/system__action=hello/post/index)
470#[derive(serde::Serialize, Debug)]
471struct HelloSpec<'a> {
472    /// List of API release IDs that the client can work with in order of preference. The server will select the first mutually supported release ID.
473    api_releases: &'a Vec<String>,
474}
475
476/// The Hello System API response. This is not full-fledged binding but a simple response to
477/// negotiate the API release version.
478#[derive(serde::Deserialize, Debug)]
479struct HelloResult {
480    /// The ID of a mutually-supported API release. This ID should be used in subsequent API calls
481    /// to the current vCenter system. If there is no mutually-supported API release, the value will
482    /// be an empty string, e.g. "". Typically, this is a case where one of the parties is much
483    /// older than the other party.
484    api_release: String,
485}