mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-08 04:58:12 +00:00
234 lines
9.2 KiB
Rust
234 lines
9.2 KiB
Rust
// Copyright 2024 RustFS Team
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
//! MVP acceptance scenarios for `rustfs diagnose` (rustfs/backlog#1281 §MVP,
|
|
//! rustfs/backlog#1288). Pure file-based tests — no server, no network —
|
|
//! exercising the same library pipeline the subcommand drives, plus CLI
|
|
//! parsing regressions for the legacy argv preprocessor.
|
|
|
|
use rustfs::config::{CommandResult, Opt};
|
|
use rustfs_log_analyzer::{
|
|
AnalysisReport, AnalyzeOptions, Analyzer, IngestOptions, ReportFormat, RuleEngine, ingest_path, render, seed_rule_set,
|
|
};
|
|
use std::io::Write;
|
|
use std::path::Path;
|
|
|
|
fn json_line(ts: &str, level: &str, message: &str, extra_fields: &[(&str, &str)]) -> String {
|
|
let mut obj = serde_json::json!({
|
|
"timestamp": ts,
|
|
"level": level,
|
|
"target": "rustfs::storage::erasure",
|
|
"message": message,
|
|
});
|
|
for (name, value) in extra_fields {
|
|
obj[*name] = serde_json::Value::String((*value).to_string());
|
|
}
|
|
obj.to_string()
|
|
}
|
|
|
|
fn analyze_dir(path: &Path) -> AnalysisReport {
|
|
let mut analyzer = Analyzer::new(RuleEngine::new(seed_rule_set()), AnalyzeOptions::default());
|
|
let ingest = ingest_path(path, &IngestOptions::default(), &mut |ev| analyzer.observe(ev)).expect("ingest");
|
|
analyzer.finalize(ingest)
|
|
}
|
|
|
|
fn quorum_and_faulty_lines() -> String {
|
|
let mut lines = Vec::new();
|
|
for disk in ["disk3", "disk7"] {
|
|
lines.push(json_line(
|
|
"2026-07-15T03:10:00+08:00",
|
|
"ERROR",
|
|
"Disk health check marked disk faulty",
|
|
&[("reason", "faulty_disk"), ("disk", disk)],
|
|
));
|
|
}
|
|
for i in 0..30 {
|
|
lines.push(json_line(
|
|
&format!("2026-07-15T03:{:02}:00+08:00", 12 + i % 40),
|
|
"ERROR",
|
|
"erasure write quorum (required=8, achieved=5, failed=3)",
|
|
&[("offline-disks", "2/16"), ("required", "8"), ("achieved", "5")],
|
|
));
|
|
}
|
|
let mut joined = lines.join("\n");
|
|
joined.push('\n');
|
|
joined
|
|
}
|
|
|
|
/// MVP scenario 1: directory with a live log + a zstd-rotated archive.
|
|
#[test]
|
|
fn directory_with_zst_archive_yields_quorum_and_disk_findings() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(dir.path().join("rustfs.log"), quorum_and_faulty_lines()).expect("write");
|
|
let archived = zstd::stream::encode_all(
|
|
std::io::Cursor::new(json_line(
|
|
"2026-07-15T02:00:00+08:00",
|
|
"ERROR",
|
|
"erasure write quorum (required=8, achieved=4, failed=4)",
|
|
&[("offline-disks", "3/16")],
|
|
)),
|
|
3,
|
|
)
|
|
.expect("zstd");
|
|
std::fs::write(dir.path().join("20260715030001.000000-0.rustfs.log.zst"), archived).expect("write");
|
|
|
|
let report = analyze_dir(dir.path());
|
|
let ids: Vec<&str> = report.findings.iter().map(|f| f.rule_id.as_str()).collect();
|
|
assert!(ids.contains(&"ec-write-quorum"), "findings: {ids:?}");
|
|
assert!(ids.contains(&"disk-marked-faulty"), "findings: {ids:?}");
|
|
|
|
let quorum = report
|
|
.findings
|
|
.iter()
|
|
.find(|f| f.rule_id == "ec-write-quorum")
|
|
.expect("quorum");
|
|
assert_eq!(quorum.count, 31, "live log + archive");
|
|
let offline = quorum.evidence.get("offline-disks").expect("offline-disks evidence");
|
|
assert!(offline.values.contains("2/16") && offline.values.contains("3/16"));
|
|
// The quorum finding must sort above the P2 disk finding.
|
|
assert!(ids.iter().position(|id| *id == "ec-write-quorum") < ids.iter().position(|id| *id == "disk-marked-faulty"));
|
|
}
|
|
|
|
/// MVP scenario 2: multi-node zip — evidence is attributed per node.
|
|
#[test]
|
|
fn multi_node_zip_attributes_findings_to_nodes() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let zip_path = dir.path().join("customer.zip");
|
|
let mut writer = zip::ZipWriter::new(std::fs::File::create(&zip_path).expect("create"));
|
|
for node in ["node1", "node2"] {
|
|
writer
|
|
.start_file(format!("{node}/rustfs.log"), zip::write::SimpleFileOptions::default())
|
|
.expect("start_file");
|
|
writer.write_all(quorum_and_faulty_lines().as_bytes()).expect("write");
|
|
}
|
|
writer.finish().expect("finish");
|
|
|
|
let report = analyze_dir(&zip_path);
|
|
assert_eq!(report.summary.nodes, vec!["node1", "node2"]);
|
|
let quorum = report
|
|
.findings
|
|
.iter()
|
|
.find(|f| f.rule_id == "ec-write-quorum")
|
|
.expect("quorum");
|
|
assert!(quorum.nodes.contains("node1") && quorum.nodes.contains("node2"));
|
|
}
|
|
|
|
/// MVP scenario 3: kubectl-logs CRI prefixes must not hurt the parse rate.
|
|
#[test]
|
|
fn cri_prefixed_logs_parse_cleanly() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let mut content = String::new();
|
|
for line in quorum_and_faulty_lines().lines() {
|
|
content.push_str("2026-07-14T19:10:00.123456789Z stdout F ");
|
|
content.push_str(line);
|
|
content.push('\n');
|
|
}
|
|
std::fs::write(dir.path().join("kubectl.log"), content).expect("write");
|
|
|
|
let report = analyze_dir(dir.path());
|
|
assert!(report.summary.parse.prefixed_json > 0);
|
|
assert!(report.summary.parse.json_ratio() > 0.99, "ratio: {}", report.summary.parse.json_ratio());
|
|
assert!(report.findings.iter().any(|f| f.rule_id == "ec-write-quorum"));
|
|
}
|
|
|
|
/// MVP scenario 4: a stderr panic block folds into a P1 finding.
|
|
#[test]
|
|
fn panic_block_becomes_a_process_panic_finding() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(
|
|
dir.path().join("stderr.log"),
|
|
"thread 'tokio-runtime-worker' panicked at crates/ecstore/src/set_disk.rs:100:17:\n\
|
|
called `Option::unwrap()` on a `None` value\n\
|
|
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace\n",
|
|
)
|
|
.expect("write");
|
|
|
|
let report = analyze_dir(dir.path());
|
|
let panic = report
|
|
.findings
|
|
.iter()
|
|
.find(|f| f.rule_id == "process-panic")
|
|
.expect("panic finding");
|
|
assert_eq!(panic.count, 1);
|
|
let location = panic.evidence.get("panic_location").expect("panic_location");
|
|
assert!(location.values.contains("crates/ecstore/src/set_disk.rs:100:17"));
|
|
assert!(panic.samples[0].message.contains("panicked at"));
|
|
}
|
|
|
|
/// MVP scenario 5: JSON output is stable and machine-readable.
|
|
#[test]
|
|
fn json_format_has_a_stable_schema() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(dir.path().join("rustfs.log"), quorum_and_faulty_lines()).expect("write");
|
|
let report = analyze_dir(dir.path());
|
|
|
|
let mut out = Vec::new();
|
|
render(&report, ReportFormat::Json, &mut out).expect("render");
|
|
let value: serde_json::Value = serde_json::from_slice(&out).expect("parse");
|
|
assert_eq!(value["schema_version"], 2);
|
|
assert!(value["findings"].as_array().expect("findings").len() >= 2);
|
|
assert!(value["summary"]["parse"]["json_ok"].as_u64().expect("json_ok") > 0);
|
|
|
|
let mut md = Vec::new();
|
|
render(&report, ReportFormat::Markdown, &mut md).expect("render md");
|
|
assert!(String::from_utf8(md).expect("utf8").starts_with("# RustFS 日志诊断报告"));
|
|
}
|
|
|
|
/// CLI parsing: diagnose is a first-class subcommand and the legacy
|
|
/// `rustfs <volume>` form still routes to the server.
|
|
#[test]
|
|
fn cli_parses_diagnose_and_keeps_legacy_server_routing() {
|
|
let parsed = Opt::parse_command(vec![
|
|
"rustfs".to_string(),
|
|
"diagnose".to_string(),
|
|
"/tmp/customer-logs".to_string(),
|
|
"--format".to_string(),
|
|
"json".to_string(),
|
|
"--since".to_string(),
|
|
"24h".to_string(),
|
|
"--redact".to_string(),
|
|
"--rules".to_string(),
|
|
"/tmp/extra-rules.json".to_string(),
|
|
])
|
|
.expect("parse");
|
|
let CommandResult::Diagnose(opts) = parsed else {
|
|
panic!("expected Diagnose command");
|
|
};
|
|
assert_eq!(opts.paths, vec!["/tmp/customer-logs"]);
|
|
assert!(opts.redact);
|
|
assert_eq!(opts.since.as_deref(), Some("24h"));
|
|
assert_eq!(opts.rules.as_deref(), Some(std::path::Path::new("/tmp/extra-rules.json")));
|
|
|
|
// Legacy preprocessor regression: a bare volume still means `server`.
|
|
let legacy = Opt::parse_command(vec!["rustfs".to_string(), "/data".to_string()]);
|
|
assert!(matches!(legacy, Ok(CommandResult::Server(_))));
|
|
}
|
|
|
|
/// True-binary smoke test for the user-facing diagnose command.
|
|
#[test]
|
|
fn binary_smoke_diagnose_json() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(dir.path().join("rustfs.log"), quorum_and_faulty_lines()).expect("write");
|
|
|
|
let output = std::process::Command::new(env!("CARGO_BIN_EXE_rustfs"))
|
|
.arg("diagnose")
|
|
.arg(dir.path())
|
|
.args(["--format", "json"])
|
|
.output()
|
|
.expect("run rustfs diagnose");
|
|
assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr));
|
|
let value: serde_json::Value = serde_json::from_slice(&output.stdout).expect("stdout JSON");
|
|
assert_eq!(value["schema_version"], 2);
|
|
}
|