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/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

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

Blame · ci/agent/spool.go

Line attribution on main · 211 line(s)

6bfc4294
Keenan Nemetz
12d ago
1 package 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
5ece70a5
Keenan Nemetz
10d ago
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.
6bfc4294
Keenan Nemetz
12d ago
11 import (
12 "fmt"
13 "os"
14 "sync"
5ece70a5
Keenan Nemetz
10d ago
15
d37d301f
Keenan Nemetz
15h ago
16 "rodent.software/vole/ci/logs"
17 "rodent.software/vole/ci/redact"
6bfc4294
Keenan Nemetz
12d ago
18 )
19
20 const (
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.
5ece70a5
Keenan Nemetz
10d ago
25 MaxLogBytes = logs.MaxBytes
6bfc4294
Keenan Nemetz
12d ago
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.
34 const (
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.
46 type spool struct {
47 mu sync.Mutex
48 f *os.File
49 n int64
50 limit int64
51 cut bool
e82bbe7d
Keenan Nemetz
7d ago
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
6bfc4294
Keenan Nemetz
12d ago
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.
61 func 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
e82bbe7d
Keenan Nemetz
7d ago
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.
71 func (s *spool) SetMask(mask *redact.Mask) {
72 s.mu.Lock()
73 defer s.mu.Unlock()
74 s.mask = mask
75 }
76
6bfc4294
Keenan Nemetz
12d ago
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.
e82bbe7d
Keenan Nemetz
7d ago
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.
6bfc4294
Keenan Nemetz
12d ago
81 func (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 }
e82bbe7d
Keenan Nemetz
7d ago
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 }
6bfc4294
Keenan Nemetz
12d ago
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.
112 func (s *spool) Len() int64 {
113 s.mu.Lock()
114 defer s.mu.Unlock()
115 return s.n
116 }
117
5ece70a5
Keenan Nemetz
10d ago
118 // Path is the file the streamer reads and Close removes. It is valid from the
119 // moment newSpool created it.
6bfc4294
Keenan Nemetz
12d ago
120 func (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
e82bbe7d
Keenan Nemetz
7d ago
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.
132 func (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
6bfc4294
Keenan Nemetz
12d ago
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.
155 func (s *spool) seal() error {
156 s.mu.Lock()
157 defer s.mu.Unlock()
158 if s.f == nil {
159 return nil
160 }
e82bbe7d
Keenan Nemetz
7d ago
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 }
6bfc4294
Keenan Nemetz
12d ago
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.
186 func (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.
201 func 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 }