use std::{collections::HashMap, time::Instant};
use anyhow::Context;
use jzon::object;
use reqwest::{
ClientBuilder,
header::{CONTENT_TYPE, HeaderMap, HeaderValue},
};
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::configuration::OpenStackSettings;
struct Token {
settings: OpenStackSettings,
token: String,
renewed_at: Instant,
}
impl Token {
async fn new(settings: &OpenStackSettings) -> Result<Self, anyhow::Error> {
Ok(Self {
settings: settings.clone(),
token: issue_token(settings).await?,
renewed_at: Instant::now(),
})
}
async fn renew(&mut self) -> Result<(), anyhow::Error> {
self.token = issue_token(&self.settings).await?;
self.renewed_at = Instant::now();
Ok(())
}
fn is_expired(&self) -> bool {
self.renewed_at.elapsed().as_secs() > 3600
}
fn get(&self) -> String {
self.token.clone()
}
}
struct TokenHandler {
token: RwLock<Token>,
}
impl TokenHandler {
async fn new(settings: &OpenStackSettings) -> Result<Self, anyhow::Error> {
Ok(TokenHandler {
token: RwLock::new(Token::new(settings).await?),
})
}
async fn get(&self) -> String {
if self.token.read().await.is_expired() {
self.token.write().await.renew().await.unwrap();
}
self.token.read().await.get()
}
}
pub struct OpenStack {
settings: OpenStackSettings,
token: TokenHandler,
}
#[derive(Clone, Debug, serde::Deserialize)]
pub struct ProjectMinimal {
pub id: String,
pub name: String,
}
#[derive(Clone, Debug, serde::Deserialize)]
pub struct Link {
pub href: String,
pub rel: String,
}
#[derive(Clone, Debug, serde::Deserialize)]
#[allow(unused)]
pub struct FlavorDetailed {
#[serde(rename = "OS-FLV-DISABLED:disabled")]
pub disabled: bool,
pub disk: u32,
#[serde(rename = "os-flavor-access:is_public")]
pub is_public: bool,
pub id: String,
pub links: Vec<Link>,
pub name: String,
pub ram: u32,
pub vcpus: u32,
pub rxtx_factor: f32,
pub description: Option<String>,
}
#[derive(Clone, Debug, serde::Deserialize)]
pub struct FlavorDetailedList {
flavors: Vec<FlavorDetailed>,
}
#[derive(Clone, Debug, serde::Deserialize)]
#[allow(unused)]
pub struct ServerDetailedFlavor {
pub id: String,
pub links: Vec<Link>,
}
#[derive(Clone, Debug, serde::Deserialize)]
#[allow(unused)]
pub struct ServerDetailedSecurityGroup {
pub name: String,
}
#[derive(Clone, Debug, serde::Deserialize)]
#[allow(unused)]
pub struct ServerDetailedVolumesAttached {
pub id: String,
}
#[derive(Clone, Debug, serde::Deserialize)]
#[allow(unused)]
pub struct ServerDetailedAddress {
pub version: usize,
pub addr: String,
#[serde(rename = "OS-EXT-IPS:type")]
pub addr_type: String,
#[serde(rename = "OS-EXT-IPS-MAC:mac_addr")]
pub mac_addr: String,
}
#[derive(Clone, Debug, serde::Deserialize)]
#[allow(unused)]
#[serde(untagged)]
pub enum ServerDetailedImage {
Some { id: String, links: Vec<Link> },
None(String),
}
#[derive(Clone, Debug, serde::Deserialize)]
#[allow(unused)]
pub struct ServerDetailed {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub status: String,
pub tenant_id: String,
pub user_id: String,
pub metadata: HashMap<String, String>,
#[serde(rename = "hostId")]
pub host_id: String,
pub image: ServerDetailedImage,
pub flavor: ServerDetailedFlavor,
pub created: String,
pub updated: String,
pub addresses: HashMap<String, Vec<ServerDetailedAddress>>,
#[serde(rename = "accessIPv4")]
pub access_ipv4: String,
#[serde(rename = "accessIPv6")]
pub access_ipv6: String,
pub links: Vec<Link>,
#[serde(rename = "OS-DCF:diskConfig")]
pub disk_config: String,
#[serde(rename = "OS-EXT-AZ:availability_zone")]
pub availability_zone: String,
pub config_drive: String,
pub key_name: Option<String>,
#[serde(rename = "OS-SRV-USG:launched_at")]
pub launched_at: Option<String>,
#[serde(rename = "OS-SRV-USG:terminated_at")]
pub terminated_at: Option<String>,
#[serde(rename = "OS-EXT-SRV-ATTR:host")]
pub host: Option<String>,
#[serde(rename = "OS-EXT-SRV-ATTR:instance_name")]
pub instance_name: String,
#[serde(rename = "OS-EXT-SRV-ATTR:hypervisor_hostname")]
pub hypervisor_hostname: Option<String>,
#[serde(rename = "OS-EXT-STS:task_state")]
pub task_state: Option<String>,
#[serde(rename = "OS-EXT-STS:vm_state")]
pub vm_state: String,
#[serde(rename = "OS-EXT-STS:power_state")]
pub power_state: usize,
#[serde(rename = "os-extended-volumes:volumes_attached")]
pub volumes_attached: Vec<ServerDetailedVolumesAttached>,
pub security_groups: Option<Vec<ServerDetailedSecurityGroup>>,
}
#[derive(Clone, Debug, serde::Deserialize)]
pub struct ServerDetailedList {
servers: Vec<ServerDetailed>,
}
#[derive(Clone, Debug, serde::Deserialize)]
#[allow(unused)]
pub struct Domain {
pub id: String,
pub name: String,
pub description: Option<String>,
pub enabled: bool,
}
#[derive(Clone, Debug, serde::Deserialize)]
pub struct DomainList {
domains: Vec<Domain>,
}
#[derive(Clone, Debug, serde::Deserialize)]
#[allow(unused)]
pub struct Project {
pub id: String,
pub name: String,
pub description: Option<String>,
pub enabled: bool,
pub is_domain: bool,
pub domain_id: String,
pub parent_id: String,
pub tags: Vec<String>,
}
#[derive(Clone, Debug, serde::Deserialize)]
pub struct ProjectList {
projects: Vec<Project>,
}
impl OpenStack {
pub async fn new(
settings: OpenStackSettings,
) -> Result<Self, anyhow::Error> {
Ok(OpenStack {
token: TokenHandler::new(&settings).await?,
settings,
})
}
async fn client(&self) -> Result<reqwest::Client, anyhow::Error> {
let mut headers = HeaderMap::new();
headers
.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
headers.insert(
"X-Auth-Token",
HeaderValue::from_str(self.token.get().await.as_str())
.context("Could not create token header")?,
);
ClientBuilder::new()
.default_headers(headers)
.build()
.context("Could not create client")
}
pub async fn validate_user_token(
&self,
token: &str,
) -> Result<ProjectMinimal, anyhow::Error> {
#[derive(Debug, serde::Deserialize)]
struct ValidateResponseToken {
project: ProjectMinimal,
}
#[derive(Debug, serde::Deserialize)]
struct ValidateResponse {
token: ValidateResponseToken,
}
let client = self.client().await?;
let url = format!("{}/auth/tokens/", self.settings.keystone_endpoint);
let response = client
.get(url.as_str())
.header("X-Subject-Token", token)
.send()
.await
.context("Could not validate user token")?;
if !response.status().is_success() {
return Err(anyhow::anyhow!(
"Failed to validate user token, returned code {}",
response.status().as_u16()
));
}
let project: ValidateResponse = serde_json::from_str(
response
.text()
.await
.context("Could not read response text")?
.as_str(),
)
.context("Could not parse response")?;
Ok(project.token.project)
}
pub async fn get_flavors(
&self,
) -> Result<Vec<FlavorDetailed>, anyhow::Error> {
let client = self.client().await?;
let url = format!(
"{}/v2.1/flavors/detail?is_public=False",
self.settings.nova_endpoint
);
let response = client
.get(url.as_str())
.send()
.await
.context("Could not retrieve flavor list")?;
if !response.status().is_success() {
return Err(anyhow::anyhow!(
"Failed to validate user token, returned code {}",
response.status().as_u16()
));
}
let flavors: FlavorDetailedList = serde_json::from_str(
response
.text()
.await
.context("Could not read response text")?
.as_str(),
)
.context("Could not parse response")?;
Ok(flavors.flavors)
}
pub async fn get_servers(
&self,
) -> Result<Vec<ServerDetailed>, anyhow::Error> {
let client = self.client().await?;
let url = format!(
"{}/v2.1/servers/detail?all_tenants=True",
self.settings.nova_endpoint
);
let response = client
.get(url.as_str())
.send()
.await
.context("Could not retrieve server list")?;
if !response.status().is_success() {
return Err(anyhow::anyhow!(
"Failed to validate user token, returned code {}",
response.status().as_u16()
));
}
let servers: ServerDetailedList = serde_json::from_str(
response
.text()
.await
.context("Could not read response text")?
.as_str(),
)
.context("Could not parse response")?;
Ok(servers.servers)
}
pub async fn get_servers_of_project(
&self,
project_id: String,
) -> Result<Vec<ServerDetailed>, anyhow::Error> {
let client = self.client().await?;
let url = format!(
"{}/v2.1/servers/detail?all_tenants=True&tenant_id={}",
self.settings.nova_endpoint, project_id,
);
let response = client
.get(url.as_str())
.send()
.await
.context("Could not retrieve server list")?;
if !response.status().is_success() {
return Err(anyhow::anyhow!(
"Failed to validate user token, returned code {}",
response.status().as_u16()
));
}
let servers: ServerDetailedList = serde_json::from_str(
response
.text()
.await
.context("Could not read response text")?
.as_str(),
)
.context("Could not parse response")?;
Ok(servers.servers)
}
pub async fn get_domains(&self) -> Result<Vec<Domain>, anyhow::Error> {
let client = self.client().await?;
let url = format!("{}/domains", self.settings.keystone_endpoint);
let response = client
.get(url.as_str())
.send()
.await
.context("Could not retrieve domain list")?;
if !response.status().is_success() {
return Err(anyhow::anyhow!(
"Failed to validate user token, returned code {}",
response.status().as_u16()
));
}
let domains: DomainList = serde_json::from_str(
response
.text()
.await
.context("Could not read response text")?
.as_str(),
)
.context("Could not parse response")?;
Ok(domains.domains)
}
pub async fn get_projects(&self) -> Result<Vec<Project>, anyhow::Error> {
let client = self.client().await?;
let url = format!("{}/projects", self.settings.keystone_endpoint);
let response = client
.get(url.as_str())
.send()
.await
.context("Could not retrieve project list")?;
if !response.status().is_success() {
return Err(anyhow::anyhow!(
"Failed to validate user token, returned code {}",
response.status().as_u16()
));
}
let projects: ProjectList = serde_json::from_str(
response
.text()
.await
.context("Could not read response text")?
.as_str(),
)
.context("Could not parse response")?;
Ok(projects.projects)
}
}
#[tracing::instrument(name = "Issue an OpenStack token", skip(settings))]
pub async fn issue_token(
settings: &OpenStackSettings,
) -> Result<String, anyhow::Error> {
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
let client = ClientBuilder::new()
.default_headers(headers)
.build()
.unwrap();
let url = format!("{}/auth/tokens/", settings.keystone_endpoint);
let data = object! {
"auth": {
"identity": {
"methods": ["password"],
"password": {
"user": {
"name": settings.username.clone(),
"domain": {"name": settings.domain.clone()},
"password": settings.password.clone(),
}
}
},
"scope": {
"project": {
"name": settings.project.clone(),
"domain": {"id": settings.domain_id.clone()}
}
}
}
};
let response = match client
.post(url.as_str())
.body(data.to_string())
.send()
.await
.context("")
{
Ok(response) => response,
Err(error) => {
return Err(anyhow::anyhow!(
"Could not complete authentication request: {}",
error.root_cause()
));
}
};
if !response.status().is_success() {
return Err(anyhow::anyhow!(
"Failed to authenticate, returned code {}",
response.status().as_u16()
));
}
let token = match response.headers().get("X-Subject-Token") {
Some(token) => token.to_str().unwrap().to_string(),
None => {
return Err(anyhow::anyhow!(
"No token in authentication response header"
));
}
}
.trim()
.to_string();
Ok(token)
}