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.
Bound what a CI job can consume
6bfc42949be4d40b3b74de8cc17c6d903cfe531e · Keenan Nemetz · 12d ago · parent f5456470
Bound what a CI job can consume A job runs untrusted build code, and everything it produces is either stored by the instance or kept on the runner. None of those channels had a bound of its own. The attempt log was a strings.Builder: the whole run in memory, then written to a temp file, then read twice by the LFS transfer (once to hash, once to send). It is now a spool file on disk from the first byte, capped at 32 MiB, uploaded once at the end as an ordinary LFS object. A capped log reports the whole write as taken so the producing process is not killed, and gets a line saying it was truncated. Step offsets are computed from the spool's length, which never exceeds the cap, so a truncated log still has valid ranges. Nothing is streamed to the server: a log's content-addressed id cannot be known before its last byte, so streaming would only buy a second protocol. A run may publish at most 256 artifact files totalling 4 GiB. The workflow limit is on declarations, and a declaration is a pattern that can match a directory tree, so the bound on what it expands to has to be where the tree is walked. Contributor caches are now namespaced per account, not one shared "contrib" namespace: one contributor's untrusted branch must not be able to seed another contributor's cache. A contributor keeps their cache across their own branches. Container volume paths are checked before the argument list is built: a colon is the field separator, and a NUL or newline would split the argument, so a path carrying one is refused rather than passed to the runtime. RunArgs is unchanged; the refusal is in Start, and the closed-set test now also pins --cap-add, --device, --pid/--ipc host and the podman socket. Image builds run through the configured runtime, so a rootless Podman runner builds rootless. The definition is pinned by digest already; the docs now say how the build gets its network and recommend pinning the base image in from: by digest for a reproducible build. Tests: the spool keeps what fits, truncates and says so, and removes its file; artifact count and byte caps (the byte cap uses a sparse file so it does not write 4 GiB); contributor caches are separated by account while one account's branches share; volume paths with a colon, NUL or newline are refused.
ci/agent/spool.go +158
| @@ -0,0 +1,158 @@ | |||
| 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 | ||
| 8 | // bounded file on disk from the first byte: the step's output goes straight in, | ||
| 9 | // the file is what gets uploaded, and the cap is where it is written rather than | ||
| 10 | // where it is remembered. | ||
| 11 | // | ||
| 12 | // The log is still uploaded once, at the end, as an ordinary LFS object. Nothing | ||
| 13 | // is streamed to the server: the content-addressed id cannot be known until the | ||
| 14 | // last byte, so the only thing streaming would buy is a second protocol. | ||
| 15 | |||
| 16 | import ( | ||
| 17 | "fmt" | ||
| 18 | "os" | ||
| 19 | "sync" | ||
| 20 | ) | ||
| 21 | |||
| 22 | const ( | ||
| 23 | // MaxLogBytes bounds one attempt's log. A log is evidence, not an archive: an | ||
| 24 | // hour of verbose build output is a few megabytes, and a job that prints more | ||
| 25 | // than this is either looping or trying to fill the runner's disk. The cap is | ||
| 26 | // large enough to hold the useful part of a very noisy build. | ||
| 27 | MaxLogBytes int64 = 32 << 20 | ||
| 28 | // truncationNote is appended to a capped log so a reader is told why it ends | ||
| 29 | // without the tool's summary line. | ||
| 30 | truncationNote = "\n[log truncated: the attempt produced more than %s of output]\n" | ||
| 31 | ) | ||
| 32 | |||
| 33 | // What one run may publish. The workflow caps declarations (ci.MaxArtifacts); | ||
| 34 | // these cap what those declarations expand to, because a pattern can match a | ||
| 35 | // directory tree and the runner is the party that finds out. | ||
| 36 | const ( | ||
| 37 | // MaxRunArtifactFiles is how many files one run may upload. A build's outputs | ||
| 38 | // are a handful of binaries, archives and reports, not a whole tree. | ||
| 39 | MaxRunArtifactFiles = 256 | ||
| 40 | // MaxRunArtifactBytes is how large those files may be together. The instance's | ||
| 41 | // object store has its own per-object cap; this bounds the total a single job | ||
| 42 | // can ask it to keep. | ||
| 43 | MaxRunArtifactBytes int64 = 4 << 30 | ||
| 44 | ) | ||
| 45 | |||
| 46 | // spool is one attempt's log file. os/exec copies stdout and stderr from separate | ||
| 47 | // goroutines, so writes are serialised here. | ||
| 48 | type spool struct { | ||
| 49 | mu sync.Mutex | ||
| 50 | f *os.File | ||
| 51 | n int64 | ||
| 52 | limit int64 | ||
| 53 | cut bool | ||
| 54 | } | ||
| 55 | |||
| 56 | // newSpool opens a log file in the runner's temporary directory. A runner that | ||
| 57 | // cannot create one cannot run a job: the log is part of the record, and a record | ||
| 58 | // without it would claim step ranges it does not have. | ||
| 59 | func newSpool(limit int64) (*spool, error) { | ||
| 60 | f, err := os.CreateTemp("", "vole-log-*") | ||
| 61 | if err != nil { | ||
| 62 | return nil, fmt.Errorf("cannot create the attempt log: %w", err) | ||
| 63 | } | ||
| 64 | return &spool{f: f, limit: limit}, nil | ||
| 65 | } | ||
| 66 | |||
| 67 | // Write keeps at most limit bytes and reports the whole write as taken, so a | ||
| 68 | // chatty process is not killed by a short write; the excess is dropped and noted. | ||
| 69 | func (s *spool) Write(p []byte) (int, error) { | ||
| 70 | s.mu.Lock() | ||
| 71 | defer s.mu.Unlock() | ||
| 72 | if s.f == nil { | ||
| 73 | return len(p), nil | ||
| 74 | } | ||
| 75 | room := s.limit - s.n | ||
| 76 | if room <= 0 { | ||
| 77 | s.cut = true | ||
| 78 | return len(p), nil | ||
| 79 | } | ||
| 80 | chunk := p | ||
| 81 | if int64(len(chunk)) > room { | ||
| 82 | chunk = chunk[:room] | ||
| 83 | s.cut = true | ||
| 84 | } | ||
| 85 | if _, err := s.f.Write(chunk); err != nil { | ||
| 86 | return 0, err | ||
| 87 | } | ||
| 88 | s.n += int64(len(chunk)) | ||
| 89 | return len(p), nil | ||
| 90 | } | ||
| 91 | |||
| 92 | // Len is how many bytes the log holds, which is what step offsets are computed | ||
| 93 | // from. It never exceeds the cap, so a truncated log still has ranges inside it. | ||
| 94 | func (s *spool) Len() int64 { | ||
| 95 | s.mu.Lock() | ||
| 96 | defer s.mu.Unlock() | ||
| 97 | return s.n | ||
| 98 | } | ||
| 99 | |||
| 100 | // Path is the file to upload. seal must have run first. | ||
| 101 | func (s *spool) Path() string { | ||
| 102 | s.mu.Lock() | ||
| 103 | defer s.mu.Unlock() | ||
| 104 | if s.f == nil { | ||
| 105 | return "" | ||
| 106 | } | ||
| 107 | return s.f.Name() | ||
| 108 | } | ||
| 109 | |||
| 110 | // seal finishes the log: a truncated log gets the note explaining why it ends, | ||
| 111 | // and the file is synced so the upload reads what was written. | ||
| 112 | func (s *spool) seal() error { | ||
| 113 | s.mu.Lock() | ||
| 114 | defer s.mu.Unlock() | ||
| 115 | if s.f == nil { | ||
| 116 | return nil | ||
| 117 | } | ||
| 118 | if s.cut { | ||
| 119 | note := fmt.Sprintf(truncationNote, sizeLabel(s.limit)) | ||
| 120 | if _, err := s.f.WriteString(note); err != nil { | ||
| 121 | return err | ||
| 122 | } | ||
| 123 | s.cut = false // only once | ||
| 124 | } | ||
| 125 | if err := s.f.Sync(); err != nil { | ||
| 126 | return err | ||
| 127 | } | ||
| 128 | return nil | ||
| 129 | } | ||
| 130 | |||
| 131 | // Close removes the file. The attempt is over; the bytes live in the object store | ||
| 132 | // or nowhere. | ||
| 133 | func (s *spool) Close() error { | ||
| 134 | s.mu.Lock() | ||
| 135 | defer s.mu.Unlock() | ||
| 136 | if s.f == nil { | ||
| 137 | return nil | ||
| 138 | } | ||
| 139 | name := s.f.Name() | ||
| 140 | err := s.f.Close() | ||
| 141 | s.f = nil | ||
| 142 | _ = os.Remove(name) | ||
| 143 | return err | ||
| 144 | } | ||
| 145 | |||
| 146 | // sizeLabel renders a byte count for the truncation note without importing the | ||
| 147 | // presentation package into the runner's hot path. | ||
| 148 | func sizeLabel(n int64) string { | ||
| 149 | switch { | ||
| 150 | case n >= 1<<30: | ||
| 151 | return fmt.Sprintf("%d GiB", n>>30) | ||
| 152 | case n >= 1<<20: | ||
| 153 | return fmt.Sprintf("%d MiB", n>>20) | ||
| 154 | case n >= 1<<10: | ||
| 155 | return fmt.Sprintf("%d KiB", n>>10) | ||
| 156 | } | ||
| 157 | return fmt.Sprintf("%d bytes", n) | ||
| 158 | } | ||