klukai_types/consul/
mod.rs

1use core::fmt;
2use std::{
3    collections::{BTreeMap, HashMap},
4    fmt::Display,
5    fs::OpenOptions,
6    io::BufReader,
7};
8
9use bytes::Bytes;
10use http_body_util::Empty;
11use hyper::http::uri::InvalidUri;
12use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder};
13use hyper_util::{client::legacy::Client as HyperClient, rt::TokioExecutor};
14use rustls::RootCertStore;
15use rustls_pki_types::{CertificateDer, PrivateKeyDer};
16use serde::{Deserialize, Serialize, de::DeserializeOwned};
17use serde_with::{NoneAsEmptyString, serde_as};
18
19use rusqlite::types::{FromSql, FromSqlError, ValueRef};
20
21pub mod config;
22pub use config::Config;
23
24pub type ConsulResult<T> = std::result::Result<T, Error>;
25
26#[derive(Clone)]
27pub struct Client {
28    client: HyperClient<
29        HttpsConnector<hyper_util::client::legacy::connect::HttpConnector>,
30        Empty<Bytes>,
31    >,
32    addr: String,
33}
34
35impl std::fmt::Debug for Client {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        f.debug_struct("Client").field("addr", &self.addr).finish()
38    }
39}
40
41impl Client {
42    pub fn new(config: Config) -> ConsulResult<Self> {
43        let scheme = if config.tls.is_some() {
44            "https"
45        } else {
46            "http"
47        };
48        let ctor = if let Some(tls) = config.tls {
49            // HTTPS path
50            let mut root_store = RootCertStore::empty();
51            let mut cacert_file = BufReader::new(
52                OpenOptions::new()
53                    .read(true)
54                    .open(&tls.ca_file)
55                    .map_err(Error::TlsSetup)?,
56            );
57            for cacert in rustls_pemfile::certs(&mut cacert_file) {
58                let cacert = cacert.map_err(Error::TlsSetup)?;
59                root_store.add(cacert)?;
60            }
61
62            let mut cert_file = BufReader::new(
63                OpenOptions::new()
64                    .read(true)
65                    .open(&tls.cert_file)
66                    .map_err(Error::TlsSetup)?,
67            );
68            let certs: Result<Vec<CertificateDer>, _> =
69                rustls_pemfile::certs(&mut cert_file).collect();
70            let certs = certs.map_err(Error::TlsSetup)?;
71
72            let mut key_file = BufReader::new(
73                OpenOptions::new()
74                    .read(true)
75                    .open(&tls.key_file)
76                    .map_err(Error::TlsSetup)?,
77            );
78            let keys: Result<Vec<_>, _> =
79                rustls_pemfile::pkcs8_private_keys(&mut key_file).collect();
80            let mut keys = keys.map_err(Error::TlsSetup)?;
81            let key = PrivateKeyDer::Pkcs8(keys.remove(0));
82
83            let tls_config = rustls::ClientConfig::builder()
84                .with_root_certificates(root_store)
85                .with_client_auth_cert(certs, key)
86                .map_err(|e| {
87                    Error::TlsSetup(std::io::Error::new(std::io::ErrorKind::InvalidData, e))
88                })?;
89
90            HttpsConnectorBuilder::new()
91                .with_tls_config(tls_config)
92                .https_or_http()
93                .enable_http2()
94                .build()
95        } else {
96            // this is always gonna be HTTP, but we still need to build this, annoyingly.
97            // TODO: build custom connector so we don't have to do this
98            HttpsConnectorBuilder::new()
99                .with_native_roots()?
100                .https_or_http()
101                .enable_http1()
102                .build()
103        };
104
105        Ok(Self {
106            client: HyperClient::builder(TokioExecutor::new()).build(ctor),
107            addr: format!("{}://{}", scheme, config.address),
108        })
109    }
110
111    pub async fn agent_services(&self) -> ConsulResult<HashMap<String, AgentService>> {
112        self.request("/v1/agent/services").await
113    }
114
115    pub async fn agent_checks(&self) -> ConsulResult<HashMap<String, AgentCheck>> {
116        self.request("/v1/agent/checks").await
117    }
118
119    async fn request<P: Display, T: DeserializeOwned>(&self, path: P) -> ConsulResult<T> {
120        let res = match self
121            .client
122            .get(format!("{}{}", &self.addr, &path).parse()?)
123            .await
124        {
125            Ok(res) => res,
126            Err(e) => {
127                return Err(e.into());
128            }
129        };
130
131        if res.status() != hyper::StatusCode::OK {
132            return Err(Error::BadStatusCode(res.status()));
133        }
134
135        use http_body_util::BodyExt;
136        let bytes = res.collect().await?.to_bytes();
137
138        Ok(serde_json::from_slice(&bytes)?)
139    }
140}
141
142#[derive(Debug, thiserror::Error)]
143pub enum Error {
144    #[error(transparent)]
145    Hyper(#[from] hyper::Error),
146    #[error("bad status code: {0}")]
147    BadStatusCode(hyper::StatusCode),
148    #[error(transparent)]
149    InvalidUri(#[from] InvalidUri),
150    #[error(transparent)]
151    Serde(#[from] serde_json::Error),
152    #[error("tls setup: {0}")]
153    TlsSetup(std::io::Error),
154    #[error(transparent)]
155    Io(#[from] std::io::Error),
156    #[error(transparent)]
157    HyperUtil(#[from] hyper_util::client::legacy::Error),
158    #[error(transparent)]
159    Rustls(#[from] rustls::Error),
160    #[error(transparent)]
161    Webpki(#[from] webpki::Error),
162}
163
164#[derive(Debug, Clone, Hash, Serialize, Deserialize)]
165#[serde(rename_all(deserialize = "PascalCase"))]
166pub struct AgentService {
167    #[serde(rename(deserialize = "ID"))]
168    pub id: String,
169    #[serde(rename(deserialize = "Service"))]
170    pub name: String,
171    #[serde(default)]
172    pub tags: Vec<String>,
173    #[serde(default)]
174    pub meta: BTreeMap<String, String>,
175    pub port: u16,
176    pub address: String,
177}
178
179#[serde_as]
180#[derive(Debug, Serialize, Deserialize)]
181#[serde(rename_all(deserialize = "PascalCase"))]
182pub struct AgentCheck {
183    #[serde(rename(deserialize = "CheckID"))]
184    pub id: String,
185    pub name: String,
186    pub status: ConsulCheckStatus,
187    pub output: String,
188    #[serde(rename(deserialize = "ServiceID"))]
189    pub service_id: String,
190    pub service_name: String,
191    #[serde_as(as = "NoneAsEmptyString")]
192    pub notes: Option<String>,
193}
194
195#[derive(Debug, Copy, Eq, PartialEq, Clone, Serialize, Deserialize)]
196#[serde(rename_all = "snake_case")]
197pub enum ConsulCheckStatus {
198    Passing,
199    Warning,
200    Critical,
201}
202
203impl ConsulCheckStatus {
204    pub fn as_str(&self) -> &'static str {
205        match self {
206            ConsulCheckStatus::Passing => "passing",
207            ConsulCheckStatus::Warning => "warning",
208            ConsulCheckStatus::Critical => "critical",
209        }
210    }
211}
212
213impl FromSql for ConsulCheckStatus {
214    fn column_result(value: ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
215        match value {
216            ValueRef::Text(s) => Ok(match String::from_utf8_lossy(s).as_ref() {
217                "passing" => Self::Passing,
218                "warning" => Self::Warning,
219                "critical" => Self::Critical,
220                _ => {
221                    return Err(FromSqlError::InvalidType);
222                }
223            }),
224            _ => Err(FromSqlError::InvalidType),
225        }
226    }
227}
228
229impl fmt::Display for ConsulCheckStatus {
230    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231        self.as_str().fmt(f)
232    }
233}
234
235impl Default for ConsulCheckStatus {
236    fn default() -> Self {
237        Self::Passing
238    }
239}