mirror of
https://github.com/telemt/telemt.git
synced 2026-06-24 11:51:10 +03:00
Add bounded file logging rotation and retention #832
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
@@ -14,6 +14,7 @@ This document lists all configuration keys accepted by `config.toml`.
|
||||
|
||||
# Table of contents
|
||||
- [Top-level keys](#top-level-keys)
|
||||
- [logging](#logging)
|
||||
- [general](#general)
|
||||
- [general.modes](#generalmodes)
|
||||
- [general.links](#generallinks)
|
||||
@@ -35,6 +36,7 @@ This document lists all configuration keys accepted by `config.toml`.
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`include`](#include) | `String` (special directive) | — | `✔` |
|
||||
| [`show_link`](#show_link) | `"*"` or `String[]` | `[]` (`ShowLink::None`) | `✘` |
|
||||
| [`logging`](#logging) | Table | default values | `✘` |
|
||||
| [`dc_overrides`](#dc_overrides) | `Map<String, String or String[]>` | `{}` | `✘` |
|
||||
| [`default_dc`](#default_dc) | `u8` | — (effective fallback: `2` in ME routing) | `✘` |
|
||||
| [`beobachten`](#beobachten) | `bool` | `true` | `✘` |
|
||||
@@ -83,6 +85,84 @@ This document lists all configuration keys accepted by `config.toml`.
|
||||
default_dc = 2
|
||||
```
|
||||
|
||||
# [logging]
|
||||
|
||||
| Key | Type | Default | Hot-Reload |
|
||||
| --- | ---- | ------- | ---------- |
|
||||
| [`destination`](#loggingdestination) | `"stderr"` / `"syslog"` / `"file"` | `"stderr"` | `✘` |
|
||||
| [`path`](#loggingpath) | `String` | — | `✘` |
|
||||
| [`rotation`](#loggingrotation) | `"never"` / `"minutely"` / `"hourly"` / `"daily"` / `"weekly"` | `"never"` | `✘` |
|
||||
| [`max_size_bytes`](#loggingmax_size_bytes) | `u64` | `0` | `✘` |
|
||||
| [`max_files`](#loggingmax_files) | `usize` | `0` | `✘` |
|
||||
| [`max_age_secs`](#loggingmax_age_secs) | `u64` | `0` | `✘` |
|
||||
|
||||
## logging.destination
|
||||
- **Constraints / validation**: Must be `stderr`, `syslog`, or `file`. `syslog` is supported only on Unix platforms. `file` requires `logging.path`.
|
||||
- **Description**: Selects the runtime log destination. CLI flags override this value.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[logging]
|
||||
destination = "file"
|
||||
path = "/var/log/telemt.log"
|
||||
```
|
||||
## logging.path
|
||||
- **Constraints / validation**: Required when `logging.destination = "file"`; must not be empty.
|
||||
- **Description**: File path used for file logging. With time rotation, the file name is used as the rolling prefix.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[logging]
|
||||
destination = "file"
|
||||
path = "/var/log/telemt.log"
|
||||
```
|
||||
## logging.rotation
|
||||
- **Constraints / validation**: Must be `never`, `minutely`, `hourly`, `daily`, or `weekly`.
|
||||
- **Description**: Time-based file rotation interval. `weekly` rotates at the Sunday UTC boundary. `never` writes to the exact `logging.path` unless size rotation is enabled.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[logging]
|
||||
destination = "file"
|
||||
path = "/var/log/telemt.log"
|
||||
rotation = "daily"
|
||||
```
|
||||
## logging.max_size_bytes
|
||||
- **Constraints / validation**: `0` disables size rotation.
|
||||
- **Description**: Rotates file logs before writing the next record when the active file is non-empty and that record would exceed this byte limit. Records are written whole and are not split.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[logging]
|
||||
destination = "file"
|
||||
path = "/var/log/telemt.log"
|
||||
max_size_bytes = 104857600
|
||||
```
|
||||
## logging.max_files
|
||||
- **Constraints / validation**: `0` disables count-based retention.
|
||||
- **Description**: Keeps at most this many matching file logs, counting the active file and rotated archives. The active file is never deleted by retention cleanup.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[logging]
|
||||
destination = "file"
|
||||
path = "/var/log/telemt.log"
|
||||
rotation = "daily"
|
||||
max_files = 14
|
||||
```
|
||||
## logging.max_age_secs
|
||||
- **Constraints / validation**: `0` disables age-based retention.
|
||||
- **Description**: Removes rotated file logs older than this many seconds based on file modification time. The active file is never deleted by retention cleanup.
|
||||
- **Example**:
|
||||
|
||||
```toml
|
||||
[logging]
|
||||
destination = "file"
|
||||
path = "/var/log/telemt.log"
|
||||
rotation = "daily"
|
||||
max_age_secs = 1209600
|
||||
```
|
||||
|
||||
# [general]
|
||||
|
||||
|
||||
|
||||
+118
-1
@@ -114,6 +114,7 @@ fn normalize_exclusive_mask_target(target: &str, field: &str) -> Result<String>
|
||||
|
||||
const TOP_LEVEL_CONFIG_KEYS: &[&str] = &[
|
||||
"general",
|
||||
"logging",
|
||||
"network",
|
||||
"server",
|
||||
"timeouts",
|
||||
@@ -459,6 +460,14 @@ const UPSTREAM_CONFIG_KEYS: &[&str] = &[
|
||||
const PROXY_MODES_CONFIG_KEYS: &[&str] = &["classic", "secure", "tls"];
|
||||
const TELEMETRY_CONFIG_KEYS: &[&str] = &["core_enabled", "user_enabled", "me_level"];
|
||||
const LINKS_CONFIG_KEYS: &[&str] = &["show", "public_host", "public_port"];
|
||||
const LOGGING_CONFIG_KEYS: &[&str] = &[
|
||||
"destination",
|
||||
"path",
|
||||
"rotation",
|
||||
"max_size_bytes",
|
||||
"max_files",
|
||||
"max_age_secs",
|
||||
];
|
||||
|
||||
#[derive(Debug)]
|
||||
struct UnknownConfigKey {
|
||||
@@ -500,6 +509,7 @@ fn known_config_keys_for_suggestion() -> Vec<&'static str> {
|
||||
PROXY_MODES_CONFIG_KEYS,
|
||||
TELEMETRY_CONFIG_KEYS,
|
||||
LINKS_CONFIG_KEYS,
|
||||
LOGGING_CONFIG_KEYS,
|
||||
] {
|
||||
keys.extend_from_slice(group);
|
||||
}
|
||||
@@ -633,6 +643,13 @@ fn collect_unknown_config_keys(parsed_toml: &toml::Value) -> Vec<UnknownConfigKe
|
||||
&["general", "links"],
|
||||
LINKS_CONFIG_KEYS,
|
||||
);
|
||||
check_known_table(
|
||||
parsed_toml,
|
||||
&mut unknown,
|
||||
&known_for_suggestion,
|
||||
&["logging"],
|
||||
LOGGING_CONFIG_KEYS,
|
||||
);
|
||||
check_known_table(
|
||||
parsed_toml,
|
||||
&mut unknown,
|
||||
@@ -998,6 +1015,24 @@ fn sanitize_ad_tag(ad_tag: &mut Option<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_logging_config(logging: &LoggingConfig) -> Result<()> {
|
||||
if let Some(path) = logging.path.as_ref()
|
||||
&& path.trim().is_empty()
|
||||
{
|
||||
return Err(ProxyError::Config(
|
||||
"logging.path cannot be empty when provided".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if matches!(logging.destination, LoggingDestination::File) && logging.path.is_none() {
|
||||
return Err(ProxyError::Config(
|
||||
"logging.path must be set when logging.destination=\"file\"".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_upstreams(config: &ProxyConfig) -> Result<()> {
|
||||
let has_enabled_shadowsocks = config.upstreams.iter().any(|upstream| {
|
||||
upstream.enabled && matches!(upstream.upstream_type, UpstreamType::Shadowsocks { .. })
|
||||
@@ -1058,13 +1093,17 @@ fn normalize_upstream_family_policy(config: &mut ProxyConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
// ============= Main Config =============
|
||||
// Main runtime configuration loaded from TOML.
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ProxyConfig {
|
||||
#[serde(default)]
|
||||
pub general: GeneralConfig,
|
||||
|
||||
/// Runtime logging destination, rotation, and retention configuration.
|
||||
#[serde(default)]
|
||||
pub logging: LoggingConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub network: NetworkConfig,
|
||||
|
||||
@@ -2288,6 +2327,7 @@ impl ProxyConfig {
|
||||
.entry("203".to_string())
|
||||
.or_insert_with(|| vec!["91.105.192.100:443".to_string()]);
|
||||
|
||||
validate_logging_config(&config.logging)?;
|
||||
validate_upstreams(&config)?;
|
||||
config.rebuild_runtime_user_auth()?;
|
||||
|
||||
@@ -2313,6 +2353,8 @@ impl ProxyConfig {
|
||||
return Err(ProxyError::Config("No users configured".to_string()));
|
||||
}
|
||||
|
||||
validate_logging_config(&self.logging)?;
|
||||
|
||||
if !self.general.modes.classic && !self.general.modes.secure && !self.general.modes.tls {
|
||||
return Err(ProxyError::Config("No modes enabled".to_string()));
|
||||
}
|
||||
@@ -2409,6 +2451,21 @@ mod tests {
|
||||
cfg
|
||||
}
|
||||
|
||||
fn load_config_error_from_temp_toml(toml: &str) -> String {
|
||||
let nonce = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("telemt_load_cfg_error_{nonce}"));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("config.toml");
|
||||
std::fs::write(&path, toml).unwrap();
|
||||
let error = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||
let _ = std::fs::remove_file(path);
|
||||
let _ = std::fs::remove_dir(dir);
|
||||
error
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_defaults_remain_unchanged_for_present_sections() {
|
||||
let toml = r#"
|
||||
@@ -2419,6 +2476,7 @@ mod tests {
|
||||
"#;
|
||||
let cfg: ProxyConfig = toml::from_str(toml).unwrap();
|
||||
|
||||
assert_eq!(cfg.logging, LoggingConfig::default());
|
||||
assert_eq!(cfg.network.ipv6, default_network_ipv6());
|
||||
assert_eq!(cfg.network.stun_use, default_true());
|
||||
assert_eq!(cfg.network.stun_tcp_fallback, default_stun_tcp_fallback());
|
||||
@@ -2586,6 +2644,65 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logging_config_is_loaded_from_strict_config() {
|
||||
let cfg = load_config_from_temp_toml(
|
||||
r#"
|
||||
[general]
|
||||
config_strict = true
|
||||
|
||||
[general.modes]
|
||||
classic = false
|
||||
secure = false
|
||||
tls = true
|
||||
|
||||
[logging]
|
||||
destination = "file"
|
||||
path = "/tmp/telemt.log"
|
||||
rotation = "daily"
|
||||
max_size_bytes = 1024
|
||||
max_files = 3
|
||||
max_age_secs = 60
|
||||
|
||||
[censorship]
|
||||
tls_domain = "example.com"
|
||||
|
||||
[access.users]
|
||||
user = "00000000000000000000000000000000"
|
||||
"#,
|
||||
);
|
||||
|
||||
assert_eq!(cfg.logging.destination, LoggingDestination::File);
|
||||
assert_eq!(cfg.logging.path.as_deref(), Some("/tmp/telemt.log"));
|
||||
assert_eq!(cfg.logging.rotation, LogRotation::Daily);
|
||||
assert_eq!(cfg.logging.max_size_bytes, 1024);
|
||||
assert_eq!(cfg.logging.max_files, 3);
|
||||
assert_eq!(cfg.logging.max_age_secs, 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_logging_requires_path() {
|
||||
let error = load_config_error_from_temp_toml(
|
||||
r#"
|
||||
[general.modes]
|
||||
classic = false
|
||||
secure = false
|
||||
tls = true
|
||||
|
||||
[logging]
|
||||
destination = "file"
|
||||
|
||||
[censorship]
|
||||
tls_domain = "example.com"
|
||||
|
||||
[access.users]
|
||||
user = "00000000000000000000000000000000"
|
||||
"#,
|
||||
);
|
||||
|
||||
assert!(error.contains("logging.path must be set"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn impl_defaults_are_sourced_from_default_helpers() {
|
||||
let network = NetworkConfig::default();
|
||||
|
||||
@@ -63,6 +63,86 @@ impl std::fmt::Display for LogLevel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Logging output destination.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum LoggingDestination {
|
||||
/// Write logs to stderr.
|
||||
#[default]
|
||||
Stderr,
|
||||
/// Write logs to syslog on Unix platforms.
|
||||
Syslog,
|
||||
/// Write logs to a file.
|
||||
File,
|
||||
}
|
||||
|
||||
/// Time-based log rotation interval for file logging.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum LogRotation {
|
||||
/// Do not rotate logs by time.
|
||||
#[default]
|
||||
Never,
|
||||
/// Rotate once per minute.
|
||||
Minutely,
|
||||
/// Rotate once per hour.
|
||||
Hourly,
|
||||
/// Rotate once per day.
|
||||
Daily,
|
||||
/// Rotate once per week.
|
||||
Weekly,
|
||||
}
|
||||
|
||||
impl LogRotation {
|
||||
/// Parse a CLI rotation value.
|
||||
pub fn from_cli_arg(value: &str) -> Option<Self> {
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"never" | "none" | "off" => Some(Self::Never),
|
||||
"minutely" | "minute" => Some(Self::Minutely),
|
||||
"hourly" | "hour" => Some(Self::Hourly),
|
||||
"daily" | "day" => Some(Self::Daily),
|
||||
"weekly" | "week" => Some(Self::Weekly),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// File logging and retention settings.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LoggingConfig {
|
||||
/// Effective logging destination.
|
||||
#[serde(default)]
|
||||
pub destination: LoggingDestination,
|
||||
/// File path used when `destination = "file"`.
|
||||
#[serde(default)]
|
||||
pub path: Option<String>,
|
||||
/// Time rotation interval for file logs.
|
||||
#[serde(default)]
|
||||
pub rotation: LogRotation,
|
||||
/// Maximum active log file size before rotating. `0` disables size rotation.
|
||||
#[serde(default)]
|
||||
pub max_size_bytes: u64,
|
||||
/// Maximum number of matching log files to keep. `0` disables count retention.
|
||||
#[serde(default)]
|
||||
pub max_files: usize,
|
||||
/// Maximum age for rotated log files in seconds. `0` disables age retention.
|
||||
#[serde(default)]
|
||||
pub max_age_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for LoggingConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
destination: LoggingDestination::Stderr,
|
||||
path: None,
|
||||
rotation: LogRotation::Never,
|
||||
max_size_bytes: 0,
|
||||
max_files: 0,
|
||||
max_age_secs: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Middle-End telemetry verbosity level.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
|
||||
+222
-101
@@ -5,16 +5,41 @@
|
||||
//! - syslog (Unix only, for traditional init systems)
|
||||
//! - file (with optional rotation)
|
||||
|
||||
#![allow(dead_code)] // Infrastructure module - used via CLI flags
|
||||
// Infrastructure module used via CLI flags.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::config::{LogRotation, LoggingConfig, LoggingDestination};
|
||||
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
use tracing_subscriber::{EnvFilter, fmt, reload};
|
||||
|
||||
// Submodules:
|
||||
// - file: bounded file appender for size and retention controls.
|
||||
mod file;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// File logging and retention options resolved from config and CLI.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FileLogOptions {
|
||||
/// Log file path or rolling filename prefix path.
|
||||
pub path: String,
|
||||
/// Time rotation interval.
|
||||
pub rotation: LogRotation,
|
||||
/// Maximum active file size before size rotation. `0` disables it.
|
||||
pub max_size_bytes: u64,
|
||||
/// Maximum number of matching log files to keep. `0` disables it.
|
||||
pub max_files: usize,
|
||||
/// Maximum rotated file age in seconds. `0` disables it.
|
||||
pub max_age_secs: u64,
|
||||
}
|
||||
|
||||
/// Log destination configuration.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub enum LogDestination {
|
||||
/// Log to stderr (default, captured by systemd journald).
|
||||
#[default]
|
||||
@@ -24,12 +49,29 @@ pub enum LogDestination {
|
||||
Syslog,
|
||||
/// Log to a file with optional rotation.
|
||||
File {
|
||||
path: String,
|
||||
/// Rotate daily if true.
|
||||
rotate_daily: bool,
|
||||
/// Resolved file logging options.
|
||||
options: FileLogOptions,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum LogCliDestination {
|
||||
Stderr,
|
||||
Syslog,
|
||||
File,
|
||||
}
|
||||
|
||||
/// Logging-related CLI overrides.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct LogCliOptions {
|
||||
destination: Option<LogCliDestination>,
|
||||
path: Option<String>,
|
||||
rotation: Option<LogRotation>,
|
||||
max_size_bytes: Option<u64>,
|
||||
max_files: Option<usize>,
|
||||
max_age_secs: Option<u64>,
|
||||
}
|
||||
|
||||
/// Logging options parsed from CLI/config.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LoggingOptions {
|
||||
@@ -101,23 +143,29 @@ pub fn init_logging(
|
||||
(filter_handle, LoggingGuard::noop())
|
||||
}
|
||||
|
||||
LogDestination::File { path, rotate_daily } => {
|
||||
let (non_blocking, guard) = if *rotate_daily {
|
||||
// Extract directory and filename prefix
|
||||
let path = Path::new(path);
|
||||
let dir = path.parent().unwrap_or(Path::new("/var/log"));
|
||||
let prefix = path
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("telemt");
|
||||
|
||||
let file_appender = tracing_appender::rolling::daily(dir, prefix);
|
||||
LogDestination::File { options } => {
|
||||
let (non_blocking, guard) = if options.max_size_bytes > 0
|
||||
|| options.max_files > 0
|
||||
|| options.max_age_secs > 0
|
||||
{
|
||||
let file_appender = file::BoundedFileAppender::new(options.clone())
|
||||
.expect("Failed to open log file");
|
||||
tracing_appender::non_blocking(file_appender)
|
||||
} else if !matches!(options.rotation, LogRotation::Never) {
|
||||
let path = Path::new(&options.path);
|
||||
let dir = log_file_dir(path);
|
||||
let prefix = log_file_name(path);
|
||||
let file_appender = tracing_appender::rolling::RollingFileAppender::builder()
|
||||
.rotation(to_tracing_rotation(options.rotation))
|
||||
.filename_prefix(prefix)
|
||||
.build(dir)
|
||||
.expect("Failed to open log file");
|
||||
tracing_appender::non_blocking(file_appender)
|
||||
} else {
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
.open(&options.path)
|
||||
.expect("Failed to open log file");
|
||||
tracing_appender::non_blocking(file)
|
||||
};
|
||||
@@ -137,6 +185,28 @@ pub fn init_logging(
|
||||
}
|
||||
}
|
||||
|
||||
fn log_file_dir(path: &Path) -> &Path {
|
||||
path.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
}
|
||||
|
||||
fn log_file_name(path: &Path) -> &str {
|
||||
path.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("telemt")
|
||||
}
|
||||
|
||||
fn to_tracing_rotation(rotation: LogRotation) -> tracing_appender::rolling::Rotation {
|
||||
match rotation {
|
||||
LogRotation::Never => tracing_appender::rolling::Rotation::NEVER,
|
||||
LogRotation::Minutely => tracing_appender::rolling::Rotation::MINUTELY,
|
||||
LogRotation::Hourly => tracing_appender::rolling::Rotation::HOURLY,
|
||||
LogRotation::Daily => tracing_appender::rolling::Rotation::DAILY,
|
||||
LogRotation::Weekly => tracing_appender::rolling::Rotation::WEEKLY,
|
||||
}
|
||||
}
|
||||
|
||||
/// Syslog writer for tracing.
|
||||
#[cfg(unix)]
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -223,121 +293,172 @@ impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for SyslogMakeWriter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse log destination from CLI arguments.
|
||||
pub fn parse_log_destination(args: &[String]) -> LogDestination {
|
||||
/// Parse logging overrides from CLI arguments.
|
||||
pub fn parse_log_cli_options(args: &[String]) -> Result<LogCliOptions, String> {
|
||||
let mut options = LogCliOptions::default();
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
#[cfg(unix)]
|
||||
"--syslog" => {
|
||||
return LogDestination::Syslog;
|
||||
options.destination = Some(LogCliDestination::Syslog);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
"--syslog" => {
|
||||
options.destination = Some(LogCliDestination::Syslog);
|
||||
}
|
||||
"--log-file" => {
|
||||
i += 1;
|
||||
if i < args.len() {
|
||||
return LogDestination::File {
|
||||
path: args[i].clone(),
|
||||
rotate_daily: false,
|
||||
};
|
||||
options.destination = Some(LogCliDestination::File);
|
||||
options.path = Some(args[i].clone());
|
||||
} else {
|
||||
return Err("Missing value for --log-file".to_string());
|
||||
}
|
||||
}
|
||||
s if s.starts_with("--log-file=") => {
|
||||
return LogDestination::File {
|
||||
path: s.trim_start_matches("--log-file=").to_string(),
|
||||
rotate_daily: false,
|
||||
};
|
||||
options.destination = Some(LogCliDestination::File);
|
||||
options.path = Some(s.trim_start_matches("--log-file=").to_string());
|
||||
}
|
||||
"--log-file-daily" => {
|
||||
i += 1;
|
||||
if i < args.len() {
|
||||
return LogDestination::File {
|
||||
path: args[i].clone(),
|
||||
rotate_daily: true,
|
||||
};
|
||||
options.destination = Some(LogCliDestination::File);
|
||||
options.path = Some(args[i].clone());
|
||||
options.rotation = Some(LogRotation::Daily);
|
||||
} else {
|
||||
return Err("Missing value for --log-file-daily".to_string());
|
||||
}
|
||||
}
|
||||
s if s.starts_with("--log-file-daily=") => {
|
||||
return LogDestination::File {
|
||||
path: s.trim_start_matches("--log-file-daily=").to_string(),
|
||||
rotate_daily: true,
|
||||
};
|
||||
options.destination = Some(LogCliDestination::File);
|
||||
options.path = Some(s.trim_start_matches("--log-file-daily=").to_string());
|
||||
options.rotation = Some(LogRotation::Daily);
|
||||
}
|
||||
"--log-rotation" => {
|
||||
i += 1;
|
||||
if i < args.len() {
|
||||
options.rotation = Some(parse_rotation_cli_value(&args[i])?);
|
||||
} else {
|
||||
return Err("Missing value for --log-rotation".to_string());
|
||||
}
|
||||
}
|
||||
s if s.starts_with("--log-rotation=") => {
|
||||
options.rotation = Some(parse_rotation_cli_value(
|
||||
s.trim_start_matches("--log-rotation="),
|
||||
)?);
|
||||
}
|
||||
"--log-max-size-bytes" => {
|
||||
i += 1;
|
||||
if i < args.len() {
|
||||
options.max_size_bytes =
|
||||
Some(parse_u64_cli_value("--log-max-size-bytes", &args[i])?);
|
||||
} else {
|
||||
return Err("Missing value for --log-max-size-bytes".to_string());
|
||||
}
|
||||
}
|
||||
s if s.starts_with("--log-max-size-bytes=") => {
|
||||
options.max_size_bytes = Some(parse_u64_cli_value(
|
||||
"--log-max-size-bytes",
|
||||
s.trim_start_matches("--log-max-size-bytes="),
|
||||
)?);
|
||||
}
|
||||
"--log-max-files" => {
|
||||
i += 1;
|
||||
if i < args.len() {
|
||||
options.max_files = Some(parse_usize_cli_value("--log-max-files", &args[i])?);
|
||||
} else {
|
||||
return Err("Missing value for --log-max-files".to_string());
|
||||
}
|
||||
}
|
||||
s if s.starts_with("--log-max-files=") => {
|
||||
options.max_files = Some(parse_usize_cli_value(
|
||||
"--log-max-files",
|
||||
s.trim_start_matches("--log-max-files="),
|
||||
)?);
|
||||
}
|
||||
"--log-max-age-secs" => {
|
||||
i += 1;
|
||||
if i < args.len() {
|
||||
options.max_age_secs =
|
||||
Some(parse_u64_cli_value("--log-max-age-secs", &args[i])?);
|
||||
} else {
|
||||
return Err("Missing value for --log-max-age-secs".to_string());
|
||||
}
|
||||
}
|
||||
s if s.starts_with("--log-max-age-secs=") => {
|
||||
options.max_age_secs = Some(parse_u64_cli_value(
|
||||
"--log-max-age-secs",
|
||||
s.trim_start_matches("--log-max-age-secs="),
|
||||
)?);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
LogDestination::Stderr
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn parse_rotation_cli_value(value: &str) -> Result<LogRotation, String> {
|
||||
LogRotation::from_cli_arg(value).ok_or_else(|| {
|
||||
format!(
|
||||
"Invalid --log-rotation value '{value}'. Expected never|minutely|hourly|daily|weekly"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_log_destination_default() {
|
||||
let args: Vec<String> = vec![];
|
||||
assert!(matches!(
|
||||
parse_log_destination(&args),
|
||||
LogDestination::Stderr
|
||||
));
|
||||
}
|
||||
fn parse_u64_cli_value(flag: &str, value: &str) -> Result<u64, String> {
|
||||
value
|
||||
.parse::<u64>()
|
||||
.map_err(|_| format!("Invalid {flag} value '{value}'. Expected unsigned integer"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_log_destination_file() {
|
||||
let args = vec!["--log-file".to_string(), "/var/log/telemt.log".to_string()];
|
||||
match parse_log_destination(&args) {
|
||||
LogDestination::File { path, rotate_daily } => {
|
||||
assert_eq!(path, "/var/log/telemt.log");
|
||||
assert!(!rotate_daily);
|
||||
fn parse_usize_cli_value(flag: &str, value: &str) -> Result<usize, String> {
|
||||
value
|
||||
.parse::<usize>()
|
||||
.map_err(|_| format!("Invalid {flag} value '{value}'. Expected unsigned integer"))
|
||||
}
|
||||
|
||||
/// Resolve effective logging destination from config and CLI overrides.
|
||||
pub fn resolve_log_destination(
|
||||
config: &LoggingConfig,
|
||||
cli: &LogCliOptions,
|
||||
) -> Result<LogDestination, String> {
|
||||
let destination = cli.destination.unwrap_or(match config.destination {
|
||||
LoggingDestination::Stderr => LogCliDestination::Stderr,
|
||||
LoggingDestination::Syslog => LogCliDestination::Syslog,
|
||||
LoggingDestination::File => LogCliDestination::File,
|
||||
});
|
||||
|
||||
match destination {
|
||||
LogCliDestination::Stderr => Ok(LogDestination::Stderr),
|
||||
LogCliDestination::Syslog => {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
Ok(LogDestination::Syslog)
|
||||
}
|
||||
_ => panic!("Expected File destination"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_log_destination_file_daily() {
|
||||
let args = vec!["--log-file-daily=/var/log/telemt".to_string()];
|
||||
match parse_log_destination(&args) {
|
||||
LogDestination::File { path, rotate_daily } => {
|
||||
assert_eq!(path, "/var/log/telemt");
|
||||
assert!(rotate_daily);
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
Err("Syslog logging is only supported on Unix platforms".to_string())
|
||||
}
|
||||
_ => panic!("Expected File destination"),
|
||||
}
|
||||
}
|
||||
LogCliDestination::File => {
|
||||
let path = cli.path.as_ref().or(config.path.as_ref()).ok_or_else(|| {
|
||||
"logging.path or --log-file must be set when file logging is enabled".to_string()
|
||||
})?;
|
||||
if path.trim().is_empty() {
|
||||
return Err("Log file path cannot be empty".to_string());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_parse_log_destination_syslog() {
|
||||
let args = vec!["--syslog".to_string()];
|
||||
assert!(matches!(
|
||||
parse_log_destination(&args),
|
||||
LogDestination::Syslog
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_syslog_priority_for_level_mapping() {
|
||||
assert_eq!(
|
||||
syslog_priority_for_level(&tracing::Level::ERROR),
|
||||
libc::LOG_ERR
|
||||
);
|
||||
assert_eq!(
|
||||
syslog_priority_for_level(&tracing::Level::WARN),
|
||||
libc::LOG_WARNING
|
||||
);
|
||||
assert_eq!(
|
||||
syslog_priority_for_level(&tracing::Level::INFO),
|
||||
libc::LOG_INFO
|
||||
);
|
||||
assert_eq!(
|
||||
syslog_priority_for_level(&tracing::Level::DEBUG),
|
||||
libc::LOG_DEBUG
|
||||
);
|
||||
assert_eq!(
|
||||
syslog_priority_for_level(&tracing::Level::TRACE),
|
||||
libc::LOG_DEBUG
|
||||
);
|
||||
Ok(LogDestination::File {
|
||||
options: FileLogOptions {
|
||||
path: path.clone(),
|
||||
rotation: cli.rotation.unwrap_or(config.rotation),
|
||||
max_size_bytes: cli.max_size_bytes.unwrap_or(config.max_size_bytes),
|
||||
max_files: cli.max_files.unwrap_or(config.max_files),
|
||||
max_age_secs: cli.max_age_secs.unwrap_or(config.max_age_secs),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use chrono::{DateTime, Datelike, Duration as ChronoDuration, Utc};
|
||||
|
||||
use crate::config::LogRotation;
|
||||
|
||||
use super::FileLogOptions;
|
||||
|
||||
const CLEANUP_INTERVAL_SECS: i64 = 60;
|
||||
|
||||
/// File appender with size rotation and local retention cleanup.
|
||||
pub(crate) struct BoundedFileAppender {
|
||||
options: FileLogOptions,
|
||||
dir: PathBuf,
|
||||
base_name: String,
|
||||
current_path: PathBuf,
|
||||
current_size: u64,
|
||||
last_cleanup: DateTime<Utc>,
|
||||
file: Option<File>,
|
||||
now: Box<dyn Fn() -> DateTime<Utc> + Send + Sync>,
|
||||
}
|
||||
|
||||
impl BoundedFileAppender {
|
||||
pub(crate) fn new(options: FileLogOptions) -> io::Result<Self> {
|
||||
Self::with_now(options, Box::new(Utc::now))
|
||||
}
|
||||
|
||||
fn with_now(
|
||||
options: FileLogOptions,
|
||||
now: Box<dyn Fn() -> DateTime<Utc> + Send + Sync>,
|
||||
) -> io::Result<Self> {
|
||||
let path = Path::new(&options.path);
|
||||
let dir = path
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.to_path_buf();
|
||||
let base_name = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("telemt")
|
||||
.to_string();
|
||||
|
||||
let start = now();
|
||||
let current_path = active_path_for(&dir, &base_name, options.rotation, &start);
|
||||
let (file, current_size) = open_append_file(¤t_path)?;
|
||||
let mut appender = Self {
|
||||
options,
|
||||
dir,
|
||||
base_name,
|
||||
current_path,
|
||||
current_size,
|
||||
last_cleanup: start,
|
||||
file: Some(file),
|
||||
now,
|
||||
};
|
||||
appender.cleanup(&start);
|
||||
Ok(appender)
|
||||
}
|
||||
|
||||
fn now(&self) -> DateTime<Utc> {
|
||||
(self.now)()
|
||||
}
|
||||
|
||||
fn refresh_active_path(&mut self, now: &DateTime<Utc>) -> io::Result<bool> {
|
||||
let next_path = active_path_for(&self.dir, &self.base_name, self.options.rotation, now);
|
||||
if next_path == self.current_path {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
self.close_current()?;
|
||||
self.current_path = next_path;
|
||||
self.open_current()?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn rotate_for_size(&mut self, now: &DateTime<Utc>) -> io::Result<()> {
|
||||
self.close_current()?;
|
||||
if self.current_path.exists() {
|
||||
let archive_path = self.archive_path(now);
|
||||
fs::rename(&self.current_path, archive_path)?;
|
||||
}
|
||||
self.open_current()
|
||||
}
|
||||
|
||||
fn archive_path(&self, now: &DateTime<Utc>) -> PathBuf {
|
||||
let file_name = self
|
||||
.current_path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(&self.base_name);
|
||||
let stamp = now.format("%Y%m%d%H%M%S");
|
||||
for seq in 0..1000 {
|
||||
let candidate = self.dir.join(format!("{file_name}.{stamp}.{seq}"));
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
self.dir.join(format!("{file_name}.{stamp}.overflow"))
|
||||
}
|
||||
|
||||
fn open_current(&mut self) -> io::Result<()> {
|
||||
let (file, current_size) = open_append_file(&self.current_path)?;
|
||||
self.file = Some(file);
|
||||
self.current_size = current_size;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn close_current(&mut self) -> io::Result<()> {
|
||||
if let Some(mut file) = self.file.take() {
|
||||
file.flush()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn should_rotate_for_size(&self, incoming_len: usize) -> bool {
|
||||
self.options.max_size_bytes > 0
|
||||
&& self.current_size > 0
|
||||
&& self.current_size.saturating_add(incoming_len as u64) > self.options.max_size_bytes
|
||||
}
|
||||
|
||||
fn cleanup_due(&self, now: &DateTime<Utc>) -> bool {
|
||||
self.options.max_age_secs > 0
|
||||
&& now.signed_duration_since(self.last_cleanup)
|
||||
>= ChronoDuration::seconds(CLEANUP_INTERVAL_SECS)
|
||||
}
|
||||
|
||||
fn cleanup(&mut self, now: &DateTime<Utc>) {
|
||||
self.last_cleanup = now.clone();
|
||||
let Ok(entries) = fs::read_dir(&self.dir) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
let prefix = format!("{}.", self.base_name);
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let Ok(file_type) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_current = path == self.current_path;
|
||||
let Some(name) = entry.file_name().to_str().map(|name| name.to_string()) else {
|
||||
continue;
|
||||
};
|
||||
if !is_current && !name.starts_with(&prefix) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(metadata) = entry.metadata() else {
|
||||
continue;
|
||||
};
|
||||
let modified = metadata.modified().unwrap_or(UNIX_EPOCH);
|
||||
candidates.push(LogFileCandidate {
|
||||
path,
|
||||
modified,
|
||||
is_current,
|
||||
});
|
||||
}
|
||||
|
||||
if self.options.max_age_secs > 0 {
|
||||
let cutoff = system_time_from_utc(now)
|
||||
.checked_sub(Duration::from_secs(self.options.max_age_secs))
|
||||
.unwrap_or(UNIX_EPOCH);
|
||||
candidates.retain(|candidate| {
|
||||
if candidate.is_current || candidate.modified >= cutoff {
|
||||
true
|
||||
} else {
|
||||
let _ = fs::remove_file(&candidate.path);
|
||||
false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if self.options.max_files > 0 && candidates.len() > self.options.max_files {
|
||||
let mut archives: Vec<_> = candidates
|
||||
.into_iter()
|
||||
.filter(|candidate| !candidate.is_current)
|
||||
.collect();
|
||||
archives.sort_by_key(|candidate| candidate.modified);
|
||||
let mut total = archives.len() + 1;
|
||||
for candidate in archives {
|
||||
if total <= self.options.max_files {
|
||||
break;
|
||||
}
|
||||
let _ = fs::remove_file(candidate.path);
|
||||
total -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for BoundedFileAppender {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
let now = self.now();
|
||||
let rotated_by_time = self.refresh_active_path(&now)?;
|
||||
if self.should_rotate_for_size(buf.len()) {
|
||||
self.rotate_for_size(&now)?;
|
||||
self.cleanup(&now);
|
||||
} else if rotated_by_time || self.cleanup_due(&now) {
|
||||
self.cleanup(&now);
|
||||
}
|
||||
|
||||
let Some(file) = self.file.as_mut() else {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
"bounded log file is not open",
|
||||
));
|
||||
};
|
||||
file.write_all(buf)?;
|
||||
self.current_size = self.current_size.saturating_add(buf.len() as u64);
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
if let Some(file) = self.file.as_mut() {
|
||||
file.flush()
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LogFileCandidate {
|
||||
path: PathBuf,
|
||||
modified: SystemTime,
|
||||
is_current: bool,
|
||||
}
|
||||
|
||||
fn open_append_file(path: &Path) -> io::Result<(File, u64)> {
|
||||
let mut options = OpenOptions::new();
|
||||
options.create(true).append(true);
|
||||
|
||||
let file = match options.open(path) {
|
||||
Ok(file) => file,
|
||||
Err(error) => {
|
||||
let Some(parent) = path.parent().filter(|parent| !parent.as_os_str().is_empty()) else {
|
||||
return Err(error);
|
||||
};
|
||||
fs::create_dir_all(parent)?;
|
||||
options.open(path)?
|
||||
}
|
||||
};
|
||||
let current_size = file.metadata()?.len();
|
||||
Ok((file, current_size))
|
||||
}
|
||||
|
||||
fn active_path_for(
|
||||
dir: &Path,
|
||||
base_name: &str,
|
||||
rotation: LogRotation,
|
||||
now: &DateTime<Utc>,
|
||||
) -> PathBuf {
|
||||
match rotation {
|
||||
LogRotation::Never => dir.join(base_name),
|
||||
LogRotation::Minutely
|
||||
| LogRotation::Hourly
|
||||
| LogRotation::Daily
|
||||
| LogRotation::Weekly => dir.join(format!(
|
||||
"{base_name}.{}",
|
||||
period_suffix_for(rotation, now)
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn period_suffix_for(rotation: LogRotation, now: &DateTime<Utc>) -> String {
|
||||
match rotation {
|
||||
LogRotation::Never | LogRotation::Daily => now.format("%Y-%m-%d").to_string(),
|
||||
LogRotation::Hourly => now.format("%Y-%m-%d-%H").to_string(),
|
||||
LogRotation::Minutely => now.format("%Y-%m-%d-%H-%M").to_string(),
|
||||
LogRotation::Weekly => {
|
||||
let days_since_sunday = now.weekday().num_days_from_sunday() as i64;
|
||||
let week_start = now.date_naive() - ChronoDuration::days(days_since_sunday);
|
||||
week_start.format("%Y-%m-%d").to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn system_time_from_utc(now: &DateTime<Utc>) -> SystemTime {
|
||||
let duration = Duration::new(now.timestamp().unsigned_abs(), now.timestamp_subsec_nanos());
|
||||
if now.timestamp() >= 0 {
|
||||
UNIX_EPOCH + duration
|
||||
} else {
|
||||
UNIX_EPOCH - duration
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::Write;
|
||||
|
||||
use tempfile::tempdir;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn fixed_now() -> DateTime<Utc> {
|
||||
DateTime::<Utc>::from(UNIX_EPOCH + Duration::from_secs(10))
|
||||
}
|
||||
|
||||
fn options(path: PathBuf) -> FileLogOptions {
|
||||
FileLogOptions {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
rotation: LogRotation::Never,
|
||||
max_size_bytes: 0,
|
||||
max_files: 0,
|
||||
max_age_secs: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn matching_logs(dir: &Path) -> Vec<PathBuf> {
|
||||
let mut files: Vec<_> = fs::read_dir(dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(|name| name.starts_with("telemt.log"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
files.sort();
|
||||
files
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_rotation_keeps_latest_write_in_active_file() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("telemt.log");
|
||||
let mut options = options(path.clone());
|
||||
options.max_size_bytes = 6;
|
||||
|
||||
let mut appender = BoundedFileAppender::with_now(options, Box::new(fixed_now)).unwrap();
|
||||
appender.write_all(b"abc\n").unwrap();
|
||||
appender.write_all(b"def\n").unwrap();
|
||||
appender.flush().unwrap();
|
||||
|
||||
assert_eq!(fs::read_to_string(path).unwrap(), "def\n");
|
||||
assert_eq!(matching_logs(dir.path()).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_files_retention_removes_oldest_archives() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("telemt.log");
|
||||
let mut options = options(path);
|
||||
options.max_size_bytes = 4;
|
||||
options.max_files = 2;
|
||||
|
||||
let mut appender = BoundedFileAppender::with_now(options, Box::new(fixed_now)).unwrap();
|
||||
for line in [b"aa\n", b"bb\n", b"cc\n", b"dd\n"] {
|
||||
appender.write_all(line).unwrap();
|
||||
}
|
||||
appender.flush().unwrap();
|
||||
|
||||
assert!(matching_logs(dir.path()).len() <= 2);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn max_age_retention_removes_old_archives() {
|
||||
use std::ffi::CString;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("telemt.log");
|
||||
let old_archive = dir.path().join("telemt.log.20000101000000.0");
|
||||
fs::write(&old_archive, "old").unwrap();
|
||||
|
||||
let c_path = CString::new(old_archive.as_os_str().as_bytes()).unwrap();
|
||||
let times = [
|
||||
libc::timespec {
|
||||
tv_sec: 0,
|
||||
tv_nsec: 0,
|
||||
},
|
||||
libc::timespec {
|
||||
tv_sec: 0,
|
||||
tv_nsec: 0,
|
||||
},
|
||||
];
|
||||
let rc = unsafe { libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0) };
|
||||
assert_eq!(rc, 0);
|
||||
|
||||
let mut options = options(path);
|
||||
options.max_age_secs = 1;
|
||||
let _appender = BoundedFileAppender::with_now(options, Box::new(fixed_now)).unwrap();
|
||||
|
||||
assert!(!old_archive.exists());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_log_cli_options_default() {
|
||||
let args: Vec<String> = vec![];
|
||||
let options = parse_log_cli_options(&args).unwrap();
|
||||
assert_eq!(
|
||||
resolve_log_destination(&LoggingConfig::default(), &options).unwrap(),
|
||||
LogDestination::Stderr
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_log_cli_options_file() {
|
||||
let args = vec!["--log-file".to_string(), "/var/log/telemt.log".to_string()];
|
||||
let options = parse_log_cli_options(&args).unwrap();
|
||||
match resolve_log_destination(&LoggingConfig::default(), &options).unwrap() {
|
||||
LogDestination::File { options } => {
|
||||
assert_eq!(options.path, "/var/log/telemt.log");
|
||||
assert_eq!(options.rotation, LogRotation::Never);
|
||||
}
|
||||
_ => panic!("Expected File destination"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_log_cli_options_file_daily() {
|
||||
let args = vec!["--log-file-daily=/var/log/telemt".to_string()];
|
||||
let options = parse_log_cli_options(&args).unwrap();
|
||||
match resolve_log_destination(&LoggingConfig::default(), &options).unwrap() {
|
||||
LogDestination::File { options } => {
|
||||
assert_eq!(options.path, "/var/log/telemt");
|
||||
assert_eq!(options.rotation, LogRotation::Daily);
|
||||
}
|
||||
_ => panic!("Expected File destination"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_log_cli_options_bounds() {
|
||||
let args = vec![
|
||||
"--log-file=/var/log/telemt.log".to_string(),
|
||||
"--log-rotation=hourly".to_string(),
|
||||
"--log-max-size-bytes=1024".to_string(),
|
||||
"--log-max-files=3".to_string(),
|
||||
"--log-max-age-secs=60".to_string(),
|
||||
];
|
||||
let options = parse_log_cli_options(&args).unwrap();
|
||||
match resolve_log_destination(&LoggingConfig::default(), &options).unwrap() {
|
||||
LogDestination::File { options } => {
|
||||
assert_eq!(options.rotation, LogRotation::Hourly);
|
||||
assert_eq!(options.max_size_bytes, 1024);
|
||||
assert_eq!(options.max_files, 3);
|
||||
assert_eq!(options.max_age_secs, 60);
|
||||
}
|
||||
_ => panic!("Expected File destination"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_log_cli_options_rejects_bad_rotation() {
|
||||
let args = vec!["--log-rotation=yearly".to_string()];
|
||||
assert!(parse_log_cli_options(&args).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_parse_log_cli_options_syslog() {
|
||||
let args = vec!["--syslog".to_string()];
|
||||
let options = parse_log_cli_options(&args).unwrap();
|
||||
assert_eq!(
|
||||
resolve_log_destination(&LoggingConfig::default(), &options).unwrap(),
|
||||
LogDestination::Syslog
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_syslog_priority_for_level_mapping() {
|
||||
assert_eq!(
|
||||
syslog_priority_for_level(&tracing::Level::ERROR),
|
||||
libc::LOG_ERR
|
||||
);
|
||||
assert_eq!(
|
||||
syslog_priority_for_level(&tracing::Level::WARN),
|
||||
libc::LOG_WARNING
|
||||
);
|
||||
assert_eq!(
|
||||
syslog_priority_for_level(&tracing::Level::INFO),
|
||||
libc::LOG_INFO
|
||||
);
|
||||
assert_eq!(
|
||||
syslog_priority_for_level(&tracing::Level::DEBUG),
|
||||
libc::LOG_DEBUG
|
||||
);
|
||||
assert_eq!(
|
||||
syslog_priority_for_level(&tracing::Level::TRACE),
|
||||
libc::LOG_DEBUG
|
||||
);
|
||||
}
|
||||
+26
-7
@@ -9,7 +9,7 @@ use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::cli;
|
||||
use crate::config::ProxyConfig;
|
||||
use crate::logging::LogDestination;
|
||||
use crate::logging::LogCliOptions;
|
||||
use crate::transport::UpstreamManager;
|
||||
use crate::transport::middle_proxy::{
|
||||
ProxyConfigData, fetch_proxy_config_with_raw_via_upstream, load_proxy_config_cache,
|
||||
@@ -113,7 +113,7 @@ pub(crate) struct CliArgs {
|
||||
pub data_path: Option<PathBuf>,
|
||||
pub silent: bool,
|
||||
pub log_level: Option<String>,
|
||||
pub log_destination: LogDestination,
|
||||
pub log_cli_options: LogCliOptions,
|
||||
}
|
||||
|
||||
pub(crate) fn parse_cli() -> CliArgs {
|
||||
@@ -125,8 +125,13 @@ pub(crate) fn parse_cli() -> CliArgs {
|
||||
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
|
||||
// Parse log destination
|
||||
let log_destination = crate::logging::parse_log_destination(&args);
|
||||
let log_cli_options = match crate::logging::parse_log_cli_options(&args) {
|
||||
Ok(options) => options,
|
||||
Err(error) => {
|
||||
eprintln!("[telemt] {error}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
|
||||
// Check for --init first (handled before tokio)
|
||||
if let Some(init_opts) = cli::parse_init_args(&args) {
|
||||
@@ -184,7 +189,16 @@ pub(crate) fn parse_cli() -> CliArgs {
|
||||
i += 1;
|
||||
}
|
||||
s if s.starts_with("--log-file=") || s.starts_with("--log-file-daily=") => {}
|
||||
#[cfg(unix)]
|
||||
"--log-rotation"
|
||||
| "--log-max-size-bytes"
|
||||
| "--log-max-files"
|
||||
| "--log-max-age-secs" => {
|
||||
i += 1;
|
||||
}
|
||||
s if s.starts_with("--log-rotation=")
|
||||
|| s.starts_with("--log-max-size-bytes=")
|
||||
|| s.starts_with("--log-max-files=")
|
||||
|| s.starts_with("--log-max-age-secs=") => {}
|
||||
"--syslog" => {}
|
||||
"--help" | "-h" => {
|
||||
print_help();
|
||||
@@ -198,7 +212,8 @@ pub(crate) fn parse_cli() -> CliArgs {
|
||||
"--daemon" | "-d" | "--foreground" | "-f" => {}
|
||||
s if s.starts_with("--pid-file") => {
|
||||
if !s.contains('=') {
|
||||
i += 1; // skip value
|
||||
// Skip the pid-file value consumed by daemon argument parsing.
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
s if s.starts_with("--run-as-user") => {
|
||||
@@ -230,7 +245,7 @@ pub(crate) fn parse_cli() -> CliArgs {
|
||||
data_path,
|
||||
silent,
|
||||
log_level,
|
||||
log_destination,
|
||||
log_cli_options,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +275,10 @@ fn print_help() {
|
||||
eprintln!("Logging options:");
|
||||
eprintln!(" --log-file <PATH> Log to file (default: stderr)");
|
||||
eprintln!(" --log-file-daily <PATH> Log to file with daily rotation");
|
||||
eprintln!(" --log-rotation <MODE> never|minutely|hourly|daily|weekly");
|
||||
eprintln!(" --log-max-size-bytes N Rotate file logs when active file exceeds N bytes");
|
||||
eprintln!(" --log-max-files N Keep at most N matching file logs (0 disables)");
|
||||
eprintln!(" --log-max-age-secs N Remove rotated file logs older than N seconds");
|
||||
#[cfg(unix)]
|
||||
eprintln!(" --syslog Log to syslog (Unix only)");
|
||||
eprintln!();
|
||||
|
||||
+9
-1
@@ -108,7 +108,7 @@ async fn run_telemt_core(
|
||||
let data_path = cli_args.data_path;
|
||||
let cli_silent = cli_args.silent;
|
||||
let cli_log_level = cli_args.log_level;
|
||||
let log_destination = cli_args.log_destination;
|
||||
let log_cli_options = cli_args.log_cli_options;
|
||||
let startup_cwd = match std::env::current_dir() {
|
||||
Ok(cwd) => cwd,
|
||||
Err(e) => {
|
||||
@@ -331,6 +331,14 @@ async fn run_telemt_core(
|
||||
};
|
||||
|
||||
let initial_filter_spec = runtime_tasks::log_filter_spec(has_rust_log, &effective_log_level);
|
||||
let log_destination =
|
||||
match crate::logging::resolve_log_destination(&config.logging, &log_cli_options) {
|
||||
Ok(destination) => destination,
|
||||
Err(error) => {
|
||||
eprintln!("[telemt] {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let (filter_layer, filter_handle) =
|
||||
reload::Layer::new(EnvFilter::new(initial_filter_spec.clone()));
|
||||
startup_tracker
|
||||
|
||||
Reference in New Issue
Block a user