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.
|
70b70264
Keenan Nemetz
15d ago
|
1 | package agent |
| 2 | ||
| 3 | // Building a custom image from a .vole/images definition. | |
| 4 | // | |
| 5 | // The job carries the pinned definition text and its digest, so the runner does | |
| 6 | // not need to read the repository again and cannot build a definition that changed | |
| 7 | // after the job was queued. The image is tagged by that digest, which makes a | |
| 8 | // second job with the same definition reuse the first build. | |
| 9 | ||
| 10 | import ( | |
| 11 | "context" | |
|
77056ba8
Keenan Nemetz
2d ago
|
12 | "errors" |
|
70b70264
Keenan Nemetz
15d ago
|
13 | "fmt" |
|
6bfc4294
Keenan Nemetz
12d ago
|
14 | "io" |
|
70b70264
Keenan Nemetz
15d ago
|
15 | "os" |
| 16 | "os/exec" | |
| 17 | "path/filepath" | |
|
77056ba8
Keenan Nemetz
2d ago
|
18 | "sync" |
|
70b70264
Keenan Nemetz
15d ago
|
19 | "time" |
| 20 | ||
|
d37d301f
Keenan Nemetz
16h ago
|
21 | "rodent.software/vole/ci" |
| 22 | "rodent.software/vole/ci/container" | |
|
70b70264
Keenan Nemetz
15d ago
|
23 | ) |
| 24 | ||
| 25 | const imageBuildTimeout = 30 * time.Minute | |
| 26 | ||
|
77056ba8
Keenan Nemetz
2d ago
|
27 | // One build per digest, per process. |
| 28 | // | |
| 29 | // With --jobs, two slots can lease jobs that name the same image-file: same bytes, | |
| 30 | // same tag, twice the CPU and twice the disk, and two processes racing to tag the | |
| 31 | // result. The first slot builds; the others wait on the same answer. | |
| 32 | // | |
| 33 | // Waiting is not free. A build can run for half an hour, and a runner that stops | |
| 34 | // hearing from the queue loses the run it is holding, so a waiter keeps its lease | |
| 35 | // alive exactly as a build of its own would have, and stops when the lease or the | |
| 36 | // run goes away. | |
| 37 | // | |
| 38 | // Two runners sharing a --dir can still both build the digest. That is waste, not | |
| 39 | // damage: the definition is pinned by digest, so both builds produce the same image | |
| 40 | // under the same tag. Serialising that too would need a lock file per digest beside | |
| 41 | // the caches, which is more machinery than the case is worth. | |
| 42 | ||
| 43 | // imageBuild is one in-flight build of a digest. done closes when the attempt has an | |
| 44 | // answer, success or failure; err is that answer. | |
| 45 | type imageBuild struct { | |
| 46 | done chan struct{} | |
| 47 | err error | |
| 48 | } | |
| 49 | ||
| 50 | // imageBuilds is the set of builds this process has in flight, shared by its slots. | |
| 51 | type imageBuilds struct { | |
| 52 | mu sync.Mutex | |
| 53 | running map[string]*imageBuild | |
| 54 | } | |
| 55 | ||
| 56 | // join makes this caller the builder of tag, or hands it the build already running. | |
| 57 | func (b *imageBuilds) join(tag string) (build *imageBuild, mine bool) { | |
| 58 | b.mu.Lock() | |
| 59 | defer b.mu.Unlock() | |
| 60 | if cur, ok := b.running[tag]; ok { | |
| 61 | return cur, false | |
| 62 | } | |
| 63 | if b.running == nil { | |
| 64 | b.running = make(map[string]*imageBuild) | |
| 65 | } | |
| 66 | build = &imageBuild{done: make(chan struct{})} | |
| 67 | b.running[tag] = build | |
| 68 | return build, true | |
| 69 | } | |
| 70 | ||
| 71 | // leave forgets a finished build, so the next job that wants the digest asks the | |
| 72 | // runtime again — which is also how an image somebody deleted gets rebuilt. | |
| 73 | func (b *imageBuilds) leave(tag string) { | |
| 74 | b.mu.Lock() | |
| 75 | defer b.mu.Unlock() | |
| 76 | delete(b.running, tag) | |
| 77 | } | |
| 78 | ||
|
70b70264
Keenan Nemetz
15d ago
|
79 | // resolveImage returns the reference to run. A registry image is used as written; |
| 80 | // an image-file definition is built locally and cached under a digest tag. | |
|
6bfc4294
Keenan Nemetz
12d ago
|
81 | func (s *runnerSession) resolveImage(job *runnerJob, box runnerBox, log io.Writer, lease time.Duration) (string, error) { |
|
70b70264
Keenan Nemetz
15d ago
|
82 | if job.ImageFile == "" { |
| 83 | if err := ci.ValidImageRef(job.Image); err != nil { | |
| 84 | return "", fmt.Errorf("the job image: %w", err) | |
| 85 | } | |
| 86 | return job.Image, nil | |
| 87 | } | |
| 88 | if job.ImageDefinition == "" { | |
| 89 | return "", fmt.Errorf("image-file %q has no pinned definition", job.ImageFile) | |
| 90 | } | |
| 91 | if job.ImageSHA == "" { | |
| 92 | return "", fmt.Errorf("image-file %q has no definition digest", job.ImageFile) | |
| 93 | } | |
|
3b39a84c
Keenan Nemetz
14d ago
|
94 | tag := container.ImageTag(job.ImageFile, job.ImageSHA) |
| 95 | if box.Pull != "always" && container.Exists(box.Runtime, tag) { | |
|
70b70264
Keenan Nemetz
15d ago
|
96 | fmt.Fprintf(log, "image-file %s: using cached %s\n", job.ImageFile, tag) |
| 97 | return tag, nil | |
| 98 | } | |
|
77056ba8
Keenan Nemetz
2d ago
|
99 | if box.builds == nil { |
| 100 | // A runnerBox built by hand (a test, or a caller that never validated) has no | |
| 101 | // shared state to join; building alone is what it did before --jobs. | |
| 102 | if err := s.buildImageFile(job, tag, box, log, lease); err != nil { | |
| 103 | return "", err | |
| 104 | } | |
| 105 | return tag, nil | |
| 106 | } | |
| 107 | ||
| 108 | build, mine := box.builds.join(tag) | |
| 109 | if mine { | |
| 110 | err := s.buildImageFile(job, tag, box, log, lease) | |
| 111 | build.err = err | |
| 112 | // Leave before closing, so a job that arrives after this one takes the | |
| 113 | // finished entry starts a fresh build instead of reading an answer about a | |
| 114 | // digest it did not ask for. Waiters already on done are unaffected. | |
| 115 | box.builds.leave(tag) | |
| 116 | close(build.done) | |
| 117 | if err != nil { | |
| 118 | return "", err | |
| 119 | } | |
| 120 | return tag, nil | |
| 121 | } | |
| 122 | ||
| 123 | // Another slot is building it. The line goes in this job's log so a reader knows | |
| 124 | // why the steps have not started. | |
| 125 | fmt.Fprintf(log, "image-file %s: %s is being built by another job; waiting\n", job.ImageFile, tag) | |
| 126 | if err := s.waitForBuild(build, lease, job.ID, log); err != nil { | |
| 127 | return "", err | |
| 128 | } | |
| 129 | if build.err != nil { | |
| 130 | // A failed build is this job's failure too: the same definition would fail | |
| 131 | // the same way, and a second attempt would only spend the time again. A lease | |
| 132 | // that went away belongs to the builder, though, and means only that nobody | |
| 133 | // may run this image on this runner's word. | |
| 134 | if errors.Is(build.err, errLeaseLost) || errors.Is(build.err, errCanceled) { | |
| 135 | return "", build.err | |
| 136 | } | |
| 137 | return "", fmt.Errorf("image-file %q: %w", job.ImageFile, build.err) | |
| 138 | } | |
| 139 | return tag, nil | |
| 140 | } | |
|
70b70264
Keenan Nemetz
15d ago
|
141 | |
|
77056ba8
Keenan Nemetz
2d ago
|
142 | // buildImageFile renders the pinned definition, builds it, and tags the result by |
| 143 | // its digest. | |
| 144 | func (s *runnerSession) buildImageFile(job *runnerJob, tag string, box runnerBox, log io.Writer, lease time.Duration) error { | |
|
70b70264
Keenan Nemetz
15d ago
|
145 | dockerfile, err := ci.RenderImage(job.ImageDefinition) |
| 146 | if err != nil { | |
|
77056ba8
Keenan Nemetz
2d ago
|
147 | return fmt.Errorf("image-file %q: %w", job.ImageFile, err) |
|
70b70264
Keenan Nemetz
15d ago
|
148 | } |
| 149 | dir, err := os.MkdirTemp("", "vole-image-*") | |
| 150 | if err != nil { | |
|
77056ba8
Keenan Nemetz
2d ago
|
151 | return err |
|
70b70264
Keenan Nemetz
15d ago
|
152 | } |
| 153 | defer os.RemoveAll(dir) | |
| 154 | if err := os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte(dockerfile), 0o600); err != nil { | |
|
77056ba8
Keenan Nemetz
2d ago
|
155 | return err |
|
70b70264
Keenan Nemetz
15d ago
|
156 | } |
| 157 | ||
| 158 | args := []string{"build", "-t", tag, "-f", filepath.Join(dir, "Dockerfile"), dir} | |
| 159 | switch { | |
|
3b39a84c
Keenan Nemetz
14d ago
|
160 | case box.Pull == "always" && box.Runtime == "podman": |
|
70b70264
Keenan Nemetz
15d ago
|
161 | args = append(args, "--pull=always") |
|
3b39a84c
Keenan Nemetz
14d ago
|
162 | case box.Pull == "always": |
|
70b70264
Keenan Nemetz
15d ago
|
163 | args = append(args, "--pull") |
| 164 | } | |
| 165 | ctx, cancel := context.WithTimeout(context.Background(), imageBuildTimeout) | |
| 166 | defer cancel() | |
|
3b39a84c
Keenan Nemetz
14d ago
|
167 | cmd := exec.CommandContext(ctx, box.Runtime, args...) |
|
0c14f078
Keenan Nemetz
6d ago
|
168 | code, lost, canceled := s.runCommand(ctx, cmd, job.ID, lease, nil, log) |
|
70b70264
Keenan Nemetz
15d ago
|
169 | if lost { |
|
77056ba8
Keenan Nemetz
2d ago
|
170 | return errLeaseLost |
|
70b70264
Keenan Nemetz
15d ago
|
171 | } |
|
0c14f078
Keenan Nemetz
6d ago
|
172 | if canceled { |
|
77056ba8
Keenan Nemetz
2d ago
|
173 | return errCanceled |
|
0c14f078
Keenan Nemetz
6d ago
|
174 | } |
|
70b70264
Keenan Nemetz
15d ago
|
175 | if ctx.Err() == context.DeadlineExceeded { |
|
77056ba8
Keenan Nemetz
2d ago
|
176 | return fmt.Errorf("building image-file %q timed out after %s", job.ImageFile, imageBuildTimeout) |
|
70b70264
Keenan Nemetz
15d ago
|
177 | } |
| 178 | if code != 0 { | |
|
77056ba8
Keenan Nemetz
2d ago
|
179 | return fmt.Errorf("building image-file %q exited %d", job.ImageFile, code) |
|
70b70264
Keenan Nemetz
15d ago
|
180 | } |
|
3b39a84c
Keenan Nemetz
14d ago
|
181 | if !container.Exists(box.Runtime, tag) { |
|
77056ba8
Keenan Nemetz
2d ago
|
182 | return fmt.Errorf("building image-file %q produced no image tagged %s", job.ImageFile, tag) |
|
70b70264
Keenan Nemetz
15d ago
|
183 | } |
| 184 | fmt.Fprintf(log, "\nimage-file %s: built %s\n", job.ImageFile, tag) | |
|
77056ba8
Keenan Nemetz
2d ago
|
185 | return nil |
| 186 | } | |
| 187 | ||
| 188 | // waitForBuild waits for another slot's build, keeping this job's lease alive for | |
| 189 | // as long as a build of its own would have. A waiter that went quiet would be | |
| 190 | // requeued to somebody else mid-wait, and would then go on to run a job the queue | |
| 191 | // no longer considers its own. | |
| 192 | func (s *runnerSession) waitForBuild(build *imageBuild, lease time.Duration, runID string, log io.Writer) error { | |
| 193 | interval := lease / 3 | |
| 194 | if interval < time.Second { | |
| 195 | interval = time.Second | |
| 196 | } | |
| 197 | tick := time.NewTicker(interval) | |
| 198 | defer tick.Stop() | |
| 199 | for { | |
| 200 | select { | |
| 201 | case <-build.done: | |
| 202 | return nil | |
| 203 | case <-tick.C: | |
| 204 | rep, err := s.call(runnerRequest{Op: "heartbeat", Run: runID}) | |
| 205 | if err != nil || !rep.OK { | |
| 206 | return errLeaseLost | |
| 207 | } | |
| 208 | if rep.Cancel != "" { | |
| 209 | fmt.Fprintf(log, "\nthe run was canceled by %s; stopping\n", rep.Cancel) | |
| 210 | return errCanceled | |
| 211 | } | |
| 212 | } | |
| 213 | } | |
|
70b70264
Keenan Nemetz
15d ago
|
214 | } |