use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use trusty_common::log_drain::{
DEFAULT_MAX_FILE_BYTES, DEFAULT_MAX_WIRE_BYTES, DestinationScheme, DestinationUri, DrainTarget,
Level, LogSource,
};
use super::TrustyToolsConfig;
mod plan;
pub use plan::DisabledSource;
pub const DEFAULT_INTERVAL_SECS: u64 = 900;
pub const STATE_SUBDIR: &str = "log-drain";
pub const DEFAULT_PRUNE_RETENTION_DAYS: u64 = 30;
const DAEMON_LOG_SUBDIR: &str = "logs";
const DEFAULT_INCLUDE: &[&str] = &["trusty-mpm.log*"];
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LogDrainConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub destination: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interval_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_file_bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_wire_bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub secrets: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sources: Vec<LogDrainSourceConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prune_after_upload: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prune_retention_days: Option<u64>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LogDrainSourceConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub crate_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub root: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub include: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub level: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub destination: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<String>,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum LogDrainConfigError {
#[error("log_drain.enabled is true but log_drain.destination is unset")]
MissingDestination,
#[error("log_drain.destination is invalid: {reason}")]
Destination {
reason: String,
},
#[error("log_drain.{field} must be greater than zero")]
NonPositive {
field: &'static str,
},
#[error("log_drain.sources[{index}].{field} is required")]
SourceField {
index: usize,
field: &'static str,
},
#[error("log_drain.sources[{index}].destination is invalid: {reason}")]
SourceDestination {
index: usize,
reason: String,
},
#[error(
"log_drain.sources[{index}] ({crate_name}, root {root}) has no owner/project: \
{reason} — set `owner:` and `project:` on that source, or point `root:` \
inside a git checkout whose `origin` names the project"
)]
SourceIdentity {
index: usize,
crate_name: String,
root: String,
reason: String,
},
#[error(
"log_drain.sources[{index}].level `{value}` is not a level — \
expected one of trace, debug, info, warn, error"
)]
SourceLevel {
index: usize,
value: String,
},
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ResolvedDrainDestination {
pub destination: DestinationUri,
pub destination_display: String,
pub target: DrainTarget,
pub sources: Vec<LogSource>,
}
impl ResolvedDrainDestination {
pub fn scheme(&self) -> &'static str {
match self.destination.scheme() {
DestinationScheme::S3 => "s3",
DestinationScheme::File => "file",
_ => "other",
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ResolvedLogDrain {
pub destinations: Vec<ResolvedDrainDestination>,
pub disabled: Vec<DisabledSource>,
pub interval: Duration,
pub max_file_bytes: u64,
pub max_wire_bytes: u64,
pub secrets: Vec<String>,
pub prune_after_upload: bool,
pub prune_retention: Duration,
}
#[derive(Debug, Clone)]
pub enum LogDrainSetting {
Disabled,
Enabled(Box<ResolvedLogDrain>),
}
pub fn resolve_log_drain(
config: &TrustyToolsConfig,
home: &Path,
) -> Result<LogDrainSetting, LogDrainConfigError> {
let Some(section) = config.log_drain.as_ref() else {
return Ok(LogDrainSetting::Disabled);
};
let enabled = section.enabled.unwrap_or(false);
let destination = match section.destination.as_deref().map(str::trim) {
Some(raw) if !raw.is_empty() => Some((
raw.to_string(),
DestinationUri::parse(raw).map_err(|e| LogDrainConfigError::Destination {
reason: e.to_string(),
})?,
)),
_ => None,
};
let interval_secs = section.interval_secs.unwrap_or(DEFAULT_INTERVAL_SECS);
if interval_secs == 0 {
return Err(LogDrainConfigError::NonPositive {
field: "interval_secs",
});
}
let max_file_bytes = section.max_file_bytes.unwrap_or(DEFAULT_MAX_FILE_BYTES);
if max_file_bytes == 0 {
return Err(LogDrainConfigError::NonPositive {
field: "max_file_bytes",
});
}
let max_wire_bytes = section.max_wire_bytes.unwrap_or(DEFAULT_MAX_WIRE_BYTES);
if max_wire_bytes == 0 {
return Err(LogDrainConfigError::NonPositive {
field: "max_wire_bytes",
});
}
let prune_retention_days = section
.prune_retention_days
.unwrap_or(DEFAULT_PRUNE_RETENTION_DAYS);
if prune_retention_days == 0 {
return Err(LogDrainConfigError::NonPositive {
field: "prune_retention_days",
});
}
let prune_after_upload = section.prune_after_upload.unwrap_or(true);
let prepared = resolve_sources(§ion.sources, home)?;
if !enabled {
return Ok(LogDrainSetting::Disabled);
}
let fallback_identity = match (
non_empty(section.owner.as_deref()),
non_empty(section.project.as_deref()),
) {
(Some(owner), Some(project)) => Some(DrainTarget { owner, project }),
_ => None,
};
let (destinations, disabled) = plan::build(prepared, destination, fallback_identity.as_ref())?;
Ok(LogDrainSetting::Enabled(Box::new(ResolvedLogDrain {
destinations,
disabled,
interval: Duration::from_secs(interval_secs),
max_file_bytes,
max_wire_bytes,
secrets: section.secrets.clone(),
prune_after_upload,
prune_retention: Duration::from_secs(prune_retention_days * 86_400),
})))
}
type NamedDestination = (String, DestinationUri);
struct PreparedSource {
index: usize,
enabled: bool,
source: LogSource,
destination: Option<NamedDestination>,
identity: Option<DrainTarget>,
}
fn resolve_sources(
configured: &[LogDrainSourceConfig],
home: &Path,
) -> Result<Vec<PreparedSource>, LogDrainConfigError> {
if configured.is_empty() {
return Ok(vec![PreparedSource {
index: 0,
enabled: true,
source: LogSource {
crate_name: super::CRATE_NAME.to_string(),
root: home.join(".trusty-mpm").join(DAEMON_LOG_SUBDIR),
include: DEFAULT_INCLUDE.iter().map(|s| (*s).to_string()).collect(),
level_filter: Some(Level::Info),
},
destination: None,
identity: None,
}]);
}
configured
.iter()
.enumerate()
.map(|(index, entry)| {
let crate_name =
non_empty(entry.crate_name.as_deref()).ok_or(LogDrainConfigError::SourceField {
index,
field: "crate_name",
})?;
let root =
non_empty(entry.root.as_deref()).ok_or(LogDrainConfigError::SourceField {
index,
field: "root",
})?;
let level_filter = match entry.level.as_deref().map(str::trim) {
None => None,
Some("") => None,
Some(raw) => {
Some(
parse_level(raw).ok_or_else(|| LogDrainConfigError::SourceLevel {
index,
value: raw.to_string(),
})?,
)
}
};
let include = if entry.include.is_empty() {
vec!["**/*".to_string()]
} else {
entry.include.clone()
};
let over = match non_empty(entry.destination.as_deref()) {
None => None,
Some(raw) => {
let uri = DestinationUri::parse(&raw).map_err(|e| {
LogDrainConfigError::SourceDestination {
index,
reason: e.to_string(),
}
})?;
Some((raw, uri))
}
};
let identity = match (
non_empty(entry.owner.as_deref()),
non_empty(entry.project.as_deref()),
) {
(None, None) => None,
(Some(owner), Some(project)) => Some(DrainTarget { owner, project }),
(owner, _) => {
return Err(LogDrainConfigError::SourceIdentity {
index,
crate_name,
root,
reason: format!(
"`{}` is set but `{}` is not",
if owner.is_some() { "owner" } else { "project" },
if owner.is_some() { "project" } else { "owner" },
),
});
}
};
Ok(PreparedSource {
index,
enabled: entry.enabled.unwrap_or(true),
source: LogSource {
crate_name,
root: expand_home(&root, home),
include,
level_filter,
},
destination: over,
identity,
})
})
.collect()
}
fn parse_level(raw: &str) -> Option<Level> {
match raw.to_ascii_lowercase().as_str() {
"trace" => Some(Level::Trace),
"debug" => Some(Level::Debug),
"info" => Some(Level::Info),
"warn" | "warning" => Some(Level::Warn),
"error" => Some(Level::Error),
_ => None,
}
}
fn non_empty(raw: Option<&str>) -> Option<String> {
raw.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
fn expand_home(raw: &str, home: &Path) -> PathBuf {
match raw.strip_prefix("~/") {
Some(rest) => home.join(rest),
None if raw == "~" => home.to_path_buf(),
None => PathBuf::from(raw),
}
}
#[cfg(test)]
mod tests;