implement intelligent config location selection.

This commit is contained in:
Edward Shen 2020-07-04 20:17:12 -04:00
parent 633a152f89
commit 7faf15889a
Signed by: edward
GPG key ID: 19182661E818369F
5 changed files with 771 additions and 578 deletions

1101
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -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"

View file

@ -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(())
}
}

View file

@ -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!"),
}
}
}

View file

@ -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
),
}