Skip to content

Commit

Permalink
Add hot reload support for configuration
Browse files Browse the repository at this point in the history
Reloads the configuration when it has been changed, and recreates all
collectors and observers. If an error occurs during reload, Duck will
continue to use the old configuration, but write an error to the
console output.

Closes #53
  • Loading branch information
patriksvensson committed Mar 20, 2020
1 parent 3643b05 commit cfc9c67
Show file tree
Hide file tree
Showing 24 changed files with 970 additions and 335 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
**/*.rs.bk

# Configuration
config.json
/config.json
data/
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
91 changes: 28 additions & 63 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 @@ -62,55 +52,19 @@ impl Configuration {
Ok(config)
}

#[cfg(test)]
pub fn empty(variables: &impl VariableProvider) -> DuckResult<Self> {
return Configuration::from_json(
variables,
r#"{
"collectors": []
}"#,
);
}

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 +460,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);
}
}
5 changes: 5 additions & 0 deletions src/config/test_data/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"interval": 99,
"title": "Duck test server",
"collectors": [ ]
}
Loading

0 comments on commit cfc9c67

Please sign in to comment.