openstack_sdk/
openstack.rs

1// Licensed under the Apache License, Version 2.0 (the "License");
2// you may not use this file except in compliance with the License.
3// You may obtain a copy of the License at
4//
5//     http://www.apache.org/licenses/LICENSE-2.0
6//
7// Unless required by applicable law or agreed to in writing, software
8// distributed under the License is distributed on an "AS IS" BASIS,
9// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10// See the License for the specific language governing permissions and
11// limitations under the License.
12//
13// SPDX-License-Identifier: Apache-2.0
14
15//! Synchronous OpenStack client
16
17#![deny(dead_code, unused_imports, unused_mut)]
18
19use bytes::Bytes;
20use chrono::TimeDelta;
21use http::{Response as HttpResponse, StatusCode};
22use reqwest::{
23    blocking::{Client, Request, Response},
24    Certificate, Url,
25};
26use std::convert::TryInto;
27use std::fmt::{self, Debug};
28use std::time::SystemTime;
29use std::{fs::File, io::Read};
30use tokio::runtime::Runtime;
31use tracing::{debug, error, event, info, instrument, trace, warn, Level};
32
33use crate::api;
34use crate::api::query;
35use crate::api::query::RawQuery;
36use crate::auth::{
37    self,
38    auth_helper::{AuthHelper, Dialoguer, Noop},
39    authtoken,
40    authtoken::{AuthTokenError, AuthType},
41    Auth, AuthError, AuthState,
42};
43use crate::catalog::{Catalog, CatalogError, ServiceEndpoint};
44use crate::config::CloudConfig;
45use crate::config::{get_config_identity_hash, ConfigFile};
46use crate::error::{OpenStackError, OpenStackResult, RestError};
47use crate::state;
48use crate::types::identity::v3::{AuthReceiptResponse, AuthResponse, Project};
49use crate::types::{ApiVersion, ServiceType};
50use crate::utils::expand_tilde;
51
52// Private enum that enables the parsing of the cert bytes to be
53// delayed until the client is built rather than when they're passed
54// to a builder.
55#[allow(dead_code)]
56#[derive(Clone)]
57enum ClientCert {
58    None,
59    #[cfg(feature = "client_der")]
60    Der(Vec<u8>, String),
61    #[cfg(feature = "client_pem")]
62    Pem(Vec<u8>),
63}
64
65/// Synchronous client for the OpenStack API for a single user.
66///
67/// Separate Identity (not the scope) should use separate instances of this.
68/// ```rust
69/// use openstack_sdk::api::{paged, Pagination, Query};
70/// use openstack_sdk::{OpenStack, config::ConfigFile, OpenStackError};
71/// use openstack_sdk::types::ServiceType;
72/// use openstack_sdk::api::compute::v2::flavor::list;
73///
74/// fn list_flavors() -> Result<(), OpenStackError> {
75///     // Get the builder for the listing Flavors Endpoint
76///     let mut ep_builder = list::Request::builder();
77///     // Set the `min_disk` query param
78///     ep_builder.min_disk("15");
79///     let ep = ep_builder.build().unwrap();
80///
81///     let cfg = ConfigFile::new().unwrap();
82///     // Get connection config from clouds.yaml/secure.yaml
83///     let profile = cfg.get_cloud_config("devstack").unwrap().unwrap();
84///     // Establish connection
85///     let mut session = OpenStack::new(&profile)?;
86///
87///     // Invoke service discovery when desired.
88///     session.discover_service_endpoint(&ServiceType::Compute)?;
89///
90///     // Execute the call with pagination limiting maximum amount of entries to 1000
91///     let data: Vec<serde_json::Value> = paged(ep, Pagination::Limit(1000))
92///         .query(&session)
93///         .unwrap();
94///
95///     println!("Data = {:?}", data);
96///     Ok(())
97/// }
98/// ```
99
100#[derive(Clone)]
101pub struct OpenStack {
102    /// The client to use for API calls.
103    client: Client,
104    /// Cloud configuration
105    config: CloudConfig,
106    /// The authentication information to use when communicating with OpenStack.
107    auth: Auth,
108    /// Endpoints catalog
109    catalog: Catalog,
110    /// Session state.
111    ///
112    /// In order to save authentication roundtrips save/load authentication
113    /// information in the file (similar to how other cli tools are doing)
114    /// and check auth expiration upon load.
115    state: state::State,
116}
117
118impl Debug for OpenStack {
119    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
120        f.debug_struct("OpenStack")
121            .field("service_endpoints", &self.catalog)
122            .finish()
123    }
124}
125
126/// Should a certificate be validated in tls connections.
127/// The Insecure option is used for self-signed certificates.
128#[allow(dead_code)]
129#[derive(Debug, Clone)]
130enum CertPolicy {
131    Default,
132    Insecure,
133}
134
135impl OpenStack {
136    /// Basic constructor
137    fn new_impl(config: &CloudConfig, auth: Auth) -> OpenStackResult<Self> {
138        let mut client_builder = Client::builder();
139
140        if let Some(cacert) = &config.cacert {
141            let mut buf = Vec::new();
142            File::open(expand_tilde(cacert).unwrap_or(cacert.into()))
143                .map_err(|e| OpenStackError::IOWithPath {
144                    source: e,
145                    path: cacert.into(),
146                })?
147                .read_to_end(&mut buf)
148                .map_err(|e| OpenStackError::IOWithPath {
149                    source: e,
150                    path: cacert.into(),
151                })?;
152            for cert in Certificate::from_pem_bundle(&buf)? {
153                client_builder = client_builder.add_root_certificate(cert);
154            }
155        }
156        if let Some(false) = &config.verify {
157            warn!(
158                "SSL Verification is disabled! Please consider using `cacert` for adding custom certificate instead."
159            );
160            client_builder = client_builder.danger_accept_invalid_certs(true);
161        }
162
163        let mut session = OpenStack {
164            client: client_builder.build()?,
165            config: config.clone(),
166            auth,
167            catalog: Catalog::default(),
168            state: state::State::new(),
169        };
170
171        let auth_data = session
172            .config
173            .auth
174            .as_ref()
175            .ok_or(AuthTokenError::MissingAuthData)?;
176
177        let identity_service_url = auth_data
178            .auth_url
179            .as_ref()
180            .ok_or(AuthTokenError::MissingAuthUrl)?;
181
182        session.catalog.register_catalog_endpoint(
183            "identity",
184            identity_service_url,
185            config.region_name.as_ref(),
186            Some("public"),
187        )?;
188
189        session.catalog.configure(config)?;
190
191        session
192            .state
193            .set_auth_hash_key(get_config_identity_hash(config))
194            .enable_auth_cache(ConfigFile::new()?.is_auth_cache_enabled());
195
196        Ok(session)
197    }
198
199    /// Create a new OpenStack API session from CloudConfig
200    #[instrument(name = "connect", level = "trace", skip(config))]
201    pub fn new(config: &CloudConfig) -> OpenStackResult<Self> {
202        let mut session = Self::new_impl(config, Auth::None)?;
203
204        // Ensure we resolve identity endpoint using version discovery
205        session.discover_service_endpoint(&ServiceType::Identity)?;
206
207        session.authorize(None, false, false)?;
208
209        Ok(session)
210    }
211
212    /// Set the authorization to be used by the client
213    fn set_auth(&mut self, auth: auth::Auth, skip_cache_update: bool) -> &mut Self {
214        self.auth = auth;
215        if !skip_cache_update {
216            if let Auth::AuthToken(auth) = &self.auth {
217                // For app creds we should save auth as unscoped since:
218                // - on request it is disallowed to specify scope
219                // - response contain fixed scope
220                // With this it is not possible to find auth in the cache if we use the real
221                // scope
222                let scope = match &auth.auth_info {
223                    Some(info) => {
224                        if info.token.application_credential.is_some() {
225                            authtoken::AuthTokenScope::Unscoped
226                        } else {
227                            auth.get_scope()
228                        }
229                    }
230                    _ => auth.get_scope(),
231                };
232                self.state.set_scope_auth(&scope, auth);
233            }
234        }
235        self
236    }
237
238    /// Set TokenAuth as current authorization
239    fn set_token_auth(&mut self, token: String, token_info: Option<AuthResponse>) -> &mut Self {
240        let token_auth = authtoken::AuthToken {
241            token,
242            auth_info: token_info,
243        };
244        self.set_auth(auth::Auth::AuthToken(Box::new(token_auth.clone())), false);
245        self
246    }
247
248    /// Authorize against the cloud using provided credentials and get the session token
249    pub fn authorize(
250        &mut self,
251        scope: Option<authtoken::AuthTokenScope>,
252        interactive: bool,
253        renew_auth: bool,
254    ) -> Result<(), OpenStackError> {
255        if interactive {
256            self.authorize_with_auth_helper(scope, &mut Dialoguer::default(), renew_auth)
257        } else {
258            self.authorize_with_auth_helper(scope, &mut Noop::default(), renew_auth)
259        }
260    }
261
262    /// Authorize against the cloud using provided credentials and get the session token
263    pub fn authorize_with_auth_helper<A>(
264        &mut self,
265        scope: Option<authtoken::AuthTokenScope>,
266        auth_helper: &mut A,
267        renew_auth: bool,
268    ) -> Result<(), OpenStackError>
269    where
270        A: AuthHelper,
271    {
272        // Create the runtime
273        let rt = Runtime::new()?;
274        let requested_scope = scope.unwrap_or(authtoken::AuthTokenScope::try_from(&self.config)?);
275
276        if let (Some(auth), false) = (self.state.get_scope_auth(&requested_scope), renew_auth) {
277            // Valid authorization is already available and no renewal is required
278            trace!("Auth already available");
279            self.auth = auth::Auth::AuthToken(Box::new(auth.clone()));
280        } else {
281            // No valid authorization data is available in the state or
282            // renewal is requested
283            let auth_type = AuthType::from_cloud_config(&self.config)?;
284            let mut force_new_auth = renew_auth;
285            if let AuthType::V3ApplicationCredential = auth_type {
286                // application_credentials token can not be used to get new token without again
287                // supplying application credentials (bug in Keystone?)
288                // So for AppCred we just force a brand new auth
289                force_new_auth = true;
290            }
291            let mut rsp;
292            if let (Some(available_auth), false) = (self.state.get_any_valid_auth(), force_new_auth)
293            {
294                // State contain valid authentication for different
295                // scope/unscoped. It is possible to request new authz
296                // using this other auth
297                trace!("Valid Auth is available for reauthz: {:?}", available_auth);
298                let auth_ep = authtoken::build_reauth_request(&available_auth, &requested_scope)?;
299                rsp = auth_ep.raw_query(self)?;
300            } else {
301                // No auth/authz information available. Proceed with new auth
302                trace!("No Auth already available. Proceeding with new login");
303
304                match AuthType::from_cloud_config(&self.config)? {
305                    AuthType::V3ApplicationCredential
306                    | AuthType::V3Password
307                    | AuthType::V3Token
308                    | AuthType::V3Totp
309                    | AuthType::V3Multifactor => {
310                        let identity = rt.block_on(authtoken::build_identity_data_from_config(
311                            &self.config,
312                            auth_helper,
313                        ))?;
314                        let auth_ep = authtoken::build_auth_request_with_identity_and_scope(
315                            &identity,
316                            &requested_scope,
317                        )?;
318                        rsp = auth_ep.raw_query(self)?;
319
320                        // Handle the MFA
321                        if let StatusCode::UNAUTHORIZED = rsp.status() {
322                            if let Some(receipt) = rsp.headers().get("openstack-auth-receipt") {
323                                let receipt_data: AuthReceiptResponse =
324                                    serde_json::from_slice(rsp.body())?;
325                                let auth_endpoint =
326                                    rt.block_on(authtoken::build_auth_request_from_receipt(
327                                        &self.config,
328                                        receipt.clone(),
329                                        &receipt_data,
330                                        &requested_scope,
331                                        auth_helper,
332                                    ))?;
333                                rsp = auth_endpoint.raw_query(self)?;
334                            }
335                        }
336                        api::check_response_error::<Self>(&rsp, None)?;
337                    }
338                    AuthType::V3OidcAccessToken => {
339                        let auth_ep = rt.block_on(auth::v3oidcaccesstoken::get_auth_ep(
340                            &self.config,
341                            auth_helper,
342                        ))?;
343                        rsp = auth_ep.raw_query(self)?;
344
345                        let token = rsp
346                            .headers()
347                            .get("x-subject-token")
348                            .ok_or(AuthError::AuthTokenNotInResponse)?
349                            .to_str()
350                            .map_err(|_| AuthError::AuthTokenNotString)?;
351
352                        // Set retrieved token as current auth
353                        let token_info: AuthResponse = serde_json::from_slice(rsp.body())?;
354                        let token_auth = authtoken::AuthToken {
355                            token: token.to_string(),
356                            auth_info: Some(token_info),
357                        };
358                        self.set_auth(Auth::AuthToken(Box::new(token_auth.clone())), false);
359
360                        // And now time to rescope the token
361                        let auth_ep =
362                            authtoken::build_reauth_request(&token_auth, &requested_scope)?;
363                        rsp = auth_ep.raw_query(self)?;
364                    }
365                    other => {
366                        return Err(AuthTokenError::IdentityMethodSync {
367                            auth_type: other.as_str().into(),
368                        })?;
369                    }
370                }
371            };
372
373            let data: AuthResponse = serde_json::from_slice(rsp.body())?;
374            debug!("Auth token is {:?}", data);
375
376            let token = rsp
377                .headers()
378                .get("x-subject-token")
379                .ok_or(AuthError::AuthTokenNotInResponse)?
380                .to_str()
381                .map_err(|_| AuthError::AuthTokenNotString)?;
382
383            self.set_token_auth(token.into(), Some(data));
384        }
385
386        if let auth::Auth::AuthToken(token_data) = &self.auth {
387            match &token_data.auth_info {
388                Some(auth_data) => {
389                    if let Some(project) = &auth_data.token.project {
390                        self.catalog.set_project_id(project.id.clone());
391                        // Reconfigure catalog since we know now the project_id
392                        self.catalog.configure(&self.config)?;
393                    }
394                    if let Some(endpoints) = &auth_data.token.catalog {
395                        self.catalog
396                            .process_catalog_endpoints(endpoints, Some("public"))?;
397                    } else {
398                        error!("No catalog information");
399                    }
400                }
401                _ => return Err(OpenStackError::NoAuth),
402            }
403        }
404        // TODO: without AuthToken authorization we may want to read catalog separately
405        Ok(())
406    }
407
408    #[instrument(skip(self))]
409    pub fn discover_service_endpoint(
410        &mut self,
411        service_type: &ServiceType,
412    ) -> Result<(), OpenStackError> {
413        if let Ok(ep) = self.catalog.get_service_endpoint(
414            service_type.to_string(),
415            None,
416            self.config.region_name.as_ref(),
417        ) {
418            if self.catalog.discovery_allowed(service_type.to_string()) {
419                info!("Performing `{}` endpoint version discovery", service_type);
420
421                let orig_url = ep.url().clone();
422                let mut try_url = ep.url().clone();
423                // Version discovery document must logically end with "/" since API url goes even
424                // deeper.
425                try_url
426                    .path_segments_mut()
427                    .map_err(|_| CatalogError::cannot_be_base(ep.url()))?
428                    .pop_if_empty()
429                    .push("");
430                let mut max_depth = 10;
431                loop {
432                    let req = http::Request::builder()
433                        .method(http::Method::GET)
434                        .uri(query::url_to_http_uri(try_url.clone())?);
435
436                    match self.rest_with_auth(req, Vec::new(), &self.auth) {
437                        Ok(rsp) => {
438                            if rsp.status() != StatusCode::NOT_FOUND
439                                && self
440                                    .catalog
441                                    .process_endpoint_discovery(
442                                        service_type,
443                                        &try_url,
444                                        rsp.body(),
445                                        self.config.region_name.as_ref(),
446                                    )
447                                    .is_ok()
448                            {
449                                debug!(
450                                    "Finished service version discovery at {}",
451                                    try_url.as_str()
452                                );
453                                return Ok(());
454                            }
455                        }
456                        Err(err) => {
457                            error!(
458                                "Error querying {} for the version discovery. It is most likely a misconfiguration on the cloud side. {}",
459                                try_url.as_str(),
460                                err
461                            );
462                        }
463                    };
464                    if try_url.path() != "/" {
465                        // We are not at the root yet and have not found a
466                        // valid version document so far, try one level up
467                        try_url
468                            .path_segments_mut()
469                            .map_err(|_| CatalogError::cannot_be_base(&orig_url))?
470                            .pop();
471                    } else {
472                        return Err(OpenStackError::Discovery {
473                            service: service_type.to_string(),
474                            url: orig_url.into(),
475                            msg: match service_type {
476                                ServiceType::Identity => "Service is not working.".into(),
477                                _ => "No Version document found. Either service is not supporting version discovery, or API is not working".into(),
478                            }
479                        });
480                    }
481
482                    max_depth -= 1;
483                    if max_depth == 0 {
484                        break;
485                    }
486                }
487                return Err(OpenStackError::Discovery {
488                    service: service_type.to_string(),
489                    url: orig_url.into(),
490                    msg: "Unknown".into(),
491                });
492            }
493            return Ok(());
494        }
495        Ok(())
496    }
497
498    /// Return current authentication token
499    pub fn get_auth_token(&self) -> Option<String> {
500        if let Auth::AuthToken(token) = &self.auth {
501            return Some(token.token.clone());
502        }
503        None
504    }
505
506    /// Return current authentication status
507    ///
508    /// Offset can be used to calculate imminent expiration.
509    pub fn get_auth_state(&self, offset: Option<TimeDelta>) -> Option<AuthState> {
510        if let Auth::AuthToken(token) = &self.auth {
511            return Some(token.get_state(offset));
512        }
513        None
514    }
515
516    /// Perform HTTP request with given request and return raw response.
517    #[instrument(name="request", skip_all, fields(http.uri = request.url().as_str(), http.method = request.method().as_str(), openstack.ver=request.headers().get("openstack-api-version").map(|v| v.to_str().unwrap_or(""))))]
518    fn execute_request(&self, request: Request) -> Result<Response, reqwest::Error> {
519        info!("Sending request {:?}", request);
520        let url: Url = request.url().clone();
521        let method = request.method().clone();
522
523        let start = SystemTime::now();
524        let rsp = self.client.execute(request)?;
525        let elapsed = SystemTime::now().duration_since(start).unwrap_or_default();
526        event!(
527            name: "http_request",
528            Level::INFO,
529            url=url.as_str(),
530            duration_ms=elapsed.as_millis(),
531            status=rsp.status().as_u16(),
532            method=method.as_str(),
533            request_id=rsp.headers().get("x-openstack-request-id").map(|v| v.to_str().unwrap_or("")),
534            "Request completed with status {}",
535            rsp.status(),
536        );
537        Ok(rsp)
538    }
539
540    /// Perform a REST query with a given auth.
541    fn rest_with_auth(
542        &self,
543        mut request: http::request::Builder,
544        body: Vec<u8>,
545        auth: &Auth,
546    ) -> Result<HttpResponse<Bytes>, api::ApiError<<Self as api::RestClient>::Error>> {
547        let call = || -> Result<_, RestError> {
548            if let Some(headers) = request.headers_mut() {
549                auth.set_header(headers)?;
550            }
551            let http_request = request.body(body)?;
552            let request = http_request.try_into()?;
553
554            let rsp = self.execute_request(request)?;
555
556            let mut http_rsp = HttpResponse::builder()
557                .status(rsp.status())
558                .version(rsp.version());
559
560            if let Some(headers) = http_rsp.headers_mut() {
561                headers.extend(rsp.headers().clone())
562            }
563
564            Ok(http_rsp.body(rsp.bytes()?)?)
565        };
566        call().map_err(api::ApiError::client)
567    }
568}
569
570impl api::RestClient for OpenStack {
571    type Error = RestError;
572
573    /// Get service endpoint from the catalog
574    fn get_service_endpoint(
575        &self,
576        service_type: &ServiceType,
577        version: Option<&ApiVersion>,
578    ) -> Result<&ServiceEndpoint, api::ApiError<Self::Error>> {
579        Ok(self
580            .catalog
581            .get_service_endpoint(service_type.to_string(), version, None::<String>)?)
582    }
583
584    fn get_current_project(&self) -> Option<Project> {
585        if let Auth::AuthToken(token) = &self.auth {
586            return token.auth_info.clone().and_then(|x| x.token.project);
587        }
588        None
589    }
590}
591
592impl api::Client for OpenStack {
593    /// Perform the query with the client specifics
594    fn rest(
595        &self,
596        request: http::request::Builder,
597        body: Vec<u8>,
598    ) -> Result<HttpResponse<Bytes>, api::ApiError<Self::Error>> {
599        self.rest_with_auth(request, body, &self.auth)
600    }
601}