diff --git a/Cargo.lock b/Cargo.lock index 6a1ea20..a212564 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1430,7 +1430,7 @@ dependencies = [ [[package]] name = "kairo" -version = "4.6.0" +version = "4.7.0" dependencies = [ "axum", "bytes", @@ -1445,7 +1445,9 @@ dependencies = [ "perf_monitor", "player", "rand 0.8.8", + "reqwest", "rustls", + "semver", "serde", "serde_json", "serde_yaml", @@ -1747,8 +1749,8 @@ checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] name = "lyrics" -version = "0.2.4" -source = "git+https://github.com/bongo-devs/lyrics?tag=v0.2.4#f2aab97f1f4abcf14f75e8a53669eea40dcee6dd" +version = "0.2.5" +source = "git+https://github.com/bongo-devs/lyrics?tag=v0.2.5#25d82588934aa5c238fe1ca6fb975313f5c7331b" dependencies = [ "async-trait", "base64", diff --git a/Cargo.toml b/Cargo.toml index f85d88d..d1e3691 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kairo" -version = "4.6.0" +version = "4.7.0" edition = "2021" license = "MIT" description = "Standalone Discord audio sending node, written in Rust. Drop-in Lavalink v4 compatible." @@ -12,6 +12,7 @@ libc = "0.2" mimalloc = "0.1.48" perf_monitor = "0.2.1" rand = "0.8" +semver = "1" serde_json = "1" serde_yaml = "0.9" tower-service = "0.3" @@ -20,7 +21,7 @@ tracing-appender = "0.2" [dependencies.lyrics] git = "https://github.com/bongo-devs/lyrics" -tag = "v0.2.4" +tag = "v0.2.5" [dependencies.player] git = "https://github.com/bongo-devs/player" @@ -34,6 +35,11 @@ tag = "v0.1.12" git = "https://github.com/bongo-devs/voice" tag = "v0.1.0" +[dependencies.reqwest] +version = "0.12" +default-features = false +features = ["rustls-tls", "json"] + [dependencies.tokio] version = "1.52" features = ["tracing", "macros", "signal", "rt-multi-thread"] diff --git a/application.yml.example b/application.yml.example index b51cebc..018424f 100644 --- a/application.yml.example +++ b/application.yml.example @@ -53,6 +53,7 @@ logging: symphonia_bundle_mp3: error # silences harmless VBR main_data_begin warnings format: compact # compact | pretty | json color: true + banner: true # print the startup banner (skipped when format is json) timestamps: true showTarget: true # prefix lines with the module name request: # REST request logging diff --git a/src/config/logging.rs b/src/config/logging.rs index c1841a2..75a319a 100644 --- a/src/config/logging.rs +++ b/src/config/logging.rs @@ -16,6 +16,10 @@ pub struct LoggingConfig { pub format: LogFormat, /// Colourise console output. Ignored for the file sink. pub color: bool, + /// Print the startup banner to the console. Ignored when [`format`] is JSON. + /// + /// [`format`]: Self::format + pub banner: bool, /// Prefix each line with a `HH:MM:SS` timestamp. pub timestamps: bool, /// Include the module target in each line. @@ -33,6 +37,7 @@ impl Default for LoggingConfig { levels: HashMap::new(), format: LogFormat::Compact, color: true, + banner: true, timestamps: true, show_target: true, file: None, diff --git a/src/main.rs b/src/main.rs index 237be1c..7dd990f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,9 +29,14 @@ async fn main() -> ExitCode { LazyLock::force(&CONFIG); + kairo::utils::banner::print(&CONFIG.logging); + // The guard flushes what the file sink buffered when dropped, so it is held for all of `main`. let _logging = kairo::utils::init(&CONFIG.logging); + // Fire and forget: it logs an upgrade notice if one exists and stays out of the way otherwise. + tokio::spawn(kairo::utils::update::check(&CONFIG.logging)); + let bind = format!("{}:{}", CONFIG.server.address, CONFIG.server.port); let http2 = CONFIG.server.http2.enabled; diff --git a/src/utils/ansi.rs b/src/utils/ansi.rs new file mode 100644 index 0000000..82dbe2d --- /dev/null +++ b/src/utils/ansi.rs @@ -0,0 +1,6 @@ +//! The ANSI escapes the console output shares, so the banner and notices read as one palette. + +pub const ACCENT: &str = "\x1b[38;5;44m"; +pub const BOLD: &str = "\x1b[1m"; +pub const DIM: &str = "\x1b[2m"; +pub const RESET: &str = "\x1b[0m"; diff --git a/src/utils/banner.rs b/src/utils/banner.rs new file mode 100644 index 0000000..c6dc794 --- /dev/null +++ b/src/utils/banner.rs @@ -0,0 +1,46 @@ +//! The startup banner printed to the console before logging begins. + +use crate::config::{LogFormat, LoggingConfig}; +use crate::utils::ansi::{ACCENT, BOLD, DIM, RESET}; + +const LOGO: &str = "\ +██╗ ██╗ █████╗ ██╗██████╗ ██████╗ +██║ ██╔╝██╔══██╗██║██╔══██╗██╔═══██╗ +█████╔╝ ███████║██║██████╔╝██║ ██║ +██╔═██╗ ██╔══██║██║██╔══██╗██║ ██║ +██║ ██╗██║ ██║██║██║ ██║╚██████╔╝ +╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═╝ ╚═════╝"; + +const TAGLINE: &str = "Standalone Discord audio sending node · Lavalink v4 compatible"; + +/// Print the logo, version and a short facts line to stdout. +/// +/// Written directly rather than through `tracing` so it lands before the subscriber is installed +/// and never carries a log prefix. Skipped when the banner is turned off, and always when the log +/// format is JSON: stdout is a machine-readable stream then, and a banner would corrupt it. +pub fn print(cfg: &LoggingConfig) { + if !cfg.banner || cfg.format == LogFormat::Json { + return; + } + + let (accent, bold, dim, reset) = if cfg.color { + (ACCENT, BOLD, DIM, RESET) + } else { + ("", "", "", "") + }; + + let cores = std::thread::available_parallelism() + .map(|n| n.get().to_string()) + .unwrap_or_else(|_| "?".to_string()); + let facts = format!( + "v{} · {} cores · {}/{}", + env!("CARGO_PKG_VERSION"), + cores, + std::env::consts::OS, + std::env::consts::ARCH, + ); + + println!("\n{accent}{bold}{LOGO}{reset}\n"); + println!(" {dim}{TAGLINE}{reset}"); + println!(" {dim}{facts}{reset}\n"); +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 9d976c9..65854aa 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,5 +1,8 @@ -//! Logging, installed once at startup. +//! Startup odds and ends: logging, the console banner, and the update check. +pub(crate) mod ansi; +pub mod banner; mod logging; +pub mod update; pub use logging::init; diff --git a/src/utils/update.rs b/src/utils/update.rs new file mode 100644 index 0000000..0e2d9ce --- /dev/null +++ b/src/utils/update.rs @@ -0,0 +1,129 @@ +//! A one-shot check against the GitHub releases API, announcing an upgrade when one is newer. + +use std::time::Duration; + +use semver::Version; +use serde::Deserialize; + +use crate::config::{LogFormat, LoggingConfig}; +use crate::utils::ansi::{ACCENT, BOLD, DIM, RESET}; + +const RELEASES_API: &str = "https://api.github.com/repos/bongo-devs/Kairo/releases/latest"; +const IMAGE: &str = "ghcr.io/bongo-devs/kairo:latest"; +const TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Deserialize)] +struct Release { + tag_name: String, +} + +/// Ask GitHub for the latest release once and, if its tag outranks the running build, announce how +/// to upgrade. Set `KAIRO_NO_UPDATE_CHECK` to skip it entirely. +/// +/// Every failure is logged at debug and otherwise dropped: a version check has no business keeping +/// the node from serving, and a proxied or air-gapped host is expected to fail here. The default +/// client honours `HTTPS_PROXY`, so a proxied deployment still reaches GitHub without extra config. +pub async fn check(logging: &LoggingConfig) { + if std::env::var_os("KAIRO_NO_UPDATE_CHECK").is_some() { + return; + } + + let Ok(current) = Version::parse(env!("CARGO_PKG_VERSION")) else { + return; + }; + + let release = match latest().await { + Ok(Some(release)) => release, + Ok(None) => return, + Err(err) => { + tracing::debug!("update check failed: {err}"); + return; + } + }; + + match upgrade_to(¤t, &release.tag_name) { + Some(latest) => announce(logging, ¤t, &latest), + None => tracing::debug!("Kairo is up to date (v{current})"), + } +} + +// Draw the upgrade notice as a whitespace-isolated callout with the banner's accent gutter, so it +// carries the same look and reads apart from the log stream around it. Under JSON logging stdout +// must stay machine-readable, so there it is a structured warning instead. +fn announce(logging: &LoggingConfig, current: &Version, latest: &Version) { + if logging.format == LogFormat::Json { + tracing::warn!(%current, %latest, "a new Kairo release is available"); + return; + } + + let (accent, bold, dim, reset) = if logging.color { + (ACCENT, BOLD, DIM, RESET) + } else { + ("", "", "", "") + }; + + println!( + "\n {accent}▍{reset} {bold}Kairo v{latest} is available{reset} {dim}· running v{current}{reset}\ + \n {accent}▍{reset} {dim}docker pull {IMAGE} · then restart the container{reset}\n" + ); +} + +// The release tag outranks `current`, if it parses and is strictly newer. Tags carry an optional +// leading `v` that is not part of the semver. +fn upgrade_to(current: &Version, tag: &str) -> Option { + Version::parse(tag.trim_start_matches('v')) + .ok() + .filter(|latest| latest > current) +} + +async fn latest() -> reqwest::Result> { + let client = reqwest::Client::builder() + .user_agent(concat!("kairo/", env!("CARGO_PKG_VERSION"))) + .timeout(TIMEOUT) + .build()?; + + let response = client + .get(RELEASES_API) + .header("Accept", "application/vnd.github+json") + .send() + .await?; + + // No published releases answers 404; nothing to compare against, and not worth a log line. + if !response.status().is_success() { + return Ok(None); + } + + Ok(Some(response.json::().await?)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recognises_a_newer_tag() { + let current = Version::parse("4.6.0").unwrap(); + assert_eq!( + upgrade_to(¤t, "v4.7.0"), + Some(Version::parse("4.7.0").unwrap()) + ); + assert_eq!( + upgrade_to(¤t, "5.0.0"), + Some(Version::parse("5.0.0").unwrap()) + ); + } + + #[test] + fn ignores_same_or_older_tags() { + let current = Version::parse("4.6.0").unwrap(); + assert_eq!(upgrade_to(¤t, "v4.6.0"), None); + assert_eq!(upgrade_to(¤t, "4.5.9"), None); + } + + #[test] + fn ignores_unparsable_tags() { + let current = Version::parse("4.6.0").unwrap(); + assert_eq!(upgrade_to(¤t, "nightly"), None); + assert_eq!(upgrade_to(¤t, ""), None); + } +}