Skip to main content

a3s_box_core/
log.rs

1//! Logging driver types and configuration.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6/// Logging driver type.
7#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "kebab-case")]
9pub enum LogDriver {
10    /// Docker-compatible JSON lines format (default).
11    #[default]
12    JsonFile,
13    /// Forward logs to a syslog endpoint.
14    ///
15    /// Options:
16    /// - `syslog-address`: UDP/TCP address (e.g., "udp://localhost:514")
17    /// - `syslog-facility`: Syslog facility (default: "daemon")
18    /// - `tag`: Log tag template (default: box name)
19    Syslog,
20    /// Disable logging entirely.
21    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/// Logging configuration for a box.
51#[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    /// Maximum log file size in bytes before rotation.
69    /// Default: 10 MiB. Set via `max-size` option (e.g., "10m", "1g").
70    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    /// Maximum number of rotated log files to keep.
78    /// Default: 3. Set via `max-file` option.
79    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    /// Syslog address (e.g., "udp://localhost:514").
87    /// Only relevant when driver is `Syslog`.
88    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    /// Syslog facility (e.g., "daemon", "local0").
96    /// Only relevant when driver is `Syslog`.
97    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    /// Log tag (used by syslog driver as the program name).
105    pub fn tag(&self) -> Option<&str> {
106        self.options.get("tag").map(|s| s.as_str())
107    }
108}
109
110/// A single structured log entry (Docker-compatible JSON format).
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112pub struct LogEntry {
113    /// The log message (including trailing newline).
114    pub log: String,
115    /// The output stream: "stdout" or "stderr".
116    pub stream: String,
117    /// RFC 3339 timestamp with nanosecond precision.
118    pub time: String,
119}
120
121/// Schema used to hand one Sandbox log worker its immutable generation data.
122pub const SANDBOX_LOG_WORKER_SCHEMA: &str = "a3s.box.sandbox-log-worker.v1";
123
124/// Configuration for the host process that projects Sandbox stdout/stderr into
125/// the configured logging driver after the launching client has detached.
126///
127/// The worker watches the exact `crun run` wrapper PID identity. Once that
128/// process exits, both inherited output descriptors are closed and EOF is
129/// authoritative, so the worker can drain the final bytes without a fixed
130/// late-write delay.
131#[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
142/// Parse a human-readable size string (e.g., "10m", "1g", "4096") into bytes.
143fn 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
169// ===========================================================================
170// Log processor — tails the VM console (`console.log`) and produces structured
171// Docker-compatible output (`container.json`) or forwards to syslog.
172//
173// This runs in the SHIM (the box's own per-process lifetime), not the ephemeral
174// CLI: the CLI exits on `run -d` detach, which would kill an in-CLI processor
175// and truncate the logs. The shim writes `console.log` and lives exactly as
176// long as the VM, so it is the correct, daemonless home (like containerd-shim).
177// ===========================================================================
178
179use std::io::{BufRead, BufReader, Seek, Write};
180use std::path::{Path, PathBuf};
181use std::sync::atomic::{AtomicBool, Ordering};
182
183/// Whether the producer may still publish bytes after its apparent exit.
184///
185/// libkrun can return before its console backend's final host write becomes
186/// visible, whereas a reaped `crun run` wrapper has already closed stdout and
187/// stderr. Keeping the distinction explicit avoids imposing the MicroVM's
188/// half-second settle window on every short Sandbox execution.
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum ConsoleEofPolicy {
191    MayReceiveLateWrites,
192    WriterClosed,
193}
194
195/// Truncate `path` to empty if it has grown past `cap` bytes; returns whether it
196/// truncated.
197///
198/// libkrun appends the guest console to the raw `console.log`/`console.err.log`
199/// for the VM's entire lifetime, so a chatty long-running box grows them without
200/// limit — only the rotated `container.json` was ever bounded. The tail loop
201/// calls this at a clean line boundary (every line so far is already durable in
202/// `container.json`), so truncation never drops queryable log data. libkrun
203/// holds the file `O_APPEND`, so its next write resumes at offset 0 — no hole.
204fn 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
220/// Path to the structured JSON log file inside a box's log dir.
221pub fn json_log_path(log_dir: &Path) -> PathBuf {
222    log_dir.join("container.json")
223}
224
225/// True for console lines that are VM/runtime boot internals, not container
226/// output — libkrun's C-init preamble (`init.krun: ...`), printed before
227/// `/sbin/init` (guest-init) takes over. guest-init's own tracing goes to
228/// `/dev/kmsg`, so this is the only remaining non-container source on the
229/// console.
230pub fn is_runtime_console_noise(line: &str) -> bool {
231    line.starts_with("init.krun:")
232}
233
234/// Read the next COMPLETE line from a tailed `console.log`, returning it without
235/// the trailing newline. Polls on EOF like `tail -f` (so lines a container logs
236/// after a quiet period are not dropped), accumulating a partial line across
237/// reads. Returns `None` only when `stop` is set AND EOF is reached — i.e. the
238/// VM has exited and `console.log` is fully drained — flushing any final partial
239/// line as the last value before the subsequent `None`.
240fn 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    // `krun_start_enter()` can return a few scheduler ticks before the
248    // virtio-console backend's final host write becomes visible. Treat the
249    // first stopped EOFs as provisional; otherwise a very short detached
250    // command can leave bytes in console.log after the processor has exited.
251    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                    // VM exited and we are at EOF: flush a trailing partial line
266                    // (no newline) once, then signal completion.
267                    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                // Caught up at a clean line boundary (no partial line buffered):
274                // let the caller bound the file's growth. If it truncated, seek
275                // back to the start so reads don't sit forever past a stale EOF.
276                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            // Partial line at EOF — keep it buffered and wait for the rest.
290            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
305/// Run the log processor for a box, blocking until `stop` is set and the console
306/// is drained. Intended to run on a dedicated thread for the VM's lifetime; set
307/// `stop` after the VM exits, then join, to guarantee the final lines are
308/// captured (no teardown race).
309pub 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
318/// Run the processor and optionally count each console reader once it has
319/// opened its file. A VM launcher can wait for two ready readers before start,
320/// preventing a short guest from exiting before the tail threads are alive.
321pub 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
338/// Run the log processor with an explicit final-EOF policy.
339///
340/// Sandbox workers use [`ConsoleEofPolicy::WriterClosed`] only after the exact
341/// `crun run` wrapper has exited. Other callers should retain the conservative
342/// default exposed by [`run_log_processor_with_ready`].
343pub 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        // `none` produces no structured output, but libkrun still writes the raw
353        // console for the VM's lifetime — drain + bound it so a chatty box with
354        // logging disabled doesn't fill the disk (same hazard as the other
355        // drivers).
356        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
376/// Wait (bounded) for `console.log` to appear, then open it. Returns `None` if it
377/// never shows up or `stop` fires first.
378fn 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
391/// Tail console.log and write one Docker-style JSON record per container line.
392/// The stderr companion to `console.log` (libkrun's 3-fd console sends guest
393/// stderr here, stdout to `console.log`).
394pub fn stderr_console_path(console_log: &Path) -> PathBuf {
395    console_log.with_file_name("console.err.log")
396}
397
398/// Tail one console file, emitting each container line via `emit(line, stream)`.
399/// `filter_noise` drops libkrun's `init.krun:` preamble (only on the stdout
400/// console). Blocks until `stop` is set and the file is drained.
401#[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    // Bound the raw console file's growth at clean line boundaries (see
426    // console_truncate_if_over). None = unbounded (used by tests).
427    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
439/// The raw `console.log`/`console.err.log` byte budget before the tail loop
440/// truncates it. Tied to the rotated `container.json` budget (`max_size *
441/// max_file`) so the raw console never outgrows the queryable log it feeds.
442fn console_cap(max_size: u64, max_file: u32) -> u64 {
443    max_size.saturating_mul(u64::from(max_file.max(1)))
444}
445
446/// Drain and bound the console for the `none` driver: tail both console files
447/// (advancing to clean line boundaries) and truncate when over `cap`, emitting
448/// nothing. Without this, `--log-driver none` would leave libkrun's raw
449/// `console.log`/`console.err.log` to grow without limit.
450fn 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    // Write one tagged JSON record per line; shared by the stdout and stderr
509    // tail threads (the Mutex serializes their interleave into container.json).
510    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        // libkrun's `init.krun:` preamble can land on EITHER stream, so filter
541        // the noise on stderr too.
542        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
559/// Forward both console streams (stdout + stderr) to a syslog endpoint.
560fn 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            // RFC 3164: <priority>tag: message; daemon(3)*8 + info(6) = 30.
589            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
677/// A file writer that rotates (and gzips) when the file exceeds `max_size`.
678struct 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
737/// Compress a file with gzip.
738fn 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
758/// Generate a rotated file path: container.json → container.json.1.gz
759fn 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        // Two complete lines (CRLF then LF) returned newline-stripped; a third
898        // read at EOF with stop=true returns None (VM exited, console drained).
899        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        // A final line without a trailing newline is still flushed once when the
939        // VM has exited (stop=true) — no dropped last line.
940        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(); // 5 bytes
970
971        assert!(!console_truncate_if_over(&path, 10)); // under cap → untouched
972        assert_eq!(std::fs::metadata(&path).unwrap().len(), 5);
973
974        assert!(console_truncate_if_over(&path, 4)); // over cap → truncated
975        assert_eq!(std::fs::metadata(&path).unwrap().len(), 0);
976
977        // Missing file: false, no panic.
978        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        // Pre-fill past a tiny cap with three complete lines.
989        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        // Let the tail drain l1..l3, hit EOF, and truncate (9 bytes > cap 4).
1013        std::thread::sleep(Duration::from_millis(300));
1014        // libkrun-style O_APPEND write after the truncation.
1015        {
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        // No data lost across the truncation: pre- and post-truncation lines both emit.
1029        for line in ["l1", "l3", "l4", "l5"] {
1030            assert!(got.contains(&line.to_string()), "missing {line} in {got:?}");
1031        }
1032        // And the raw file stayed bounded (truncated, not left at full history).
1033        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(); // 9 bytes
1093        std::fs::write(dir.path().join("console.err.log"), b"").unwrap();
1094
1095        // none driver, tiny cap (max_size 4 * max_file 1).
1096        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        // The raw console was bounded even though `none` produces no output, and
1113        // no container.json was written.
1114        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        // The processor must emit a record for EVERY console line, then stop
1131        // cleanly once the VM has exited (stop=true). The original bug dropped
1132        // every line logged after the first EOF (here: BBB after a quiet line).
1133        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}