1use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6#[derive(Debug, Clone, Default, PartialEq, Eq, 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, PartialEq, Eq, 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, Clone, PartialEq, Eq, Serialize, Deserialize)]
112pub struct LogEntry {
113 pub log: String,
115 pub stream: String,
117 pub time: String,
119}
120
121pub const SANDBOX_LOG_WORKER_SCHEMA: &str = "a3s.box.sandbox-log-worker.v1";
123
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132pub struct SandboxLogWorkerSpec {
133 pub schema: String,
134 pub box_id: String,
135 pub console_log: PathBuf,
136 pub log_config: LogConfig,
137 pub watched_pid: u32,
138 pub watched_pid_start_time: u64,
139 pub ready_file: PathBuf,
140}
141
142fn parse_size(s: &str) -> std::result::Result<u64, String> {
144 let s = s.trim().to_lowercase();
145 if let Ok(n) = s.parse::<u64>() {
146 return Ok(n);
147 }
148 let (num, mult) = if s.ends_with("gb") || s.ends_with('g') {
149 (
150 s.trim_end_matches("gb").trim_end_matches('g'),
151 1024u64 * 1024 * 1024,
152 )
153 } else if s.ends_with("mb") || s.ends_with('m') {
154 (
155 s.trim_end_matches("mb").trim_end_matches('m'),
156 1024u64 * 1024,
157 )
158 } else if s.ends_with("kb") || s.ends_with('k') {
159 (s.trim_end_matches("kb").trim_end_matches('k'), 1024u64)
160 } else if s.ends_with('b') {
161 (s.trim_end_matches('b'), 1u64)
162 } else {
163 return Err(format!("unrecognized size format: {s}"));
164 };
165 let n: u64 = num.parse().map_err(|_| format!("invalid number: {num}"))?;
166 Ok(n * mult)
167}
168
169use std::io::{BufRead, BufReader, Seek, Write};
180use std::path::{Path, PathBuf};
181use std::sync::atomic::{AtomicBool, Ordering};
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum ConsoleEofPolicy {
191 MayReceiveLateWrites,
192 WriterClosed,
193}
194
195fn console_truncate_if_over(path: &Path, cap: u64) -> bool {
205 let Ok(meta) = std::fs::metadata(path) else {
206 return false;
207 };
208 if meta.len() <= cap {
209 return false;
210 }
211 match std::fs::OpenOptions::new().write(true).open(path) {
212 Ok(f) if f.set_len(0).is_ok() => {
213 tracing::debug!(path = %path.display(), cap, "console log exceeded cap; truncated");
214 true
215 }
216 _ => false,
217 }
218}
219
220pub fn json_log_path(log_dir: &Path) -> PathBuf {
222 log_dir.join("container.json")
223}
224
225pub fn is_runtime_console_noise(line: &str) -> bool {
231 line.starts_with("init.krun:")
232}
233
234fn tail_next_line(
241 reader: &mut (impl BufRead + Seek),
242 buf: &mut String,
243 stop: &AtomicBool,
244 on_eof: Option<&dyn Fn() -> bool>,
245 eof_policy: ConsoleEofPolicy,
246) -> Option<String> {
247 const STOPPED_EOF_SETTLE_MILLIS: u64 = 20;
252 let stopped_eof_settle_polls = stopped_eof_settle_polls(eof_policy);
253 let mut stopped_eof_polls = 0u8;
254 loop {
255 match reader.read_line(buf) {
256 Ok(0) | Err(_) => {
257 if stop.load(Ordering::Relaxed) {
258 stopped_eof_polls = stopped_eof_polls.saturating_add(1);
259 if stopped_eof_polls < stopped_eof_settle_polls {
260 std::thread::sleep(std::time::Duration::from_millis(
261 STOPPED_EOF_SETTLE_MILLIS,
262 ));
263 continue;
264 }
265 if buf.is_empty() {
268 return None;
269 }
270 let line = std::mem::take(buf);
271 return Some(line.trim_end_matches(['\n', '\r']).to_string());
272 }
273 if buf.is_empty() {
277 if let Some(on_eof) = on_eof {
278 if on_eof() {
279 let _ = reader.seek(std::io::SeekFrom::Start(0));
280 }
281 }
282 }
283 std::thread::sleep(std::time::Duration::from_millis(100));
284 continue;
285 }
286 Ok(_) => stopped_eof_polls = 0,
287 }
288 if !buf.ends_with('\n') {
289 continue;
291 }
292 let line = std::mem::take(buf);
293 return Some(line.trim_end_matches(['\n', '\r']).to_string());
294 }
295}
296
297fn stopped_eof_settle_polls(eof_policy: ConsoleEofPolicy) -> u8 {
298 const LATE_WRITE_POLLS: u8 = 25;
299 match eof_policy {
300 ConsoleEofPolicy::MayReceiveLateWrites => LATE_WRITE_POLLS,
301 ConsoleEofPolicy::WriterClosed => 1,
302 }
303}
304
305pub fn run_log_processor(
310 console_log: &Path,
311 log_dir: &Path,
312 config: &LogConfig,
313 stop: &AtomicBool,
314) {
315 run_log_processor_with_ready(console_log, log_dir, config, stop, None);
316}
317
318pub fn run_log_processor_with_ready(
322 console_log: &Path,
323 log_dir: &Path,
324 config: &LogConfig,
325 stop: &AtomicBool,
326 ready: Option<&std::sync::atomic::AtomicUsize>,
327) {
328 run_log_processor_with_ready_and_eof_policy(
329 console_log,
330 log_dir,
331 config,
332 stop,
333 ready,
334 ConsoleEofPolicy::MayReceiveLateWrites,
335 );
336}
337
338pub fn run_log_processor_with_ready_and_eof_policy(
344 console_log: &Path,
345 log_dir: &Path,
346 config: &LogConfig,
347 stop: &AtomicBool,
348 ready: Option<&std::sync::atomic::AtomicUsize>,
349 eof_policy: ConsoleEofPolicy,
350) {
351 match config.driver {
352 LogDriver::None => run_discard_processor(
357 console_log,
358 Some(console_cap(config.max_size(), config.max_file())),
359 stop,
360 ready,
361 eof_policy,
362 ),
363 LogDriver::JsonFile => run_json_file_processor(
364 console_log,
365 log_dir,
366 config.max_size(),
367 config.max_file(),
368 stop,
369 ready,
370 eof_policy,
371 ),
372 LogDriver::Syslog => run_syslog_processor(console_log, config, stop, ready, eof_policy),
373 }
374}
375
376fn open_console(console_log: &Path, stop: &AtomicBool) -> Option<std::fs::File> {
379 for _ in 0..300 {
380 if console_log.exists() {
381 break;
382 }
383 if stop.load(Ordering::Relaxed) && !console_log.exists() {
384 return None;
385 }
386 std::thread::sleep(std::time::Duration::from_millis(100));
387 }
388 std::fs::File::open(console_log).ok()
389}
390
391pub fn stderr_console_path(console_log: &Path) -> PathBuf {
395 console_log.with_file_name("console.err.log")
396}
397
398#[derive(Clone, Copy)]
402struct TaggedTailOptions<'a> {
403 stream: &'static str,
404 filter_noise: bool,
405 bound: Option<u64>,
406 ready: Option<&'a std::sync::atomic::AtomicUsize>,
407 eof_policy: ConsoleEofPolicy,
408}
409
410fn run_tagged_tail(
411 file: &Path,
412 stop: &AtomicBool,
413 emit: &(dyn Fn(&str, &str) + Sync),
414 options: TaggedTailOptions<'_>,
415) {
416 let f = match open_console(file, stop) {
417 Some(f) => f,
418 None => return,
419 };
420 if let Some(ready) = options.ready {
421 ready.fetch_add(1, Ordering::Release);
422 }
423 let mut reader = BufReader::new(f);
424 let mut buf = String::new();
425 let truncate = options
428 .bound
429 .map(|cap| move || console_truncate_if_over(file, cap));
430 let on_eof: Option<&dyn Fn() -> bool> = truncate.as_ref().map(|t| t as &dyn Fn() -> bool);
431 while let Some(line) = tail_next_line(&mut reader, &mut buf, stop, on_eof, options.eof_policy) {
432 if options.filter_noise && is_runtime_console_noise(&line) {
433 continue;
434 }
435 emit(&line, options.stream);
436 }
437}
438
439fn console_cap(max_size: u64, max_file: u32) -> u64 {
443 max_size.saturating_mul(u64::from(max_file.max(1)))
444}
445
446fn run_discard_processor(
451 console_log: &Path,
452 cap: Option<u64>,
453 stop: &AtomicBool,
454 ready: Option<&std::sync::atomic::AtomicUsize>,
455 eof_policy: ConsoleEofPolicy,
456) {
457 let err_log = stderr_console_path(console_log);
458 let discard = |_line: &str, _stream: &str| {};
459 let discard: &(dyn Fn(&str, &str) + Sync) = &discard;
460 std::thread::scope(|s| {
461 s.spawn(|| {
462 run_tagged_tail(
463 console_log,
464 stop,
465 discard,
466 TaggedTailOptions {
467 stream: "stdout",
468 filter_noise: false,
469 bound: cap,
470 ready,
471 eof_policy,
472 },
473 )
474 });
475 s.spawn(|| {
476 run_tagged_tail(
477 &err_log,
478 stop,
479 discard,
480 TaggedTailOptions {
481 stream: "stderr",
482 filter_noise: false,
483 bound: cap,
484 ready,
485 eof_policy,
486 },
487 )
488 });
489 });
490}
491
492fn run_json_file_processor(
493 console_log: &Path,
494 log_dir: &Path,
495 max_size: u64,
496 max_file: u32,
497 stop: &AtomicBool,
498 ready: Option<&std::sync::atomic::AtomicUsize>,
499 eof_policy: ConsoleEofPolicy,
500) {
501 let json_path = json_log_path(log_dir);
502 let writer = std::sync::Mutex::new(match RotatingWriter::new(&json_path, max_size, max_file) {
503 Ok(w) => w,
504 Err(_) => return,
505 });
506 let err_log = stderr_console_path(console_log);
507
508 let emit = |line: &str, stream: &str| {
511 let entry = LogEntry {
512 log: format!("{line}\n"),
513 stream: stream.to_string(),
514 time: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Nanos, true),
515 };
516 if let Ok(json) = serde_json::to_string(&entry) {
517 if let Ok(mut w) = writer.lock() {
518 let _ = w.write_line(&json);
519 }
520 }
521 };
522 let emit: &(dyn Fn(&str, &str) + Sync) = &emit;
523
524 let cap = Some(console_cap(max_size, max_file));
525 std::thread::scope(|s| {
526 s.spawn(|| {
527 run_tagged_tail(
528 console_log,
529 stop,
530 emit,
531 TaggedTailOptions {
532 stream: "stdout",
533 filter_noise: true,
534 bound: cap,
535 ready,
536 eof_policy,
537 },
538 )
539 });
540 s.spawn(|| {
543 run_tagged_tail(
544 &err_log,
545 stop,
546 emit,
547 TaggedTailOptions {
548 stream: "stderr",
549 filter_noise: true,
550 bound: cap,
551 ready,
552 eof_policy,
553 },
554 )
555 });
556 });
557}
558
559fn run_syslog_processor(
561 console_log: &Path,
562 config: &LogConfig,
563 stop: &AtomicBool,
564 ready: Option<&std::sync::atomic::AtomicUsize>,
565 eof_policy: ConsoleEofPolicy,
566) {
567 use std::net::UdpSocket;
568
569 let address = config.syslog_address();
570 let _facility = config.syslog_facility();
571 let tag = config.tag().unwrap_or("a3s-box");
572 let cap = Some(console_cap(config.max_size(), config.max_file()));
573 let (proto, addr) = if let Some(rest) = address.strip_prefix("udp://") {
574 ("udp", rest)
575 } else if let Some(rest) = address.strip_prefix("tcp://") {
576 ("tcp", rest)
577 } else {
578 ("udp", address)
579 };
580 let err_log = stderr_console_path(console_log);
581
582 match proto {
583 "udp" => {
584 let socket = match UdpSocket::bind("0.0.0.0:0") {
585 Ok(s) => s,
586 Err(_) => return,
587 };
588 let emit = |line: &str, _stream: &str| {
590 let msg = format!("<30>{tag}: {line}");
591 let _ = socket.send_to(msg.as_bytes(), addr);
592 };
593 let emit: &(dyn Fn(&str, &str) + Sync) = &emit;
594 std::thread::scope(|s| {
595 s.spawn(|| {
596 run_tagged_tail(
597 console_log,
598 stop,
599 emit,
600 TaggedTailOptions {
601 stream: "stdout",
602 filter_noise: true,
603 bound: cap,
604 ready,
605 eof_policy,
606 },
607 )
608 });
609 s.spawn(|| {
610 run_tagged_tail(
611 &err_log,
612 stop,
613 emit,
614 TaggedTailOptions {
615 stream: "stderr",
616 filter_noise: true,
617 bound: cap,
618 ready,
619 eof_policy,
620 },
621 )
622 });
623 });
624 }
625 "tcp" => {
626 let stream = match std::net::TcpStream::connect(addr) {
627 Ok(s) => std::sync::Mutex::new(s),
628 Err(_) => return,
629 };
630 let emit = |line: &str, _stream: &str| {
631 let msg = format!("<30>{tag}: {line}\n");
632 if let Ok(mut s) = stream.lock() {
633 if s.write_all(msg.as_bytes()).is_err() {
634 if let Ok(news) = std::net::TcpStream::connect(addr) {
635 *s = news;
636 let _ = s.write_all(msg.as_bytes());
637 }
638 }
639 }
640 };
641 let emit: &(dyn Fn(&str, &str) + Sync) = &emit;
642 std::thread::scope(|sc| {
643 sc.spawn(|| {
644 run_tagged_tail(
645 console_log,
646 stop,
647 emit,
648 TaggedTailOptions {
649 stream: "stdout",
650 filter_noise: true,
651 bound: cap,
652 ready,
653 eof_policy,
654 },
655 )
656 });
657 sc.spawn(|| {
658 run_tagged_tail(
659 &err_log,
660 stop,
661 emit,
662 TaggedTailOptions {
663 stream: "stderr",
664 filter_noise: true,
665 bound: cap,
666 ready,
667 eof_policy,
668 },
669 )
670 });
671 });
672 }
673 _ => {}
674 }
675}
676
677struct RotatingWriter {
679 path: PathBuf,
680 file: std::fs::File,
681 written: u64,
682 max_size: u64,
683 max_file: u32,
684}
685
686impl RotatingWriter {
687 fn new(path: &Path, max_size: u64, max_file: u32) -> std::io::Result<Self> {
688 let file = std::fs::OpenOptions::new()
689 .create(true)
690 .append(true)
691 .open(path)?;
692 let written = file.metadata()?.len();
693 Ok(Self {
694 path: path.to_path_buf(),
695 file,
696 written,
697 max_size,
698 max_file,
699 })
700 }
701
702 fn write_line(&mut self, line: &str) -> std::io::Result<()> {
703 let bytes = format!("{line}\n");
704 self.file.write_all(bytes.as_bytes())?;
705 self.file.flush()?;
706 self.written += bytes.len() as u64;
707 if self.written >= self.max_size {
708 self.rotate()?;
709 }
710 Ok(())
711 }
712
713 fn rotate(&mut self) -> std::io::Result<()> {
714 for i in (1..self.max_file).rev() {
715 let from = rotated_path(&self.path, i);
716 let to = rotated_path(&self.path, i + 1);
717 if from.exists() {
718 std::fs::rename(&from, &to)?;
719 }
720 }
721 let oldest = rotated_path(&self.path, self.max_file);
722 if oldest.exists() {
723 std::fs::remove_file(&oldest)?;
724 }
725 let rotated = rotated_path(&self.path, 1);
726 compress_file(&self.path, &rotated)?;
727 std::fs::remove_file(&self.path)?;
728 self.file = std::fs::OpenOptions::new()
729 .create(true)
730 .append(true)
731 .open(&self.path)?;
732 self.written = 0;
733 Ok(())
734 }
735}
736
737fn compress_file(src: &Path, dst: &Path) -> std::io::Result<()> {
739 use flate2::write::GzEncoder;
740 use flate2::Compression;
741 use std::io::Read;
742
743 let mut input = std::fs::File::open(src)?;
744 let output = std::fs::File::create(dst)?;
745 let mut encoder = GzEncoder::new(output, Compression::fast());
746 let mut buf = [0u8; 8192];
747 loop {
748 let n = input.read(&mut buf)?;
749 if n == 0 {
750 break;
751 }
752 encoder.write_all(&buf[..n])?;
753 }
754 encoder.finish()?;
755 Ok(())
756}
757
758fn rotated_path(base: &Path, index: u32) -> PathBuf {
760 let mut p = base.as_os_str().to_owned();
761 p.push(format!(".{index}.gz"));
762 PathBuf::from(p)
763}
764
765#[cfg(test)]
766mod tests {
767 use super::*;
768
769 #[test]
770 fn test_log_driver_from_str() {
771 assert_eq!(
772 "json-file".parse::<LogDriver>().unwrap(),
773 LogDriver::JsonFile
774 );
775 assert_eq!("syslog".parse::<LogDriver>().unwrap(), LogDriver::Syslog);
776 assert_eq!("none".parse::<LogDriver>().unwrap(), LogDriver::None);
777 assert!("unknown".parse::<LogDriver>().is_err());
778 }
779
780 #[test]
781 fn test_log_config_defaults() {
782 let config = LogConfig::default();
783 assert_eq!(config.driver, LogDriver::JsonFile);
784 assert_eq!(config.max_size(), 10 * 1024 * 1024);
785 assert_eq!(config.max_file(), 3);
786 }
787
788 #[test]
789 fn test_log_config_custom_options() {
790 let mut config = LogConfig::default();
791 config
792 .options
793 .insert("max-size".to_string(), "50m".to_string());
794 config
795 .options
796 .insert("max-file".to_string(), "5".to_string());
797 assert_eq!(config.max_size(), 50 * 1024 * 1024);
798 assert_eq!(config.max_file(), 5);
799 }
800
801 #[test]
802 fn test_parse_size() {
803 assert_eq!(parse_size("1024").unwrap(), 1024);
804 assert_eq!(parse_size("10m").unwrap(), 10 * 1024 * 1024);
805 assert_eq!(parse_size("1g").unwrap(), 1024 * 1024 * 1024);
806 assert_eq!(parse_size("512k").unwrap(), 512 * 1024);
807 assert!(parse_size("abc").is_err());
808 }
809
810 #[test]
811 fn test_log_entry_serialization() {
812 let entry = LogEntry {
813 log: "hello\n".to_string(),
814 stream: "stdout".to_string(),
815 time: "2026-02-12T06:00:00.000000000Z".to_string(),
816 };
817 let json = serde_json::to_string(&entry).unwrap();
818 assert!(json.contains("\"log\":\"hello\\n\""));
819 assert!(json.contains("\"stream\":\"stdout\""));
820 }
821
822 #[test]
823 fn sandbox_log_worker_spec_round_trips_generation_identity() {
824 let spec = SandboxLogWorkerSpec {
825 schema: SANDBOX_LOG_WORKER_SCHEMA.to_string(),
826 box_id: "sandbox-id".to_string(),
827 console_log: PathBuf::from("/tmp/sandbox-id/logs/console.log"),
828 log_config: LogConfig::default(),
829 watched_pid: 123,
830 watched_pid_start_time: 456,
831 ready_file: PathBuf::from("/tmp/sandbox-id/sandbox/log-worker.ready"),
832 };
833
834 let encoded = serde_json::to_vec(&spec).unwrap();
835 let decoded: SandboxLogWorkerSpec = serde_json::from_slice(&encoded).unwrap();
836
837 assert_eq!(decoded, spec);
838 }
839
840 #[test]
841 fn writer_closed_eof_skips_the_late_console_settle_window() {
842 assert_eq!(
843 stopped_eof_settle_polls(ConsoleEofPolicy::MayReceiveLateWrites),
844 25
845 );
846 assert_eq!(stopped_eof_settle_polls(ConsoleEofPolicy::WriterClosed), 1);
847 }
848
849 #[test]
850 fn test_syslog_config_defaults() {
851 let config = LogConfig {
852 driver: LogDriver::Syslog,
853 options: HashMap::new(),
854 };
855 assert_eq!(config.syslog_address(), "udp://localhost:514");
856 assert_eq!(config.syslog_facility(), "daemon");
857 assert_eq!(config.tag(), None);
858 }
859
860 #[test]
861 fn test_syslog_config_custom() {
862 let mut options = HashMap::new();
863 options.insert(
864 "syslog-address".to_string(),
865 "tcp://loghost:1514".to_string(),
866 );
867 options.insert("syslog-facility".to_string(), "local0".to_string());
868 options.insert("tag".to_string(), "myapp".to_string());
869 let config = LogConfig {
870 driver: LogDriver::Syslog,
871 options,
872 };
873 assert_eq!(config.syslog_address(), "tcp://loghost:1514");
874 assert_eq!(config.syslog_facility(), "local0");
875 assert_eq!(config.tag(), Some("myapp"));
876 }
877
878 #[test]
879 fn test_log_driver_display() {
880 assert_eq!(LogDriver::JsonFile.to_string(), "json-file");
881 assert_eq!(LogDriver::Syslog.to_string(), "syslog");
882 assert_eq!(LogDriver::None.to_string(), "none");
883 }
884
885 #[test]
886 fn test_log_driver_serde_roundtrip() {
887 let driver = LogDriver::Syslog;
888 let json = serde_json::to_string(&driver).unwrap();
889 assert_eq!(json, "\"syslog\"");
890 let parsed: LogDriver = serde_json::from_str(&json).unwrap();
891 assert_eq!(parsed, LogDriver::Syslog);
892 }
893
894 #[test]
895 fn test_tail_next_line_returns_complete_lines() {
896 use std::io::Cursor;
897 let mut reader = BufReader::new(Cursor::new(b"alpha\r\nbeta\n".to_vec()));
900 let mut buf = String::new();
901 let stop = AtomicBool::new(true);
902 assert_eq!(
903 tail_next_line(
904 &mut reader,
905 &mut buf,
906 &stop,
907 None,
908 ConsoleEofPolicy::MayReceiveLateWrites,
909 ),
910 Some("alpha".to_string())
911 );
912 assert_eq!(
913 tail_next_line(
914 &mut reader,
915 &mut buf,
916 &stop,
917 None,
918 ConsoleEofPolicy::MayReceiveLateWrites,
919 ),
920 Some("beta".to_string())
921 );
922 assert_eq!(
923 tail_next_line(
924 &mut reader,
925 &mut buf,
926 &stop,
927 None,
928 ConsoleEofPolicy::MayReceiveLateWrites,
929 ),
930 None
931 );
932 assert!(buf.is_empty());
933 }
934
935 #[test]
936 fn test_tail_next_line_flushes_trailing_partial_on_stop() {
937 use std::io::Cursor;
938 let mut reader = BufReader::new(Cursor::new(b"only-partial".to_vec()));
941 let mut buf = String::new();
942 let stop = AtomicBool::new(true);
943 assert_eq!(
944 tail_next_line(
945 &mut reader,
946 &mut buf,
947 &stop,
948 None,
949 ConsoleEofPolicy::MayReceiveLateWrites,
950 ),
951 Some("only-partial".to_string())
952 );
953 assert_eq!(
954 tail_next_line(
955 &mut reader,
956 &mut buf,
957 &stop,
958 None,
959 ConsoleEofPolicy::MayReceiveLateWrites,
960 ),
961 None
962 );
963 }
964
965 #[test]
966 fn test_console_truncate_if_over_only_when_over_cap() {
967 let dir = tempfile::tempdir().unwrap();
968 let path = dir.path().join("c.log");
969 std::fs::write(&path, b"hello").unwrap(); assert!(!console_truncate_if_over(&path, 10)); assert_eq!(std::fs::metadata(&path).unwrap().len(), 5);
973
974 assert!(console_truncate_if_over(&path, 4)); assert_eq!(std::fs::metadata(&path).unwrap().len(), 0);
976
977 assert!(!console_truncate_if_over(&dir.path().join("nope"), 0));
979 }
980
981 #[test]
982 fn test_run_tagged_tail_truncates_over_cap_and_keeps_emitting() {
983 use std::sync::{Arc, Mutex};
984 use std::time::Duration;
985
986 let dir = tempfile::tempdir().unwrap();
987 let path = dir.path().join("console.log");
988 std::fs::write(&path, b"l1\nl2\nl3\n").unwrap();
990 let cap = 4u64;
991
992 let collected = Arc::new(Mutex::new(Vec::<String>::new()));
993 let stop = Arc::new(AtomicBool::new(false));
994 let (c2, s2, p2) = (collected.clone(), stop.clone(), path.clone());
995 let handle = std::thread::spawn(move || {
996 let emit = move |line: &str, _stream: &str| c2.lock().unwrap().push(line.to_string());
997 let emit: &(dyn Fn(&str, &str) + Sync) = &emit;
998 run_tagged_tail(
999 &p2,
1000 &s2,
1001 emit,
1002 TaggedTailOptions {
1003 stream: "stdout",
1004 filter_noise: false,
1005 bound: Some(cap),
1006 ready: None,
1007 eof_policy: ConsoleEofPolicy::MayReceiveLateWrites,
1008 },
1009 );
1010 });
1011
1012 std::thread::sleep(Duration::from_millis(300));
1014 {
1016 use std::io::Write as _;
1017 let mut f = std::fs::OpenOptions::new()
1018 .append(true)
1019 .open(&path)
1020 .unwrap();
1021 f.write_all(b"l4\nl5\n").unwrap();
1022 }
1023 std::thread::sleep(Duration::from_millis(300));
1024 stop.store(true, Ordering::Relaxed);
1025 handle.join().unwrap();
1026
1027 let got = collected.lock().unwrap().clone();
1028 for line in ["l1", "l3", "l4", "l5"] {
1030 assert!(got.contains(&line.to_string()), "missing {line} in {got:?}");
1031 }
1032 let final_len = std::fs::metadata(&path).unwrap().len();
1034 assert!(
1035 final_len <= cap + 6,
1036 "console.log unbounded: {final_len} bytes"
1037 );
1038 }
1039
1040 #[test]
1041 fn test_stopped_tail_waits_for_delayed_final_console_write() {
1042 use std::io::Write as _;
1043
1044 let dir = tempfile::tempdir().unwrap();
1045 let path = dir.path().join("console.log");
1046 std::fs::write(&path, b"").unwrap();
1047 let writer_path = path.clone();
1048 let writer = std::thread::spawn(move || {
1049 std::thread::sleep(std::time::Duration::from_millis(30));
1050 let mut file = std::fs::OpenOptions::new()
1051 .append(true)
1052 .open(writer_path)
1053 .unwrap();
1054 file.write_all(b"late-final-line\n").unwrap();
1055 file.flush().unwrap();
1056 });
1057
1058 let file = std::fs::File::open(&path).unwrap();
1059 let mut reader = BufReader::new(file);
1060 let mut buffer = String::new();
1061 let stop = AtomicBool::new(true);
1062 assert_eq!(
1063 tail_next_line(
1064 &mut reader,
1065 &mut buffer,
1066 &stop,
1067 None,
1068 ConsoleEofPolicy::MayReceiveLateWrites,
1069 ),
1070 Some("late-final-line".to_string())
1071 );
1072 assert_eq!(
1073 tail_next_line(
1074 &mut reader,
1075 &mut buffer,
1076 &stop,
1077 None,
1078 ConsoleEofPolicy::MayReceiveLateWrites,
1079 ),
1080 None
1081 );
1082 writer.join().unwrap();
1083 }
1084
1085 #[test]
1086 fn test_none_driver_still_bounds_console() {
1087 use std::sync::Arc;
1088 use std::time::Duration;
1089
1090 let dir = tempfile::tempdir().unwrap();
1091 let console = dir.path().join("console.log");
1092 std::fs::write(&console, b"l1\nl2\nl3\n").unwrap(); std::fs::write(dir.path().join("console.err.log"), b"").unwrap();
1094
1095 let mut options = HashMap::new();
1097 options.insert("max-size".to_string(), "4".to_string());
1098 options.insert("max-file".to_string(), "1".to_string());
1099 let config = LogConfig {
1100 driver: LogDriver::None,
1101 options,
1102 };
1103
1104 let stop = Arc::new(AtomicBool::new(false));
1105 let (s2, c2, d2) = (stop.clone(), console.clone(), dir.path().to_path_buf());
1106 let handle = std::thread::spawn(move || run_log_processor(&c2, &d2, &config, &s2));
1107
1108 std::thread::sleep(Duration::from_millis(300));
1109 stop.store(true, Ordering::Relaxed);
1110 handle.join().unwrap();
1111
1112 assert!(std::fs::metadata(&console).unwrap().len() <= 4);
1115 assert!(!dir.path().join("container.json").exists());
1116 }
1117
1118 #[test]
1119 fn test_is_runtime_console_noise() {
1120 assert!(is_runtime_console_noise("init.krun: mount_filesystems ok"));
1121 assert!(!is_runtime_console_noise("L1"));
1122 assert!(!is_runtime_console_noise(
1123 "starting app (init.krun: ignored)"
1124 ));
1125 assert!(!is_runtime_console_noise(""));
1126 }
1127
1128 #[test]
1129 fn test_run_json_file_processor_captures_all_lines_after_stop() {
1130 let dir = tempfile::tempdir().unwrap();
1134 let console = dir.path().join("console.log");
1135 std::fs::write(&console, "AAA\ninit.krun: noise\nBBB\n").unwrap();
1136 let stop = AtomicBool::new(true);
1137 run_json_file_processor(
1138 &console,
1139 dir.path(),
1140 10 * 1024 * 1024,
1141 3,
1142 &stop,
1143 None,
1144 ConsoleEofPolicy::MayReceiveLateWrites,
1145 );
1146 let json = std::fs::read_to_string(json_log_path(dir.path())).unwrap();
1147 assert!(json.contains("\"log\":\"AAA\\n\""), "AAA missing: {json}");
1148 assert!(
1149 json.contains("\"log\":\"BBB\\n\""),
1150 "BBB (after a quiet line) missing: {json}"
1151 );
1152 assert!(
1153 !json.contains("init.krun"),
1154 "runtime noise must be filtered: {json}"
1155 );
1156 }
1157
1158 #[test]
1159 fn test_rotating_writer_rotates_and_gzips() {
1160 let dir = tempfile::tempdir().unwrap();
1161 let path = dir.path().join("container.json");
1162 let mut w = RotatingWriter::new(&path, 20, 3).unwrap();
1163 for i in 0..10 {
1164 w.write_line(&format!("line-{i}")).unwrap();
1165 }
1166 assert!(
1167 rotated_path(&path, 1).exists(),
1168 "expected a rotated .1.gz file"
1169 );
1170 }
1171}