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.
|
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 | } |