Skip to content

Commit

Permalink
Add hot reloading of configuration
Browse files Browse the repository at this point in the history
Closes #53
  • Loading branch information
patriksvensson committed Mar 19, 2020
1 parent 38ca2b2 commit 2e17fd7
Show file tree
Hide file tree
Showing 21 changed files with 1,027 additions and 411 deletions.
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
url = "2.1.0"
reqwest = { version = "0.9.22", default-features = false, features = [ "rustls-tls" ] }
waithandle = "0.1.0"
waithandle = "0.3.0"
actix-web = "2"
actix-files = "0.2.1"
actix-cors = "0.2.0"
Expand Down
6 changes: 4 additions & 2 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use actix_files as fs;
use actix_web::web;
use actix_web::{App, HttpServer};
use actix_web_static_files;
use log::info;
use log::{debug, info};

mod endpoints;
mod models;
Expand Down Expand Up @@ -54,7 +54,7 @@ pub async fn start_and_block(

// Are we running embedded web?
if cfg!(feature = "embedded-web") {
info!("Serving embedded UI.");
debug!("Serving embedded UI.");
}

info!("Duck server address: {}", bind);
Expand Down Expand Up @@ -85,5 +85,7 @@ pub async fn start_and_block(
.run()
.await?;

info!("Web server stopped.");

Ok(())
}
2 changes: 1 addition & 1 deletion src/api/endpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use super::models::{BuildViewModel, ServerInfoModel, ViewInfoModel};

pub async fn server_info(state: web::Data<Arc<EngineState>>) -> HttpResponse {
let info = ServerInfoModel {
title: &state.title[..],
title: &state.title()[..],
started: state
.started
.duration_since(std::time::SystemTime::UNIX_EPOCH)
Expand Down
81 changes: 28 additions & 53 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
use std::path::PathBuf;

use schemars::JsonSchema;
use serde::Deserialize;

Expand All @@ -10,22 +8,31 @@ use crate::DuckResult;
mod expansions;
mod validation;

pub mod loader;

pub trait Validate {
fn validate(&self) -> DuckResult<()>;
}

#[derive(Serialize, Deserialize, JsonSchema, Clone)]
/// Represents a way of loading a configuration
pub trait ConfigurationLoader: Sync + Send + Clone {
fn exist(&self) -> bool;
fn has_changed(&self) -> DuckResult<bool>;
fn load(&self, variables: &dyn VariableProvider) -> DuckResult<Configuration>;
}

#[derive(Serialize, Deserialize, JsonSchema, Clone, Default)]
pub struct Configuration {
/// # Update interval
/// The update interval in seconds
#[serde(default)]
pub interval: Option<Interval>,
#[serde(default = "default_interval")]
pub interval: u16,
/// # Views
pub views: Option<Vec<ViewConfiguration>>,
/// # Duck frontend title
/// The title that is displayed in the UI
#[serde(default)]
pub title: Option<String>,
#[serde(default = "default_title")]
pub title: String,
/// # Collectors
pub collectors: Vec<CollectorConfiguration>,
/// # Observers
Expand All @@ -34,25 +41,8 @@ pub struct Configuration {
}

impl Configuration {
pub fn from_file(variables: &impl VariableProvider, path: PathBuf) -> DuckResult<Self> {
if !path.exists() {
if cfg!(feature = "docker") {
return Err(format_err!(
"The configuration '{}' does not exist. Have you added a Docker volume mapping?",
path.to_str().unwrap()
));
}
return Err(format_err!(
"The configuration '{}' does not exist",
path.to_str().unwrap()
));
}
let json = std::fs::read_to_string(path)?;
Configuration::from_json(variables, json)
}

pub fn from_json<T: Into<String>>(
variables: &impl VariableProvider,
variables: &dyn VariableProvider,
json: T,
) -> DuckResult<Self> {
let expander = &Expander::new(variables);
Expand All @@ -72,45 +62,19 @@ impl Configuration {
);
}

pub fn get_title(&self) -> &str {
match &self.title {
Some(title) => title,
None => "Duck",
}
}

pub fn get_interval(&self) -> u64 {
if let Some(i) = &self.interval {
if i.0 >= 15 {
return u64::from(i.0);
}
}
return 15;
}

pub fn get_all_ids(&self) -> Vec<String> {
// Get all collector id:s
let mut result: Vec<String> = self
.collectors
.iter()
.map(|i| match i {
CollectorConfiguration::TeamCity(c) => c.id.clone(),
CollectorConfiguration::Azure(c) => c.id.clone(),
CollectorConfiguration::OctopusDeploy(c) => c.id.clone(),
CollectorConfiguration::GitHub(c) => c.id.clone(),
CollectorConfiguration::AppVeyor(c) => c.id.clone(),
})
.map(|i| i.get_id().to_owned())
.collect();
// Get all observer id:s
match self.observers {
Option::None => (),
Option::Some(ref observers) => {
for observer in observers.iter() {
match observer {
ObserverConfiguration::Hue(c) => result.push(c.id.clone()),
ObserverConfiguration::Slack(c) => result.push(c.id.clone()),
ObserverConfiguration::Mattermost(c) => result.push(c.id.clone()),
};
result.push(observer.get_id().to_owned());
}
}
}
Expand Down Expand Up @@ -506,3 +470,14 @@ pub enum MattermostCredentials {
#[serde(rename = "webhook")]
Webhook { url: String },
}

///////////////////////////////////////////////////////////
// Default values

fn default_title() -> String {
"Duck".to_owned()
}

fn default_interval() -> u16 {
15
}
168 changes: 168 additions & 0 deletions src/config/loader.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
use std::fs;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::SystemTime;

use crate::config::{Configuration, ConfigurationLoader};
use crate::utils::text::VariableProvider;
use crate::DuckResult;

///////////////////////////////////////////////////////////
// Configuration loader

#[derive(Clone)]
pub struct JsonConfigurationLoader<'a> {
path: PathBuf,
reader: &'a dyn FileReader,
modified: Arc<Mutex<u64>>,
}

impl<'a> JsonConfigurationLoader<'a> {
pub fn create(path: PathBuf) -> Self {
JsonConfigurationLoader::new(path, &DefaultFileReader {})
}

fn new(path: PathBuf, reader: &'a dyn FileReader) -> Self {
JsonConfigurationLoader {
path,
reader,
modified: Arc::new(Mutex::new(0)),
}
}
}

impl<'a> ConfigurationLoader for JsonConfigurationLoader<'a> {
fn exist(&self) -> bool {
self.path.exists()
}

fn has_changed(&self) -> DuckResult<bool> {
let modified = self.reader.modified(&self.path)?;
if *self.modified.lock().unwrap() != modified {
return Ok(true);
}
Ok(false)
}

fn load(&self, variables: &dyn VariableProvider) -> DuckResult<Configuration> {
// Read the configuration and deserialize it
let json = self.reader.read_to_string(&self.path)?;
let config: Configuration = Configuration::from_json(variables, json)?;
// Update the modified time to the current one.
let modified = self.reader.modified(&self.path)?;
*self.modified.lock().unwrap() = modified;
Ok(config)
}
}

///////////////////////////////////////////////////////////
// File reader

trait FileReader: Send + Sync {
/// Returns the content of the file as a string
fn read_to_string(&self, path: &PathBuf) -> DuckResult<String>;
/// Gets the modified time as Epoch time
fn modified(&self, path: &PathBuf) -> DuckResult<u64>;
}

struct DefaultFileReader {}
impl FileReader for DefaultFileReader {
fn read_to_string(&self, path: &PathBuf) -> DuckResult<String> {
Ok(fs::read_to_string(path)?)
}

fn modified(&self, path: &PathBuf) -> DuckResult<u64> {
Ok(fs::metadata(path)?
.modified()?
.duration_since(SystemTime::UNIX_EPOCH)?
.as_secs())
}
}

///////////////////////////////////////////////////////////
// Tests

#[cfg(test)]
mod tests {
use super::*;
use crate::utils::text::TestVariableProvider;

struct FakeFileReader {
json: String,
modified: Arc<Mutex<u64>>,
}

impl FakeFileReader {
fn new<T: Into<String>>(json: T, modified: u64) -> Self {
Self {
json: json.into(),
modified: Arc::new(Mutex::new(modified)),
}
}

pub fn inc_modified(&self) {
let mut modified = self.modified.lock().unwrap();
*modified = *modified + 1;
}
}

impl FileReader for FakeFileReader {
fn read_to_string(&self, _path: &PathBuf) -> DuckResult<String> {
Ok(self.json.clone())
}

fn modified(&self, _path: &PathBuf) -> DuckResult<u64> {
let modified = self.modified.lock().unwrap();
Ok(*modified)
}
}

#[test]
fn should_load_expected_configuration() {
// Given
let path = PathBuf::from("config.json");
let reader = FakeFileReader::new(include_str!("test_data/config.json"), 1583092970);
let config = JsonConfigurationLoader::new(path, &reader);
let variables = TestVariableProvider::new();

// When
let config = config.load(&variables).unwrap();

// Then
assert_eq!(99, config.interval);
assert_eq!("Duck test server", config.title);
}

#[test]
fn should_indicate_if_configuration_has_not_changed_since_read() {
// Given
let path = PathBuf::from("config.json");
let reader = FakeFileReader::new(include_str!("test_data/config.json"), 1583092970);
let handle = JsonConfigurationLoader::new(path, &reader);
let variables = TestVariableProvider::new();

// When
handle.load(&variables).unwrap();
let has_changed = handle.has_changed().unwrap();

// Then
assert!(!has_changed);
}

#[test]
fn should_indicate_if_configuration_changed_since_read() {
// Given
let path = PathBuf::from("config.json");
let reader = FakeFileReader::new(include_str!("test_data/config.json"), 1583092970);
let handle = JsonConfigurationLoader::new(path, &reader);
let variables = TestVariableProvider::new();

// When
handle.load(&variables).unwrap();
reader.inc_modified();
let has_changed = handle.has_changed().unwrap();

// Then
assert!(has_changed);
}
}
Loading

0 comments on commit 2e17fd7

Please sign in to comment.