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)]
131pub struct SandboxLogWorkerSpec {
132 pub schema: String,
133 pub box_id: String,
134 pub console_log: PathBuf,
135 pub log_config: LogConfig,
136 pub watched_pid: u32,
137 pub watched_pid_start_time: u64,
138 pub ready_file: PathBuf,
139}
140
141pub const MANAGED_OCI_LOG_WORKER_SCHEMA: &str = "a3s.box.managed-oci-log-worker.v1";
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(tag = "kind", rename_all = "kebab-case")]
147pub enum ManagedOciLogEndpoint {
148 UnixSocket { path: PathBuf },
149 WindowsNamedPipe { name: String },
150}
151
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159pub struct ManagedOciLogWorkerSpec {
160 pub schema: String,
161 pub box_id: String,
162 pub execution_generation: u64,
163 pub endpoint: ManagedOciLogEndpoint,
164 pub runtime_container_id: String,
165 pub runtime_generation: u64,
166 pub console_log: PathBuf,
167 pub log_config: LogConfig,
168 pub ready_file: PathBuf,
169 pub drained_file: PathBuf,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176pub struct ManagedOciLogWorkerMarker {
177 pub schema: String,
178 pub box_id: String,
179 pub execution_generation: u64,
180 pub runtime_container_id: String,
181 pub runtime_generation: u64,
182 pub pid: u32,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub pid_start_time: Option<u64>,
185}
186
187fn parse_size(s: &str) -> std::result::Result<u64, String> {
189 let s = s.trim().to_lowercase();
190 if let Ok(n) = s.parse::<u64>() {
191 return Ok(n);
192 }
193 let (num, mult) = if s.ends_with("gb") || s.ends_with('g') {
194 (
195 s.trim_end_matches("gb").trim_end_matches('g'),
196 1024u64 * 1024 * 1024,
197 )
198 } else if s.ends_with("mb") || s.ends_with('m') {
199 (
200 s.trim_end_matches("mb").trim_end_matches('m'),
201 1024u64 * 1024,
202 )
203 } else if s.ends_with("kb") || s.ends_with('k') {
204 (s.trim_end_matches("kb").trim_end_matches('k'), 1024u64)
205 } else if s.ends_with('b') {
206 (s.trim_end_matches('b'), 1u64)
207 } else {
208 return Err(format!("unrecognized size format: {s}"));
209 };
210 let n: u64 = num.parse().map_err(|_| format!("invalid number: {num}"))?;
211 Ok(n * mult)
212}
213
214use std::io::{BufRead, BufReader, Seek, Write};
225use std::path::{Path, PathBuf};
226use std::sync::atomic::{AtomicBool, Ordering};
227
228#[cfg(target_os = "windows")]
229type ConsoleFileIdentity = crate::windows_file::WindowsFileIdentity;
230#[cfg(not(target_os = "windows"))]
231type ConsoleFileIdentity = ();
232
233#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240pub enum ConsoleEofPolicy {
241 MayReceiveLateWrites,
242 WriterClosed,
243}
244
245fn console_truncate_if_over(
255 path: &Path,
256 cap: u64,
257 expected_identity: Option<ConsoleFileIdentity>,
258) -> bool {
259 #[cfg(not(target_os = "windows"))]
260 let _ = expected_identity;
261
262 #[cfg(target_os = "windows")]
263 let file = crate::windows_file::open_regular_file_for_write(path, expected_identity)
264 .map(|(file, _)| file);
265 #[cfg(not(target_os = "windows"))]
266 let file = std::fs::OpenOptions::new().write(true).open(path);
267
268 let Ok(file) = file else {
269 return false;
270 };
271 if file
272 .metadata()
273 .map_or(true, |metadata| metadata.len() <= cap)
274 {
275 return false;
276 }
277 if file.set_len(0).is_ok() {
278 tracing::debug!(path = %path.display(), cap, "console log exceeded cap; truncated");
279 true
280 } else {
281 false
282 }
283}
284
285pub fn json_log_path(log_dir: &Path) -> PathBuf {
287 log_dir.join("container.json")
288}
289
290#[derive(Debug)]
298pub struct RuntimeConsoleFilter {
299 preamble_active: AtomicBool,
300}
301
302impl RuntimeConsoleFilter {
303 pub fn new() -> Self {
304 Self {
305 preamble_active: AtomicBool::new(true),
306 }
307 }
308
309 pub fn keep_line(&self, line: &str) -> bool {
314 if !self.preamble_active.load(Ordering::Acquire) {
315 return true;
316 }
317
318 match classify_runtime_console_line(line) {
319 RuntimeConsoleLineKind::Workload => true,
320 RuntimeConsoleLineKind::Preamble => {
321 !self.preamble_active.load(Ordering::Acquire)
325 }
326 RuntimeConsoleLineKind::EndPreamble => {
327 !self.preamble_active.swap(false, Ordering::AcqRel)
330 }
331 }
332 }
333
334 pub fn preamble_active(&self) -> bool {
335 self.preamble_active.load(Ordering::Acquire)
336 }
337}
338
339impl Default for RuntimeConsoleFilter {
340 fn default() -> Self {
341 Self::new()
342 }
343}
344
345#[derive(Debug, Clone, Copy, PartialEq, Eq)]
346enum RuntimeConsoleLineKind {
347 Workload,
348 Preamble,
349 EndPreamble,
350}
351
352fn classify_runtime_console_line(line: &str) -> RuntimeConsoleLineKind {
353 let line = line.trim_end_matches(['\n', '\r']);
354
355 if matches!(
356 line,
357 "init.krun: mount_filesystems ok"
358 | "init.krun: root propagation ok"
359 | "init.krun: tty/session configured"
360 | "init.krun: config parsed"
361 | "init.krun: setup_redirects ok"
362 ) {
363 return RuntimeConsoleLineKind::Preamble;
364 }
365
366 if line
367 .strip_prefix("init.krun: entered main argc=")
368 .is_some_and(is_ascii_decimal)
369 {
370 return RuntimeConsoleLineKind::Preamble;
371 }
372
373 if let Some(fields) = line.strip_prefix("init.krun: after cmdline env import KRUN_INIT=") {
374 if let Some((krun_init, fields)) = fields.split_once(" KRUN_INIT_PID1=") {
375 if let Some((krun_init_pid1, box_exec_exec)) = fields.split_once(" BOX_EXEC_EXEC=") {
376 if [krun_init, krun_init_pid1, box_exec_exec]
377 .iter()
378 .all(|value| !value.is_empty())
379 {
380 return RuntimeConsoleLineKind::Preamble;
381 }
382 }
383 }
384 }
385
386 if let Some(selected) = line.strip_prefix("init.krun: selected exec=") {
387 if let Some((executable, init_pid1)) = selected.rsplit_once(" init_pid1=") {
388 if !executable.is_empty() && matches!(init_pid1, "0" | "1") {
389 return RuntimeConsoleLineKind::Preamble;
390 }
391 }
392 }
393
394 if line
395 .strip_prefix("init.krun: execvp(")
396 .and_then(|rest| rest.strip_suffix(") starting"))
397 .is_some_and(|executable| !executable.is_empty())
398 {
399 return RuntimeConsoleLineKind::EndPreamble;
400 }
401
402 RuntimeConsoleLineKind::Workload
403}
404
405fn is_ascii_decimal(value: &str) -> bool {
406 !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit())
407}
408
409pub fn is_runtime_console_noise(line: &str) -> bool {
414 classify_runtime_console_line(line) != RuntimeConsoleLineKind::Workload
415}
416
417fn tail_next_line_with_completeness<R: BufRead + Seek>(
424 reader: &mut R,
425 buf: &mut String,
426 stop: &AtomicBool,
427 on_eof: Option<&dyn Fn() -> bool>,
428 eof_policy: ConsoleEofPolicy,
429 reopen_at_eof: Option<&dyn Fn(u64) -> Option<(R, u64)>>,
430) -> Option<(String, bool)> {
431 const STOPPED_EOF_SETTLE_MILLIS: u64 = 20;
436 let stopped_eof_settle_polls = stopped_eof_settle_polls(eof_policy);
437 let mut stopped_eof_polls = 0u8;
438 let mut refreshed_after_stop = false;
439 loop {
440 match reader.read_line(buf) {
441 Ok(0) | Err(_) => {
442 let mut position = reader.stream_position().ok();
446 if buf.is_empty() {
447 if let Some(on_eof) = on_eof {
448 if on_eof() {
449 let _ = reader.seek(std::io::SeekFrom::Start(0));
450 position = Some(0);
451 }
452 }
453 }
454
455 let stopping = stop.load(Ordering::Relaxed);
456
457 if let (Some(position), Some(reopen_at_eof)) = (position, reopen_at_eof) {
464 if !(stopping && refreshed_after_stop) {
465 if !stopping {
466 std::thread::sleep(std::time::Duration::from_millis(100));
467 }
468 if let Some((mut replacement, replacement_position)) =
469 reopen_at_eof(position)
470 {
471 if replacement_position != position {
472 buf.clear();
473 }
474 std::mem::swap(reader, &mut replacement);
475 refreshed_after_stop = stopping;
476 continue;
477 }
478 }
479 }
480
481 if stopping {
482 stopped_eof_polls = stopped_eof_polls.saturating_add(1);
483 if stopped_eof_polls < stopped_eof_settle_polls {
484 refreshed_after_stop = false;
487 std::thread::sleep(std::time::Duration::from_millis(
488 STOPPED_EOF_SETTLE_MILLIS,
489 ));
490 continue;
491 }
492 if buf.is_empty() {
495 return None;
496 }
497 let line = std::mem::take(buf);
498 return Some((line.trim_end_matches(['\n', '\r']).to_string(), false));
499 }
500 if reopen_at_eof.is_none() {
501 std::thread::sleep(std::time::Duration::from_millis(100));
502 }
503 continue;
504 }
505 Ok(_) => {
506 stopped_eof_polls = 0;
507 refreshed_after_stop = false;
508 }
509 }
510 if !buf.ends_with('\n') {
511 continue;
513 }
514 let line = std::mem::take(buf);
515 return Some((line.trim_end_matches(['\n', '\r']).to_string(), true));
516 }
517}
518
519#[cfg(test)]
520fn tail_next_line<R: BufRead + Seek>(
521 reader: &mut R,
522 buf: &mut String,
523 stop: &AtomicBool,
524 on_eof: Option<&dyn Fn() -> bool>,
525 eof_policy: ConsoleEofPolicy,
526 reopen_at_eof: Option<&dyn Fn(u64) -> Option<(R, u64)>>,
527) -> Option<String> {
528 tail_next_line_with_completeness(reader, buf, stop, on_eof, eof_policy, reopen_at_eof)
529 .map(|(line, _complete)| line)
530}
531
532fn stopped_eof_settle_polls(eof_policy: ConsoleEofPolicy) -> u8 {
533 const LATE_WRITE_POLLS: u8 = 25;
534 match eof_policy {
535 ConsoleEofPolicy::MayReceiveLateWrites => LATE_WRITE_POLLS,
536 ConsoleEofPolicy::WriterClosed => 1,
537 }
538}
539
540pub fn run_log_processor(
545 console_log: &Path,
546 log_dir: &Path,
547 config: &LogConfig,
548 stop: &AtomicBool,
549) {
550 run_log_processor_with_ready(console_log, log_dir, config, stop, None);
551}
552
553pub fn run_log_processor_with_ready(
557 console_log: &Path,
558 log_dir: &Path,
559 config: &LogConfig,
560 stop: &AtomicBool,
561 ready: Option<&std::sync::atomic::AtomicUsize>,
562) {
563 run_log_processor_with_ready_and_eof_policy(
564 console_log,
565 log_dir,
566 config,
567 stop,
568 ready,
569 ConsoleEofPolicy::MayReceiveLateWrites,
570 );
571}
572
573pub fn run_log_processor_with_ready_and_eof_policy(
579 console_log: &Path,
580 log_dir: &Path,
581 config: &LogConfig,
582 stop: &AtomicBool,
583 ready: Option<&std::sync::atomic::AtomicUsize>,
584 eof_policy: ConsoleEofPolicy,
585) {
586 let stderr_log = stderr_console_path(console_log);
587 run_log_processor_streams_with_ready_and_eof_policy(
588 console_log,
589 &stderr_log,
590 log_dir,
591 config,
592 stop,
593 ready,
594 eof_policy,
595 );
596}
597
598pub fn run_log_processor_streams(
605 stdout_log: &Path,
606 stderr_log: &Path,
607 log_dir: &Path,
608 config: &LogConfig,
609 stop: &AtomicBool,
610) {
611 run_log_processor_streams_with_ready_and_eof_policy(
612 stdout_log,
613 stderr_log,
614 log_dir,
615 config,
616 stop,
617 None,
618 ConsoleEofPolicy::WriterClosed,
619 );
620}
621
622pub fn run_log_processor_streams_with_ready(
628 stdout_log: &Path,
629 stderr_log: &Path,
630 log_dir: &Path,
631 config: &LogConfig,
632 stop: &AtomicBool,
633 ready: Option<&std::sync::atomic::AtomicUsize>,
634) {
635 run_log_processor_streams_with_ready_and_eof_policy(
636 stdout_log,
637 stderr_log,
638 log_dir,
639 config,
640 stop,
641 ready,
642 ConsoleEofPolicy::MayReceiveLateWrites,
643 );
644}
645
646fn run_log_processor_streams_with_ready_and_eof_policy(
647 stdout_log: &Path,
648 stderr_log: &Path,
649 log_dir: &Path,
650 config: &LogConfig,
651 stop: &AtomicBool,
652 ready: Option<&std::sync::atomic::AtomicUsize>,
653 eof_policy: ConsoleEofPolicy,
654) {
655 match config.driver {
656 LogDriver::None => run_discard_processor(
661 stdout_log,
662 stderr_log,
663 Some(console_cap(config.max_size(), config.max_file())),
664 stop,
665 ready,
666 eof_policy,
667 ),
668 LogDriver::JsonFile => run_json_file_processor(
669 stdout_log, stderr_log, log_dir, config, stop, ready, eof_policy,
670 ),
671 LogDriver::Syslog => {
672 run_syslog_processor(stdout_log, stderr_log, config, stop, ready, eof_policy)
673 }
674 }
675}
676
677fn open_console(
680 console_log: &Path,
681 stop: &AtomicBool,
682) -> Option<(std::fs::File, ConsoleFileIdentity)> {
683 for _ in 0..300 {
684 #[cfg(target_os = "windows")]
685 match crate::windows_file::open_regular_file(console_log, None) {
686 Ok(opened) => return Some(opened),
687 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
688 Err(error) => {
689 tracing::warn!(path = %console_log.display(), %error, "Refusing unsafe Windows console source");
690 return None;
691 }
692 }
693 #[cfg(not(target_os = "windows"))]
694 match std::fs::File::open(console_log) {
695 Ok(file) => return Some((file, ())),
696 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
697 Err(_) => return None,
698 }
699 if stop.load(Ordering::Relaxed) && !console_log.exists() {
700 return None;
701 }
702 std::thread::sleep(std::time::Duration::from_millis(100));
703 }
704 None
705}
706
707#[cfg(target_os = "windows")]
708fn reopen_console(
709 console_log: &Path,
710 position: u64,
711 expected_identity: ConsoleFileIdentity,
712) -> Option<(BufReader<std::fs::File>, u64)> {
713 let (mut file, _) =
714 crate::windows_file::open_regular_file(console_log, Some(expected_identity)).ok()?;
715 let visible_len = file.seek(std::io::SeekFrom::End(0)).ok()?;
716 let replacement_position = if visible_len < position { 0 } else { position };
717 file.seek(std::io::SeekFrom::Start(replacement_position))
718 .ok()?;
719 Some((BufReader::new(file), replacement_position))
720}
721
722pub fn stderr_console_path(console_log: &Path) -> PathBuf {
726 console_log.with_file_name("console.err.log")
727}
728
729#[derive(Clone, Copy)]
734struct TaggedTailOptions<'a> {
735 stream: &'static str,
736 runtime_filter: Option<&'a RuntimeConsoleFilter>,
737 bound: Option<u64>,
738 ready: Option<&'a std::sync::atomic::AtomicUsize>,
739 eof_policy: ConsoleEofPolicy,
740}
741
742fn run_tagged_tail(
743 file: &Path,
744 stop: &AtomicBool,
745 emit: &(dyn Fn(&str, &str) + Sync),
746 options: TaggedTailOptions<'_>,
747) {
748 let (f, identity) = match open_console(file, stop) {
749 Some(opened) => opened,
750 None => return,
751 };
752 if let Some(ready) = options.ready {
753 ready.fetch_add(1, Ordering::Release);
754 }
755 let mut reader = BufReader::new(f);
756 let mut buf = String::new();
757 let truncate = options
760 .bound
761 .map(|cap| move || console_truncate_if_over(file, cap, Some(identity)));
762 let on_eof: Option<&dyn Fn() -> bool> = truncate.as_ref().map(|t| t as &dyn Fn() -> bool);
763 #[cfg(target_os = "windows")]
764 let reopen = |position| reopen_console(file, position, identity);
765 #[cfg(target_os = "windows")]
766 let reopen_at_eof = Some(&reopen as &dyn Fn(u64) -> Option<(BufReader<std::fs::File>, u64)>);
767 #[cfg(not(target_os = "windows"))]
768 let reopen_at_eof = None;
769
770 while let Some((line, complete)) = tail_next_line_with_completeness(
771 &mut reader,
772 &mut buf,
773 stop,
774 on_eof,
775 options.eof_policy,
776 reopen_at_eof,
777 ) {
778 if complete
779 && options
780 .runtime_filter
781 .is_some_and(|filter| !filter.keep_line(&line))
782 {
783 continue;
784 }
785 emit(&line, options.stream);
786 }
787}
788
789fn console_cap(max_size: u64, max_file: u32) -> u64 {
793 max_size.saturating_mul(u64::from(max_file.max(1)))
794}
795
796fn run_discard_processor(
801 console_log: &Path,
802 err_log: &Path,
803 cap: Option<u64>,
804 stop: &AtomicBool,
805 ready: Option<&std::sync::atomic::AtomicUsize>,
806 eof_policy: ConsoleEofPolicy,
807) {
808 let discard = |_line: &str, _stream: &str| {};
809 let discard: &(dyn Fn(&str, &str) + Sync) = &discard;
810 std::thread::scope(|s| {
811 s.spawn(|| {
812 run_tagged_tail(
813 console_log,
814 stop,
815 discard,
816 TaggedTailOptions {
817 stream: "stdout",
818 runtime_filter: None,
819 bound: cap,
820 ready,
821 eof_policy,
822 },
823 )
824 });
825 s.spawn(|| {
826 run_tagged_tail(
827 err_log,
828 stop,
829 discard,
830 TaggedTailOptions {
831 stream: "stderr",
832 runtime_filter: None,
833 bound: cap,
834 ready,
835 eof_policy,
836 },
837 )
838 });
839 });
840}
841
842fn run_json_file_processor(
843 console_log: &Path,
844 err_log: &Path,
845 log_dir: &Path,
846 config: &LogConfig,
847 stop: &AtomicBool,
848 ready: Option<&std::sync::atomic::AtomicUsize>,
849 eof_policy: ConsoleEofPolicy,
850) {
851 let max_size = config.max_size();
852 let max_file = config.max_file();
853 let json_path = json_log_path(log_dir);
854 let writer = std::sync::Mutex::new(
855 match OrderedJsonWriter::new(&json_path, max_size, max_file) {
856 Ok(writer) => writer,
857 Err(_) => return,
858 },
859 );
860 let emit = |line: &str, stream: &str| {
864 if let Ok(mut writer) = writer.lock() {
865 writer.write_entry(line, stream, chrono::Utc::now());
866 }
867 };
868 let emit: &(dyn Fn(&str, &str) + Sync) = &emit;
869
870 let cap = Some(console_cap(max_size, max_file));
871 let runtime_filter = RuntimeConsoleFilter::new();
872 std::thread::scope(|s| {
873 s.spawn(|| {
874 run_tagged_tail(
875 console_log,
876 stop,
877 emit,
878 TaggedTailOptions {
879 stream: "stdout",
880 runtime_filter: Some(&runtime_filter),
881 bound: cap,
882 ready,
883 eof_policy,
884 },
885 )
886 });
887 s.spawn(|| {
890 run_tagged_tail(
891 err_log,
892 stop,
893 emit,
894 TaggedTailOptions {
895 stream: "stderr",
896 runtime_filter: Some(&runtime_filter),
897 bound: cap,
898 ready,
899 eof_policy,
900 },
901 )
902 });
903 });
904}
905
906struct OrderedJsonWriter {
907 output: RotatingWriter,
908 last_timestamp: Option<chrono::DateTime<chrono::Utc>>,
909}
910
911impl OrderedJsonWriter {
912 fn new(path: &Path, max_size: u64, max_file: u32) -> std::io::Result<Self> {
913 Ok(Self {
914 output: RotatingWriter::new(path, max_size, max_file)?,
915 last_timestamp: None,
916 })
917 }
918
919 fn write_entry(&mut self, line: &str, stream: &str, timestamp: chrono::DateTime<chrono::Utc>) {
920 let timestamp = match &self.last_timestamp {
921 Some(previous) if previous > ×tamp => previous.to_owned(),
922 _ => timestamp,
923 };
924 let entry = LogEntry {
925 log: format!("{line}\n"),
926 stream: stream.to_string(),
927 time: timestamp.to_rfc3339_opts(chrono::SecondsFormat::Nanos, true),
928 };
929 self.last_timestamp = Some(timestamp);
930 if let Ok(json) = serde_json::to_string(&entry) {
931 let _ = self.output.write_line(&json);
932 }
933 }
934}
935
936fn run_syslog_processor(
938 console_log: &Path,
939 err_log: &Path,
940 config: &LogConfig,
941 stop: &AtomicBool,
942 ready: Option<&std::sync::atomic::AtomicUsize>,
943 eof_policy: ConsoleEofPolicy,
944) {
945 use std::net::UdpSocket;
946
947 let address = config.syslog_address();
948 let _facility = config.syslog_facility();
949 let tag = config.tag().unwrap_or("a3s-box");
950 let cap = Some(console_cap(config.max_size(), config.max_file()));
951 let runtime_filter = RuntimeConsoleFilter::new();
952 let (proto, addr) = if let Some(rest) = address.strip_prefix("udp://") {
953 ("udp", rest)
954 } else if let Some(rest) = address.strip_prefix("tcp://") {
955 ("tcp", rest)
956 } else {
957 ("udp", address)
958 };
959 match proto {
960 "udp" => {
961 let socket = match UdpSocket::bind("0.0.0.0:0") {
962 Ok(s) => s,
963 Err(_) => return,
964 };
965 let emit = |line: &str, _stream: &str| {
967 let msg = format!("<30>{tag}: {line}");
968 let _ = socket.send_to(msg.as_bytes(), addr);
969 };
970 let emit: &(dyn Fn(&str, &str) + Sync) = &emit;
971 std::thread::scope(|s| {
972 s.spawn(|| {
973 run_tagged_tail(
974 console_log,
975 stop,
976 emit,
977 TaggedTailOptions {
978 stream: "stdout",
979 runtime_filter: Some(&runtime_filter),
980 bound: cap,
981 ready,
982 eof_policy,
983 },
984 )
985 });
986 s.spawn(|| {
987 run_tagged_tail(
988 err_log,
989 stop,
990 emit,
991 TaggedTailOptions {
992 stream: "stderr",
993 runtime_filter: Some(&runtime_filter),
994 bound: cap,
995 ready,
996 eof_policy,
997 },
998 )
999 });
1000 });
1001 }
1002 "tcp" => {
1003 let stream = match std::net::TcpStream::connect(addr) {
1004 Ok(s) => std::sync::Mutex::new(s),
1005 Err(_) => return,
1006 };
1007 let emit = |line: &str, _stream: &str| {
1008 let msg = format!("<30>{tag}: {line}\n");
1009 if let Ok(mut s) = stream.lock() {
1010 if s.write_all(msg.as_bytes()).is_err() {
1011 if let Ok(news) = std::net::TcpStream::connect(addr) {
1012 *s = news;
1013 let _ = s.write_all(msg.as_bytes());
1014 }
1015 }
1016 }
1017 };
1018 let emit: &(dyn Fn(&str, &str) + Sync) = &emit;
1019 std::thread::scope(|sc| {
1020 sc.spawn(|| {
1021 run_tagged_tail(
1022 console_log,
1023 stop,
1024 emit,
1025 TaggedTailOptions {
1026 stream: "stdout",
1027 runtime_filter: Some(&runtime_filter),
1028 bound: cap,
1029 ready,
1030 eof_policy,
1031 },
1032 )
1033 });
1034 sc.spawn(|| {
1035 run_tagged_tail(
1036 err_log,
1037 stop,
1038 emit,
1039 TaggedTailOptions {
1040 stream: "stderr",
1041 runtime_filter: Some(&runtime_filter),
1042 bound: cap,
1043 ready,
1044 eof_policy,
1045 },
1046 )
1047 });
1048 });
1049 }
1050 _ => {}
1051 }
1052}
1053
1054struct RotatingWriter {
1056 path: PathBuf,
1057 file: std::fs::File,
1058 written: u64,
1059 max_size: u64,
1060 max_file: u32,
1061}
1062
1063impl RotatingWriter {
1064 fn new(path: &Path, max_size: u64, max_file: u32) -> std::io::Result<Self> {
1065 let file = std::fs::OpenOptions::new()
1066 .create(true)
1067 .append(true)
1068 .open(path)?;
1069 let written = file.metadata()?.len();
1070 Ok(Self {
1071 path: path.to_path_buf(),
1072 file,
1073 written,
1074 max_size,
1075 max_file,
1076 })
1077 }
1078
1079 fn write_line(&mut self, line: &str) -> std::io::Result<()> {
1080 let bytes = format!("{line}\n");
1081 self.file.write_all(bytes.as_bytes())?;
1082 self.file.flush()?;
1083 self.written += bytes.len() as u64;
1084 if self.written >= self.max_size {
1085 self.rotate()?;
1086 }
1087 Ok(())
1088 }
1089
1090 fn rotate(&mut self) -> std::io::Result<()> {
1091 for i in (1..self.max_file).rev() {
1092 let from = rotated_path(&self.path, i);
1093 let to = rotated_path(&self.path, i + 1);
1094 if from.exists() {
1095 std::fs::rename(&from, &to)?;
1096 }
1097 }
1098 let oldest = rotated_path(&self.path, self.max_file);
1099 if oldest.exists() {
1100 std::fs::remove_file(&oldest)?;
1101 }
1102 let rotated = rotated_path(&self.path, 1);
1103 compress_file(&self.path, &rotated)?;
1104 std::fs::remove_file(&self.path)?;
1105 self.file = std::fs::OpenOptions::new()
1106 .create(true)
1107 .append(true)
1108 .open(&self.path)?;
1109 self.written = 0;
1110 Ok(())
1111 }
1112}
1113
1114fn compress_file(src: &Path, dst: &Path) -> std::io::Result<()> {
1116 use flate2::write::GzEncoder;
1117 use flate2::Compression;
1118 use std::io::Read;
1119
1120 let mut input = std::fs::File::open(src)?;
1121 let output = std::fs::File::create(dst)?;
1122 let mut encoder = GzEncoder::new(output, Compression::fast());
1123 let mut buf = [0u8; 8192];
1124 loop {
1125 let n = input.read(&mut buf)?;
1126 if n == 0 {
1127 break;
1128 }
1129 encoder.write_all(&buf[..n])?;
1130 }
1131 encoder.finish()?;
1132 Ok(())
1133}
1134
1135fn rotated_path(base: &Path, index: u32) -> PathBuf {
1137 let mut p = base.as_os_str().to_owned();
1138 p.push(format!(".{index}.gz"));
1139 PathBuf::from(p)
1140}
1141
1142#[cfg(test)]
1143mod tests {
1144 use super::*;
1145
1146 #[test]
1147 fn test_log_driver_from_str() {
1148 assert_eq!(
1149 "json-file".parse::<LogDriver>().unwrap(),
1150 LogDriver::JsonFile
1151 );
1152 assert_eq!("syslog".parse::<LogDriver>().unwrap(), LogDriver::Syslog);
1153 assert_eq!("none".parse::<LogDriver>().unwrap(), LogDriver::None);
1154 assert!("unknown".parse::<LogDriver>().is_err());
1155 }
1156
1157 #[test]
1158 fn test_log_config_defaults() {
1159 let config = LogConfig::default();
1160 assert_eq!(config.driver, LogDriver::JsonFile);
1161 assert_eq!(config.max_size(), 10 * 1024 * 1024);
1162 assert_eq!(config.max_file(), 3);
1163 }
1164
1165 #[test]
1166 fn test_log_config_custom_options() {
1167 let mut config = LogConfig::default();
1168 config
1169 .options
1170 .insert("max-size".to_string(), "50m".to_string());
1171 config
1172 .options
1173 .insert("max-file".to_string(), "5".to_string());
1174 assert_eq!(config.max_size(), 50 * 1024 * 1024);
1175 assert_eq!(config.max_file(), 5);
1176 }
1177
1178 #[test]
1179 fn test_parse_size() {
1180 assert_eq!(parse_size("1024").unwrap(), 1024);
1181 assert_eq!(parse_size("10m").unwrap(), 10 * 1024 * 1024);
1182 assert_eq!(parse_size("1g").unwrap(), 1024 * 1024 * 1024);
1183 assert_eq!(parse_size("512k").unwrap(), 512 * 1024);
1184 assert!(parse_size("abc").is_err());
1185 }
1186
1187 #[test]
1188 fn test_log_entry_serialization() {
1189 let entry = LogEntry {
1190 log: "hello\n".to_string(),
1191 stream: "stdout".to_string(),
1192 time: "2026-02-12T06:00:00.000000000Z".to_string(),
1193 };
1194 let json = serde_json::to_string(&entry).unwrap();
1195 assert!(json.contains("\"log\":\"hello\\n\""));
1196 assert!(json.contains("\"stream\":\"stdout\""));
1197 }
1198
1199 #[test]
1200 fn sandbox_log_worker_spec_round_trips_generation_identity() {
1201 let spec = SandboxLogWorkerSpec {
1202 schema: SANDBOX_LOG_WORKER_SCHEMA.to_string(),
1203 box_id: "sandbox-id".to_string(),
1204 console_log: PathBuf::from("/tmp/sandbox-id/logs/console.log"),
1205 log_config: LogConfig::default(),
1206 watched_pid: 123,
1207 watched_pid_start_time: 456,
1208 ready_file: PathBuf::from("/tmp/sandbox-id/sandbox/log-worker.ready"),
1209 };
1210
1211 let encoded = serde_json::to_vec(&spec).unwrap();
1212 let decoded: SandboxLogWorkerSpec = serde_json::from_slice(&encoded).unwrap();
1213
1214 assert_eq!(decoded, spec);
1215 }
1216
1217 #[test]
1218 fn writer_closed_eof_skips_the_late_console_settle_window() {
1219 assert_eq!(
1220 stopped_eof_settle_polls(ConsoleEofPolicy::MayReceiveLateWrites),
1221 25
1222 );
1223 assert_eq!(stopped_eof_settle_polls(ConsoleEofPolicy::WriterClosed), 1);
1224 }
1225
1226 #[test]
1227 fn test_syslog_config_defaults() {
1228 let config = LogConfig {
1229 driver: LogDriver::Syslog,
1230 options: HashMap::new(),
1231 };
1232 assert_eq!(config.syslog_address(), "udp://localhost:514");
1233 assert_eq!(config.syslog_facility(), "daemon");
1234 assert_eq!(config.tag(), None);
1235 }
1236
1237 #[test]
1238 fn test_syslog_config_custom() {
1239 let mut options = HashMap::new();
1240 options.insert(
1241 "syslog-address".to_string(),
1242 "tcp://loghost:1514".to_string(),
1243 );
1244 options.insert("syslog-facility".to_string(), "local0".to_string());
1245 options.insert("tag".to_string(), "myapp".to_string());
1246 let config = LogConfig {
1247 driver: LogDriver::Syslog,
1248 options,
1249 };
1250 assert_eq!(config.syslog_address(), "tcp://loghost:1514");
1251 assert_eq!(config.syslog_facility(), "local0");
1252 assert_eq!(config.tag(), Some("myapp"));
1253 }
1254
1255 #[test]
1256 fn test_log_driver_display() {
1257 assert_eq!(LogDriver::JsonFile.to_string(), "json-file");
1258 assert_eq!(LogDriver::Syslog.to_string(), "syslog");
1259 assert_eq!(LogDriver::None.to_string(), "none");
1260 }
1261
1262 #[test]
1263 fn test_log_driver_serde_roundtrip() {
1264 let driver = LogDriver::Syslog;
1265 let json = serde_json::to_string(&driver).unwrap();
1266 assert_eq!(json, "\"syslog\"");
1267 let parsed: LogDriver = serde_json::from_str(&json).unwrap();
1268 assert_eq!(parsed, LogDriver::Syslog);
1269 }
1270
1271 #[test]
1272 fn test_tail_next_line_returns_complete_lines() {
1273 use std::io::Cursor;
1274 let mut reader = BufReader::new(Cursor::new(b"alpha\r\nbeta\n".to_vec()));
1277 let mut buf = String::new();
1278 let stop = AtomicBool::new(true);
1279 assert_eq!(
1280 tail_next_line(
1281 &mut reader,
1282 &mut buf,
1283 &stop,
1284 None,
1285 ConsoleEofPolicy::MayReceiveLateWrites,
1286 None,
1287 ),
1288 Some("alpha".to_string())
1289 );
1290 assert_eq!(
1291 tail_next_line(
1292 &mut reader,
1293 &mut buf,
1294 &stop,
1295 None,
1296 ConsoleEofPolicy::MayReceiveLateWrites,
1297 None,
1298 ),
1299 Some("beta".to_string())
1300 );
1301 assert_eq!(
1302 tail_next_line(
1303 &mut reader,
1304 &mut buf,
1305 &stop,
1306 None,
1307 ConsoleEofPolicy::MayReceiveLateWrites,
1308 None,
1309 ),
1310 None
1311 );
1312 assert!(buf.is_empty());
1313 }
1314
1315 #[test]
1316 fn test_tail_next_line_flushes_trailing_partial_on_stop() {
1317 use std::io::Cursor;
1318 let mut reader = BufReader::new(Cursor::new(b"only-partial".to_vec()));
1321 let mut buf = String::new();
1322 let stop = AtomicBool::new(true);
1323 assert_eq!(
1324 tail_next_line(
1325 &mut reader,
1326 &mut buf,
1327 &stop,
1328 None,
1329 ConsoleEofPolicy::MayReceiveLateWrites,
1330 None,
1331 ),
1332 Some("only-partial".to_string())
1333 );
1334 assert_eq!(
1335 tail_next_line(
1336 &mut reader,
1337 &mut buf,
1338 &stop,
1339 None,
1340 ConsoleEofPolicy::MayReceiveLateWrites,
1341 None,
1342 ),
1343 None
1344 );
1345 }
1346
1347 #[test]
1348 fn test_console_truncate_if_over_only_when_over_cap() {
1349 let dir = tempfile::tempdir().unwrap();
1350 let path = dir.path().join("c.log");
1351 std::fs::write(&path, b"hello").unwrap(); assert!(!console_truncate_if_over(&path, 10, None)); assert_eq!(std::fs::metadata(&path).unwrap().len(), 5);
1355
1356 assert!(console_truncate_if_over(&path, 4, None)); assert_eq!(std::fs::metadata(&path).unwrap().len(), 0);
1358
1359 assert!(!console_truncate_if_over(&dir.path().join("nope"), 0, None));
1361 }
1362
1363 #[test]
1364 fn test_run_tagged_tail_truncates_over_cap_and_keeps_emitting() {
1365 use std::sync::{Arc, Mutex};
1366 use std::time::Duration;
1367
1368 let dir = tempfile::tempdir().unwrap();
1369 let path = dir.path().join("console.log");
1370 std::fs::write(&path, b"l1\nl2\nl3\n").unwrap();
1372 let cap = 4u64;
1373
1374 let collected = Arc::new(Mutex::new(Vec::<String>::new()));
1375 let stop = Arc::new(AtomicBool::new(false));
1376 let (c2, s2, p2) = (collected.clone(), stop.clone(), path.clone());
1377 let handle = std::thread::spawn(move || {
1378 let emit = move |line: &str, _stream: &str| c2.lock().unwrap().push(line.to_string());
1379 let emit: &(dyn Fn(&str, &str) + Sync) = &emit;
1380 run_tagged_tail(
1381 &p2,
1382 &s2,
1383 emit,
1384 TaggedTailOptions {
1385 stream: "stdout",
1386 runtime_filter: None,
1387 bound: Some(cap),
1388 ready: None,
1389 eof_policy: ConsoleEofPolicy::MayReceiveLateWrites,
1390 },
1391 );
1392 });
1393
1394 std::thread::sleep(Duration::from_millis(300));
1396 {
1398 use std::io::Write as _;
1399 let mut f = std::fs::OpenOptions::new()
1400 .append(true)
1401 .open(&path)
1402 .unwrap();
1403 f.write_all(b"l4\nl5\n").unwrap();
1404 }
1405 std::thread::sleep(Duration::from_millis(300));
1406 stop.store(true, Ordering::Relaxed);
1407 handle.join().unwrap();
1408
1409 let got = collected.lock().unwrap().clone();
1410 for line in ["l1", "l3", "l4", "l5"] {
1412 assert!(got.contains(&line.to_string()), "missing {line} in {got:?}");
1413 }
1414 let final_len = std::fs::metadata(&path).unwrap().len();
1416 assert!(
1417 final_len <= cap + 6,
1418 "console.log unbounded: {final_len} bytes"
1419 );
1420 }
1421
1422 #[cfg(target_os = "windows")]
1423 #[test]
1424 fn test_run_tagged_tail_refuses_replaced_source_identity() {
1425 use std::sync::{Arc, Mutex};
1426 use std::time::Duration;
1427
1428 let dir = tempfile::tempdir().unwrap();
1429 let path = dir.path().join("guest-init.stdout.log");
1430 let retired = dir.path().join("guest-init.stdout.log.retired");
1431 std::fs::write(&path, b"").unwrap();
1432
1433 let collected = Arc::new(Mutex::new(Vec::<String>::new()));
1434 let stop = Arc::new(AtomicBool::new(false));
1435 let (c2, s2, p2) = (collected.clone(), stop.clone(), path.clone());
1436 let handle = std::thread::spawn(move || {
1437 let emit = move |line: &str, _stream: &str| c2.lock().unwrap().push(line.to_string());
1438 let emit: &(dyn Fn(&str, &str) + Sync) = &emit;
1439 run_tagged_tail(
1440 &p2,
1441 &s2,
1442 emit,
1443 TaggedTailOptions {
1444 stream: "stdout",
1445 runtime_filter: None,
1446 bound: None,
1447 ready: None,
1448 eof_policy: ConsoleEofPolicy::MayReceiveLateWrites,
1449 },
1450 );
1451 });
1452
1453 std::thread::sleep(Duration::from_millis(300));
1456 std::fs::rename(&path, &retired).unwrap();
1457 std::fs::write(&path, b"late-line\n").unwrap();
1458
1459 std::thread::sleep(Duration::from_millis(300));
1460
1461 stop.store(true, Ordering::Relaxed);
1462 handle.join().unwrap();
1463 let got = collected.lock().unwrap().clone();
1464 assert!(!got.iter().any(|line| line == "late-line"), "{got:?}");
1465 }
1466
1467 #[test]
1468 fn explicit_streams_with_ready_reports_both_open_readers() {
1469 use std::sync::Arc;
1470 use std::time::{Duration, Instant};
1471
1472 let dir = tempfile::tempdir().unwrap();
1473 let stdout = dir.path().join("guest.stdout.log");
1474 let stderr = dir.path().join("guest.stderr.log");
1475 std::fs::write(&stdout, b"").unwrap();
1476 std::fs::write(&stderr, b"").unwrap();
1477
1478 let stop = Arc::new(AtomicBool::new(false));
1479 let ready = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1480 let (thread_stop, thread_ready) = (Arc::clone(&stop), Arc::clone(&ready));
1481 let log_dir = dir.path().to_path_buf();
1482 let handle = std::thread::spawn(move || {
1483 run_log_processor_streams_with_ready(
1484 &stdout,
1485 &stderr,
1486 &log_dir,
1487 &LogConfig::default(),
1488 &thread_stop,
1489 Some(&thread_ready),
1490 );
1491 });
1492
1493 let deadline = Instant::now() + Duration::from_secs(2);
1494 while ready.load(Ordering::Acquire) < 2 && Instant::now() < deadline {
1495 std::thread::sleep(Duration::from_millis(10));
1496 }
1497 assert_eq!(ready.load(Ordering::Acquire), 2);
1498
1499 stop.store(true, Ordering::Release);
1500 handle.join().unwrap();
1501 }
1502
1503 #[test]
1504 fn test_stopped_tail_waits_for_delayed_final_console_write() {
1505 use std::io::Write as _;
1506
1507 let dir = tempfile::tempdir().unwrap();
1508 let path = dir.path().join("console.log");
1509 std::fs::write(&path, b"").unwrap();
1510 let writer_path = path.clone();
1511 let writer = std::thread::spawn(move || {
1512 std::thread::sleep(std::time::Duration::from_millis(30));
1513 let mut file = std::fs::OpenOptions::new()
1514 .append(true)
1515 .open(writer_path)
1516 .unwrap();
1517 file.write_all(b"late-final-line\n").unwrap();
1518 file.flush().unwrap();
1519 });
1520
1521 let file = std::fs::File::open(&path).unwrap();
1522 let mut reader = BufReader::new(file);
1523 let mut buffer = String::new();
1524 let stop = AtomicBool::new(true);
1525 assert_eq!(
1526 tail_next_line(
1527 &mut reader,
1528 &mut buffer,
1529 &stop,
1530 None,
1531 ConsoleEofPolicy::MayReceiveLateWrites,
1532 None,
1533 ),
1534 Some("late-final-line".to_string())
1535 );
1536 assert_eq!(
1537 tail_next_line(
1538 &mut reader,
1539 &mut buffer,
1540 &stop,
1541 None,
1542 ConsoleEofPolicy::MayReceiveLateWrites,
1543 None,
1544 ),
1545 None
1546 );
1547 writer.join().unwrap();
1548 }
1549
1550 #[test]
1551 fn test_none_driver_still_bounds_console() {
1552 use std::sync::Arc;
1553 use std::time::Duration;
1554
1555 let dir = tempfile::tempdir().unwrap();
1556 let console = dir.path().join("console.log");
1557 std::fs::write(&console, b"l1\nl2\nl3\n").unwrap(); std::fs::write(dir.path().join("console.err.log"), b"").unwrap();
1559
1560 let mut options = HashMap::new();
1562 options.insert("max-size".to_string(), "4".to_string());
1563 options.insert("max-file".to_string(), "1".to_string());
1564 let config = LogConfig {
1565 driver: LogDriver::None,
1566 options,
1567 };
1568
1569 let stop = Arc::new(AtomicBool::new(false));
1570 let (s2, c2, d2) = (stop.clone(), console.clone(), dir.path().to_path_buf());
1571 let handle = std::thread::spawn(move || run_log_processor(&c2, &d2, &config, &s2));
1572
1573 std::thread::sleep(Duration::from_millis(300));
1574 stop.store(true, Ordering::Relaxed);
1575 handle.join().unwrap();
1576
1577 assert!(std::fs::metadata(&console).unwrap().len() <= 4);
1580 assert!(!dir.path().join("container.json").exists());
1581 }
1582
1583 #[test]
1584 fn test_is_runtime_console_noise() {
1585 assert!(is_runtime_console_noise("init.krun: mount_filesystems ok"));
1586 assert!(is_runtime_console_noise("init.krun: entered main argc=1"));
1587 assert!(is_runtime_console_noise(
1588 "init.krun: selected exec=/bin/app init_pid1=0"
1589 ));
1590 assert!(is_runtime_console_noise(
1591 "init.krun: execvp(/bin/app) starting"
1592 ));
1593 assert!(!is_runtime_console_noise("init.krun: business"));
1594 assert!(!is_runtime_console_noise(
1595 "init.krun: entered main argc=not-a-number"
1596 ));
1597 assert!(!is_runtime_console_noise(
1598 "init.krun: execvp(/bin/app) failed errno=2"
1599 ));
1600 assert!(!is_runtime_console_noise("L1"));
1601 assert!(!is_runtime_console_noise(
1602 "starting app (init.krun: ignored)"
1603 ));
1604 assert!(!is_runtime_console_noise(""));
1605 }
1606
1607 #[test]
1608 fn runtime_console_filter_shares_sentinel_phase_across_streams() {
1609 let filter = RuntimeConsoleFilter::new();
1610
1611 assert!(!filter.keep_line("init.krun: mount_filesystems ok"));
1614 assert!(filter.keep_line("init.krun: business"));
1615 assert!(!filter.keep_line("init.krun: execvp(/bin/app) starting"));
1616 assert!(!filter.preamble_active());
1617 assert!(filter.keep_line("init.krun: mount_filesystems ok"));
1618 assert!(filter.keep_line("init.krun: execvp(/bin/app) starting"));
1619 assert!(filter.keep_line("init.krun: execvp(/bin/app) failed errno=2"));
1620 }
1621
1622 #[test]
1623 fn test_run_json_file_processor_captures_all_lines_after_stop() {
1624 let dir = tempfile::tempdir().unwrap();
1628 let console = dir.path().join("console.log");
1629 let stderr = dir.path().join("persisted-stderr.log");
1630 std::fs::write(
1631 &console,
1632 concat!(
1633 "init.krun: entered main argc=1\n",
1634 "init.krun: mount_filesystems ok\n",
1635 "init.krun: execvp(/bin/app) starting\n",
1636 "AAA\n",
1637 "init.krun: business\n",
1638 "BBB\n",
1639 ),
1640 )
1641 .unwrap();
1642 std::fs::write(&stderr, "ERR\n").unwrap();
1643 let stop = AtomicBool::new(true);
1644 run_log_processor_streams(&console, &stderr, dir.path(), &LogConfig::default(), &stop);
1645 let json = std::fs::read_to_string(json_log_path(dir.path())).unwrap();
1646 assert!(json.contains("\"log\":\"AAA\\n\""), "AAA missing: {json}");
1647 assert!(
1648 json.contains("\"log\":\"BBB\\n\""),
1649 "BBB (after a quiet line) missing: {json}"
1650 );
1651 assert!(
1652 json.contains("\"log\":\"ERR\\n\"") && json.contains("\"stream\":\"stderr\""),
1653 "custom stderr stream missing: {json}"
1654 );
1655 assert!(
1656 json.contains("\"log\":\"init.krun: business\\n\""),
1657 "generic init.krun workload output missing: {json}"
1658 );
1659 assert!(
1660 !json.contains("entered main"),
1661 "C-init noise leaked: {json}"
1662 );
1663 assert!(
1664 !json.contains("mount_filesystems ok"),
1665 "C-init noise leaked: {json}"
1666 );
1667 assert!(
1668 !json.contains("execvp(/bin/app) starting"),
1669 "C-init sentinel leaked: {json}"
1670 );
1671 }
1672
1673 #[test]
1674 fn test_run_json_file_processor_preserves_unterminated_prefix_line() {
1675 let dir = tempfile::tempdir().unwrap();
1676 let console = dir.path().join("console.log");
1677 let stderr = dir.path().join("console.err.log");
1678 std::fs::write(&console, "init.krun: mount_filesystems ok").unwrap();
1679 std::fs::write(&stderr, "").unwrap();
1680
1681 let stop = AtomicBool::new(true);
1682 run_log_processor_streams(&console, &stderr, dir.path(), &LogConfig::default(), &stop);
1683
1684 let json = std::fs::read_to_string(json_log_path(dir.path())).unwrap();
1685 assert!(
1686 json.contains("\"log\":\"init.krun: mount_filesystems ok\\n\""),
1687 "unterminated workload fragment was dropped: {json}"
1688 );
1689 }
1690
1691 #[test]
1692 fn ordered_json_writer_clamps_a_regressed_clock() {
1693 let dir = tempfile::tempdir().unwrap();
1694 let path = dir.path().join("container.json");
1695 let mut writer = OrderedJsonWriter::new(&path, 10 * 1024 * 1024, 3).unwrap();
1696 let newer = chrono::DateTime::parse_from_rfc3339("2026-07-19T12:00:01Z")
1697 .unwrap()
1698 .to_utc();
1699 let older = chrono::DateTime::parse_from_rfc3339("2026-07-19T12:00:00Z")
1700 .unwrap()
1701 .to_utc();
1702
1703 writer.write_entry("first", "stdout", newer);
1704 writer.write_entry("second", "stderr", older);
1705 drop(writer);
1706
1707 let entries = std::fs::read_to_string(path)
1708 .unwrap()
1709 .lines()
1710 .map(|line| serde_json::from_str::<LogEntry>(line).unwrap())
1711 .collect::<Vec<_>>();
1712 assert_eq!(entries.len(), 2);
1713 assert_eq!(entries[0].time, entries[1].time);
1714 }
1715
1716 #[test]
1717 fn test_rotating_writer_rotates_and_gzips() {
1718 let dir = tempfile::tempdir().unwrap();
1719 let path = dir.path().join("container.json");
1720 let mut w = RotatingWriter::new(&path, 20, 3).unwrap();
1721 for i in 0..10 {
1722 w.write_line(&format!("line-{i}")).unwrap();
1723 }
1724 assert!(
1725 rotated_path(&path, 1).exists(),
1726 "expected a rotated .1.gz file"
1727 );
1728 }
1729}