Skip to main content

runnerd_core/observe/
capture.rs

1//! The job a runner took and the job's log, read from inside its container while it runs. A small script, run with
2//! `docker exec` as root, waits for the runner's `_diag/Worker_*.log`, prints the job's ID (`check_run_id`) and run
3//! ID from the job message there, then follows the job's log pages in `_diag/pages`, which the runner writes in
4//! 4 KiB blocks and deletes once uploaded: the script hard-links each page so none is lost, and prints what it
5//! has not printed before. These pages are what GitHub shows, with secrets already masked by the runner.
6//!
7//! The script's output: `@@job <id> <run id>`, then `@@page <page> <offset> <length>` followed by exactly that
8//! many bytes, and `@@end <page>` once a page is complete. `Parser` turns it into whole lines and the position to
9//! resume from, so a restarted daemon continues where the stored log ends.
10
11use std::fmt::Write as _;
12
13use crate::store::LogPosition;
14
15/// The script; `bash -c SCRIPT capture <page> <offset>` resumes at that page and byte offset.
16pub const SCRIPT: &str = r#"
17diag=/home/runner/_diag keep=/home/runner/_diag/.runnerd-pages start_page=$1 start_offset=$2 job= record=
18declare -A off
19page_of() { local page=${1##*_}; echo "${page%.log}"; }
20while :; do
21    if [ -z "$job" ]; then
22        for w in "$diag"/Worker_*.log; do
23            [ -f "$w" ] || continue
24            id=$(grep -m1 -A1 '"k": "check_run_id"' "$w" | sed -n 's/.*"v": \([0-9]*\).*/\1/p')
25            run=$(grep -m1 -A1 '"k": "run_id"' "$w" | sed -n 's/.*"v": "\([0-9]*\)".*/\1/p')
26            record=$(grep -m1 '"jobId": "' "$w" | sed -n 's/.*"jobId": "\([0-9a-f-]*\)".*/\1/p')
27            # Only now: creating _diag before the runner does would leave it unwritable to the runner.
28            if [ -n "$id" ] && [ -n "$run" ] && [ -n "$record" ]; then
29                job=$id
30                mkdir -p -m 700 "$keep"
31                echo "@@job $id $run"
32            fi
33        done
34    fi
35    if [ -n "$job" ]; then
36        for f in "$diag"/pages/*_"$record"_*.log; do
37            [ -f "$f" ] && [ "$(page_of "$f")" -ge "$start_page" ] && [ ! -e "$keep/${f##*/}" ] \
38                && ln "$f" "$keep/${f##*/}" 2> /dev/null
39        done
40        for k in "$keep"/*_"$record"_*.log; do
41            [ -f "$k" ] || continue
42            name=${k##*/} page=$(page_of "$k") gone=
43            if [ "$page" -lt "$start_page" ]; then rm -f "$k"; continue; fi
44            [ -e "$diag/pages/$name" ] || gone=1
45            size=$(stat -c %s "$k")
46            o=${off[$name]:-0}
47            [ -n "${off[$name]}" ] || [ "$page" -ne "$start_page" ] || o=$start_offset
48            if [ "$size" -gt "$o" ]; then
49                echo "@@page $page $o $((size - o))"
50                tail -c +$((o + 1)) "$k" | head -c $((size - o))
51            fi
52            off[$name]=$size
53            if [ -n "$gone" ]; then echo "@@end $page"; rm -f "$k"; fi
54        done
55    fi
56    sleep 0.2
57done
58"#;
59
60/// The `docker exec` command line that captures a job from `position` on.
61pub fn command(position: LogPosition) -> Vec<String> {
62    vec![
63        "bash".to_owned(),
64        "-c".to_owned(),
65        SCRIPT.to_owned(),
66        "capture".to_owned(),
67        position.page.to_string(),
68        position.offset.to_string(),
69    ]
70}
71
72/// What the script reported.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum Captured {
75    /// The runner took this job.
76    Job {
77        /// GitHub's job ID.
78        id: u64,
79        /// The workflow run.
80        run_id: u64,
81    },
82    /// Whole log lines, and where the capture resumes after them.
83    Lines {
84        /// The lines, without their line ends.
85        lines: Vec<String>,
86        /// The position after the last line.
87        position: LogPosition,
88    },
89}
90
91/// Turns the script's output, in chunks of any size, into `Captured`.
92#[derive(Debug, Default)]
93pub struct Parser {
94    input: Vec<u8>,
95    /// Bytes of the current `@@page` block still to come.
96    remaining: usize,
97    /// The page being read, and the offset in it where `partial` starts.
98    page: u64,
99    partial_offset: u64,
100    /// The start of a line whose end has not arrived, at most `MAX_LINE` bytes of it.
101    partial: Vec<u8>,
102    /// That line's length so far, with the bytes past `MAX_LINE` that were dropped.
103    partial_len: u64,
104}
105
106/// The longest line kept: the rest of a longer one is dropped as it arrives, so one line never holds more memory.
107pub const MAX_LINE: usize = 1 << 20;
108
109/// Bytes of lines per `Captured::Lines`, about: a long page comes in batches, each with where to resume after it.
110const BATCH: usize = 1 << 20;
111
112const BOM: &[u8] = "\u{feff}".as_bytes();
113
114fn text(line: &[u8]) -> String {
115    let line = line.strip_suffix(b"\r").unwrap_or(line);
116    String::from_utf8_lossy(line.strip_prefix(BOM).unwrap_or(line)).into_owned()
117}
118
119impl Parser {
120    /// Feeds output and returns what it completed.
121    pub fn feed(&mut self, chunk: &[u8]) -> Vec<Captured> {
122        self.input.extend_from_slice(chunk);
123        let mut captured = Vec::new();
124        loop {
125            if self.remaining > 0 {
126                if self.input.is_empty() {
127                    break;
128                }
129                let take = self.remaining.min(self.input.len());
130                let data: Vec<u8> = self.input.drain(..take).collect();
131                self.remaining -= take;
132                self.data(&data, &mut captured);
133                continue;
134            }
135            let Some(end) = self.input.iter().position(|byte| *byte == b'\n') else {
136                break;
137            };
138            let header: Vec<u8> = self.input.drain(..=end).collect();
139            self.header(&String::from_utf8_lossy(&header[..end]), &mut captured);
140        }
141        captured
142    }
143
144    fn header(&mut self, header: &str, captured: &mut Vec<Captured>) {
145        let mut words = header.split(' ');
146        let numbers: Vec<u64> = words
147            .clone()
148            .skip(1)
149            .filter_map(|word| word.parse().ok())
150            .collect();
151        match (words.next(), numbers.as_slice()) {
152            (Some("@@job"), [id, run_id]) => captured.push(Captured::Job {
153                id: *id,
154                run_id: *run_id,
155            }),
156            (Some("@@page"), [page, offset, length]) => {
157                if *page != self.page || *offset != self.partial_offset + self.partial_len {
158                    // A new page, or a resumed one: whatever was pending belongs to a page now complete.
159                    self.page = *page;
160                    self.take_line();
161                    self.partial_offset = *offset;
162                }
163                self.remaining = usize::try_from(*length).unwrap_or(usize::MAX);
164            }
165            (Some("@@end"), [page]) if *page == self.page && self.partial_len > 0 => {
166                let line = self.take_line();
167                self.partial_offset = 0;
168                captured.push(Captured::Lines {
169                    lines: vec![line],
170                    position: LogPosition {
171                        page: page + 1,
172                        offset: 0,
173                    },
174                });
175            }
176            (Some("@@end"), [page]) if *page == self.page => {
177                self.partial_offset = 0;
178                captured.push(Captured::Lines {
179                    lines: Vec::new(),
180                    position: LogPosition {
181                        page: page + 1,
182                        offset: 0,
183                    },
184                });
185            }
186            // Anything else is not from the script; nothing to do with it.
187            _ => {}
188        }
189    }
190
191    /// The pending line as text, said to be cut if it was; clears it.
192    fn take_line(&mut self) -> String {
193        let mut line = text(&self.partial);
194        let dropped = self.partial_len - self.partial.len() as u64;
195        if dropped > 0 {
196            let _ = write!(
197                line,
198                " [runnerd: {dropped} more bytes of this line not kept]"
199            );
200        }
201        self.partial.clear();
202        self.partial_len = 0;
203        line
204    }
205
206    fn data(&mut self, data: &[u8], captured: &mut Vec<Captured>) {
207        let mut lines = Vec::new();
208        let mut bytes = 0;
209        for (index, segment) in data.split(|byte| *byte == b'\n').enumerate() {
210            // Every segment but the first follows a newline, which ended the pending line.
211            if index > 0 {
212                let length = self.partial_len + 1;
213                let line = self.take_line();
214                self.partial_offset += length;
215                bytes += line.len() + 1;
216                lines.push(line);
217                if bytes >= BATCH {
218                    bytes = 0;
219                    captured.push(Captured::Lines {
220                        lines: std::mem::take(&mut lines),
221                        position: self.position(),
222                    });
223                }
224            }
225            let room = MAX_LINE - self.partial.len();
226            self.partial
227                .extend_from_slice(&segment[..room.min(segment.len())]);
228            self.partial_len += segment.len() as u64;
229        }
230        if !lines.is_empty() {
231            captured.push(Captured::Lines {
232                lines,
233                position: self.position(),
234            });
235        }
236    }
237
238    /// Where the capture resumes: after the last complete line.
239    fn position(&self) -> LogPosition {
240        LogPosition {
241            page: self.page,
242            offset: self.partial_offset,
243        }
244    }
245
246    /// Bytes held for the pending line.
247    #[cfg(test)]
248    pub(crate) fn pending(&self) -> usize {
249        self.partial.capacity()
250    }
251}