use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use tokio_util::sync::CancellationToken;
use tracing::{info, warn};
use trusty_common::log_drain::{DrainConfig, DrainReport, ObjectStoreDestination, run_once};
use crate::core::trusty_tools_config::{
LOG_DRAIN_STATE_SUBDIR, LogDrainSetting, ResolvedDrainDestination, ResolvedLogDrain,
TrustyToolsConfig, resolve_log_drain,
};
mod log_drain_prune;
const STATUS_FILENAME: &str = "status.json";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DrainOutcome {
Success,
SkippedDisabled,
Failed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LogDrainDestinationStatus {
pub destination: String,
#[serde(default)]
pub project: String,
pub scheme: String,
pub outcome: DrainOutcome,
pub uploaded: usize,
pub skipped_unchanged: usize,
#[serde(default)]
pub pruned: usize,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LogDrainStatus {
pub outcome: DrainOutcome,
pub at: String,
#[serde(default)]
pub destinations: Vec<LogDrainDestinationStatus>,
pub uploaded: usize,
pub skipped_unchanged: usize,
#[serde(default)]
pub pruned: usize,
pub detail: String,
}
impl LogDrainStatus {
fn disabled(detail: impl Into<String>) -> Self {
Self {
outcome: DrainOutcome::SkippedDisabled,
at: chrono::Utc::now().to_rfc3339(),
destinations: Vec::new(),
uploaded: 0,
skipped_unchanged: 0,
pruned: 0,
detail: detail.into(),
}
}
fn config_error(reason: &str) -> Self {
Self {
outcome: DrainOutcome::Failed,
at: chrono::Utc::now().to_rfc3339(),
destinations: Vec::new(),
uploaded: 0,
skipped_unchanged: 0,
pruned: 0,
detail: format!("config error: {reason}"),
}
}
fn from_destinations(destinations: Vec<LogDrainDestinationStatus>) -> Self {
let failed = destinations
.iter()
.any(|d| d.outcome == DrainOutcome::Failed);
let uploaded = destinations.iter().map(|d| d.uploaded).sum();
let skipped_unchanged = destinations.iter().map(|d| d.skipped_unchanged).sum();
let pruned = destinations.iter().map(|d| d.pruned).sum();
let detail = match destinations.as_slice() {
[] => "no destination had any source configured".to_string(),
[only] => only.detail.clone(),
many => many
.iter()
.map(|d| {
format!(
"{} → {} [{}]: {}",
d.scheme, d.destination, d.project, d.detail
)
})
.collect::<Vec<_>>()
.join("; "),
};
Self {
outcome: if failed {
DrainOutcome::Failed
} else {
DrainOutcome::Success
},
at: chrono::Utc::now().to_rfc3339(),
destinations,
uploaded,
skipped_unchanged,
pruned,
detail,
}
}
}
impl LogDrainDestinationStatus {
fn failed(group: &ResolvedDrainDestination, detail: impl Into<String>) -> Self {
Self {
destination: group.destination_display.clone(),
project: group.target.key_prefix(),
scheme: group.scheme().to_string(),
outcome: DrainOutcome::Failed,
uploaded: 0,
skipped_unchanged: 0,
pruned: 0,
detail: detail.into(),
}
}
fn from_report(group: &ResolvedDrainDestination, report: &DrainReport) -> Self {
let failed = !report.errors.is_empty();
let detail = if failed {
let (key, message) = &report.errors[0];
format!(
"{} file(s) uploaded, {} file error(s) — first: {key}: {message}",
report.uploaded,
report.errors.len()
)
} else {
format!(
"{} file(s) uploaded ({} B), {} unchanged, {} over the size ceiling \
({} newly recorded)",
report.uploaded,
report.bytes_plain,
report.skipped_unchanged,
report.skipped_too_large,
report.skips_recorded
)
};
Self {
destination: group.destination_display.clone(),
project: group.target.key_prefix(),
scheme: group.scheme().to_string(),
outcome: if failed {
DrainOutcome::Failed
} else {
DrainOutcome::Success
},
uploaded: report.uploaded,
skipped_unchanged: report.skipped_unchanged,
pruned: 0,
detail,
}
}
fn apply_prune_outcome(&mut self, pruned: usize, errors: &[(String, String)]) {
if pruned == 0 && errors.is_empty() {
return;
}
self.pruned += pruned;
if !errors.is_empty() {
self.outcome = DrainOutcome::Failed;
let (key, message) = &errors[0];
self.detail = format!(
"{}; prune: {} pruned, {} error(s) — first: {key}: {message}",
self.detail,
pruned,
errors.len()
);
} else {
self.detail = format!("{}; {} pruned", self.detail, pruned);
}
}
fn apply_prune_refusal(&mut self, refusal: &trusty_common::log_drain::PruneRefusal) {
self.detail = format!(
"{}; prune refused: {} candidate(s) against {} manifest entries — {}",
self.detail, refusal.batch_len, refusal.manifest_len, refusal.reason
);
}
}
pub fn state_dir(framework_root: &Path) -> PathBuf {
framework_root.join(LOG_DRAIN_STATE_SUBDIR)
}
pub fn status_path(state_dir: &Path) -> PathBuf {
state_dir.join(STATUS_FILENAME)
}
pub fn load_status(state_dir: &Path) -> Option<LogDrainStatus> {
let raw = std::fs::read_to_string(status_path(state_dir)).ok()?;
serde_json::from_str(&raw).ok()
}
pub fn save_status(state_dir: &Path, status: &LogDrainStatus) {
if let Err(e) = std::fs::create_dir_all(state_dir) {
warn!("log_drain: cannot create {}: {e}", state_dir.display());
return;
}
match serde_json::to_string_pretty(status) {
Ok(json) => {
if let Err(e) = std::fs::write(status_path(state_dir), json) {
warn!("log_drain: cannot record status: {e}");
}
}
Err(e) => warn!("log_drain: cannot serialise status: {e}"),
}
}
pub async fn run_tick(
plan: &ResolvedLogDrain,
state_dir: &Path,
prune_debounce: &mut crate::session_manager::RetentionDebounce<log_drain_prune::PruneCandidate>,
) -> LogDrainStatus {
let mut per_destination = Vec::with_capacity(plan.destinations.len());
for group in &plan.destinations {
per_destination.push(run_destination_pass(plan, group, state_dir).await);
}
if plan.prune_after_upload {
log_drain_prune::apply(plan, state_dir, &mut per_destination, prune_debounce).await;
}
LogDrainStatus::from_destinations(per_destination)
}
async fn run_destination_pass(
plan: &ResolvedLogDrain,
group: &ResolvedDrainDestination,
state_dir: &Path,
) -> LogDrainDestinationStatus {
let dest = match ObjectStoreDestination::connect(&group.destination).await {
Ok(dest) => dest,
Err(e) => {
return LogDrainDestinationStatus::failed(
group,
format!("cannot reach the destination: {e}"),
);
}
};
let cfg = DrainConfig::new(state_dir)
.with_secrets(plan.secrets.clone())
.with_max_file_bytes(plan.max_file_bytes)
.with_max_wire_bytes(plan.max_wire_bytes);
match run_once(&cfg, &dest, &group.target, &group.sources).await {
Ok(report) => LogDrainDestinationStatus::from_report(group, &report),
Err(e) => LogDrainDestinationStatus::failed(group, format!("drain run failed: {e}")),
}
}
pub async fn drain_once(
config: &TrustyToolsConfig,
framework_root: &Path,
home: &Path,
) -> LogDrainStatus {
let mut prune_debounce = crate::session_manager::RetentionDebounce::new();
drain_once_with(config, framework_root, home, &mut prune_debounce).await
}
async fn drain_once_with(
config: &TrustyToolsConfig,
framework_root: &Path,
home: &Path,
prune_debounce: &mut crate::session_manager::RetentionDebounce<log_drain_prune::PruneCandidate>,
) -> LogDrainStatus {
let dir = state_dir(framework_root);
let status = match resolve_log_drain(config, home) {
Err(e) => LogDrainStatus::config_error(&e.to_string()),
Ok(LogDrainSetting::Disabled) => {
LogDrainStatus::disabled("log_drain is disabled in config")
}
Ok(LogDrainSetting::Enabled(plan)) => run_tick(&plan, &dir, prune_debounce).await,
};
save_status(&dir, &status);
status
}
pub async fn log_drain_loop(framework_root: PathBuf, home: PathBuf, cancel: CancellationToken) {
let mut interval_secs = current_interval_secs(&home);
let mut tick = tokio::time::interval(Duration::from_secs(interval_secs));
let mut prune_debounce = crate::session_manager::RetentionDebounce::new();
loop {
if cancel.is_cancelled() {
info!("log_drain_loop: already cancelled; exiting without a pass");
return;
}
tokio::select! {
_ = cancel.cancelled() => {
info!("log_drain_loop: cancel signal received; exiting");
return;
}
_ = tick.tick() => {
let status = drain_once_with(
&TrustyToolsConfig::load(),
&framework_root,
&home,
&mut prune_debounce,
)
.await;
match status.outcome {
DrainOutcome::Success | DrainOutcome::SkippedDisabled => {
info!("log_drain: {}", status.detail);
}
DrainOutcome::Failed => warn!("log_drain: {}", status.detail),
}
let next = current_interval_secs(&home);
if next != interval_secs {
interval_secs = next;
tick = tokio::time::interval(Duration::from_secs(interval_secs));
tick.tick().await;
}
}
}
}
}
fn current_interval_secs(home: &Path) -> u64 {
match resolve_log_drain(&TrustyToolsConfig::load(), home) {
Ok(LogDrainSetting::Enabled(plan)) => plan.interval.as_secs(),
_ => crate::core::trusty_tools_config::LOG_DRAIN_DEFAULT_INTERVAL_SECS,
}
}
pub fn config_error_status(reason: &str) -> LogDrainStatus {
LogDrainStatus::config_error(reason)
}
pub fn should_spawn(config: &TrustyToolsConfig, home: &Path) -> Result<bool, String> {
match resolve_log_drain(config, home) {
Ok(LogDrainSetting::Enabled(_)) => Ok(true),
Ok(LogDrainSetting::Disabled) => Ok(false),
Err(e) => Err(e.to_string()),
}
}
#[cfg(test)]
#[path = "log_drain_tests.rs"]
mod tests;