implement intelligent config location selection.
This commit is contained in:
parent
633a152f89
commit
7faf15889a
5 changed files with 771 additions and 578 deletions
1101
Cargo.lock
generated
1101
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -12,6 +12,7 @@ exclude = ["/aux/"]
|
|||
[dependencies]
|
||||
actix-web = "2.0"
|
||||
actix-rt = "1.0"
|
||||
dirs = "3.0"
|
||||
serde = "1.0"
|
||||
serde_yaml = "0.8"
|
||||
handlebars = "2.0"
|
||||
|
|
201
src/config.rs
201
src/config.rs
|
@ -1,14 +1,17 @@
|
|||
use crate::BunBunError;
|
||||
use log::{debug, error, info, trace};
|
||||
use dirs::{config_dir, home_dir};
|
||||
use log::{debug, info, trace};
|
||||
use serde::{
|
||||
de::{Deserializer, Visitor},
|
||||
Deserialize, Serialize, Serializer,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::fs::{read_to_string, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
const CONFIG_FILENAME: &str = "bunbun.yaml";
|
||||
const DEFAULT_CONFIG: &[u8] = include_bytes!("../bunbun.default.yaml");
|
||||
|
||||
#[derive(Deserialize, Debug, PartialEq)]
|
||||
|
@ -93,40 +96,104 @@ impl std::fmt::Display for Route {
|
|||
}
|
||||
}
|
||||
|
||||
/// Attempts to read the config file. If it doesn't exist, generate one a
|
||||
/// default config file before attempting to parse it.
|
||||
pub fn read_config(config_file_path: &str) -> Result<Config, BunBunError> {
|
||||
trace!("Loading config file...");
|
||||
let config_str = match read_to_string(config_file_path) {
|
||||
Ok(conf_str) => {
|
||||
debug!("Successfully loaded config file into memory.");
|
||||
conf_str
|
||||
}
|
||||
Err(_) => {
|
||||
info!(
|
||||
"Unable to find a {} file. Creating default!",
|
||||
config_file_path
|
||||
);
|
||||
pub struct ConfigData {
|
||||
pub path: PathBuf,
|
||||
pub file: File,
|
||||
}
|
||||
|
||||
let fd = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(config_file_path);
|
||||
/// If a provided config path isn't found, this function checks known good
|
||||
/// locations for a place to write a config file to. In order, it checks the
|
||||
/// system-wide config location (`/etc/`, in Linux), followed by the config
|
||||
/// folder, followed by the user's home folder.
|
||||
pub fn get_config_data() -> Result<ConfigData, BunBunError> {
|
||||
// Locations to check, with highest priority first
|
||||
let locations: Vec<_> = {
|
||||
let mut folders = vec![PathBuf::from("/etc/")];
|
||||
|
||||
match fd {
|
||||
Ok(mut fd) => fd.write_all(DEFAULT_CONFIG)?,
|
||||
Err(e) => {
|
||||
error!("Failed to write to {}: {}. Default config will be loaded but not saved.", config_file_path, e);
|
||||
}
|
||||
};
|
||||
// Config folder
|
||||
if let Some(folder) = config_dir() { folders.push(folder) }
|
||||
|
||||
String::from_utf8_lossy(DEFAULT_CONFIG).into_owned()
|
||||
}
|
||||
// Home folder
|
||||
if let Some(folder) = home_dir() { folders.push(folder) }
|
||||
|
||||
folders
|
||||
.iter_mut()
|
||||
.for_each(|folder| folder.push(CONFIG_FILENAME));
|
||||
|
||||
folders
|
||||
};
|
||||
|
||||
debug!("Checking locations for config file: {:?}", &locations);
|
||||
|
||||
for location in &locations {
|
||||
let file = OpenOptions::new().read(true).open(location.clone());
|
||||
match file {
|
||||
Ok(file) => {
|
||||
debug!("Found file at {:?}.", location);
|
||||
return Ok(ConfigData {
|
||||
path: location.clone(),
|
||||
file,
|
||||
})
|
||||
}
|
||||
Err(e) => debug!(
|
||||
"Tried to read '{:?}' but failed due to error: {}",
|
||||
location,
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
debug!("Failed to find any config. Now trying to find first writable path");
|
||||
|
||||
// If we got here, we failed to read any file paths, meaning no config exists
|
||||
// yet. In that case, try to return the first location that we can write to,
|
||||
// after writing the default config
|
||||
for location in locations {
|
||||
let file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(location.clone());
|
||||
match file {
|
||||
Ok(mut file) => {
|
||||
info!("Creating new config file at {:?}.", location);
|
||||
file.write_all(DEFAULT_CONFIG)?;
|
||||
|
||||
let file = OpenOptions::new().read(true).open(location.clone())?;
|
||||
return Ok(ConfigData {
|
||||
path: location,
|
||||
file,
|
||||
});
|
||||
}
|
||||
Err(e) => debug!(
|
||||
"Tried to open a new file at '{:?}' but failed due to error: {}",
|
||||
location,
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
Err(BunBunError::NoValidConfigPath)
|
||||
}
|
||||
|
||||
/// Assumes that the user knows what they're talking about and will only try
|
||||
/// to load the config at the given path.
|
||||
pub fn load_custom_path_config(
|
||||
path: impl Into<PathBuf>,
|
||||
) -> Result<ConfigData, BunBunError> {
|
||||
let path = path.into();
|
||||
Ok(ConfigData {
|
||||
file: OpenOptions::new().read(true).open(&path)?,
|
||||
path,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_config(mut config_file: File) -> Result<Config, BunBunError> {
|
||||
trace!("Loading config file...");
|
||||
let mut config_data = String::new();
|
||||
config_file.read_to_string(&mut config_data)?;
|
||||
// Reading from memory is faster than reading directly from a reader for some
|
||||
// reason; see https://github.com/serde-rs/json/issues/160
|
||||
Ok(serde_yaml::from_str(&config_str)?)
|
||||
Ok(serde_yaml::from_str(&config_data)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
@ -182,77 +249,3 @@ mod route {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod read_config {
|
||||
use super::*;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[test]
|
||||
fn returns_default_config_if_path_does_not_exist() {
|
||||
assert_eq!(
|
||||
read_config("/this_is_a_non_existent_file").unwrap(),
|
||||
serde_yaml::from_slice(DEFAULT_CONFIG).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_error_if_given_empty_config() {
|
||||
assert_eq!(
|
||||
read_config("/dev/null").unwrap_err().to_string(),
|
||||
"EOF while parsing a value"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_error_if_given_invalid_config() -> Result<(), std::io::Error> {
|
||||
let mut tmp_file = NamedTempFile::new()?;
|
||||
tmp_file.write_all(b"g")?;
|
||||
assert_eq!(
|
||||
read_config(tmp_file.path().to_str().unwrap())
|
||||
.unwrap_err()
|
||||
.to_string(),
|
||||
r#"invalid type: string "g", expected struct Config at line 1 column 1"#
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_error_if_config_missing_field() -> Result<(), std::io::Error> {
|
||||
let mut tmp_file = NamedTempFile::new()?;
|
||||
tmp_file.write_all(
|
||||
br#"
|
||||
bind_address: "localhost"
|
||||
public_address: "localhost"
|
||||
"#,
|
||||
)?;
|
||||
assert_eq!(
|
||||
read_config(tmp_file.path().to_str().unwrap())
|
||||
.unwrap_err()
|
||||
.to_string(),
|
||||
"missing field `groups` at line 2 column 19"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_ok_if_valid_config() -> Result<(), std::io::Error> {
|
||||
let mut tmp_file = NamedTempFile::new()?;
|
||||
tmp_file.write_all(
|
||||
br#"
|
||||
bind_address: "a"
|
||||
public_address: "b"
|
||||
groups: []"#,
|
||||
)?;
|
||||
assert_eq!(
|
||||
read_config(tmp_file.path().to_str().unwrap()).unwrap(),
|
||||
Config {
|
||||
bind_address: String::from("a"),
|
||||
public_address: String::from("b"),
|
||||
groups: vec![],
|
||||
default_route: None,
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ pub enum BunBunError {
|
|||
WatchError(hotwatch::Error),
|
||||
LoggerInitError(log::SetLoggerError),
|
||||
CustomProgramError(String),
|
||||
NoValidConfigPath
|
||||
}
|
||||
|
||||
impl Error for BunBunError {}
|
||||
|
@ -21,6 +22,7 @@ impl fmt::Display for BunBunError {
|
|||
Self::WatchError(e) => e.fmt(f),
|
||||
Self::LoggerInitError(e) => e.fmt(f),
|
||||
Self::CustomProgramError(msg) => write!(f, "{}", msg),
|
||||
Self::NoValidConfigPath => write!(f, "No valid config path was found!"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
42
src/main.rs
42
src/main.rs
|
@ -1,6 +1,9 @@
|
|||
#![forbid(unsafe_code)]
|
||||
|
||||
use crate::config::{read_config, Route, RouteGroup};
|
||||
use crate::config::{
|
||||
get_config_data, load_custom_path_config, read_config, ConfigData, Route,
|
||||
RouteGroup,
|
||||
};
|
||||
use actix_web::{middleware::Logger, App, HttpServer};
|
||||
use clap::{crate_authors, crate_version, load_yaml, App as ClapApp};
|
||||
use error::BunBunError;
|
||||
|
@ -41,8 +44,12 @@ async fn main() -> Result<(), BunBunError> {
|
|||
)?;
|
||||
|
||||
// config has default location provided, unwrapping is fine.
|
||||
let conf_file_location = String::from(matches.value_of("config").unwrap());
|
||||
let conf = read_config(&conf_file_location)?;
|
||||
let conf_data = match matches.value_of("config") {
|
||||
Some(file_name) => load_custom_path_config(file_name),
|
||||
None => get_config_data(),
|
||||
}?;
|
||||
|
||||
let conf = read_config(conf_data.file.try_clone()?)?;
|
||||
let state = Arc::from(RwLock::new(State {
|
||||
public_address: conf.public_address,
|
||||
default_route: conf.default_route,
|
||||
|
@ -50,7 +57,7 @@ async fn main() -> Result<(), BunBunError> {
|
|||
groups: conf.groups,
|
||||
}));
|
||||
|
||||
let _watch = start_watch(state.clone(), conf_file_location)?;
|
||||
let _watch = start_watch(state.clone(), conf_data)?;
|
||||
|
||||
HttpServer::new(move || {
|
||||
App::new()
|
||||
|
@ -104,7 +111,7 @@ fn cache_routes(groups: &[RouteGroup]) -> HashMap<String, Route> {
|
|||
match mapping.insert(kw.clone(), dest.clone()) {
|
||||
None => trace!("Inserting {} into mapping.", kw),
|
||||
Some(old_value) => {
|
||||
debug!("Overriding {} route from {} to {}.", kw, old_value, dest)
|
||||
trace!("Overriding {} route from {} to {}.", kw, old_value, dest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -153,18 +160,25 @@ fn compile_templates() -> Handlebars {
|
|||
/// watches.
|
||||
fn start_watch(
|
||||
state: Arc<RwLock<State>>,
|
||||
config_file_path: String,
|
||||
config_data: ConfigData,
|
||||
) -> Result<Hotwatch, BunBunError> {
|
||||
let mut watch = Hotwatch::new_with_custom_delay(Duration::from_millis(500))?;
|
||||
// TODO: keep retry watching in separate thread
|
||||
|
||||
// Closures need their own copy of variables for proper life cycle management
|
||||
let config_file_path_clone = config_file_path.clone();
|
||||
let watch_result = watch.watch(&config_file_path, move |e: Event| {
|
||||
let config_data = Arc::new(config_data);
|
||||
let config_data_ref = Arc::clone(&config_data);
|
||||
|
||||
let watch_result = watch.watch(&config_data.path, move |e: Event| {
|
||||
if let Event::Write(_) = e {
|
||||
trace!("Grabbing writer lock on state...");
|
||||
let mut state = state.write().unwrap();
|
||||
let mut state = state.write().expect("Failed to get write lock on state");
|
||||
trace!("Obtained writer lock on state!");
|
||||
match read_config(&config_file_path_clone) {
|
||||
match read_config(
|
||||
config_data_ref
|
||||
.file
|
||||
.try_clone()
|
||||
.expect("Failed to clone file handle"),
|
||||
) {
|
||||
Ok(conf) => {
|
||||
state.public_address = conf.public_address;
|
||||
state.default_route = conf.default_route;
|
||||
|
@ -180,10 +194,10 @@ fn start_watch(
|
|||
});
|
||||
|
||||
match watch_result {
|
||||
Ok(_) => info!("Watcher is now watching {}", &config_file_path),
|
||||
Ok(_) => info!("Watcher is now watching {:?}", &config_data.path),
|
||||
Err(e) => warn!(
|
||||
"Couldn't watch {}: {}. Changes to this file won't be seen!",
|
||||
&config_file_path, e
|
||||
"Couldn't watch {:?}: {}. Changes to this file won't be seen!",
|
||||
&config_data.path, e
|
||||
),
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue