Skip to main content

overbrainer/runpod/logs/
kept.rs

1//! The pod's logs as a run directory keeps them, in `.pod/`: the log stream's
2//! lines ([`POD_LOG`]) and cursor ([`POD_LOG_CURSOR`]), and the copies of the
3//! bootstrap's and watchdog's own logs.
4//!
5//! A pod writes into `.pod/` (the results it sends back are extracted into
6//! the run directory), so nothing here goes through a symbolic link: `.pod`
7//! and each file are opened with `O_NOFOLLOW`, relative to their directory,
8//! and a file with a second hard link is refused, like the project's own
9//! state files.
10
11use std::fs::File;
12use std::io::{self, ErrorKind, Read, Seek, SeekFrom, Write};
13use std::os::fd::OwnedFd;
14use std::os::unix::fs::{FileExt, MetadataExt};
15use std::path::{Path, PathBuf};
16use std::time::{Duration, Instant, SystemTime};
17
18use rustix::fs::{AtFlags, Mode, OFlags};
19use rustix::io::Errno;
20
21use super::{
22    CAPPED_SOURCE, LogQuery, POD_LOG, POD_LOG_CAP, POD_LOG_CURSOR, PodLogLine, TAIL_MAX,
23    valid_cursor,
24};
25
26/// The directory of the kept logs in a run directory.
27const POD_DIR: &str = ".pod";
28
29/// Room kept under the cap for the line saying the log was capped.
30const CAP_RESERVE: u64 = 512;
31
32/// How often, at most, the cursor is written while lines keep coming.
33const CURSOR_EVERY: Duration = Duration::from_secs(1);
34
35/// The file name, in `.pod/`, of `path` (such as [`POD_LOG`]): `None` unless
36/// it is `.pod/<name>`.
37fn name_in_pod(path: &str) -> Option<&str> {
38    path.strip_prefix(".pod/")
39        .filter(|name| !name.is_empty() && !name.contains('/'))
40}
41
42/// The error for a path that is a symbolic link, a hard link to another
43/// file, or not what it should be.
44fn unsafe_path(path: &Path) -> io::Error {
45    io::Error::new(
46        ErrorKind::InvalidInput,
47        format!("refusing to use {}: it is not a safe path", path.display()),
48    )
49}
50
51/// Maps the failure of an `O_NOFOLLOW` open of `path`.
52fn open_error(path: &Path, errno: Errno) -> io::Error {
53    if errno == Errno::LOOP || errno == Errno::NOTDIR {
54        unsafe_path(path)
55    } else {
56        errno.into()
57    }
58}
59
60/// The `.pod` directory of `run_dir`, never through a symbolic link, created
61/// (mode 0700, with the run directory) when `create`.
62fn pod_dir(run_dir: &Path, create: bool) -> io::Result<OwnedFd> {
63    if create {
64        std::fs::create_dir_all(run_dir)?;
65    }
66    let run = rustix::fs::open(
67        run_dir,
68        OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
69        Mode::empty(),
70    )?;
71    if create {
72        match rustix::fs::mkdirat(&run, POD_DIR, Mode::from_raw_mode(0o700)) {
73            Ok(()) => {},
74            Err(errno) if errno == Errno::EXIST => {},
75            Err(errno) => return Err(errno.into()),
76        }
77    }
78    rustix::fs::openat(
79        &run,
80        POD_DIR,
81        OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
82        Mode::empty(),
83    )
84    .map_err(|errno| open_error(&run_dir.join(POD_DIR), errno))
85}
86
87/// Opens `name` in the `.pod` directory `dir` (whose path is `dir_path`),
88/// never through a symbolic link, and only when it is a regular file with a
89/// single link. A file opened for writing is made mode 0600.
90fn open_in(dir: &OwnedFd, dir_path: &Path, name: &str, flags: OFlags) -> io::Result<File> {
91    let path = dir_path.join(name);
92    let fd = rustix::fs::openat(
93        dir,
94        name,
95        flags | OFlags::NOFOLLOW | OFlags::CLOEXEC,
96        Mode::from_raw_mode(0o600),
97    )
98    .map_err(|errno| open_error(&path, errno))?;
99    let file = File::from(fd);
100    let metadata = file.metadata()?;
101    if !metadata.is_file() || metadata.nlink() != 1 {
102        return Err(unsafe_path(&path));
103    }
104    if flags.intersects(OFlags::WRONLY | OFlags::RDWR) {
105        rustix::fs::fchmod(&file, Mode::from_raw_mode(0o600))?;
106    }
107    Ok(file)
108}
109
110/// Opens the kept file `path` (such as [`POD_LOG`] or
111/// [`BOOTSTRAP_LOG`](super::BOOTSTRAP_LOG)) of `run_dir` for reading; `None`
112/// when it, or `.pod`, does not exist.
113///
114/// # Errors
115///
116/// Returns an error of kind `InvalidInput` when `.pod` or the file is a
117/// symbolic link, a hard link or not a regular file, and the I/O error of
118/// opening it otherwise.
119pub fn open_kept(run_dir: &Path, path: &str) -> io::Result<Option<File>> {
120    let name = name_in_pod(path).ok_or_else(|| unsafe_path(Path::new(path)))?;
121    let dir_path = run_dir.join(POD_DIR);
122    let dir = match pod_dir(run_dir, false) {
123        Ok(dir) => dir,
124        Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
125        Err(error) => return Err(error),
126    };
127    match open_in(&dir, &dir_path, name, OFlags::RDONLY) {
128        Ok(file) => Ok(Some(file)),
129        Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
130        Err(error) => Err(error),
131    }
132}
133
134/// The whole kept file `path` of `run_dir` (see [`open_kept`]); `None` when
135/// it does not exist.
136///
137/// # Errors
138///
139/// As [`open_kept`], and the I/O error of reading it.
140pub fn read_kept(run_dir: &Path, path: &str) -> io::Result<Option<Vec<u8>>> {
141    let Some(mut file) = open_kept(run_dir, path)? else {
142        return Ok(None);
143    };
144    let mut bytes = Vec::new();
145    file.read_to_end(&mut bytes)?;
146    Ok(Some(bytes))
147}
148
149/// Replaces the kept file `path` of `run_dir` with `bytes`, whole or not at
150/// all: written to a new temporary file (mode 0600, a stale one removed
151/// first), then renamed over it.
152///
153/// # Errors
154///
155/// As [`open_kept`], and the I/O error of writing or renaming.
156pub fn keep_file(run_dir: &Path, path: &str, bytes: &[u8]) -> io::Result<()> {
157    let name = name_in_pod(path).ok_or_else(|| unsafe_path(Path::new(path)))?;
158    let dir = pod_dir(run_dir, true)?;
159    replace_in(&dir, &run_dir.join(POD_DIR), name, bytes)
160}
161
162/// [`keep_file`] in the open `.pod` directory `dir`.
163fn replace_in(dir: &OwnedFd, dir_path: &Path, name: &str, bytes: &[u8]) -> io::Result<()> {
164    let temporary = format!("{name}.tmp");
165    match rustix::fs::unlinkat(dir, temporary.as_str(), AtFlags::empty()) {
166        Ok(()) => {},
167        Err(errno) if errno == Errno::NOENT => {},
168        Err(errno) => return Err(errno.into()),
169    }
170    let flags = OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL;
171    let mut file = open_in(dir, dir_path, &temporary, flags)?;
172    file.write_all(bytes)?;
173    rustix::fs::renameat(dir, temporary.as_str(), dir, name)?;
174    Ok(())
175}
176
177/// The event ID the log kept in `run_dir` ends at, if any.
178#[must_use]
179pub fn kept_cursor(run_dir: &Path) -> Option<String> {
180    let bytes = read_kept(run_dir, POD_LOG_CURSOR).ok()??;
181    let cursor = String::from_utf8_lossy(&bytes).trim().to_string();
182    valid_cursor(&cursor).then_some(cursor)
183}
184
185/// A line of the kept log, cleaned like a line of the stream (see
186/// [`PodLogLine::cleaned`]); `None` when it is not one.
187#[must_use]
188pub fn parse_kept_line(text: &str) -> Option<PodLogLine> {
189    serde_json::from_str::<PodLogLine>(text)
190        .ok()
191        .map(PodLogLine::cleaned)
192}
193
194/// The lines of the log kept in `run_dir`, oldest first; a line that cannot
195/// be read is skipped. Empty when none is kept.
196///
197/// # Errors
198///
199/// As [`read_kept`].
200pub fn kept_lines(run_dir: &Path) -> io::Result<Vec<PodLogLine>> {
201    let Some(bytes) = read_kept(run_dir, POD_LOG)? else {
202        return Ok(Vec::new());
203    };
204    Ok(String::from_utf8_lossy(&bytes)
205        .lines()
206        .filter_map(parse_kept_line)
207        .collect())
208}
209
210/// The bytes of the kept log of `run_dir` from `offset`, or from its start
211/// when it is now shorter, with where they start; `None` when none is kept.
212///
213/// # Errors
214///
215/// As [`open_kept`], and the I/O error of reading it.
216pub fn read_kept_from(run_dir: &Path, offset: u64) -> io::Result<Option<(Vec<u8>, u64)>> {
217    let Some(mut file) = open_kept(run_dir, POD_LOG)? else {
218        return Ok(None);
219    };
220    let start = if file.metadata()?.len() < offset {
221        0
222    } else {
223        offset
224    };
225    file.seek(SeekFrom::Start(start))?;
226    let mut bytes = Vec::new();
227    file.read_to_end(&mut bytes)?;
228    Ok(Some((bytes, start)))
229}
230
231/// The pod's log kept in a run directory ([`POD_LOG`], mode 0600) and its
232/// cursor ([`POD_LOG_CURSOR`]). Past [`POD_LOG_CAP`], one line says the log
233/// was capped and nothing more is kept. The cursor is written when it moved,
234/// at most once a second while lines keep coming, then once more at the end
235/// (dropping the capture writes it too): a cursor behind the log repeats a
236/// few lines on the next resume, never loses one.
237#[derive(Debug)]
238pub struct Capture {
239    dir: OwnedFd,
240    dir_path: PathBuf,
241    file: File,
242    size: u64,
243    cap: u64,
244    capped: bool,
245    /// The cursor the log ends at, written or not.
246    cursor: Option<String>,
247    /// Whether `cursor` is not written yet.
248    unsaved: bool,
249    saved_at: Option<Instant>,
250}
251
252impl Capture {
253    /// Opens the kept log of the run directory `run_dir`, created when absent,
254    /// with its cursor. A last line left half written (a crash) is ended first.
255    ///
256    /// # Errors
257    ///
258    /// Returns an error of kind `InvalidInput` when `.pod` or the log is a
259    /// symbolic link or a hard link, and the I/O error of creating or opening
260    /// them otherwise.
261    pub fn open(run_dir: &Path) -> io::Result<Self> {
262        let dir = pod_dir(run_dir, true)?;
263        let dir_path = run_dir.join(POD_DIR);
264        let log = name_in_pod(POD_LOG).unwrap_or("pod.log");
265        let flags = OFlags::RDWR | OFlags::APPEND | OFlags::CREATE;
266        let mut file = open_in(&dir, &dir_path, log, flags)?;
267        let mut size = file.metadata()?.len();
268        let mut last = [0_u8];
269        if size > 0 && file.read_at(&mut last, size - 1)? == 1 && last[0] != b'\n' {
270            file.write_all(b"\n")?;
271            size += 1;
272        }
273        let mut capture = Self {
274            dir,
275            dir_path,
276            file,
277            size,
278            cap: POD_LOG_CAP,
279            capped: false,
280            cursor: kept_cursor(run_dir),
281            unsaved: false,
282            saved_at: None,
283        };
284        capture.capped = capture.full(0) || capture.ends_capped();
285        Ok(capture)
286    }
287
288    /// This capture with another cap, in bytes (tests use small ones).
289    #[must_use]
290    pub fn with_cap(mut self, cap: u64) -> Self {
291        self.cap = cap;
292        self.capped = self.full(0) || self.ends_capped();
293        self
294    }
295
296    /// The event ID the kept log ends at.
297    #[must_use]
298    pub fn cursor(&self) -> Option<&str> {
299        self.cursor.as_deref()
300    }
301
302    /// Whether the cap was reached: nothing more is kept.
303    #[must_use]
304    pub fn capped(&self) -> bool {
305        self.capped
306    }
307
308    /// Where the lines of the log stream start for this capture: after its
309    /// cursor, or the longest replay without one.
310    #[must_use]
311    pub fn query(&self) -> LogQuery {
312        LogQuery {
313            source: None,
314            tail: self.cursor.is_none().then_some(TAIL_MAX),
315            since: None,
316            cursor: self.cursor.clone(),
317        }
318    }
319
320    fn full(&self, adding: u64) -> bool {
321        self.size + adding > self.cap.saturating_sub(CAP_RESERVE)
322    }
323
324    /// Whether the log ends with the line saying it was capped.
325    fn ends_capped(&self) -> bool {
326        let from = self.size.saturating_sub(1024);
327        let length = usize::try_from(self.size - from).unwrap_or(0);
328        let mut tail = vec![0_u8; length];
329        if self.file.read_exact_at(&mut tail, from).is_err() {
330            return false;
331        }
332        String::from_utf8_lossy(&tail)
333            .lines()
334            .last()
335            .and_then(|last| serde_json::from_str::<PodLogLine>(last).ok())
336            .is_some_and(|last| last.source == CAPPED_SOURCE)
337    }
338
339    /// Appends `lines`, then keeps `cursor`. Reaching the cap writes one line
340    /// saying so instead of the rest.
341    ///
342    /// # Errors
343    ///
344    /// Returns the I/O error of a write.
345    pub fn take(&mut self, lines: &[PodLogLine], cursor: Option<&str>) -> io::Result<()> {
346        if self.capped {
347            return Ok(());
348        }
349        let mut text = String::new();
350        for line in lines {
351            let json = serde_json::to_string(line).map_err(io::Error::other)?;
352            let adding = u64::try_from(text.len() + json.len() + 1).unwrap_or(u64::MAX);
353            if self.full(adding) {
354                self.capped = true;
355                text.push_str(&self.marker()?);
356                break;
357            }
358            text.push_str(&json);
359            text.push('\n');
360        }
361        self.file.write_all(text.as_bytes())?;
362        self.size += u64::try_from(text.len()).unwrap_or(u64::MAX);
363        if self.capped {
364            return Ok(());
365        }
366        if let Some(cursor) = cursor
367            && self.cursor.as_deref() != Some(cursor)
368        {
369            self.cursor = Some(cursor.to_string());
370            self.unsaved = true;
371        }
372        if self.saved_at.is_none_or(|at| at.elapsed() >= CURSOR_EVERY) {
373            self.finish()?;
374        }
375        Ok(())
376    }
377
378    /// The line saying the log was capped, with its newline.
379    fn marker(&self) -> io::Result<String> {
380        let marker = PodLogLine {
381            ts: crate::runs::rfc3339(SystemTime::now()),
382            source: CAPPED_SOURCE.to_string(),
383            line: format!(
384                "the pod's log reached {} MiB: later lines are not kept",
385                self.cap / (1024 * 1024)
386            ),
387        };
388        let mut text = serde_json::to_string(&marker).map_err(io::Error::other)?;
389        text.push('\n');
390        Ok(text)
391    }
392
393    /// Writes the cursor when it moved since it was last written.
394    ///
395    /// # Errors
396    ///
397    /// Returns the I/O error of writing it.
398    pub fn finish(&mut self) -> io::Result<()> {
399        if !self.unsaved {
400            return Ok(());
401        }
402        let Some(cursor) = self.cursor.clone() else {
403            return Ok(());
404        };
405        let name = name_in_pod(POD_LOG_CURSOR).unwrap_or("pod.log.cursor");
406        replace_in(&self.dir, &self.dir_path, name, cursor.as_bytes())?;
407        self.unsaved = false;
408        self.saved_at = Some(Instant::now());
409        Ok(())
410    }
411}
412
413impl Drop for Capture {
414    fn drop(&mut self) {
415        if let Err(error) = self.finish() {
416            tracing::debug!("cannot keep the cursor of the pod's log: {error}");
417        }
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use std::fs;
424    use std::os::unix::fs::{PermissionsExt, symlink};
425
426    use super::*;
427
428    fn line(length: usize) -> PodLogLine {
429        PodLogLine {
430            ts: "2026-06-01T12:02:03Z".into(),
431            source: "container".into(),
432            line: "x".repeat(length),
433        }
434    }
435
436    fn kept(dir: &Path) -> Result<Vec<PodLogLine>, Box<dyn std::error::Error>> {
437        Ok(kept_lines(dir)?)
438    }
439
440    #[test]
441    fn a_capture_keeps_lines_and_its_cursor_then_stops_at_its_cap()
442    -> Result<(), Box<dyn std::error::Error>> {
443        let dir = tempfile::tempdir()?;
444        let mut capture = Capture::open(dir.path())?.with_cap(CAP_RESERVE + 400);
445        assert_eq!(capture.query().tail, Some(TAIL_MAX));
446        capture.take(std::slice::from_ref(&line(100)), Some("c/1"))?;
447        assert_eq!(kept_cursor(dir.path()).as_deref(), Some("c/1"));
448        let reopened = Capture::open(dir.path())?;
449        assert_eq!(
450            reopened.query(),
451            LogQuery {
452                source: None,
453                tail: None,
454                since: None,
455                cursor: Some("c/1".into())
456            }
457        );
458        drop(reopened);
459        capture.take(&[line(100), line(100), line(100)], Some("c/4"))?;
460        assert!(capture.capped());
461        capture.take(&[line(1)], Some("c/5"))?;
462        let lines = kept(dir.path())?;
463        assert_eq!(lines.len(), 3, "{lines:?}");
464        assert_eq!(lines[2].source, CAPPED_SOURCE);
465        assert!(lines[2].line.contains("later lines are not kept"));
466        let mode = fs::metadata(dir.path().join(POD_LOG))?.permissions().mode();
467        assert_eq!(mode & 0o777, 0o600);
468        Ok(())
469    }
470
471    /// The line past the cap can be longer than the marker written instead:
472    /// the log reopened must still count as capped, with any cap.
473    #[test]
474    fn a_capped_log_stays_capped_when_reopened() -> Result<(), Box<dyn std::error::Error>> {
475        let dir = tempfile::tempdir()?;
476        let cap = CAP_RESERVE + 400;
477        let mut capture = Capture::open(dir.path())?.with_cap(cap);
478        capture.take(&[line(100), line(300)], Some("c/2"))?;
479        assert!(capture.capped());
480        drop(capture);
481        let size = fs::metadata(dir.path().join(POD_LOG))?.len();
482        assert!(size < cap - CAP_RESERVE, "{size}");
483        assert!(Capture::open(dir.path())?.with_cap(cap).capped());
484        assert!(Capture::open(dir.path())?.capped());
485        Ok(())
486    }
487
488    #[test]
489    fn a_half_written_last_line_is_ended_on_open() -> Result<(), Box<dyn std::error::Error>> {
490        let dir = tempfile::tempdir()?;
491        fs::create_dir_all(dir.path().join(".pod"))?;
492        let first = serde_json::to_string(&line(3))?;
493        fs::write(
494            dir.path().join(POD_LOG),
495            format!("{first}\n{{\"ts\":\"2026"),
496        )?;
497        let mut capture = Capture::open(dir.path())?;
498        capture.take(&[line(4)], None)?;
499        let lines = kept(dir.path())?;
500        assert_eq!(lines, [line(3), line(4)]);
501        Ok(())
502    }
503
504    #[test]
505    fn the_cursor_is_written_at_most_once_a_second_then_at_the_end()
506    -> Result<(), Box<dyn std::error::Error>> {
507        let dir = tempfile::tempdir()?;
508        let mut capture = Capture::open(dir.path())?;
509        capture.take(&[line(1)], Some("c/1"))?;
510        capture.take(&[line(1)], Some("c/2"))?;
511        assert_eq!(capture.cursor(), Some("c/2"));
512        assert_eq!(kept_cursor(dir.path()).as_deref(), Some("c/1"));
513        drop(capture);
514        assert_eq!(kept_cursor(dir.path()).as_deref(), Some("c/2"));
515        Ok(())
516    }
517
518    #[test]
519    fn links_planted_in_the_run_directory_are_refused() -> Result<(), Box<dyn std::error::Error>> {
520        let root = tempfile::tempdir()?;
521        let outside = root.path().join("outside");
522        fs::write(&outside, "mine\n")?;
523        // A symbolic link in place of the log.
524        let run = root.path().join("r1");
525        fs::create_dir_all(run.join(".pod"))?;
526        symlink(&outside, run.join(POD_LOG))?;
527        let refused = Capture::open(&run).map(drop);
528        assert_eq!(refused.map_err(|e| e.kind()), Err(ErrorKind::InvalidInput));
529        assert_eq!(
530            open_kept(&run, POD_LOG).map(drop).map_err(|e| e.kind()),
531            Err(ErrorKind::InvalidInput)
532        );
533        // A hard link to another file.
534        let run = root.path().join("r2");
535        fs::create_dir_all(run.join(".pod"))?;
536        fs::hard_link(&outside, run.join(POD_LOG))?;
537        let refused = Capture::open(&run).map(drop);
538        assert_eq!(refused.map_err(|e| e.kind()), Err(ErrorKind::InvalidInput));
539        // `.pod` itself a symbolic link to another directory.
540        let run = root.path().join("r3");
541        let elsewhere = root.path().join("elsewhere");
542        fs::create_dir_all(&run)?;
543        fs::create_dir_all(&elsewhere)?;
544        symlink(&elsewhere, run.join(".pod"))?;
545        let refused = Capture::open(&run).map(drop);
546        assert_eq!(refused.map_err(|e| e.kind()), Err(ErrorKind::InvalidInput));
547        assert!(fs::read_dir(&elsewhere)?.next().is_none());
548        // A temporary cursor file planted as a link is replaced, not followed.
549        let run = root.path().join("r4");
550        fs::create_dir_all(run.join(".pod"))?;
551        symlink(&outside, run.join(".pod/pod.log.cursor.tmp"))?;
552        let mut capture = Capture::open(&run)?;
553        capture.take(&[line(1)], Some("c/1"))?;
554        assert_eq!(kept_cursor(&run).as_deref(), Some("c/1"));
555        // The copies of the pod's own logs go through the same checks.
556        symlink(&outside, run.join(".pod/watchdog.log.tmp"))?;
557        keep_file(&run, super::super::super::WATCHDOG_LOG, b"watchdog\n")?;
558        assert_eq!(fs::read(run.join(".pod/watchdog.log"))?, b"watchdog\n");
559        assert_eq!(fs::read_to_string(&outside)?, "mine\n");
560        Ok(())
561    }
562}