consul_client/
lib.rs

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