Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

395 commit(s) on main

update readme Keenan Nemetz · 38m ago 32290114
history blame raw 6.2 KiB
1package agent
2
3// The attempt log.
4//
5// It used to be a strings.Builder: the whole attempt in memory, then written to a
6// temp file, then read twice by the LFS transfer (once to hash, once to send). A
7// job that prints for an hour cost its runner an hour of RAM. The log is now a
8// bounded file on disk from the first byte: the step's output goes straight in, the
9// streamer reads the file as it grows, and the cap is where it is written rather
10// than where it is remembered.
11import (
12 "fmt"
13 "os"
14 "sync"
15
16 "rodent.software/vole/ci/logs"
17 "rodent.software/vole/ci/redact"
18)
19
20const (
21 // MaxLogBytes bounds one attempt's log. A log is evidence, not an archive: an
22 // hour of verbose build output is a few megabytes, and a job that prints more
23 // than this is either looping or trying to fill the runner's disk. The cap is
24 // large enough to hold the useful part of a very noisy build.
25 MaxLogBytes = logs.MaxBytes
26 // truncationNote is appended to a capped log so a reader is told why it ends
27 // without the tool's summary line.
28 truncationNote = "\n[log truncated: the attempt produced more than %s of output]\n"
29)
30
31// What one run may publish. The workflow caps declarations (ci.MaxArtifacts);
32// these cap what those declarations expand to, because a pattern can match a
33// directory tree and the runner is the party that finds out.
34const (
35 // MaxRunArtifactFiles is how many files one run may upload. A build's outputs
36 // are a handful of binaries, archives and reports, not a whole tree.
37 MaxRunArtifactFiles = 256
38 // MaxRunArtifactBytes is how large those files may be together. The instance's
39 // object store has its own per-object cap; this bounds the total a single job
40 // can ask it to keep.
41 MaxRunArtifactBytes int64 = 4 << 30
42)
43
44// spool is one attempt's log file. os/exec copies stdout and stderr from separate
45// goroutines, so writes are serialised here.
46type spool struct {
47 mu sync.Mutex
48 f *os.File
49 n int64
50 limit int64
51 cut bool
52 // mask removes the job's secret values as the log is written, so the file the
53 // streamer sends and the instance stores never holds one. It is set before the
54 // first step and before anything a step could print.
55 mask *redact.Mask
56}
57
58// newSpool opens a log file in the runner's temporary directory. A runner that
59// cannot create one cannot run a job: the log is part of the record, and a record
60// without it would claim step ranges it does not have.
61func newSpool(limit int64) (*spool, error) {
62 f, err := os.CreateTemp("", "vole-log-*")
63 if err != nil {
64 return nil, fmt.Errorf("cannot create the attempt log: %w", err)
65 }
66 return &spool{f: f, limit: limit}, nil
67}
68
69// SetMask makes the log redact the job's secret values from everything written
70// after this call. It is called before the first step, so a step cannot print one.
71func (s *spool) SetMask(mask *redact.Mask) {
72 s.mu.Lock()
73 defer s.mu.Unlock()
74 s.mask = mask
75}
76
77// Write keeps at most limit bytes and reports the whole write as taken, so a
78// chatty process is not killed by a short write; the excess is dropped and noted.
79// A value is masked before the cap is counted, so the limit bounds what a reader
80// sees rather than what the step printed.
81func (s *spool) Write(p []byte) (int, error) {
82 s.mu.Lock()
83 defer s.mu.Unlock()
84 if s.f == nil {
85 return len(p), nil
86 }
87 chunk := p
88 if s.mask != nil {
89 chunk = s.mask.Write(p)
90 }
91 if len(chunk) == 0 {
92 return len(p), nil
93 }
94 room := s.limit - s.n
95 if room <= 0 {
96 s.cut = true
97 return len(p), nil
98 }
99 if int64(len(chunk)) > room {
100 chunk = chunk[:room]
101 s.cut = true
102 }
103 if _, err := s.f.Write(chunk); err != nil {
104 return 0, err
105 }
106 s.n += int64(len(chunk))
107 return len(p), nil
108}
109
110// Len is how many bytes the log holds, which is what step offsets are computed
111// from. It never exceeds the cap, so a truncated log still has ranges inside it.
112func (s *spool) Len() int64 {
113 s.mu.Lock()
114 defer s.mu.Unlock()
115 return s.n
116}
117
118// Path is the file the streamer reads and Close removes. It is valid from the
119// moment newSpool created it.
120func (s *spool) Path() string {
121 s.mu.Lock()
122 defer s.mu.Unlock()
123 if s.f == nil {
124 return ""
125 }
126 return s.f.Name()
127}
128
129// Flush writes whatever the mask held back. The runner calls it at the end of each
130// step, so a step's byte range ends where its own output ended rather than carrying
131// a few held-back bytes into the next step's range.
132func (s *spool) Flush() {
133 s.mu.Lock()
134 defer s.mu.Unlock()
135 if s.mask == nil || s.f == nil {
136 return
137 }
138 tail := s.mask.Flush()
139 room := s.limit - s.n
140 if room <= 0 || len(tail) == 0 {
141 return
142 }
143 if int64(len(tail)) > room {
144 tail = tail[:room]
145 }
146 // The file's own error surfaces at seal, which is where a caller can act on it;
147 // this tail is at most one value's worth of bytes.
148 if _, err := s.f.Write(tail); err == nil {
149 s.n += int64(len(tail))
150 }
151}
152
153// seal finishes the log: a truncated log gets the note explaining why it ends,
154// and the file is synced so the upload reads what was written.
155func (s *spool) seal() error {
156 s.mu.Lock()
157 defer s.mu.Unlock()
158 if s.f == nil {
159 return nil
160 }
161 // The bytes the mask held back are written before the note, so a value split
162 // across the last two writes is still masked rather than lost.
163 if s.mask != nil {
164 if tail := s.mask.Flush(); len(tail) > 0 {
165 if _, err := s.f.Write(tail); err != nil {
166 return err
167 }
168 s.n += int64(len(tail))
169 }
170 }
171 if s.cut {
172 note := fmt.Sprintf(truncationNote, sizeLabel(s.limit))
173 if _, err := s.f.WriteString(note); err != nil {
174 return err
175 }
176 s.cut = false // only once
177 }
178 if err := s.f.Sync(); err != nil {
179 return err
180 }
181 return nil
182}
183
184// Close removes the file. The attempt is over; the bytes live in the object store
185// or nowhere.
186func (s *spool) Close() error {
187 s.mu.Lock()
188 defer s.mu.Unlock()
189 if s.f == nil {
190 return nil
191 }
192 name := s.f.Name()
193 err := s.f.Close()
194 s.f = nil
195 _ = os.Remove(name)
196 return err
197}
198
199// sizeLabel renders a byte count for the truncation note without importing the
200// presentation package into the runner's hot path.
201func sizeLabel(n int64) string {
202 switch {
203 case n >= 1<<30:
204 return fmt.Sprintf("%d GiB", n>>30)
205 case n >= 1<<20:
206 return fmt.Sprintf("%d MiB", n>>20)
207 case n >= 1<<10:
208 return fmt.Sprintf("%d KiB", n>>10)
209 }
210 return fmt.Sprintf("%d bytes", n)
211}
212