use net_sdk::dataforts::BlobInventoryEntry;
use net_sdk::deck::{
AdminAuditRecord, AdminEvent, FailureRecord, LogLevel, LogRecord, VerificationOutcome,
};
const EXTENSION: &str = ".txt";
pub struct ExportResult {
pub path: String,
pub count: usize,
}
pub type ExportError = String;
fn sanitize(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\n' | '\r' | '\t' => out.push(' '),
c if c.is_control() => out.push('?'),
c => out.push(c),
}
}
out
}
pub fn write_logs(records: &[LogRecord]) -> Result<ExportResult, ExportError> {
let (path, mut f) = open_unique("logs")?;
let mut count = 0;
for rec in records {
let level = match rec.level {
LogLevel::Debug => "DEBUG",
LogLevel::Info => "INFO ",
LogLevel::Warn => "WARN ",
LogLevel::Error => "ERROR",
_ => "? ",
};
let source = format_log_source(rec);
writeln!(
f,
"{ts} {level} {source} {msg}",
ts = format_ts_ms(rec.ts_ms),
msg = sanitize(&rec.message),
)
.map_err(|e| format!("write: {e}"))?;
count += 1;
}
use std::io::Write;
f.flush().map_err(|e| format!("flush: {e}"))?;
Ok(ExportResult { path, count })
}
pub fn write_audit(records: &[AdminAuditRecord]) -> Result<ExportResult, ExportError> {
let (path, mut f) = open_unique("audit")?;
let mut count = 0;
for rec in records.iter().rev() {
let outcome = match rec.outcome {
VerificationOutcome::Accepted => "Accepted",
VerificationOutcome::Unverified => "Unverified",
VerificationOutcome::Rejected { .. } => "Rejected",
_ => "?",
};
let op = if rec.operator_ids.is_empty() {
"—".to_string()
} else {
rec.operator_ids
.iter()
.map(|id| format!("0x{id:x}"))
.collect::<Vec<_>>()
.join(",")
};
let (cmd, target) = format_admin_event(&rec.event);
writeln!(
f,
"seq={seq:>5} ts_ms={ts} {outcome} op={op} cmd={cmd} target={target}",
seq = rec.seq,
ts = rec.committed_at_ms,
)
.map_err(|e| format!("write: {e}"))?;
count += 1;
}
use std::io::Write;
f.flush().map_err(|e| format!("flush: {e}"))?;
Ok(ExportResult { path, count })
}
pub fn write_blobs(entries: &[BlobInventoryEntry]) -> Result<ExportResult, ExportError> {
let (path, mut f) = open_unique("blobs")?;
let mut count = 0;
for e in entries {
writeln!(
f,
"hash={hash} ref={ref_} pin={pin} first_seen_ms={first} last_seen_ms={last}",
hash = sanitize(&e.hash_hex),
ref_ = e.refcount,
pin = if e.pinned { "1" } else { "0" },
first = e.first_seen_unix_ms,
last = e.last_seen_unix_ms,
)
.map_err(|e| format!("write: {e}"))?;
count += 1;
}
use std::io::Write;
f.flush().map_err(|e| format!("flush: {e}"))?;
Ok(ExportResult { path, count })
}
pub fn write_failures(records: &[FailureRecord]) -> Result<ExportResult, ExportError> {
let (path, mut f) = open_unique("failures")?;
let mut count = 0;
for rec in records.iter().rev() {
writeln!(
f,
"seq={seq:>5} ts_ms={ts} source={src} reason={reason}",
seq = rec.seq,
ts = rec.recorded_at_ms,
src = sanitize(&rec.source),
reason = sanitize(&rec.reason),
)
.map_err(|e| format!("write: {e}"))?;
count += 1;
}
use std::io::Write;
f.flush().map_err(|e| format!("flush: {e}"))?;
Ok(ExportResult { path, count })
}
fn open_unique(tab: &str) -> Result<(String, std::io::BufWriter<std::fs::File>), ExportError> {
let now = chrono::Utc::now();
let stamp = now.format("%Y-%m-%dT%H-%M-%SZ").to_string();
let cwd = std::env::current_dir().ok();
let base = format!("deck-{tab}-{stamp}");
for attempt in 0..100 {
let filename = if attempt == 0 {
format!("{base}{EXTENSION}")
} else {
format!("{base}-{attempt}{EXTENSION}")
};
let full_path = match cwd.as_ref() {
Some(d) => d.join(&filename),
None => std::path::PathBuf::from(&filename),
};
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&full_path)
{
Ok(f) => return Ok((full_path.display().to_string(), std::io::BufWriter::new(f))),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(format!("create {}: {e}", full_path.display())),
}
}
Err(format!("create deck-{tab}-{stamp}: too many collisions"))
}
fn format_ts_ms(ts_ms: u64) -> String {
let total_sec = ts_ms / 1_000;
let hh = total_sec / 3_600;
let mm = (total_sec / 60) % 60;
let ss = total_sec % 60;
let ms = ts_ms % 1_000;
format!("{hh:02}:{mm:02}:{ss:02}.{ms:03}")
}
fn format_log_source(rec: &LogRecord) -> String {
match (rec.node_id, rec.daemon_id) {
(Some(n), Some(d)) => format!("0x{n:x}/0x{d:x}"),
(Some(n), None) => format!("0x{n:x}"),
(None, Some(d)) => format!("daemon.0x{d:x}"),
(None, None) => "—".to_string(),
}
}
fn format_admin_event(event: &AdminEvent) -> (&'static str, String) {
use AdminEvent::*;
match event {
EnterMaintenance { node, .. } => ("enter_maintenance", format!("0x{node:x}")),
ExitMaintenance { node } => ("exit_maintenance", format!("0x{node:x}")),
Drain { node, .. } => ("drain", format!("0x{node:x}")),
Cordon { node } => ("cordon", format!("0x{node:x}")),
Uncordon { node } => ("uncordon", format!("0x{node:x}")),
RestartAllDaemons { node } => ("restart_all_daemons", format!("0x{node:x}")),
ClearAvoidList { node } => ("clear_avoid_list", format!("0x{node:x}")),
InvalidatePlacement { node } => ("invalidate_placement", format!("0x{node:x}")),
DropReplicas { node, chains } => (
"drop_replicas",
format!("0x{node:x} chains={}", chains.len()),
),
FreezeCluster { ttl } => ("freeze_cluster", format!("ttl={}s", ttl.as_secs())),
ThawCluster => ("thaw_cluster", "cluster".to_string()),
FlushAvoidLists { .. } => ("flush_avoid_lists", "avoid-lists".to_string()),
ForceEvictReplica { chain, victim } => (
"force_evict_replica",
format!("chain=0x{chain:x} victim=0x{victim:x}"),
),
ForceRestartDaemon { daemon } => {
("force_restart_daemon", format!("daemon=0x{:x}", daemon.id))
}
ForceCutover { chain, target } => (
"force_cutover",
format!("chain=0x{chain:x} target=0x{target:x}"),
),
KillMigration { migration } => ("kill_migration", format!("migration=0x{migration:x}")),
_ => ("unknown", "—".to_string()),
}
}