use std::path::Path;
use tempfile::TempDir;
use super::*;
use crate::core::trusty_tools_config::LogDrainConfig;
use crate::session_manager::RetentionDebounce;
const FIXTURE_OWNER: &str = "octocat";
const FIXTURE_PROJECT: &str = "fixtures";
fn enabled_config(dest_dir: &Path, log_dir: &Path) -> TrustyToolsConfig {
TrustyToolsConfig {
log_drain: Some(LogDrainConfig {
enabled: Some(true),
destination: Some(format!("file://{}", dest_dir.display())),
owner: Some(FIXTURE_OWNER.to_string()),
project: Some(FIXTURE_PROJECT.to_string()),
sources: vec![crate::core::trusty_tools_config::LogDrainSourceConfig {
crate_name: Some("trusty-mpm".to_string()),
root: Some(log_dir.display().to_string()),
include: vec!["*.log".to_string()],
..Default::default()
}],
..Default::default()
}),
..Default::default()
}
}
fn source_entry(
crate_name: &str,
root: &Path,
destination: Option<&Path>,
) -> crate::core::trusty_tools_config::LogDrainSourceConfig {
crate::core::trusty_tools_config::LogDrainSourceConfig {
crate_name: Some(crate_name.to_string()),
root: Some(root.display().to_string()),
include: vec!["*.log".to_string()],
destination: destination.map(|d| format!("file://{}", d.display())),
..Default::default()
}
}
fn two_destination_config(
default_dest: &Path,
inheriting_logs: &Path,
override_dest: &Path,
overriding_logs: &Path,
) -> TrustyToolsConfig {
TrustyToolsConfig {
log_drain: Some(LogDrainConfig {
enabled: Some(true),
destination: Some(format!("file://{}", default_dest.display())),
owner: Some(FIXTURE_OWNER.to_string()),
project: Some(FIXTURE_PROJECT.to_string()),
sources: vec![
source_entry("trusty-mpm", inheriting_logs, None),
source_entry("trusty-code", overriding_logs, Some(override_dest)),
],
..Default::default()
}),
..Default::default()
}
}
fn drained_path(dest: &Path, crate_name: &str, file: &str) -> std::path::PathBuf {
dest.join(FIXTURE_OWNER)
.join(FIXTURE_PROJECT)
.join(crate_name)
.join(file)
}
fn named_log_dir(tmp: &TempDir, name: &str) -> std::path::PathBuf {
let dir = tmp.path().join(name);
std::fs::create_dir_all(&dir).expect("create log dir");
std::fs::write(dir.join(format!("{name}.log")), "INFO a line\n").expect("write log file");
dir
}
fn broken_config(destination: &str) -> TrustyToolsConfig {
TrustyToolsConfig {
log_drain: Some(LogDrainConfig {
enabled: Some(true),
destination: Some(destination.to_string()),
..Default::default()
}),
..Default::default()
}
}
fn plan_of(config: &TrustyToolsConfig, home: &Path) -> ResolvedLogDrain {
match resolve_log_drain(config, home).expect("fixture config resolves") {
LogDrainSetting::Enabled(plan) => *plan,
LogDrainSetting::Disabled => panic!("fixture config should be enabled"),
}
}
fn log_dir_with(tmp: &TempDir, body: &str) -> std::path::PathBuf {
let dir = tmp.path().join("logs");
std::fs::create_dir_all(&dir).expect("create log dir");
std::fs::write(dir.join("trusty-mpm.log"), body).expect("write log file");
dir
}
#[tokio::test]
async fn a_successful_tick_uploads_and_records_success() {
let tmp = TempDir::new().expect("tempdir");
let dest = tmp.path().join("dest");
let logs = log_dir_with(&tmp, "INFO first line\nINFO second line\n");
let state = tmp.path().join("state");
let config = enabled_config(&dest, &logs);
let plan = plan_of(&config, tmp.path());
let status = run_tick(&plan, &state, &mut RetentionDebounce::new()).await;
assert_eq!(status.outcome, DrainOutcome::Success, "{}", status.detail);
assert_eq!(status.uploaded, 1, "{}", status.detail);
assert_eq!(status.destinations.len(), 1);
assert_eq!(status.destinations[0].scheme, "file");
let key = drained_path(&dest, "trusty-mpm", "trusty-mpm.log");
assert!(
key.exists(),
"expected an uploaded object at {}",
key.display()
);
assert_eq!(
status.destinations[0].project,
format!("{FIXTURE_OWNER}/{FIXTURE_PROJECT}"),
"the record names the project it uploaded under"
);
}
#[tokio::test]
async fn a_second_tick_dedupes() {
let tmp = TempDir::new().expect("tempdir");
let dest = tmp.path().join("dest");
let logs = log_dir_with(&tmp, "INFO only line\n");
let state = tmp.path().join("state");
let config = enabled_config(&dest, &logs);
let plan = plan_of(&config, tmp.path());
let first = run_tick(&plan, &state, &mut RetentionDebounce::new()).await;
assert_eq!(first.uploaded, 1, "{}", first.detail);
let second = run_tick(&plan, &state, &mut RetentionDebounce::new()).await;
assert_eq!(second.outcome, DrainOutcome::Success, "{}", second.detail);
assert_eq!(second.uploaded, 0, "{}", second.detail);
assert_eq!(second.skipped_unchanged, 1, "{}", second.detail);
}
#[tokio::test]
async fn a_ceiling_skip_is_decided_once_across_ticks() {
let tmp = TempDir::new().expect("tempdir");
let dest = tmp.path().join("dest");
let logs = log_dir_with(&tmp, &"x".repeat(4096));
let state = tmp.path().join("state");
let mut config = enabled_config(&dest, &logs);
config
.log_drain
.as_mut()
.expect("fixture section")
.max_file_bytes = Some(1024);
let plan = plan_of(&config, tmp.path());
let first = run_tick(&plan, &state, &mut RetentionDebounce::new()).await;
assert_eq!(first.outcome, DrainOutcome::Success, "{}", first.detail);
assert_eq!(first.uploaded, 0, "{}", first.detail);
assert!(
first
.detail
.contains("1 over the size ceiling (1 newly recorded)"),
"the first tick decides: {}",
first.detail
);
let second = run_tick(&plan, &state, &mut RetentionDebounce::new()).await;
assert!(
second
.detail
.contains("1 over the size ceiling (0 newly recorded)"),
"a later tick must not re-decide an unchanged file: {}",
second.detail
);
}
#[tokio::test]
async fn the_wire_ceiling_reaches_the_drain_config() {
let tmp = TempDir::new().expect("tempdir");
let dest = tmp.path().join("dest");
let logs = log_dir_with(&tmp, "INFO a line that will not gzip under 8 bytes\n");
let state = tmp.path().join("state");
let mut config = enabled_config(&dest, &logs);
config
.log_drain
.as_mut()
.expect("fixture section")
.max_wire_bytes = Some(8);
let plan = plan_of(&config, tmp.path());
assert_eq!(plan.max_wire_bytes, 8);
let status = run_tick(&plan, &state, &mut RetentionDebounce::new()).await;
assert_eq!(status.uploaded, 0, "{}", status.detail);
assert!(
status
.detail
.contains("1 over the size ceiling (1 newly recorded)"),
"the wire ceiling must be reported like any other skip: {}",
status.detail
);
}
#[tokio::test]
async fn two_destinations_each_get_their_own_pass() {
let tmp = TempDir::new().expect("tempdir");
let dest_a = tmp.path().join("dest-a");
let dest_b = tmp.path().join("dest-b");
let logs_a = named_log_dir(&tmp, "alpha");
let logs_b = named_log_dir(&tmp, "beta");
let state = tmp.path().join("state");
let config = two_destination_config(&dest_a, &logs_a, &dest_b, &logs_b);
let plan = plan_of(&config, tmp.path());
assert_eq!(
plan.destinations.len(),
2,
"two groups, one per destination"
);
let status = run_tick(&plan, &state, &mut RetentionDebounce::new()).await;
assert_eq!(status.outcome, DrainOutcome::Success, "{}", status.detail);
assert_eq!(status.uploaded, 2, "{}", status.detail);
assert_eq!(status.destinations.len(), 2);
assert!(
status
.destinations
.iter()
.all(|d| d.outcome == DrainOutcome::Success && d.uploaded == 1),
"each destination uploads its own single file: {:?}",
status.destinations
);
let a = drained_path(&dest_a, "trusty-mpm", "alpha.log");
let b = drained_path(&dest_b, "trusty-code", "beta.log");
assert!(a.exists(), "expected {}", a.display());
assert!(b.exists(), "expected {}", b.display());
assert!(!drained_path(&dest_a, "trusty-code", "beta.log").exists());
assert!(!drained_path(&dest_b, "trusty-mpm", "alpha.log").exists());
}
#[tokio::test]
async fn one_failing_destination_does_not_stop_the_others() {
let tmp = TempDir::new().expect("tempdir");
let dest_a = tmp.path().join("dest-a");
let blocker = tmp.path().join("not-a-directory");
std::fs::write(&blocker, b"x").expect("write blocker");
let dest_b = blocker.join("dest-b");
let logs_a = named_log_dir(&tmp, "alpha");
let logs_b = named_log_dir(&tmp, "beta");
let state = tmp.path().join("state");
let config = two_destination_config(&dest_a, &logs_a, &dest_b, &logs_b);
let plan = plan_of(&config, tmp.path());
let status = run_tick(&plan, &state, &mut RetentionDebounce::new()).await;
assert_eq!(status.outcome, DrainOutcome::Failed, "{}", status.detail);
assert_eq!(status.destinations.len(), 2);
assert_eq!(status.destinations[0].outcome, DrainOutcome::Success);
assert_eq!(status.destinations[0].uploaded, 1);
assert_eq!(status.destinations[1].outcome, DrainOutcome::Failed);
assert!(
status.destinations[1]
.detail
.contains("cannot reach the destination"),
"unhelpful detail: {}",
status.destinations[1].detail
);
assert!(drained_path(&dest_a, "trusty-mpm", "alpha.log").exists());
assert!(
!drained_path(&dest_a, "trusty-code", "beta.log").exists(),
"a failed destination must not fall back to the section default"
);
}
#[tokio::test]
async fn a_failing_destination_records_failed() {
let tmp = TempDir::new().expect("tempdir");
let blocker = tmp.path().join("not-a-directory");
std::fs::write(&blocker, b"x").expect("write blocker");
let dest = blocker.join("dest");
let logs = log_dir_with(&tmp, "INFO line\n");
let state = tmp.path().join("state");
let config = enabled_config(&dest, &logs);
let plan = plan_of(&config, tmp.path());
let status = run_tick(&plan, &state, &mut RetentionDebounce::new()).await;
assert_eq!(status.outcome, DrainOutcome::Failed, "{}", status.detail);
assert_eq!(status.uploaded, 0);
assert!(
status.detail.contains("cannot reach the destination"),
"unhelpful detail: {}",
status.detail
);
}
#[tokio::test]
async fn a_failed_run_is_not_reported_as_drained_by_the_doctor_row() {
let tmp = TempDir::new().expect("tempdir");
let blocker = tmp.path().join("not-a-directory");
std::fs::write(&blocker, b"x").expect("write blocker");
let dest = blocker.join("dest");
let logs = log_dir_with(&tmp, "INFO line\n");
let root = tmp.path().join("framework");
let config = enabled_config(&dest, &logs);
let status = drain_once(&config, &root, tmp.path()).await;
assert_eq!(status.outcome, DrainOutcome::Failed, "{}", status.detail);
let persisted = load_status(&state_dir(&root)).expect("status was written");
assert_eq!(persisted.outcome, DrainOutcome::Failed);
assert_eq!(persisted.detail, status.detail);
}
#[tokio::test]
async fn a_disabled_config_records_skipped() {
let tmp = TempDir::new().expect("tempdir");
let root = tmp.path().join("framework");
let status = drain_once(&TrustyToolsConfig::default(), &root, tmp.path()).await;
assert_eq!(status.outcome, DrainOutcome::SkippedDisabled);
assert!(status.destinations.is_empty());
}
#[tokio::test]
async fn a_tick_over_only_disabled_sources_names_the_empty_plan() {
let tmp = TempDir::new().expect("tempdir");
let dest = tmp.path().join("dest");
let logs = log_dir_with(&tmp, "INFO a line\n");
let state = tmp.path().join("state");
let mut config = enabled_config(&dest, &logs);
let section = config.log_drain.as_mut().expect("fixture section");
section.sources[0].enabled = Some(false);
let plan = plan_of(&config, tmp.path());
assert!(
plan.destinations.is_empty(),
"no source is left for a destination to carry"
);
assert_eq!(plan.disabled.len(), 1);
let status = run_tick(&plan, &state, &mut RetentionDebounce::new()).await;
assert_eq!(status.outcome, DrainOutcome::Success, "{}", status.detail);
assert_eq!(status.detail, "no destination had any source configured");
assert!(status.destinations.is_empty());
assert_eq!(status.uploaded, 0);
assert_eq!(status.skipped_unchanged, 0);
assert!(
!dest.exists(),
"an empty plan must not touch the destination"
);
}
#[tokio::test]
async fn a_config_error_records_failed() {
let tmp = TempDir::new().expect("tempdir");
let root = tmp.path().join("framework");
let config = broken_config("not-a-uri");
let status = drain_once(&config, &root, tmp.path()).await;
assert_eq!(status.outcome, DrainOutcome::Failed, "{}", status.detail);
assert!(
status.detail.starts_with("config error:"),
"unhelpful detail: {}",
status.detail
);
}
#[test]
fn should_spawn_matches_the_setting() {
let tmp = TempDir::new().expect("tempdir");
assert_eq!(
should_spawn(&TrustyToolsConfig::default(), tmp.path()),
Ok(false)
);
let config = enabled_config(&tmp.path().join("dest"), &tmp.path().join("logs"));
assert_eq!(should_spawn(&config, tmp.path()), Ok(true));
let broken = broken_config("gs://bucket/prefix");
assert!(
should_spawn(&broken, tmp.path()).is_err(),
"a reserved scheme must refuse startup rather than spawn a doomed loop"
);
}
#[test]
fn status_round_trips_through_the_state_dir() {
let tmp = TempDir::new().expect("tempdir");
let dir = tmp.path().join("state");
assert_eq!(load_status(&dir), None, "nothing recorded yet");
let status = config_error_status("destination is invalid");
save_status(&dir, &status);
assert_eq!(load_status(&dir).as_ref(), Some(&status));
}
#[tokio::test]
async fn the_loop_exits_on_cancel() {
let tmp = TempDir::new().expect("tempdir");
let cancel = tokio_util::sync::CancellationToken::new();
cancel.cancel();
let handle = tokio::spawn(log_drain_loop(
tmp.path().join("framework"),
tmp.path().to_path_buf(),
cancel.child_token(),
));
handle.await.expect("the loop exits cleanly on cancel");
}
#[tokio::test]
async fn a_disabled_source_uploads_nothing_and_is_still_reported() {
let tmp = TempDir::new().expect("tempdir");
let dest = tmp.path().join("dest");
let logs_on = named_log_dir(&tmp, "alpha");
let logs_off = named_log_dir(&tmp, "beta");
let state = tmp.path().join("state");
let mut config = enabled_config(&dest, &logs_on);
let section = config.log_drain.as_mut().expect("fixture section");
section.sources[0].crate_name = Some("trusty-mpm".to_string());
section.sources[0].include = vec!["*.log".to_string()];
section
.sources
.push(crate::core::trusty_tools_config::LogDrainSourceConfig {
crate_name: Some("trusty-code".to_string()),
root: Some(logs_off.display().to_string()),
include: vec!["*.log".to_string()],
enabled: Some(false),
..Default::default()
});
let plan = plan_of(&config, tmp.path());
assert_eq!(plan.destinations.len(), 1, "only the enabled source drains");
assert_eq!(plan.disabled.len(), 1);
assert_eq!(plan.disabled[0].crate_name, "trusty-code");
let status = run_tick(&plan, &state, &mut RetentionDebounce::new()).await;
assert_eq!(status.outcome, DrainOutcome::Success, "{}", status.detail);
assert_eq!(status.uploaded, 1, "{}", status.detail);
assert!(drained_path(&dest, "trusty-mpm", "alpha.log").exists());
assert!(
!drained_path(&dest, "trusty-code", "beta.log").exists(),
"a disabled source must upload nothing"
);
}
#[tokio::test]
async fn the_object_key_comes_from_the_source_root_s_git_origin() {
let tmp = TempDir::new().expect("tempdir");
let repo = tmp.path().join("checkout");
let logs = repo.join("logs");
std::fs::create_dir_all(&logs).expect("repo dirs");
let git = |args: &[&str]| {
std::process::Command::new("git")
.arg("-C")
.arg(&repo)
.args(args)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
};
if !git(&["init"]) {
return; }
let _ = git(&[
"remote",
"add",
"origin",
"git@github.com:duettoresearch/pricing.git",
]);
std::fs::write(logs.join("trusty-code.log"), "INFO a line\n").expect("write log file");
let dest = tmp.path().join("dest");
let state = tmp.path().join("state");
let config = TrustyToolsConfig {
log_drain: Some(LogDrainConfig {
enabled: Some(true),
destination: Some(format!("file://{}", dest.display())),
sources: vec![crate::core::trusty_tools_config::LogDrainSourceConfig {
crate_name: Some("trusty-code".to_string()),
root: Some(logs.display().to_string()),
include: vec!["*.log".to_string()],
..Default::default()
}],
..Default::default()
}),
..Default::default()
};
let plan = plan_of(&config, tmp.path());
assert_eq!(
plan.destinations[0].target.key_prefix(),
"duettoresearch/pricing"
);
let status = run_tick(&plan, &state, &mut RetentionDebounce::new()).await;
assert_eq!(status.outcome, DrainOutcome::Success, "{}", status.detail);
assert_eq!(status.uploaded, 1, "{}", status.detail);
let key = dest
.join("duettoresearch")
.join("pricing")
.join("trusty-code")
.join("trusty-code.log");
assert!(
key.exists(),
"expected the drained object at {}",
key.display()
);
assert!(
!dest.join("octocat").exists() && !key.parent().unwrap().join("logs").exists(),
"the old <github_id>/<session>/logs layout must be gone"
);
}