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.

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 }