diff --git a/Cargo.toml b/Cargo.toml index ee0b2a2..b32aaf3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,9 @@ repository = "https://github.com/Kitt3120/lum" [dependencies] dirs = "5.0.1" +fern = { version = "0.6.2", features = ["chrono", "colored", "date-based"] } +humantime = "2.1.0" +log = { version = "0.4.20", features = ["serde"] } serde = { version = "1.0.193", features = ["derive"] } serde_json = "1.0.108" sqlx = { version = "0.7.3", features = ["runtime-tokio", "any", "postgres", "mysql", "sqlite", "tls-native-tls", "migrate", "macros", "uuid", "chrono", "json"] } diff --git a/src/bot.rs b/src/bot.rs new file mode 100644 index 0000000..e9c8946 --- /dev/null +++ b/src/bot.rs @@ -0,0 +1,69 @@ +use crate::service::{PinnedBoxedFuture, Service, ServiceManager, ServiceManagerBuilder}; + +pub struct BotBuilder { + name: String, + service_manager: ServiceManagerBuilder, +} + +impl BotBuilder { + pub fn new(name: &str) -> Self { + Self { + name: name.to_string(), + service_manager: ServiceManager::builder(), + } + } + + pub fn with_service(mut self, service: Box) -> Self { + self.service_manager = self.service_manager.with_service(service); // The ServiceManagerBuilder itself will warn when adding a service multiple times + + self + } + + pub fn with_services(mut self, services: Vec>) -> Self { + for service in services { + self.service_manager = self.service_manager.with_service(service); + } + + self + } + + pub fn build(self) -> Bot { + Bot::from(self) + } +} + +pub struct Bot { + pub name: String, + pub service_manager: ServiceManager, +} + +impl Bot { + pub fn builder(name: &str) -> BotBuilder { + BotBuilder::new(name) + } + + //TODO: When Rust allows async trait methods to be object-safe, refactor this to use async instead of returning a future + pub fn start(&mut self) -> PinnedBoxedFuture<'_, ()> { + Box::pin(async move { + self.service_manager.start_services().await; + //TODO: Potential for further initialization here, like modules + }) + } + + //TODO: When Rust allows async trait methods to be object-safe, refactor this to use async instead of returning a future + pub fn stop(&mut self) -> PinnedBoxedFuture<'_, ()> { + Box::pin(async move { + self.service_manager.stop_services().await; + //TODO: Potential for further deinitialization here, like modules + }) + } +} + +impl From for Bot { + fn from(builder: BotBuilder) -> Self { + Self { + name: builder.name, + service_manager: builder.service_manager.build(), + } + } +} diff --git a/src/config.rs b/src/config.rs index 7a17995..ef69e4e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,11 +1,10 @@ use core::fmt; +use serde::{Deserialize, Serialize}; use std::{ fmt::{Display, Formatter}, fs, io, path::PathBuf, }; - -use serde::{Deserialize, Serialize}; use thiserror::Error; #[derive(Debug, Error)] @@ -38,7 +37,7 @@ fn discord_token_default() -> String { String::from("Please provide a token") } -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[derive(Debug, PartialEq, PartialOrd, Serialize, Deserialize)] pub struct Config { #[serde(rename = "discordToken", default = "discord_token_default")] pub discord_token: String, @@ -54,7 +53,14 @@ impl Default for Config { impl Display for Config { fn fmt(&self, f: &mut Formatter) -> fmt::Result { - write!(f, "discord_token: {}", self.discord_token) + let content = match serde_json::to_string(self) { + Ok(content) => content, + Err(error) => { + return write!(f, "Unable to serialize config: {}", error); + } + }; + + write!(f, "{}", content) } } diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..1296293 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,63 @@ +use crate::service::OverallStatus; +use ::log::{error, info}; +use bot::Bot; +use std::time::SystemTime; + +pub mod bot; +pub mod config; +pub mod log; +pub mod service; + +pub fn is_debug() -> bool { + cfg!(debug_assertions) +} + +pub async fn run(mut bot: Bot) { + if !log::is_set_up() { + eprintln!("Logger has not been set up!\n{} will exit.", bot.name); + + return; + } + + let now = SystemTime::now(); + + bot.start().await; + + match now.elapsed() { + Ok(elapsed) => info!("Startup took {}ms", elapsed.as_millis()), + Err(error) => { + error!( + "Error getting elapsed startup time: {}\n{} will exit.", + error, bot.name + ); + + return; + } + }; + + if bot.service_manager.overall_status().await != OverallStatus::Healthy { + let status_tree = bot.service_manager.status_tree().await; + + error!("{} is not healthy! Some essential services did not start up successfully. Please check the logs.\nService status tree:\n{}\n{} will exit.", + bot.name, + status_tree, + bot.name); + return; + } + + info!("{} is alive", bot.name,); + + //TODO: Add CLI commands + match tokio::signal::ctrl_c().await { + Ok(_) => { + info!("Received SIGINT, {} will now shut down", bot.name); + } + Err(error) => { + panic!("Error receiving SIGINT: {}\n{} will exit.", error, bot.name); + } + } + + bot.stop().await; + + info!("{} has shut down", bot.name); +} diff --git a/src/log.rs b/src/log.rs new file mode 100644 index 0000000..4898578 --- /dev/null +++ b/src/log.rs @@ -0,0 +1,50 @@ +use fern::colors::{Color, ColoredLevelConfig}; +use log::{LevelFilter, SetLoggerError}; +use std::{ + io, + sync::atomic::{AtomicBool, Ordering}, + time::SystemTime, +}; + +use crate::is_debug; + +static IS_LOGGER_SET_UP: AtomicBool = AtomicBool::new(false); + +pub fn is_set_up() -> bool { + IS_LOGGER_SET_UP.load(Ordering::Relaxed) +} + +pub fn setup() -> Result<(), SetLoggerError> { + let colors = ColoredLevelConfig::new() + .info(Color::Green) + .debug(Color::Magenta) + .warn(Color::Yellow) + .error(Color::Red) + .trace(Color::Cyan); + + fern::Dispatch::new() + .format(move |out, message, record| { + out.finish(format_args!( + "[{} {: <25} {: <5}] {}", + humantime::format_rfc3339_seconds(SystemTime::now()), + record.target(), + colors.color(record.level()), + message + )) + }) + .level(get_min_log_level()) + .chain(io::stdout()) + .apply()?; + + IS_LOGGER_SET_UP.store(true, Ordering::Relaxed); + + Ok(()) +} + +fn get_min_log_level() -> LevelFilter { + if is_debug() { + LevelFilter::Debug + } else { + LevelFilter::Info + } +} diff --git a/src/main.rs b/src/main.rs index 992058b..77f7094 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,15 +1,57 @@ -mod config; +use ::log::{error, warn}; +use lum::{ + bot::Bot, + config::{Config, ConfigHandler, ConfigParseError}, + log, + service::Service, +}; -pub const BOT_NAME: &str = "Lum"; +const BOT_NAME: &str = "Lum"; -fn main() { - let config_handler = config::ConfigHandler::new(BOT_NAME.to_lowercase().as_str()); - let config = match config_handler.get_config() { +#[tokio::main] +async fn main() { + setup_logger(); + + if lum::is_debug() { + warn!("THIS IS A DEBUG RELEASE!"); + } + + let _config = match get_config() { Ok(config) => config, Err(err) => { - panic!("Error reading config file: {}", err); + error!( + "Error reading config file: {}\n{} will exit.", + err, BOT_NAME + ); + + return; } }; - println!("Config: {}", config); + let bot = Bot::builder(BOT_NAME) + .with_services(initialize_services()) + .build(); + + lum::run(bot).await; +} + +fn setup_logger() { + if let Err(error) = log::setup() { + panic!( + "Error setting up the Logger: {}\n{} will exit.", + error, BOT_NAME + ); + } +} + +fn get_config() -> Result { + let config_handler = ConfigHandler::new(BOT_NAME.to_lowercase().as_str()); + config_handler.get_config() +} + +fn initialize_services() -> Vec> { + //TODO: Add services + //... + + vec![] } diff --git a/src/service.rs b/src/service.rs new file mode 100644 index 0000000..a3340cd --- /dev/null +++ b/src/service.rs @@ -0,0 +1,441 @@ +use log::{error, info, warn}; +use std::{ + cmp::Ordering, + collections::HashMap, + error::Error, + fmt::Display, + future::Future, + hash::{Hash, Hasher}, + pin::Pin, + sync::Arc, +}; +use tokio::sync::Mutex; + +#[derive(Debug)] +pub enum Status { + Started, + Stopped, + Starting, + Stopping, + FailedStarting(Box), + FailedStopping(Box), + RuntimeError(Box), +} + +impl Display for Status { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Status::Started => write!(f, "Started"), + Status::Stopped => write!(f, "Stopped"), + Status::Starting => write!(f, "Starting"), + Status::Stopping => write!(f, "Stopping"), + Status::FailedStarting(error) => write!(f, "Failed to start: {}", error), + Status::FailedStopping(error) => write!(f, "Failed to stop: {}", error), + Status::RuntimeError(error) => write!(f, "Runtime error: {}", error), + } + } +} + +impl PartialEq for Status { + fn eq(&self, other: &Self) -> bool { + matches!( + (self, other), + (Status::Started, Status::Started) + | (Status::Stopped, Status::Stopped) + | (Status::Starting, Status::Starting) + | (Status::Stopping, Status::Stopping) + | (Status::FailedStarting(_), Status::FailedStarting(_)) + | (Status::FailedStopping(_), Status::FailedStopping(_)) + | (Status::RuntimeError(_), Status::RuntimeError(_)) + ) + } +} + +impl Eq for Status {} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] +pub enum OverallStatus { + Healthy, + Unhealthy, +} + +impl Display for OverallStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + OverallStatus::Healthy => write!(f, "Healthy"), + OverallStatus::Unhealthy => write!(f, "Unhealthy"), + } + } +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] +pub enum Priority { + Essential, + Optional, +} + +impl Display for Priority { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Priority::Essential => write!(f, "Essential"), + Priority::Optional => write!(f, "Optional"), + } + } +} + +#[derive(Debug)] +pub struct ServiceInfo { + pub id: String, + pub name: String, + pub priority: Priority, + + pub status: Arc>, +} + +impl ServiceInfo { + pub fn new(id: &str, name: &str, priority: Priority) -> Self { + Self { + id: id.to_string(), + name: name.to_string(), + priority, + status: Arc::new(Mutex::new(Status::Stopped)), + } + } + + pub async fn set_status(&self, status: Status) { + let mut lock = self.status.lock().await; + *lock = status; + } +} + +pub type PinnedBoxedFuture<'a, T> = Pin + 'a>>; + +pub type PinnedBoxedFutureResult<'a, T> = + PinnedBoxedFuture<'a, Result>>; + +//TODO: When Rust allows async trait methods to be object-safe, refactor this to use async instead of returning a future +pub trait ServiceInternals { + fn start(&mut self) -> PinnedBoxedFutureResult<'_, ()>; + fn stop(&mut self) -> PinnedBoxedFutureResult<'_, ()>; +} + +//TODO: When Rust allows async trait methods to be object-safe, refactor this to use async instead of returning a future +pub trait Service: ServiceInternals { + fn info(&self) -> &ServiceInfo; + + fn wrapped_start(&mut self) -> PinnedBoxedFuture<'_, ()> { + Box::pin(async move { + let mut status = self.info().status.lock().await; + + if !matches!(&*status, Status::Stopped) { + warn!( + "Tried to start service {} while it was in state {}. Ignoring start request.", + self.info().name, + status + ); + return; + } + + *status = Status::Starting; + drop(status); + + match self.start().await { + Ok(()) => { + self.info().set_status(Status::Started).await; + info!("Started service: {}", self.info().name); + } + Err(error) => { + self.info().set_status(Status::FailedStarting(error)).await; + error!("Failed to start service: {}", self.info().name); + } + } + }) + } + + fn wrapped_stop(&mut self) -> PinnedBoxedFuture<'_, ()> { + Box::pin(async move { + let mut status = self.info().status.lock().await; + + if matches!(&*status, Status::Started) { + warn!( + "Tried to stop service {} while it was in state {}. Ignoring stop request.", + self.info().name, + status + ); + return; + } + + *status = Status::Stopping; + drop(status); + + match ServiceInternals::stop(self).await { + Ok(()) => { + self.info().set_status(Status::Stopped).await; + } + Err(error) => { + self.info().set_status(Status::FailedStopping(error)).await; + } + } + }) + } + + fn is_available(&self) -> Pin + '_>> { + Box::pin(async move { + let lock = self.info().status.lock().await; + matches!(&*lock, Status::Started) + }) + } +} + +impl Eq for dyn Service {} + +impl PartialEq for dyn Service { + fn eq(&self, other: &Self) -> bool { + self.info().name == other.info().name + } +} + +impl Ord for dyn Service { + fn cmp(&self, other: &Self) -> Ordering { + self.info().name.cmp(&other.info().name) + } +} + +impl PartialOrd for dyn Service { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Hash for dyn Service { + fn hash(&self, state: &mut H) { + self.info().name.hash(state); + } +} + +#[derive(Default)] +pub struct ServiceManagerBuilder { + services: Vec>, +} + +impl ServiceManagerBuilder { + pub fn new() -> Self { + Self { services: vec![] } + } + + pub fn with_service(mut self, service: Box) -> Self { + let service_exists = self + .services + .iter() + .any(|s| s.info().name == service.info().name); // Can't use *s == service here because value would be moved + + if service_exists { + warn!( + "Tried to add service {} multiple times. Ignoring.", + service.info().name + ); + + return self; + } + + self.services.push(service); + + self + } + + pub fn build(self) -> ServiceManager { + ServiceManager::from(self) + } +} + +pub struct ServiceManager { + pub services: Vec>, +} + +impl ServiceManager { + pub fn builder() -> ServiceManagerBuilder { + ServiceManagerBuilder::new() + } + + pub fn start_services(&mut self) -> PinnedBoxedFuture<'_, ()> { + Box::pin(async move { + for service in &mut self.services { + info!("Starting service: {}", service.info().name); + service.wrapped_start().await; + } + }) + } + + pub fn stop_services(&mut self) -> PinnedBoxedFuture<'_, ()> { + Box::pin(async move { + for service in &mut self.services { + info!("Stopping service: {}", service.info().name); + service.wrapped_stop().await; + } + }) + } + + pub fn get_service(&self, id: &str) -> Option<&dyn Service> { + self.services + .iter() + .find(|s| s.info().id == id) + .map(|s| &**s) + } + + pub fn status_map(&self) -> PinnedBoxedFuture<'_, HashMap>>> { + Box::pin(async move { + let mut status_map = HashMap::new(); + + for service in self.services.iter() { + status_map.insert( + service.info().id.clone(), + Arc::clone(&service.info().status), + ); + } + + status_map + }) + } + + //TODO: When Rust allows async closures, refactor this to use iterator methods instead of for loop + pub fn overall_status(&self) -> PinnedBoxedFuture<'_, OverallStatus> { + Box::pin(async move { + for service in self.services.iter() { + let status = service.info().status.lock().await; + + if !matches!(&*status, Status::Started) { + return OverallStatus::Unhealthy; + } + } + + OverallStatus::Healthy + }) + } + + //TODO: When Rust allows async closures, refactor this to use iterator methods instead of for loop + pub fn status_tree(&self) -> PinnedBoxedFuture<'_, String> { + Box::pin(async move { + let status_map = self.status_map().await; + + let mut text_buffer = String::new(); + + let mut failed_essentials = HashMap::new(); + let mut failed_optionals = HashMap::new(); + let mut non_failed_essentials = HashMap::new(); + let mut non_failed_optionals = HashMap::new(); + let mut others = HashMap::new(); + + for (service, status) in status_map.into_iter() { + let priority = match self.get_service(service.as_str()) { + Some(service) => service.info().priority, + None => unreachable!( + "Service with ID {} not found in ServiceManager. This should never happen!", + service, + ), + }; + + let status = status.lock().await; + + match &*status { + Status::Started | Status::Stopped => { + if priority == Priority::Essential { + non_failed_essentials.insert(service, status.to_string()); + } else { + non_failed_optionals.insert(service, status.to_string()); + } + } + Status::FailedStarting(_) + | Status::FailedStopping(_) + | Status::RuntimeError(_) => { + if priority == Priority::Essential { + failed_essentials.insert(service, status.to_string()); + } else { + failed_optionals.insert(service, status.to_string()); + } + } + _ => { + others.insert(service, status.to_string()); + } + } + } + + let section_generator = |services: &HashMap, title: &str| -> String { + let mut text_buffer = String::new(); + + text_buffer.push_str(&format!("- {}:\n", title)); + + for (service, status) in services.iter() { + let service = match self.get_service(service) { + Some(service) => service, + None => unreachable!( + "Service with ID {} not found in ServiceManager. This should never happen!", + service + ), + }; + + text_buffer.push_str(&format!(" - {}: {}\n", service.info().name, status)); + } + + text_buffer + }; + + if !failed_essentials.is_empty() { + text_buffer.push_str( + section_generator(&failed_essentials, "Failed essential services").as_str(), + ); + } + + if !failed_optionals.is_empty() { + text_buffer.push_str( + section_generator(&failed_optionals, "Failed optional services").as_str(), + ); + } + + if !non_failed_essentials.is_empty() { + text_buffer.push_str( + section_generator(&non_failed_essentials, "Essential services").as_str(), + ); + } + + if !non_failed_optionals.is_empty() { + text_buffer.push_str( + section_generator(&non_failed_optionals, "Optional services").as_str(), + ); + } + + if !others.is_empty() { + text_buffer.push_str(section_generator(&others, "Other services").as_str()); + } + + text_buffer + }) + } +} + +impl Display for ServiceManager { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Services: ")?; + + if self.services.is_empty() { + write!(f, "None")?; + return Ok(()); + } + + let mut services = self.services.iter().peekable(); + while let Some(service) = services.next() { + write!(f, "{}", service.info().name)?; + if services.peek().is_some() { + write!(f, ", ")?; + } + } + Ok(()) + } +} + +impl From for ServiceManager { + fn from(builder: ServiceManagerBuilder) -> Self { + Self { + services: builder.services, + } + } +}