diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/DockerUpdater.iml b/.idea/DockerUpdater.iml new file mode 100644 index 0000000..cf84ae4 --- /dev/null +++ b/.idea/DockerUpdater.iml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..59d9f5b --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + {} + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..07851bb --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..31c80f6 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,416 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "confy" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8807c397789cbe02bbdb1a27ea5f345584132808697b2a3f957c829829ee4814" +dependencies = [ + "etcetera", + "lazy_static", + "serde", + "thiserror", + "toml", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "docker-update" +version = "0.1.0" +dependencies = [ + "confy", + "libc 1.0.0-alpha.4", + "serde", + "serde_yaml", + "time", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "etcetera" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c7b13d0780cb82722fd59f6f57f925e143427e4a75313a6c77243bf5326ae6" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.59.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libc" +version = "1.0.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0f24f33af482526a4e3f9b47f0abb2c6377a1713c8aa4a8106994689a4cfa5" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc 0.2.189", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc 0.2.189", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..33b80ef --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "docker-update" +version = "0.1.0" +edition = "2024" +authors = ['DragonSlayer_14'] +readme = "README.md" +license-file = "LICENSE" +repository = "https://gitea.creative-dragonslayer.de/Linuxapps/SmartMount" +description = "" + +[dependencies] +time = { version="0.3.41", features = ["formatting", "macros", "local-offset"] } +serde = { version="1.0.219", features = ["derive"] } +serde_yaml = "0.9" +confy = "2.0.0" +libc = "1.0.0-alpha.1" + +[profile.release] +debug = "none" + +[package.metadata.deb] +section = "utils" +priority = "optional" + +provides = ["docker-update"] +depends = ["$auto"] diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..84ae1bb --- /dev/null +++ b/src/config.rs @@ -0,0 +1,101 @@ +//! Modul für die Konfigurationsverwaltung der Anwendung. +//! +//! Dieses Modul stellt die Funktionalität zum Laden, Speichern und Verwalten +//! der Anwendungskonfiguration bereit. Die Konfiguration wird in einer Datei gespeichert +//! und beim Programmstart automatisch geladen. + +use crate::program; +use crate::sudo::is_run_as_root; +use serde::{Deserialize, Serialize}; +use std::sync::OnceLock; + +/// Hauptkonfigurationsstruktur der Anwendung. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct AppConfig { + /// Allgemeine Einstellungen + pub general: General, +} + +impl Default for AppConfig { + fn default() -> Self { + Self { + general: General::default(), + } + } +} + +/// Allgemeine Konfigurationseinstellungen. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct General { + /// Log-Level für die Anwendung (error, warn, info, debug) + pub log_level: String, + /// Pfad zum Verzeichnis mit den Docker-Applikationen + pub apps_dir: String, +} + +impl Default for General { + fn default() -> Self { + Self { + log_level: "info".to_string(), + apps_dir: "/var/apps".to_string(), + } + } +} + +static CONFIG_NAME: &str = "config"; +static CONFIG: OnceLock = OnceLock::new(); + +/// Gibt die aktuelle Konfiguration zurück. +/// +/// Lädt die Konfiguration beim ersten Aufruf und speichert sie zwischen. +/// Nachfolgende Aufrufe geben die gespeicherte Konfiguration zurück. +pub fn get_config() -> &'static AppConfig { + CONFIG.get_or_init(|| { + load_config() + }) +} + +/// Modifiziert die aktuelle Konfiguration mit der übergebenen Mutator-Funktion. +/// +/// Die Funktion lädt die Konfiguration neu von der Festplatte, wendet die Mutator-Funktion an +/// und speichert die geänderte Konfiguration anschließend wieder. +#[allow(dead_code)] +pub fn modify_config(mutator: F) +where + F: FnOnce(&mut AppConfig), +{ + // Immer frisch von Disk laden, damit Änderungen konsistent sind + let mut cfg = load_config(); + mutator(&mut cfg); + save_config(cfg); +} + +/// Lädt die Konfiguration aus der Konfigurationsdatei. +fn load_config() -> AppConfig { + if is_run_as_root() { + return confy::load_path(format!("/etc/{}/{}.toml", program::program_name(), CONFIG_NAME)).unwrap_or_default(); + } + confy::load(&*program::program_name(), CONFIG_NAME).unwrap_or_default() +} + +/// Speichert die übergebene Konfiguration in der Konfigurationsdatei. +#[allow(dead_code)] +fn save_config(config: AppConfig) { + if is_run_as_root() { + let _ = confy::store_path(format!("/etc/{}/{}.toml", program::program_name(), CONFIG_NAME), config); + return; + } + let _ = confy::store(&*program::program_name(), CONFIG_NAME, config); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let config = AppConfig::default(); + assert_eq!(config.general.apps_dir, "/var/apps"); + assert_eq!(config.general.log_level, "info"); + } +} \ No newline at end of file diff --git a/src/log.rs b/src/log.rs new file mode 100644 index 0000000..f36016a --- /dev/null +++ b/src/log.rs @@ -0,0 +1,272 @@ +//! Einfaches, threadsicheres Logging-Modul. +//! +//! Merkmale: +//! - Ausgabe in Terminal (stdout/stderr) und zusätzlich in eine Logdatei im temporären Verzeichnis. +//! - Nachrichtenvorlage: `[DD.MM.YYYY HH:MM:SS.mmm][LEVEL][TAG]: ` mit Präfix ([!]/[?]/[i]/[d]). +//! - Der aktuell verwendete Schweregradfilter ist statisch (`LOG_LEVEL`) und wird zur Laufzeit nicht geändert. +//! - Terminal-Erkennung und Dateihandle werden lazily initialisiert und zwischengespeichert (`OnceLock`). +//! +//! Hinweise: +//! - Bei einem leeren `tag` wird ein leerer Tag-Abschnitt erzeugt. +//! - Die Logdatei wird unterhalb eines prozessspezifischen Ordners im System-Temp-Verzeichnis abgelegt und nach Datum +//! benannt (z. B. `log-2025-08-18.log`). Es wird im Append-Modus geschrieben. +//! +//! Beispiel (ohne Ausführung in Doctests): +//! ```rust,no_run +//! use crate::log::{log, LogLevel}; +//! +//! log("startup", "Dienst wird initialisiert...", LogLevel::Info); +//! log("db", "Verbindung unterbrochen!", LogLevel::Warn); +//! log("core", "Unerwarteter Fehler", LogLevel::Error); +//! ``` + +use std::env; +use std::fmt::{Display, Formatter}; +use std::fs::{create_dir_all, File, OpenOptions}; +use std::io::{stdout, IsTerminal, Write}; +use std::sync::{Mutex, OnceLock}; + +use crate::config; +use crate::program; +use time::{macros::format_description, OffsetDateTime}; + +/// Schweregrade für Logeinträge in aufsteigender Detailtiefe. +/// +/// Die Reihenfolge bestimmt den Filter: Nur Einträge mit `log_level <= LOG_LEVEL` werden ausgegeben. +/// +/// Anzeige (Display): +/// - `Error` -> `ERROR` +/// - `Warn` -> `WARN` +/// - `Info` -> `INFO` +/// - `Debug` -> `DEBUG` +#[derive(PartialEq, Eq, PartialOrd, Ord, Copy, Clone)] +pub enum LogLevel { + /// Kritische Fehler, gehen zusätzlich auf `stderr`. + Error = 1, + /// Wichtige Warnungen über potenzielle Probleme. + Warn = 2, + /// Allgemeine Betriebsinformationen. + Info = 3, + /// Ausführliche Diagnoseausgaben für die Entwicklung. + Debug = 4, +} + +impl Display for LogLevel { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + LogLevel::Error => write!(f, "ERROR"), + LogLevel::Warn => write!(f, "WARN"), + LogLevel::Info => write!(f, "INFO"), + LogLevel::Debug => write!(f, "DEBUG"), + } + } +} + +impl TryFrom for LogLevel { + type Error = LogLevel; + + fn try_from(value: String) -> Result { + match value.to_lowercase().as_str() { + "error" => Ok(LogLevel::Error), + "warn" => Ok(LogLevel::Warn), + "info" => Ok(LogLevel::Info), + "debug" => Ok(LogLevel::Debug), + _ => Err(LogLevel::Info) + } + } +} + +/// Zwischenspeicher für die einmalig ermittelte Terminal-Fähigkeit von `stdout`. +static IS_TERMINAL: OnceLock = OnceLock::new(); + +/// Globaler Schweregradfilter für Ausgabe. +static LOG_LEVEL: OnceLock = OnceLock::new(); + +/// Lazy-initialisiertes Handle zur Logdatei; kann `None` sein, falls das Öffnen fehlschlug. +static LOG_FILE: OnceLock>> = OnceLock::new(); + +/// Protokolliert eine Nachricht abhängig vom angegebenen Schweregrad. +/// +/// Verhalten: +/// - Wenn `log_level` größer als der globale Filter ist, wird nichts ausgegeben. +/// - Bei Terminalausgabe gehen `Error`-Meldungen nach `stderr`, alle anderen nach `stdout`. +/// - Zusätzlich wird in eine tägliche Logdatei im Temp-Verzeichnis geschrieben (wenn erfolgreich geöffnet). +/// +/// Parameter: +/// - `tag`: Kurzer Kontext (z. B. Modulname). +/// - `message`: Die eigentliche Nachricht (eine Zeile). +/// - `log_level`: Schweregrad der Nachricht. +/// +/// Thread-Sicherheit: +/// - Dateischreibzugriffe sind über `Mutex` serialisiert. +/// +/// Beispiel: +/// ```rust,no_run +/// # use crate::log::{log, LogLevel}; +/// log("http", "Server gestartet auf Port 8080", LogLevel::Info); +/// ``` +pub fn log(tag: &str, message: &str, log_level: LogLevel) { + if log_level <= get_log_level() { + let message: String = format_message(tag, message, &log_level); + + if is_terminal() { + if log_level == LogLevel::Error { + eprintln!("{}", message) + } else { + println!("{}", message) + } + } + + let file_lock = get_or_init_log_file(); + if let Ok(mut guard) = file_lock.lock() { + if let Some(f) = guard.as_mut() { + let _ = writeln!(f, "{}", message); + let _ = f.flush(); + } + } + } +} + +/// Liefert das einmalig initialisierte Log-Level für die Filterung. +/// +/// Ermittelt das konfigurierte Log-Level aus der Config-Datei. +/// Wenn ungültig oder nicht vorhanden, wird LogLevel::Info als Fallback verwendet. +/// +/// Rückgabe: +/// - `LogLevel`: Das zu verwendende Log-Level als enum-Wert. +fn get_log_level() -> LogLevel { + *LOG_LEVEL.get_or_init(|| { + let log_level = &config::get_config().general.log_level; + LogLevel::try_from(log_level.to_string()).unwrap_or(LogLevel::Info) + } + ) +} + +/// Ermittelt einmalig, ob `stdout` ein Terminal ist, und cached das Ergebnis. +/// +/// Rückgabe: +/// - `true`, wenn `stdout` ein TTY/Terminal ist. +/// - `false` andernfalls. +fn is_terminal() -> bool { + *IS_TERMINAL.get_or_init(|| stdout().is_terminal()) +} + +/// Formatiert eine Lognachricht mit Zeitstempel, Level, Tag und Präfix. +/// +/// Format: +/// - Datum/Zeit lokal (Fallback: UTC) im Format `DD.MM.YYYY HH:MM:SS.mmm`. +/// - Level als Text (`ERROR`, `WARN`, `INFO`, `DEBUG`). +/// - Tag in eckigen Klammern; bei leerem Tag entsteht ein leeres `[]`. +/// - Präfix pro Level: `[!]` (Error), `[?]` (Warn), `[i]` (Info), `[d]` (Debug). +/// +/// Beispielausgabe: +/// - `[i][18.08.2025 14:23:45.012][INFO][init]: Fertig` +/// +/// Hinweis: +/// - Diese Funktion formatiert nur; sie führt keinen I/O aus. +fn format_message(tag: &str, message: &str, log_level: &LogLevel) -> String { + let mut prefix: String = String::new(); + + let now_local: OffsetDateTime = + OffsetDateTime::now_local().unwrap_or(OffsetDateTime::now_utc()); + let fmt = + format_description!("[day].[month].[year] [hour]:[minute]:[second].[subsecond digits:3]"); + + if tag != "" { + prefix = format!("[{}]", tag); + } + + let message = format!( + "[{}][{}]{}: {}", + now_local.format(fmt).unwrap(), + log_level.to_string(), + prefix, + message + ); + + match log_level { + &LogLevel::Error => format!("[!]{}", message), + &LogLevel::Warn => format!("[?]{}", message), + &LogLevel::Info => format!("[i]{}", message), + &LogLevel::Debug => format!("[d]{}", message), + } +} + +/// Initialisiert das Logdatei-Handle beim ersten Aufruf und liefert eine Referenz darauf. +/// +/// Rückgabe: +/// - `&'static Mutex>`: Das Mutex schützt den optionalen Dateihandler. +/// `None` bedeutet, dass das Öffnen fehlgeschlagen ist (z. B. fehlende Rechte). +fn get_or_init_log_file() -> &'static Mutex> { + LOG_FILE.get_or_init(|| { + let file = open_log_file().ok(); + Mutex::new(file) + }) +} + +/// Öffnet (und erstellt bei Bedarf) die tagesbasierte Logdatei im Temp-Verzeichnis. +/// +/// Pfadaufbau: +/// - Basis: `std::env::temp_dir()` +/// - Unterordner: `-` +/// - Datei: `log-YYYY-MM-DD.log` im Append-Modus +/// +/// Rückgabe: +/// - `Ok(File)`, wenn der Ordner erstellt/gefunden und die Datei geöffnet/angelegt werden konnte. +/// - `Err(std::io::Error)`, wenn ein I/O-Fehler auftrat. +/// +/// Fehler: +/// - Gibt I/O-Fehler unverändert weiter (z. B. beim Erstellen des Ordners oder Öffnen der Datei). +fn open_log_file() -> std::io::Result { + let program = program::program_name(); + let rand = "13692bbf-a93b-43e9-9cc6-f05f94a8cfb6"; + + let mut dir = env::temp_dir(); + dir.push(format!("{}-{}", program, rand)); + create_dir_all(&dir)?; + + let today_fmt = format_description!("[year]-[month]-[day]"); + let date_str = OffsetDateTime::now_local() + .unwrap_or(OffsetDateTime::now_utc()) + .format(today_fmt) + .unwrap_or_else(|_| "0000-00-00".to_string()); + + let file_name = format!("log-{}.log", date_str); + dir.push(file_name); + + OpenOptions::new() + .create(true) + .append(true) + .open(dir) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_message_prefixes() { + let msg_info = format_message("test", "Info message", &LogLevel::Info); + assert!(msg_info.starts_with("[i]")); + assert!(msg_info.contains("[INFO][test]: Info message")); + + let msg_err = format_message("test", "Error message", &LogLevel::Error); + assert!(msg_err.starts_with("[!]")); + assert!(msg_err.contains("[ERROR][test]: Error message")); + + let msg_warn = format_message("test", "Warn message", &LogLevel::Warn); + assert!(msg_warn.starts_with("[?]")); + assert!(msg_warn.contains("[WARN][test]: Warn message")); + + let msg_debug = format_message("test", "Debug message", &LogLevel::Debug); + assert!(msg_debug.starts_with("[d]")); + assert!(msg_debug.contains("[DEBUG][test]: Debug message")); + } + + #[test] + fn test_format_message_empty_tag() { + let msg = format_message("", "No tag message", &LogLevel::Info); + assert!(msg.starts_with("[i]")); + assert!(msg.contains("[INFO]: No tag message")); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..debdcd3 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,19 @@ +mod program; +mod sudo; +mod log; +mod config; +mod updater; + +use crate::log::{log, LogLevel}; + +fn main() { + if !sudo::is_run_as_root() { + log("auth", "Root-Rechte erforderlich. Starte mit sudo neu...", LogLevel::Info); + sudo::run_as_root(); + return; + } + + log("main", "docker-update gestartet.", LogLevel::Info); + updater::run_updates(); + log("main", "docker-update beendet.", LogLevel::Info); +} \ No newline at end of file diff --git a/src/program.rs b/src/program.rs new file mode 100644 index 0000000..8847199 --- /dev/null +++ b/src/program.rs @@ -0,0 +1,14 @@ +use std::env; + +/// Liefert den Programmnamen (Dateistamm der aktuellen ausführbaren Datei). +/// +/// Rückgabe: +/// - Dateistamm der aktuellen Executable als `String`. +/// - Fallback `"app"`, wenn der Name nicht ermittelt werden kann. +pub(crate) fn program_name() -> String { + env::current_exe() + .ok() + .and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string())) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "app".to_string()) +} diff --git a/src/sudo.rs b/src/sudo.rs new file mode 100644 index 0000000..fd0ef65 --- /dev/null +++ b/src/sudo.rs @@ -0,0 +1,20 @@ +use std::env; +use std::os::unix::process::CommandExt; +use std::process::Command; + +/// Prüft, ob das Programm mit Root-/Administrator-Rechten ausgeführt wird. +/// +/// # Returns +/// +/// * `true` - Das Programm läuft mit erhöhten Rechten +/// * `false` - Das Programm läuft mit normalen Benutzerrechten +pub fn is_run_as_root() -> bool { + unsafe { libc::geteuid() == 0 } +} +/// Startet das Programm mit Root-Rechten über `sudo` neu. +/// +/// Diese Funktion versucht das Programm mit erhöhten Rechten neu zu starten. +pub fn run_as_root() { + let commandline_args: Vec = env::args().collect(); + let _output = Command::new("sudo").args(&commandline_args).exec(); // Bye bye never returns +} diff --git a/src/updater.rs b/src/updater.rs new file mode 100644 index 0000000..24b24d4 --- /dev/null +++ b/src/updater.rs @@ -0,0 +1,326 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use serde::Deserialize; +use std::collections::HashMap; +use crate::log::{log, LogLevel}; +use crate::config; + +const COMPOSE_FILENAMES: &[&str] = &[ + "docker-compose.yaml", + "docker-compose.yml", + "compose.yaml", + "compose.yml", +]; + +#[derive(Debug, Deserialize)] +struct ComposeFile { + #[serde(default)] + services: HashMap, +} + +#[derive(Debug, Deserialize)] +struct ServiceConfig { + #[serde(default)] + image: Option, +} + +/// Prüft, ob ein Image-Tag auf `:latest` zeigt (oder implizit `:latest` ist, weil kein Tag angegeben wurde). +pub fn is_latest_image(image: &str) -> bool { + let image = image.trim(); + if image.is_empty() { + return false; + } + // Wenn das Image an einen Digest gepinnt ist (z. B. image@sha256:...), ist es kein dynamisches latest-Tag. + if image.contains('@') { + return false; + } + // Den Tag-Teil nach dem letzten Schrägstrich ermitteln (um Host:Port/Repo nicht fälschlich als Tag zu werten) + let repo_and_tag = image.rsplit('/').next().unwrap_or(image); + if let Some((_, tag)) = repo_and_tag.split_once(':') { + tag.eq_ignore_ascii_case("latest") + } else { + // Kein Tag angegeben bedeutet bei Docker implizit `:latest` + true + } +} + +/// Sucht in einem Verzeichnis nach einer gültigen Docker-Compose-Datei. +pub fn find_compose_file(dir: &Path) -> Option { + for filename in COMPOSE_FILENAMES { + let path = dir.join(filename); + if path.is_file() { + return Some(path); + } + } + None +} + +/// Liest eine Compose-Datei ein und liefert eine Liste von (Service-Name, Image-Name). +pub fn parse_compose_services(compose_path: &Path) -> Result, String> { + let content = fs::read_to_string(compose_path) + .map_err(|e| format!("Fehler beim Lesen der Datei '{}': {}", compose_path.display(), e))?; + + let compose: ComposeFile = serde_yaml::from_str(&content) + .map_err(|e| format!("Fehler beim Parsen der YAML-Datei '{}': {}", compose_path.display(), e))?; + + let mut services = Vec::new(); + for (service_name, config) in compose.services { + if let Some(image) = config.image { + let image = image.trim().to_string(); + if !image.is_empty() { + services.push((service_name, image)); + } + } + } + + Ok(services) +} + +/// Führt einen Docker-Befehl aus und liefert die Ausgabe. +fn run_docker_command(args: &[&str]) -> std::io::Result { + Command::new("docker").args(args).output() +} + +/// Führt einen Docker-Compose-Befehl im angegebenen Verzeichnis aus. +/// Versucht zuerst `docker compose` und fällt bei Bedarf auf `docker-compose` zurück. +pub fn run_compose_command(dir: &Path, args: &[&str]) -> std::io::Result { + let mut compose_args = vec!["compose"]; + compose_args.extend_from_slice(args); + + let output = Command::new("docker") + .current_dir(dir) + .args(&compose_args) + .output(); + + match output { + Ok(out) if out.status.success() || out.status.code() != Some(127) => Ok(out), + _ => { + // Fallback auf docker-compose (v1) + Command::new("docker-compose") + .current_dir(dir) + .args(args) + .output() + } + } +} + +/// Ermittelt die lokale Image-ID für ein gegebenes Docker-Image. +pub fn get_local_image_id(image: &str) -> Option { + let output = run_docker_command(&["image", "inspect", "--format", "{{.Id}}", image]).ok()?; + if output.status.success() { + let id = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !id.is_empty() { + return Some(id); + } + } + None +} + +/// Zieht ein Docker-Image herunter und gibt zurück, ob ein neueres Image heruntergeladen wurde. +pub fn pull_image(image: &str) -> Result { + let old_id = get_local_image_id(image); + + let output = run_docker_command(&["pull", image]) + .map_err(|e| format!("Fehler beim Ausführen von 'docker pull {}': {}", image, e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(format!("'docker pull {}' fehlgeschlagen: {}", image, stderr)); + } + + let stdout_str = String::from_utf8_lossy(&output.stdout); + let new_id = get_local_image_id(image); + + let updated = match (&old_id, &new_id) { + (Some(old), Some(new)) => old != new, + (None, Some(_)) => true, + _ => stdout_str.contains("Downloaded newer image") || stdout_str.contains("Pull complete"), + }; + + Ok(updated) +} + +/// Aktualisiert und startet die Docker-Compose-Services im angegebenen Verzeichnis neu. +pub fn restart_compose_service(dir: &Path, service_name: Option<&str>) -> Result<(), String> { + let mut args = vec!["up", "-d"]; + if let Some(service) = service_name { + args.push(service); + } + + let output = run_compose_command(dir, &args) + .map_err(|e| format!("Fehler beim Ausführen von 'docker compose up -d': {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(format!("Neustart fehlgeschlagen: {}", stderr)); + } + + Ok(()) +} + +/// Prüft alle Unterverzeichnisse im konfigurierten Apps-Verzeichnis auf Aktualisierungen. +pub fn run_updates() { + let config = config::get_config(); + let apps_dir_path = Path::new(&config.general.apps_dir); + + log("scanner", &format!("Überprüfe Anwendungsverzeichnis: {}", apps_dir_path.display()), LogLevel::Info); + + if !apps_dir_path.exists() { + log("scanner", &format!("Verzeichnis '{}' existiert nicht.", apps_dir_path.display()), LogLevel::Warn); + return; + } + + let entries = match fs::read_dir(apps_dir_path) { + Ok(e) => e, + Err(e) => { + log("scanner", &format!("Fehler beim Lesen von '{}': {}", apps_dir_path.display(), e), LogLevel::Error); + return; + } + }; + + let mut app_dirs: Vec = entries + .filter_map(|res| res.ok().map(|e| e.path())) + .filter(|p| p.is_dir()) + .collect(); + + app_dirs.sort(); + + if app_dirs.is_empty() { + log("scanner", &format!("Keine Unterverzeichnisse in '{}' gefunden.", apps_dir_path.display()), LogLevel::Info); + return; + } + + for app_dir in app_dirs { + let app_name = app_dir.file_name().unwrap_or_default().to_string_lossy(); + log("scanner", &format!("Untersuche Anwendung '{}'...", app_name), LogLevel::Info); + + let compose_file = match find_compose_file(&app_dir) { + Some(f) => f, + None => { + log("scanner", &format!("Keine Docker-Compose-Datei in '{}' gefunden. Überspringe.", app_dir.display()), LogLevel::Debug); + continue; + } + }; + + log("compose", &format!("Gefundene Compose-Datei: {}", compose_file.display()), LogLevel::Debug); + + let services = match parse_compose_services(&compose_file) { + Ok(s) => s, + Err(e) => { + log("compose", &format!("Fehler beim Analysieren von '{}': {}", compose_file.display(), e), LogLevel::Warn); + continue; + } + }; + + let mut updated_any = false; + + for (service_name, image) in services { + if !is_latest_image(&image) { + log("updater", &format!("Service '{}' verwendet kein :latest-Image ('{}'). Überspringe.", service_name, image), LogLevel::Debug); + continue; + } + + log("updater", &format!("Prüfe auf neues Image für Service '{}' ('{}')...", service_name, image), LogLevel::Info); + + match pull_image(&image) { + Ok(true) => { + log("updater", &format!("Neueres Image für Service '{}' ('{}') gefunden und heruntergeladen.", service_name, image), LogLevel::Info); + updated_any = true; + } + Ok(false) => { + log("updater", &format!("Image für Service '{}' ('{}') ist bereits aktuell.", service_name, image), LogLevel::Info); + } + Err(e) => { + log("updater", &format!("Fehler beim Aktualisieren von Image '{}': {}", image, e), LogLevel::Error); + } + } + } + + if updated_any { + log("updater", &format!("Starte Anwendung '{}' neu mit aktualisierten Images...", app_name), LogLevel::Info); + match restart_compose_service(&app_dir, None) { + Ok(_) => { + log("updater", &format!("Anwendung '{}' erfolgreich aktualisiert und neu gestartet.", app_name), LogLevel::Info); + } + Err(e) => { + log("updater", &format!("Fehler beim Neustarten der Anwendung '{}': {}", app_name, e), LogLevel::Error); + } + } + } + } + + log("scanner", "Aktualisierungsvorgang abgeschlossen.", LogLevel::Info); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::File; + use std::io::Write; + + #[test] + fn test_is_latest_image() { + assert!(is_latest_image("nginx")); + assert!(is_latest_image("nginx:latest")); + assert!(is_latest_image("nginx:LATEST")); + assert!(is_latest_image("gitea/gitea")); + assert!(is_latest_image("gitea/gitea:latest")); + assert!(is_latest_image("localhost:5000/my-app")); + assert!(is_latest_image("localhost:5000/my-app:latest")); + + assert!(!is_latest_image("nginx:alpine")); + assert!(!is_latest_image("gitea/gitea:1.20")); + assert!(!is_latest_image("postgres:15-alpine")); + assert!(!is_latest_image("localhost:5000/my-app:v1.0")); + assert!(!is_latest_image("nginx@sha256:1234567890abcdef")); + assert!(!is_latest_image("")); + } + + #[test] + fn test_parse_compose_services() { + let temp_dir = std::env::temp_dir().join("docker_update_test_compose"); + let _ = fs::create_dir_all(&temp_dir); + let compose_path = temp_dir.join("docker-compose.yaml"); + + let yaml_content = r#" +services: + gitea: + image: gitea/gitea:latest + ports: + - "3000:3000" + db: + image: postgres:15 + environment: + POSTGRES_DB: gitea +"#; + let mut file = File::create(&compose_path).unwrap(); + file.write_all(yaml_content.as_bytes()).unwrap(); + + let services = parse_compose_services(&compose_path).unwrap(); + assert_eq!(services.len(), 2); + + let gitea_svc = services.iter().find(|(s, _)| s == "gitea").unwrap(); + assert_eq!(gitea_svc.1, "gitea/gitea:latest"); + + let db_svc = services.iter().find(|(s, _)| s == "db").unwrap(); + assert_eq!(db_svc.1, "postgres:15"); + + let _ = fs::remove_dir_all(&temp_dir); + } + + #[test] + fn test_find_compose_file() { + let temp_dir = std::env::temp_dir().join("docker_update_test_find"); + let _ = fs::create_dir_all(&temp_dir); + + assert_eq!(find_compose_file(&temp_dir), None); + + let compose_path = temp_dir.join("docker-compose.yaml"); + File::create(&compose_path).unwrap(); + + assert_eq!(find_compose_file(&temp_dir), Some(compose_path)); + + let _ = fs::remove_dir_all(&temp_dir); + } +}