1use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
8#[serde(rename_all = "kebab-case")]
9pub enum LogDriver {
10 #[default]
12 JsonFile,
13 Syslog,
20 None,
22}
23
24impl std::fmt::Display for LogDriver {
25 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26 match self {
27 Self::JsonFile => write!(f, "json-file"),
28 Self::Syslog => write!(f, "syslog"),
29 Self::None => write!(f, "none"),
30 }
31 }
32}
33
34impl std::str::FromStr for LogDriver {
35 type Err = String;
36
37 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
38 match s {
39 "json-file" => Ok(Self::JsonFile),
40 "syslog" => Ok(Self::Syslog),
41 "none" => Ok(Self::None),
42 _ => Err(format!(
43 "unknown log driver: '{}' (supported: json-file, syslog, none)",
44 s
45 )),
46 }
47 }
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct LogConfig {
53 pub driver: LogDriver,
54 #[serde(default)]
55 pub options: HashMap<String, String>,
56}
57
58impl Default for LogConfig {
59 fn default() -> Self {
60 Self {
61 driver: LogDriver::JsonFile,
62 options: HashMap::new(),
63 }
64 }
65}
66
67impl LogConfig {
68 pub fn max_size(&self) -> u64 {
71 self.options
72 .get("max-size")
73 .and_then(|s| parse_size(s).ok())
74 .unwrap_or(10 * 1024 * 1024)
75 }
76
77 pub fn max_file(&self) -> u32 {
80 self.options
81 .get("max-file")
82 .and_then(|s| s.parse().ok())
83 .unwrap_or(3)
84 }
85
86 pub fn syslog_address(&self) -> &str {
89 self.options
90 .get("syslog-address")
91 .map(|s| s.as_str())
92 .unwrap_or("udp://localhost:514")
93 }
94
95 pub fn syslog_facility(&self) -> &str {
98 self.options
99 .get("syslog-facility")
100 .map(|s| s.as_str())
101 .unwrap_or("daemon")
102 }
103
104 pub fn tag(&self) -> Option<&str> {
106 self.options.get("tag").map(|s| s.as_str())
107 }
108}
109
110#[derive(Debug, Serialize, Deserialize)]
112pub struct LogEntry {
113 pub log: String,
115 pub stream: String,
117 pub time: String,
119}
120
121fn parse_size(s: &str) -> std::result::Result<u64, String> {
123 let s = s.trim().to_lowercase();
124 if let Ok(n) = s.parse::<u64>() {
125 return Ok(n);
126 }
127 let (num, mult) = if s.ends_with("gb") || s.ends_with('g') {
128 (
129 s.trim_end_matches("gb").trim_end_matches('g'),
130 1024u64 * 1024 * 1024,
131 )
132 } else if s.ends_with("mb") || s.ends_with('m') {
133 (
134 s.trim_end_matches("mb").trim_end_matches('m'),
135 1024u64 * 1024,
136 )
137 } else if s.ends_with("kb") || s.ends_with('k') {
138 (s.trim_end_matches("kb").trim_end_matches('k'), 1024u64)
139 } else if s.ends_with('b') {
140 (s.trim_end_matches('b'), 1u64)
141 } else {
142 return Err(format!("unrecognized size format: {s}"));
143 };
144 let n: u64 = num.parse().map_err(|_| format!("invalid number: {num}"))?;
145 Ok(n * mult)
146}
147
148use std::io::{BufRead, BufReader, Seek, Write};
159use std::path::{Path, PathBuf};
160use std::sync::atomic::{AtomicBool, Ordering};
161
162fn console_truncate_if_over(path: &Path, cap: u64) -> bool {
172 let Ok(meta) = std::fs::metadata(path) else {
173 return false;
174 };
175 if meta.len() <= cap {
176 return false;
177 }
178 match std::fs::OpenOptions::new().write(true).open(path) {
179 Ok(f) if f.set_len(0).is_ok() => {
180 tracing::debug!(path = %path.display(), cap, "console log exceeded cap; truncated");
181 true
182 }
183 _ => false,
184 }
185}
186
187pub fn json_log_path(log_dir: &Path) -> PathBuf {
189 log_dir.join("container.json")
190}
191
192pub fn is_runtime_console_noise(line: &str) -> bool {
198 line.starts_with("init.krun:")
199}
200
201fn tail_next_line(
208 reader: &mut (impl BufRead + Seek),
209 buf: &mut String,
210 stop: &AtomicBool,
211 on_eof: Option<&dyn Fn() -> bool>,
212) -> Option<String> {
213 loop {
214 match reader.read_line(buf) {
215 Ok(0) | Err(_) => {
216 if stop.load(Ordering::Relaxed) {
217 if buf.is_empty() {
220 return None;
221 }
222 let line = std::mem::take(buf);
223 return Some(line.trim_end_matches(['\n', '\r']).to_string());
224 }
225 if buf.is_empty() {
229 if let Some(on_eof) = on_eof {
230 if on_eof() {
231 let _ = reader.seek(std::io::SeekFrom::Start(0));
232 }
233 }
234 }
235 std::thread::sleep(std::time::Duration::from_millis(100));
236 continue;
237 }
238 Ok(_) => {}
239 }
240 if !buf.ends_with('\n') {
241 continue;
243 }
244 let line = std::mem::take(buf);
245 return Some(line.trim_end_matches(['\n', '\r']).to_string());
246 }
247}
248
249pub fn run_log_processor(
254 console_log: &Path,
255 log_dir: &Path,
256 config: &LogConfig,
257 stop: &AtomicBool,
258) {
259 match config.driver {
260 LogDriver::None => run_discard_processor(
265 console_log,
266 Some(console_cap(config.max_size(), config.max_file())),
267 stop,
268 ),
269 LogDriver::JsonFile => run_json_file_processor(
270 console_log,
271 log_dir,
272 config.max_size(),
273 config.max_file(),
274 stop,
275 ),
276 LogDriver::Syslog => run_syslog_processor(
277 console_log,
278 config.syslog_address(),
279 config.syslog_facility(),
280 config.tag().unwrap_or("a3s-box"),
281 Some(console_cap(config.max_size(), config.max_file())),
282 stop,
283 ),
284 }
285}
286
287fn open_console(console_log: &Path, stop: &AtomicBool) -> Option<std::fs::File> {
290 for _ in 0..300 {
291 if console_log.exists() {
292 break;
293 }
294 if stop.load(Ordering::Relaxed) && !console_log.exists() {
295 return None;
296 }
297 std::thread::sleep(std::time::Duration::from_millis(100));
298 }
299 std::fs::File::open(console_log).ok()
300}
301
302pub fn stderr_console_path(console_log: &Path) -> PathBuf {
306 console_log.with_file_name("console.err.log")
307}
308
309fn run_tagged_tail(
313 file: &Path,
314 stream: &str,
315 filter_noise: bool,
316 bound: Option<u64>,
317 stop: &AtomicBool,
318 emit: &(dyn Fn(&str, &str) + Sync),
319) {
320 let f = match open_console(file, stop) {
321 Some(f) => f,
322 None => return,
323 };
324 let mut reader = BufReader::new(f);
325 let mut buf = String::new();
326 let truncate = bound.map(|cap| move || console_truncate_if_over(file, cap));
329 let on_eof: Option<&dyn Fn() -> bool> = truncate.as_ref().map(|t| t as &dyn Fn() -> bool);
330 while let Some(line) = tail_next_line(&mut reader, &mut buf, stop, on_eof) {
331 if filter_noise && is_runtime_console_noise(&line) {
332 continue;
333 }
334 emit(&line, stream);
335 }
336}
337
338fn console_cap(max_size: u64, max_file: u32) -> u64 {
342 max_size.saturating_mul(u64::from(max_file.max(1)))
343}
344
345fn run_discard_processor(console_log: &Path, cap: Option<u64>, stop: &AtomicBool) {
350 let err_log = stderr_console_path(console_log);
351 let discard = |_line: &str, _stream: &str| {};
352 let discard: &(dyn Fn(&str, &str) + Sync) = &discard;
353 std::thread::scope(|s| {
354 s.spawn(|| run_tagged_tail(console_log, "stdout", false, cap, stop, discard));
355 s.spawn(|| run_tagged_tail(&err_log, "stderr", false, cap, stop, discard));
356 });
357}
358
359fn run_json_file_processor(
360 console_log: &Path,
361 log_dir: &Path,
362 max_size: u64,
363 max_file: u32,
364 stop: &AtomicBool,
365) {
366 let json_path = json_log_path(log_dir);
367 let writer = std::sync::Mutex::new(match RotatingWriter::new(&json_path, max_size, max_file) {
368 Ok(w) => w,
369 Err(_) => return,
370 });
371 let err_log = stderr_console_path(console_log);
372
373 let emit = |line: &str, stream: &str| {
376 let entry = LogEntry {
377 log: format!("{line}\n"),
378 stream: stream.to_string(),
379 time: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Nanos, true),
380 };
381 if let Ok(json) = serde_json::to_string(&entry) {
382 if let Ok(mut w) = writer.lock() {
383 let _ = w.write_line(&json);
384 }
385 }
386 };
387 let emit: &(dyn Fn(&str, &str) + Sync) = &emit;
388
389 let cap = Some(console_cap(max_size, max_file));
390 std::thread::scope(|s| {
391 s.spawn(|| run_tagged_tail(console_log, "stdout", true, cap, stop, emit));
392 s.spawn(|| run_tagged_tail(&err_log, "stderr", true, cap, stop, emit));
395 });
396}
397
398fn run_syslog_processor(
400 console_log: &Path,
401 address: &str,
402 _facility: &str,
403 tag: &str,
404 cap: Option<u64>,
405 stop: &AtomicBool,
406) {
407 use std::net::UdpSocket;
408
409 let (proto, addr) = if let Some(rest) = address.strip_prefix("udp://") {
410 ("udp", rest)
411 } else if let Some(rest) = address.strip_prefix("tcp://") {
412 ("tcp", rest)
413 } else {
414 ("udp", address)
415 };
416 let err_log = stderr_console_path(console_log);
417
418 match proto {
419 "udp" => {
420 let socket = match UdpSocket::bind("0.0.0.0:0") {
421 Ok(s) => s,
422 Err(_) => return,
423 };
424 let emit = |line: &str, _stream: &str| {
426 let msg = format!("<30>{tag}: {line}");
427 let _ = socket.send_to(msg.as_bytes(), addr);
428 };
429 let emit: &(dyn Fn(&str, &str) + Sync) = &emit;
430 std::thread::scope(|s| {
431 s.spawn(|| run_tagged_tail(console_log, "stdout", true, cap, stop, emit));
432 s.spawn(|| run_tagged_tail(&err_log, "stderr", true, cap, stop, emit));
433 });
434 }
435 "tcp" => {
436 let stream = match std::net::TcpStream::connect(addr) {
437 Ok(s) => std::sync::Mutex::new(s),
438 Err(_) => return,
439 };
440 let emit = |line: &str, _stream: &str| {
441 let msg = format!("<30>{tag}: {line}\n");
442 if let Ok(mut s) = stream.lock() {
443 if s.write_all(msg.as_bytes()).is_err() {
444 if let Ok(news) = std::net::TcpStream::connect(addr) {
445 *s = news;
446 let _ = s.write_all(msg.as_bytes());
447 }
448 }
449 }
450 };
451 let emit: &(dyn Fn(&str, &str) + Sync) = &emit;
452 std::thread::scope(|sc| {
453 sc.spawn(|| run_tagged_tail(console_log, "stdout", true, cap, stop, emit));
454 sc.spawn(|| run_tagged_tail(&err_log, "stderr", true, cap, stop, emit));
455 });
456 }
457 _ => {}
458 }
459}
460
461struct RotatingWriter {
463 path: PathBuf,
464 file: std::fs::File,
465 written: u64,
466 max_size: u64,
467 max_file: u32,
468}
469
470impl RotatingWriter {
471 fn new(path: &Path, max_size: u64, max_file: u32) -> std::io::Result<Self> {
472 let file = std::fs::OpenOptions::new()
473 .create(true)
474 .append(true)
475 .open(path)?;
476 let written = file.metadata()?.len();
477 Ok(Self {
478 path: path.to_path_buf(),
479 file,
480 written,
481 max_size,
482 max_file,
483 })
484 }
485
486 fn write_line(&mut self, line: &str) -> std::io::Result<()> {
487 let bytes = format!("{line}\n");
488 self.file.write_all(bytes.as_bytes())?;
489 self.file.flush()?;
490 self.written += bytes.len() as u64;
491 if self.written >= self.max_size {
492 self.rotate()?;
493 }
494 Ok(())
495 }
496
497 fn rotate(&mut self) -> std::io::Result<()> {
498 for i in (1..self.max_file).rev() {
499 let from = rotated_path(&self.path, i);
500 let to = rotated_path(&self.path, i + 1);
501 if from.exists() {
502 std::fs::rename(&from, &to)?;
503 }
504 }
505 let oldest = rotated_path(&self.path, self.max_file);
506 if oldest.exists() {
507 std::fs::remove_file(&oldest)?;
508 }
509 let rotated = rotated_path(&self.path, 1);
510 compress_file(&self.path, &rotated)?;
511 std::fs::remove_file(&self.path)?;
512 self.file = std::fs::OpenOptions::new()
513 .create(true)
514 .append(true)
515 .open(&self.path)?;
516 self.written = 0;
517 Ok(())
518 }
519}
520
521fn compress_file(src: &Path, dst: &Path) -> std::io::Result<()> {
523 use flate2::write::GzEncoder;
524 use flate2::Compression;
525 use std::io::Read;
526
527 let mut input = std::fs::File::open(src)?;
528 let output = std::fs::File::create(dst)?;
529 let mut encoder = GzEncoder::new(output, Compression::fast());
530 let mut buf = [0u8; 8192];
531 loop {
532 let n = input.read(&mut buf)?;
533 if n == 0 {
534 break;
535 }
536 encoder.write_all(&buf[..n])?;
537 }
538 encoder.finish()?;
539 Ok(())
540}
541
542fn rotated_path(base: &Path, index: u32) -> PathBuf {
544 let mut p = base.as_os_str().to_owned();
545 p.push(format!(".{index}.gz"));
546 PathBuf::from(p)
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552
553 #[test]
554 fn test_log_driver_from_str() {
555 assert_eq!(
556 "json-file".parse::<LogDriver>().unwrap(),
557 LogDriver::JsonFile
558 );
559 assert_eq!("syslog".parse::<LogDriver>().unwrap(), LogDriver::Syslog);
560 assert_eq!("none".parse::<LogDriver>().unwrap(), LogDriver::None);
561 assert!("unknown".parse::<LogDriver>().is_err());
562 }
563
564 #[test]
565 fn test_log_config_defaults() {
566 let config = LogConfig::default();
567 assert_eq!(config.driver, LogDriver::JsonFile);
568 assert_eq!(config.max_size(), 10 * 1024 * 1024);
569 assert_eq!(config.max_file(), 3);
570 }
571
572 #[test]
573 fn test_log_config_custom_options() {
574 let mut config = LogConfig::default();
575 config
576 .options
577 .insert("max-size".to_string(), "50m".to_string());
578 config
579 .options
580 .insert("max-file".to_string(), "5".to_string());
581 assert_eq!(config.max_size(), 50 * 1024 * 1024);
582 assert_eq!(config.max_file(), 5);
583 }
584
585 #[test]
586 fn test_parse_size() {
587 assert_eq!(parse_size("1024").unwrap(), 1024);
588 assert_eq!(parse_size("10m").unwrap(), 10 * 1024 * 1024);
589 assert_eq!(parse_size("1g").unwrap(), 1024 * 1024 * 1024);
590 assert_eq!(parse_size("512k").unwrap(), 512 * 1024);
591 assert!(parse_size("abc").is_err());
592 }
593
594 #[test]
595 fn test_log_entry_serialization() {
596 let entry = LogEntry {
597 log: "hello\n".to_string(),
598 stream: "stdout".to_string(),
599 time: "2026-02-12T06:00:00.000000000Z".to_string(),
600 };
601 let json = serde_json::to_string(&entry).unwrap();
602 assert!(json.contains("\"log\":\"hello\\n\""));
603 assert!(json.contains("\"stream\":\"stdout\""));
604 }
605
606 #[test]
607 fn test_syslog_config_defaults() {
608 let config = LogConfig {
609 driver: LogDriver::Syslog,
610 options: HashMap::new(),
611 };
612 assert_eq!(config.syslog_address(), "udp://localhost:514");
613 assert_eq!(config.syslog_facility(), "daemon");
614 assert_eq!(config.tag(), None);
615 }
616
617 #[test]
618 fn test_syslog_config_custom() {
619 let mut options = HashMap::new();
620 options.insert(
621 "syslog-address".to_string(),
622 "tcp://loghost:1514".to_string(),
623 );
624 options.insert("syslog-facility".to_string(), "local0".to_string());
625 options.insert("tag".to_string(), "myapp".to_string());
626 let config = LogConfig {
627 driver: LogDriver::Syslog,
628 options,
629 };
630 assert_eq!(config.syslog_address(), "tcp://loghost:1514");
631 assert_eq!(config.syslog_facility(), "local0");
632 assert_eq!(config.tag(), Some("myapp"));
633 }
634
635 #[test]
636 fn test_log_driver_display() {
637 assert_eq!(LogDriver::JsonFile.to_string(), "json-file");
638 assert_eq!(LogDriver::Syslog.to_string(), "syslog");
639 assert_eq!(LogDriver::None.to_string(), "none");
640 }
641
642 #[test]
643 fn test_log_driver_serde_roundtrip() {
644 let driver = LogDriver::Syslog;
645 let json = serde_json::to_string(&driver).unwrap();
646 assert_eq!(json, "\"syslog\"");
647 let parsed: LogDriver = serde_json::from_str(&json).unwrap();
648 assert_eq!(parsed, LogDriver::Syslog);
649 }
650
651 #[test]
652 fn test_tail_next_line_returns_complete_lines() {
653 use std::io::Cursor;
654 let mut reader = BufReader::new(Cursor::new(b"alpha\r\nbeta\n".to_vec()));
657 let mut buf = String::new();
658 let stop = AtomicBool::new(true);
659 assert_eq!(
660 tail_next_line(&mut reader, &mut buf, &stop, None),
661 Some("alpha".to_string())
662 );
663 assert_eq!(
664 tail_next_line(&mut reader, &mut buf, &stop, None),
665 Some("beta".to_string())
666 );
667 assert_eq!(tail_next_line(&mut reader, &mut buf, &stop, None), None);
668 assert!(buf.is_empty());
669 }
670
671 #[test]
672 fn test_tail_next_line_flushes_trailing_partial_on_stop() {
673 use std::io::Cursor;
674 let mut reader = BufReader::new(Cursor::new(b"only-partial".to_vec()));
677 let mut buf = String::new();
678 let stop = AtomicBool::new(true);
679 assert_eq!(
680 tail_next_line(&mut reader, &mut buf, &stop, None),
681 Some("only-partial".to_string())
682 );
683 assert_eq!(tail_next_line(&mut reader, &mut buf, &stop, None), None);
684 }
685
686 #[test]
687 fn test_console_truncate_if_over_only_when_over_cap() {
688 let dir = tempfile::tempdir().unwrap();
689 let path = dir.path().join("c.log");
690 std::fs::write(&path, b"hello").unwrap(); assert!(!console_truncate_if_over(&path, 10)); assert_eq!(std::fs::metadata(&path).unwrap().len(), 5);
694
695 assert!(console_truncate_if_over(&path, 4)); assert_eq!(std::fs::metadata(&path).unwrap().len(), 0);
697
698 assert!(!console_truncate_if_over(&dir.path().join("nope"), 0));
700 }
701
702 #[test]
703 fn test_run_tagged_tail_truncates_over_cap_and_keeps_emitting() {
704 use std::sync::{Arc, Mutex};
705 use std::time::Duration;
706
707 let dir = tempfile::tempdir().unwrap();
708 let path = dir.path().join("console.log");
709 std::fs::write(&path, b"l1\nl2\nl3\n").unwrap();
711 let cap = 4u64;
712
713 let collected = Arc::new(Mutex::new(Vec::<String>::new()));
714 let stop = Arc::new(AtomicBool::new(false));
715 let (c2, s2, p2) = (collected.clone(), stop.clone(), path.clone());
716 let handle = std::thread::spawn(move || {
717 let emit = move |line: &str, _stream: &str| c2.lock().unwrap().push(line.to_string());
718 let emit: &(dyn Fn(&str, &str) + Sync) = &emit;
719 run_tagged_tail(&p2, "stdout", false, Some(cap), &s2, emit);
720 });
721
722 std::thread::sleep(Duration::from_millis(300));
724 {
726 use std::io::Write as _;
727 let mut f = std::fs::OpenOptions::new()
728 .append(true)
729 .open(&path)
730 .unwrap();
731 f.write_all(b"l4\nl5\n").unwrap();
732 }
733 std::thread::sleep(Duration::from_millis(300));
734 stop.store(true, Ordering::Relaxed);
735 handle.join().unwrap();
736
737 let got = collected.lock().unwrap().clone();
738 for line in ["l1", "l3", "l4", "l5"] {
740 assert!(got.contains(&line.to_string()), "missing {line} in {got:?}");
741 }
742 let final_len = std::fs::metadata(&path).unwrap().len();
744 assert!(
745 final_len <= cap + 6,
746 "console.log unbounded: {final_len} bytes"
747 );
748 }
749
750 #[test]
751 fn test_none_driver_still_bounds_console() {
752 use std::sync::Arc;
753 use std::time::Duration;
754
755 let dir = tempfile::tempdir().unwrap();
756 let console = dir.path().join("console.log");
757 std::fs::write(&console, b"l1\nl2\nl3\n").unwrap(); std::fs::write(dir.path().join("console.err.log"), b"").unwrap();
759
760 let mut options = HashMap::new();
762 options.insert("max-size".to_string(), "4".to_string());
763 options.insert("max-file".to_string(), "1".to_string());
764 let config = LogConfig {
765 driver: LogDriver::None,
766 options,
767 };
768
769 let stop = Arc::new(AtomicBool::new(false));
770 let (s2, c2, d2) = (stop.clone(), console.clone(), dir.path().to_path_buf());
771 let handle = std::thread::spawn(move || run_log_processor(&c2, &d2, &config, &s2));
772
773 std::thread::sleep(Duration::from_millis(300));
774 stop.store(true, Ordering::Relaxed);
775 handle.join().unwrap();
776
777 assert!(std::fs::metadata(&console).unwrap().len() <= 4);
780 assert!(!dir.path().join("container.json").exists());
781 }
782
783 #[test]
784 fn test_is_runtime_console_noise() {
785 assert!(is_runtime_console_noise("init.krun: mount_filesystems ok"));
786 assert!(!is_runtime_console_noise("L1"));
787 assert!(!is_runtime_console_noise(
788 "starting app (init.krun: ignored)"
789 ));
790 assert!(!is_runtime_console_noise(""));
791 }
792
793 #[test]
794 fn test_run_json_file_processor_captures_all_lines_after_stop() {
795 let dir = tempfile::tempdir().unwrap();
799 let console = dir.path().join("console.log");
800 std::fs::write(&console, "AAA\ninit.krun: noise\nBBB\n").unwrap();
801 let stop = AtomicBool::new(true);
802 run_json_file_processor(&console, dir.path(), 10 * 1024 * 1024, 3, &stop);
803 let json = std::fs::read_to_string(json_log_path(dir.path())).unwrap();
804 assert!(json.contains("\"log\":\"AAA\\n\""), "AAA missing: {json}");
805 assert!(
806 json.contains("\"log\":\"BBB\\n\""),
807 "BBB (after a quiet line) missing: {json}"
808 );
809 assert!(
810 !json.contains("init.krun"),
811 "runtime noise must be filtered: {json}"
812 );
813 }
814
815 #[test]
816 fn test_rotating_writer_rotates_and_gzips() {
817 let dir = tempfile::tempdir().unwrap();
818 let path = dir.path().join("container.json");
819 let mut w = RotatingWriter::new(&path, 20, 3).unwrap();
820 for i in 0..10 {
821 w.write_line(&format!("line-{i}")).unwrap();
822 }
823 assert!(
824 rotated_path(&path, 1).exists(),
825 "expected a rotated .1.gz file"
826 );
827 }
828}