vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
refactor
c5902451905acdaa478f8476844ccd4a510d9ca9 · Keenan Nemetz · 17d ago · parent ec2aba4b
ci/agent/agent.go +865
| @@ -0,0 +1,865 @@ | |||
| 1 | // Package agent implements the Vole CI agent. | ||
| 2 | // | ||
| 3 | // A runner connects to the instance over SSH with the key its account pushes | ||
| 4 | // with, leases one job at a time, runs its steps in a persistent checkout, then | ||
| 5 | // uploads the log and artifacts and publishes the attempt as a signed commit. | ||
| 6 | // This package is the agent; argument parsing and the CLI's messages stay in | ||
| 7 | // cmd/vole. | ||
| 8 | package agent | ||
| 9 | |||
| 10 | import ( | ||
| 11 | "bufio" | ||
| 12 | "context" | ||
| 13 | "encoding/json" | ||
| 14 | "errors" | ||
| 15 | "fmt" | ||
| 16 | "net/url" | ||
| 17 | "os" | ||
| 18 | "os/exec" | ||
| 19 | "path" | ||
| 20 | "path/filepath" | ||
| 21 | "runtime" | ||
| 22 | "sort" | ||
| 23 | "strings" | ||
| 24 | "sync" | ||
| 25 | "syscall" | ||
| 26 | "time" | ||
| 27 | |||
| 28 | "github.com/rodent-software/vole/ci" | ||
| 29 | "github.com/rodent-software/vole/ci/runs" | ||
| 30 | "github.com/rodent-software/vole/client" | ||
| 31 | "github.com/rodent-software/vole/core/lfs" | ||
| 32 | "github.com/rodent-software/vole/internal/format" | ||
| 33 | "github.com/rodent-software/vole/metadata" | ||
| 34 | ) | ||
| 35 | |||
| 36 | // leaseWait is how long an idle runner parks on the lease before asking again. It is | ||
| 37 | // below the 60-second cap the server applies and long enough that a quiet pool is | ||
| 38 | // invisible in the queue's load. | ||
| 39 | const leaseWait = 25 * time.Second | ||
| 40 | |||
| 41 | type runnerStep struct { | ||
| 42 | Name string `json:"name"` | ||
| 43 | Run string `json:"run"` | ||
| 44 | Timeout string `json:"timeout"` | ||
| 45 | } | ||
| 46 | |||
| 47 | type runnerJob struct { | ||
| 48 | ID string `json:"id"` | ||
| 49 | Repo string `json:"repo"` | ||
| 50 | SHA string `json:"sha"` | ||
| 51 | // Attempt is which try at this job this is. The record lives under it, so the | ||
| 52 | // runner has to know the number it is reporting. | ||
| 53 | Attempt int `json:"attempt"` | ||
| 54 | // Ref, WorkflowSHA and StepsSHA are part of what this runner signs when the | ||
| 55 | // attempt ends: the exact revision, the exact workflow file, and the digest of | ||
| 56 | // the steps that were pinned when the job was queued. | ||
| 57 | Ref string `json:"ref"` | ||
| 58 | WorkflowSHA string `json:"workflow_sha"` | ||
| 59 | StepsSHA string `json:"steps_sha"` | ||
| 60 | Branch string `json:"branch"` | ||
| 61 | Workflow string `json:"workflow"` | ||
| 62 | Job string `json:"job"` | ||
| 63 | Steps []runnerStep `json:"steps"` | ||
| 64 | // Artifacts are path patterns, expanded in the checkout after the steps pass. | ||
| 65 | Artifacts []string `json:"artifacts,omitempty"` | ||
| 66 | // Tag is set for a tag-triggered run; TagAnnotated says whether it carries | ||
| 67 | // notes, which is what makes it a release. ReleaseArtifacts are the patterns | ||
| 68 | // that also publish to that release. | ||
| 69 | Tag string `json:"tag,omitempty"` | ||
| 70 | TagAnnotated bool `json:"tag_annotated,omitempty"` | ||
| 71 | ReleaseArtifacts []string `json:"release_artifacts,omitempty"` | ||
| 72 | // Pages is the site deploy this run performs, if any. | ||
| 73 | Pages *runnerPages `json:"pages,omitempty"` | ||
| 74 | } | ||
| 75 | |||
| 76 | type runnerPages struct { | ||
| 77 | Path string `json:"path"` | ||
| 78 | Ref string `json:"ref"` | ||
| 79 | } | ||
| 80 | |||
| 81 | type runnerArtifact struct { | ||
| 82 | Name string `json:"name"` | ||
| 83 | OID string `json:"oid"` | ||
| 84 | Size int64 `json:"size"` | ||
| 85 | } | ||
| 86 | |||
| 87 | // runnerRequest is the wire. A finish carries nothing but the run id: the result | ||
| 88 | // went up as a git push before this was sent, because the record is the runner's | ||
| 89 | // statement and the push is what the gate checks. This channel only closes the lease. | ||
| 90 | type runnerRequest struct { | ||
| 91 | Op string `json:"op"` | ||
| 92 | Run string `json:"run"` | ||
| 93 | // Repos is the --repos filter, sent with every lease. The server intersects it | ||
| 94 | // with what the account's allowlists grant, so it can only narrow. | ||
| 95 | Repos []string `json:"repos,omitempty"` | ||
| 96 | // Labels are what this machine is, sent with every lease so the server can match | ||
| 97 | // them against the runs-on a job asked for. | ||
| 98 | Labels []string `json:"labels,omitempty"` | ||
| 99 | // Wait is how many seconds an idle lease may park before answering "nothing to | ||
| 100 | // do"; the server caps it. It is what makes an idle runner cost a parked channel | ||
| 101 | // rather than a query every couple of seconds. | ||
| 102 | Wait int `json:"wait,omitempty"` | ||
| 103 | } | ||
| 104 | |||
| 105 | type runnerReply struct { | ||
| 106 | Op string `json:"op"` | ||
| 107 | Error string `json:"error,omitempty"` | ||
| 108 | Run *runnerJob `json:"run,omitempty"` | ||
| 109 | OK bool `json:"ok"` | ||
| 110 | Lease int `json:"lease,omitempty"` | ||
| 111 | // The account this session authenticated as and the fingerprints of its keys, | ||
| 112 | // which is how a runner finds out before it starts work whether the key it will | ||
| 113 | // sign with is one the server will accept. | ||
| 114 | Account string `json:"account,omitempty"` | ||
| 115 | Name string `json:"name,omitempty"` | ||
| 116 | // Repos are the repositories this account may run jobs for. A runner answers the | ||
| 117 | // "ping" with these so a --repos filter can be checked against them at startup: | ||
| 118 | // a filter that matches nothing would otherwise look exactly like a quiet instance. | ||
| 119 | Repos []string `json:"repos,omitempty"` | ||
| 120 | } | ||
| 121 | |||
| 122 | // Options configure one runner process. | ||
| 123 | type Options struct { | ||
| 124 | // Remote is the instance's SSH URL, e.g. ssh://git@host:2222. | ||
| 125 | Remote string | ||
| 126 | // Dir is where checkouts are cached. Empty means the user cache directory. | ||
| 127 | Dir string | ||
| 128 | // Repos narrows this runner to the named repositories. The server intersects | ||
| 129 | // it with the account's runner allowlist, so it can only subtract. | ||
| 130 | Repos []string | ||
| 131 | // Labels describe the machine. A job is only leased to a runner carrying every | ||
| 132 | // label the job asked for with runs-on. | ||
| 133 | Labels []string | ||
| 134 | // Once runs one job and exits, which is what a scheduled container wants. | ||
| 135 | Once bool | ||
| 136 | } | ||
| 137 | |||
| 138 | // Run leases and executes CI jobs until the connection fails, the context ends, | ||
| 139 | // or (with Once) one job has been handled. | ||
| 140 | func Run(ctx context.Context, opts Options) error { | ||
| 141 | if opts.Remote == "" { | ||
| 142 | return errors.New("--remote is required, e.g. --remote ssh://git@example.com:2222") | ||
| 143 | } | ||
| 144 | cache := opts.Dir | ||
| 145 | if cache == "" { | ||
| 146 | base, err := os.UserCacheDir() | ||
| 147 | if err != nil { | ||
| 148 | base = os.TempDir() | ||
| 149 | } | ||
| 150 | cache = filepath.Join(base, "vole-runner") | ||
| 151 | } | ||
| 152 | if err := os.MkdirAll(cache, 0o755); err != nil { | ||
| 153 | return err | ||
| 154 | } | ||
| 155 | |||
| 156 | // A label has to be one the workflow validator would accept, or the machine would | ||
| 157 | // advertise something no job can ask for — a typo with no symptom at all. | ||
| 158 | for _, label := range opts.Labels { | ||
| 159 | if err := ci.ValidLabel(label); err != nil { | ||
| 160 | return fmt.Errorf("--label %w", err) | ||
| 161 | } | ||
| 162 | } | ||
| 163 | |||
| 164 | sess, err := dialRunner(opts.Remote) | ||
| 165 | if err != nil { | ||
| 166 | return err | ||
| 167 | } | ||
| 168 | defer sess.close() | ||
| 169 | |||
| 170 | // Ask who the server thinks this is, so the runner can say whose results it reports | ||
| 171 | // and check its own --repos filter against what the account may run. | ||
| 172 | who, err := sess.call(runnerRequest{Op: "ping"}) | ||
| 173 | if err != nil { | ||
| 174 | return err | ||
| 175 | } | ||
| 176 | if who.Error != "" { | ||
| 177 | return errors.New(who.Error) | ||
| 178 | } | ||
| 179 | // --repos narrows the account's grant. Check it against the grant now: a filter that | ||
| 180 | // matches nothing is indistinguishable from a quiet instance, so it would idle | ||
| 181 | // forever while looking healthy — cheaper to find at startup than after a build that | ||
| 182 | // never arrives. | ||
| 183 | if err := checkRepoFilter(opts.Repos, who); err != nil { | ||
| 184 | return err | ||
| 185 | } | ||
| 186 | sess.account, sess.name = who.Account, who.Name | ||
| 187 | sess.repos, sess.labels = opts.Repos, opts.Labels | ||
| 188 | fmt.Fprintf(os.Stderr, "vole runner: %s\n", sess.describe(who.Name)) | ||
| 189 | for { | ||
| 190 | // A resident runner parks on the server's lease: the reply arrives when work is | ||
| 191 | // queued, not on a fixed beat, so an idle pool costs one open channel per | ||
| 192 | // runner instead of a query every couple of seconds. A one-shot runner asks | ||
| 193 | // once and leaves. | ||
| 194 | req := runnerRequest{Op: "lease", Repos: sess.repos, Labels: sess.labels} | ||
| 195 | if !opts.Once { | ||
| 196 | req.Wait = int(leaseWait.Seconds()) | ||
| 197 | } | ||
| 198 | rep, err := sess.call(req) | ||
| 199 | if err != nil { | ||
| 200 | return err | ||
| 201 | } | ||
| 202 | if rep.Error != "" { | ||
| 203 | return errors.New(rep.Error) | ||
| 204 | } | ||
| 205 | if rep.Run == nil { | ||
| 206 | if opts.Once { | ||
| 207 | return nil | ||
| 208 | } | ||
| 209 | // Either the wait expired with nothing to do, or the server answered at | ||
| 210 | // once because it is older than this client and ignores `wait`. The sleep | ||
| 211 | // keeps the second case at a polite two requests per second instead of a | ||
| 212 | // spin. | ||
| 213 | select { | ||
| 214 | case <-ctx.Done(): | ||
| 215 | return nil | ||
| 216 | case <-time.After(2 * time.Second): | ||
| 217 | } | ||
| 218 | continue | ||
| 219 | } | ||
| 220 | lease := time.Duration(rep.Lease) * time.Second | ||
| 221 | if lease <= 0 { | ||
| 222 | lease = 60 * time.Second | ||
| 223 | } | ||
| 224 | sess.run(opts.Remote, cache, rep.Run, lease) | ||
| 225 | if opts.Once { | ||
| 226 | return nil | ||
| 227 | } | ||
| 228 | } | ||
| 229 | } | ||
| 230 | |||
| 231 | // ---------- connection ---------- | ||
| 232 | |||
| 233 | type runnerSession struct { | ||
| 234 | cmd *exec.Cmd | ||
| 235 | in *bufio.Writer | ||
| 236 | out *bufio.Reader | ||
| 237 | enc *json.Encoder | ||
| 238 | dec *json.Decoder | ||
| 239 | mu sync.Mutex | ||
| 240 | // account is the account id the server resolved from the connection. A record | ||
| 241 | // names it, and the gate refuses a record whose signer is not the pusher. | ||
| 242 | account string | ||
| 243 | name string | ||
| 244 | // repos is the --repos filter, sent with every lease. It is what the operator | ||
| 245 | // asked for, not the effective list: the server intersects it with the grant on | ||
| 246 | // each request, so an allowlist edit takes effect without a restart. | ||
| 247 | repos []string | ||
| 248 | // labels is what this machine is (--label). A job is only given to a runner that | ||
| 249 | // carries every label the job asked for, so an unlabelled runner only sees jobs | ||
| 250 | // that ask for nothing. | ||
| 251 | labels []string | ||
| 252 | } | ||
| 253 | |||
| 254 | // describe is the one line a runner prints when it starts work, because "why is | ||
| 255 | // nothing running" is answered by reading what it says it will take. Both filters are | ||
| 256 | // named when they are set and omitted when they are not, so the common case stays one | ||
| 257 | // short line. | ||
| 258 | func (s *runnerSession) describe(name string) string { | ||
| 259 | out := "connected as " + name | ||
| 260 | if len(s.labels) > 0 { | ||
| 261 | out += ", taking jobs that ask for " + strings.Join(s.labels, ", ") | ||
| 262 | } | ||
| 263 | if len(s.repos) > 0 { | ||
| 264 | out += ", only in " + strings.Join(s.repos, ", ") | ||
| 265 | } | ||
| 266 | return out + "; waiting for work" | ||
| 267 | } | ||
| 268 | |||
| 269 | // checkRepoFilter refuses a --repos filter that can never match. A name the server | ||
| 270 | // did not grant is dropped by the intersection rather than refused — an operator may | ||
| 271 | // reasonably name a repository before it exists — but a filter whose names are *all* | ||
| 272 | // outside the grant is a typo or a permissions problem, and it would otherwise present | ||
| 273 | // as a runner that connected happily and never did anything. | ||
| 274 | func checkRepoFilter(asked []string, who runnerReply) error { | ||
| 275 | if len(asked) == 0 { | ||
| 276 | if len(who.Repos) == 0 { | ||
| 277 | // Not an error: an allowlist nobody has written yet is a normal state, and it | ||
| 278 | // takes effect without a restart. Say so instead of idling silently. | ||
| 279 | fmt.Fprintf(os.Stderr, "vole runner: %s may not run jobs for any repository yet; "+ | ||
| 280 | "waiting (an admin allows it with `vole acl runners <repo> --add %s`)\n", | ||
| 281 | who.Name, who.Name) | ||
| 282 | } | ||
| 283 | return nil | ||
| 284 | } | ||
| 285 | granted := make(map[string]bool, len(who.Repos)) | ||
| 286 | for _, r := range who.Repos { | ||
| 287 | granted[r] = true | ||
| 288 | } | ||
| 289 | for _, r := range asked { | ||
| 290 | if granted[r] { | ||
| 291 | return nil | ||
| 292 | } | ||
| 293 | } | ||
| 294 | allowed := "none" | ||
| 295 | if len(who.Repos) > 0 { | ||
| 296 | allowed = strings.Join(who.Repos, ", ") | ||
| 297 | } | ||
| 298 | return fmt.Errorf("--repos %s matches nothing %s may run (allowed: %s); "+ | ||
| 299 | "a runner can only narrow what its account is allowed", | ||
| 300 | strings.Join(asked, ", "), who.Name, allowed) | ||
| 301 | } | ||
| 302 | |||
| 303 | func dialRunner(remote string) (*runnerSession, error) { | ||
| 304 | u, err := url.Parse(remote) | ||
| 305 | if err != nil { | ||
| 306 | return nil, fmt.Errorf("%q is not an ssh URL: %w", remote, err) | ||
| 307 | } | ||
| 308 | if u.Scheme != "ssh" || u.Hostname() == "" { | ||
| 309 | return nil, fmt.Errorf("%q is not an ssh URL (expected ssh://user@host:port)", remote) | ||
| 310 | } | ||
| 311 | user := u.User.Username() | ||
| 312 | if user == "" { | ||
| 313 | user = "git" | ||
| 314 | } | ||
| 315 | cmd := client.SSHCommand(user+"@"+u.Hostname(), u.Port(), "vole-runner") | ||
| 316 | cmd.Stderr = os.Stderr | ||
| 317 | stdin, err := cmd.StdinPipe() | ||
| 318 | if err != nil { | ||
| 319 | return nil, err | ||
| 320 | } | ||
| 321 | stdout, err := cmd.StdoutPipe() | ||
| 322 | if err != nil { | ||
| 323 | return nil, err | ||
| 324 | } | ||
| 325 | if err := cmd.Start(); err != nil { | ||
| 326 | return nil, fmt.Errorf("start ssh: %w", err) | ||
| 327 | } | ||
| 328 | s := &runnerSession{cmd: cmd, in: bufio.NewWriter(stdin), out: bufio.NewReader(stdout)} | ||
| 329 | s.enc = json.NewEncoder(s.in) | ||
| 330 | s.dec = json.NewDecoder(s.out) | ||
| 331 | return s, nil | ||
| 332 | } | ||
| 333 | |||
| 334 | func (s *runnerSession) call(req runnerRequest) (runnerReply, error) { | ||
| 335 | var rep runnerReply | ||
| 336 | s.mu.Lock() | ||
| 337 | defer s.mu.Unlock() | ||
| 338 | if err := s.enc.Encode(req); err != nil { | ||
| 339 | return rep, fmt.Errorf("connection lost: %w", err) | ||
| 340 | } | ||
| 341 | if err := s.in.Flush(); err != nil { | ||
| 342 | return rep, fmt.Errorf("connection lost: %w", err) | ||
| 343 | } | ||
| 344 | if err := s.dec.Decode(&rep); err != nil { | ||
| 345 | return rep, fmt.Errorf("connection lost: %w", err) | ||
| 346 | } | ||
| 347 | return rep, nil | ||
| 348 | } | ||
| 349 | |||
| 350 | func (s *runnerSession) close() { | ||
| 351 | s.mu.Lock() | ||
| 352 | _ = s.enc.Encode(runnerRequest{Op: "bye"}) | ||
| 353 | _ = s.in.Flush() | ||
| 354 | s.mu.Unlock() | ||
| 355 | _ = s.cmd.Process.Signal(syscall.SIGTERM) | ||
| 356 | _ = s.cmd.Wait() | ||
| 357 | } | ||
| 358 | |||
| 359 | // ---------- execution ---------- | ||
| 360 | |||
| 361 | // run executes a leased job and reports the outcome. A lost lease ends the job | ||
| 362 | // without reporting a result: another runner owns it now. | ||
| 363 | func (s *runnerSession) run(remote, cache string, job *runnerJob, lease time.Duration) { | ||
| 364 | log := &strings.Builder{} | ||
| 365 | work, err := prepareWorktree(remote, cache, job.Repo, job.SHA) | ||
| 366 | if err != nil { | ||
| 367 | // No checkout means no branch to commit a record to, so there is nothing to | ||
| 368 | // report: the lease expires and the queue gives the job to a runner that can. | ||
| 369 | fmt.Fprintf(os.Stderr, "vole runner: cannot prepare a checkout of %s at %s: %v\n", | ||
| 370 | job.Repo, format.SHA(job.SHA), err) | ||
| 371 | return | ||
| 372 | } | ||
| 373 | fail := func(format string, args ...any) { | ||
| 374 | msg := fmt.Sprintf(format, args...) | ||
| 375 | fmt.Fprintf(os.Stderr, "vole runner: %s\n", msg) | ||
| 376 | fmt.Fprintf(log, "\n%s\n", msg) | ||
| 377 | s.finish(remote, work, job, log, 1, nil, nil) | ||
| 378 | } | ||
| 379 | fmt.Fprintf(log, "=== %s · %s · %s @ %s\n", job.Repo, job.Job, job.Branch, format.SHA(job.SHA)) | ||
| 380 | |||
| 381 | for _, step := range job.Steps { | ||
| 382 | fmt.Fprintf(log, "\n--- %s\n", step.Name) | ||
| 383 | code, chunk, lost := s.execStep(job.ID, work, step, lease) | ||
| 384 | log.WriteString(chunk) | ||
| 385 | if lost { | ||
| 386 | fmt.Fprintf(os.Stderr, "vole runner: lease lost while running %q; stopping\n", step.Name) | ||
| 387 | return // no finish: the queue already gave it to somebody else | ||
| 388 | } | ||
| 389 | if code != 0 { | ||
| 390 | fmt.Fprintf(log, "\nstep %q exited %d\n", step.Name, code) | ||
| 391 | s.finish(remote, work, job, log, code, nil, nil) | ||
| 392 | return | ||
| 393 | } | ||
| 394 | } | ||
| 395 | arts, releaseArts, err := publishArtifacts(remote, work, job, log) | ||
| 396 | if err != nil { | ||
| 397 | fail("%s", err) | ||
| 398 | return | ||
| 399 | } | ||
| 400 | if len(releaseArts) > 0 { | ||
| 401 | // The pointers ride the record's push, but a declaration that cannot name a | ||
| 402 | // release is a mistake worth failing on before the push rather than after it. | ||
| 403 | if err := checkReleaseJob(job); err != nil { | ||
| 404 | fail("%s", err) | ||
| 405 | return | ||
| 406 | } | ||
| 407 | } | ||
| 408 | if err := publishSite(remote, work, job, log); err != nil { | ||
| 409 | fail("%s", err) | ||
| 410 | return | ||
| 411 | } | ||
| 412 | s.finish(remote, work, job, log, 0, arts, releaseArts) | ||
| 413 | } | ||
| 414 | |||
| 415 | // finish uploads this attempt's log, commits its record and pushes it, and only then | ||
| 416 | // tells the server it is done. | ||
| 417 | // | ||
| 418 | // The bytes go in first, the same rule artifacts follow: a record is a set of | ||
| 419 | // pointers, and a pointer to an object the store does not have would fail the push | ||
| 420 | // that carries it. Then one signed commit carries the whole run — the record and any | ||
| 421 | // release pointers the build published — and one push lands it. The server creates | ||
| 422 | // nothing here: `pre-receive` checks the signature and the runner allowlist, and the | ||
| 423 | // finish message only closes the lease. | ||
| 424 | // | ||
| 425 | // A log that cannot be stored does not lose the result: the record then names no log | ||
| 426 | // and the failure is reported on stderr rather than swallowed. A record that cannot be | ||
| 427 | // pushed is not reported at all, because there is nothing for the server to read. | ||
| 428 | func (s *runnerSession) finish(remote, work string, job *runnerJob, log *strings.Builder, exit int, arts, releaseArts []runnerArtifact) { | ||
| 429 | oid, size, err := uploadLog(remote, job.Repo, log.String()) | ||
| 430 | if err != nil { | ||
| 431 | fmt.Fprintf(os.Stderr, "vole runner: could not upload the log for %s: %v\n", job.ID, err) | ||
| 432 | } | ||
| 433 | artifacts := make([]runs.Artifact, 0, len(arts)) | ||
| 434 | for _, a := range arts { | ||
| 435 | artifacts = append(artifacts, runs.Artifact{Name: a.Name, OID: a.OID, Size: a.Size}) | ||
| 436 | } | ||
| 437 | rec := runs.Record{ | ||
| 438 | RunID: job.ID, Attempt: job.Attempt, Repo: job.Repo, Ref: job.Ref, SHA: job.SHA, | ||
| 439 | Workflow: job.Workflow, WorkflowSHA: job.WorkflowSHA, Job: job.Job, | ||
| 440 | StepsSHA: job.StepsSHA, Exit: exit, Finished: time.Now().UTC(), | ||
| 441 | Signer: s.account, Artifacts: artifacts, | ||
| 442 | // The machine this attempt ran on. The runner binary's own build target, | ||
| 443 | // which is the honest thing it can say: a live probe would add a subprocess | ||
| 444 | // and a platform quirk for a fact the binary already knows. | ||
| 445 | OS: runtime.GOOS, Arch: runtime.GOARCH, | ||
| 446 | } | ||
| 447 | if oid != "" { | ||
| 448 | rec.Log = &lfs.Pointer{OID: oid, Size: size} | ||
| 449 | } | ||
| 450 | |||
| 451 | // Keep the lease through the push: the record is checked against it, and a slow | ||
| 452 | // upload must not turn a finished job into one that expired mid-report. | ||
| 453 | if rep, err := s.call(runnerRequest{Op: "heartbeat", Run: job.ID}); err != nil { | ||
| 454 | fmt.Fprintf(os.Stderr, "vole runner: lost the connection while finishing %s: %v\n", job.ID, err) | ||
| 455 | return | ||
| 456 | } else if !rep.OK { | ||
| 457 | fmt.Fprintf(os.Stderr, "vole runner: the lease for %s is gone; the result was not published\n", job.ID) | ||
| 458 | return | ||
| 459 | } | ||
| 460 | |||
| 461 | if err := publishRecord(remote, work, job, rec, releaseArts, log); err != nil { | ||
| 462 | fmt.Fprintf(os.Stderr, "vole runner: could not publish the record for %s: %v\n", job.ID, err) | ||
| 463 | return | ||
| 464 | } | ||
| 465 | |||
| 466 | rep, err := s.call(runnerRequest{Op: "finish", Run: job.ID}) | ||
| 467 | switch { | ||
| 468 | case err != nil: | ||
| 469 | fmt.Fprintf(os.Stderr, "vole runner: connection lost after publishing %s; it is on the branch\n", job.ID) | ||
| 470 | case rep.Error != "": | ||
| 471 | fmt.Fprintf(os.Stderr, "vole runner: %s\n", rep.Error) | ||
| 472 | case !rep.OK: | ||
| 473 | fmt.Fprintf(os.Stderr, "vole runner: the lease for %s moved; the record is on the branch but the queue did not take it\n", job.ID) | ||
| 474 | } | ||
| 475 | } | ||
| 476 | |||
| 477 | // publishRecord commits the attempt's record, with the release pointers the build | ||
| 478 | // published beside it, and pushes once. | ||
| 479 | // | ||
| 480 | // One push per run is the point: the result and the artifacts it produced are one | ||
| 481 | // commit, so a release cannot carry bytes whose build has no record and a run does not | ||
| 482 | // pay a push per output. A lost race against somebody else's issue is re-applied on | ||
| 483 | // the new tip, the same way every other metadata write is. | ||
| 484 | func publishRecord(remote, work string, job *runnerJob, rec runs.Record, rel []runnerArtifact, log *strings.Builder) error { | ||
| 485 | url, err := client.RepoURL(remote, job.Repo) | ||
| 486 | if err != nil { | ||
| 487 | return err | ||
| 488 | } | ||
| 489 | gitDir, err := client.GitDirOf(work) | ||
| 490 | if err != nil { | ||
| 491 | return err | ||
| 492 | } | ||
| 493 | c, err := client.OpenBranch(gitDir, url, strings.TrimPrefix(metadata.Ref, "refs/heads/"), "vole-runner") | ||
| 494 | if err != nil { | ||
| 495 | return err | ||
| 496 | } | ||
| 497 | add := func(files map[string][]byte) error { | ||
| 498 | for _, a := range rel { | ||
| 499 | files[metadata.ReleasePath(job.Tag, a.Name)] = lfs.Pointer{OID: a.OID, Size: a.Size}.Encode() | ||
| 500 | } | ||
| 501 | return nil | ||
| 502 | } | ||
| 503 | write := func() error { | ||
| 504 | _, err := runs.WriteRecord(c.Repo, metadata.Ref, rec, c.Author, add) | ||
| 505 | return err | ||
| 506 | } | ||
| 507 | if err := write(); err != nil { | ||
| 508 | return err | ||
| 509 | } | ||
| 510 | if err := c.CommitAndPush(false, write); err != nil { | ||
| 511 | return err | ||
| 512 | } | ||
| 513 | if rec.Attempt > 0 { | ||
| 514 | fmt.Fprintf(log, "\nrecord: pushed %s\n", runs.RecordPath(rec.RunID, rec.Attempt)) | ||
| 515 | } | ||
| 516 | if len(rel) > 0 { | ||
| 517 | fmt.Fprintf(log, "\nrelease: attached %d artifact(s) to %s\n", len(rel), job.Tag) | ||
| 518 | } | ||
| 519 | return nil | ||
| 520 | } | ||
| 521 | |||
| 522 | // checkReleaseJob refuses a workflow that publishes to a release it cannot name. It | ||
| 523 | // runs before the push so the refusal lands in the job's log, where somebody will see | ||
| 524 | // it, rather than as a push error after a successful build. | ||
| 525 | func checkReleaseJob(job *runnerJob) error { | ||
| 526 | if job.Tag == "" { | ||
| 527 | return fmt.Errorf("this job declares release artifacts but ran on a branch; a release " + | ||
| 528 | "is built by a tag push (add on.push.tags and push a tag)") | ||
| 529 | } | ||
| 530 | if !job.TagAnnotated { | ||
| 531 | return fmt.Errorf("%s is a lightweight tag; a release needs an annotated tag with notes "+ | ||
| 532 | "(git tag -a %s -F notes.md)", job.Tag, job.Tag) | ||
| 533 | } | ||
| 534 | return nil | ||
| 535 | } | ||
| 536 | |||
| 537 | // uploadLog puts the log's bytes in the instance's object store over the same SSH | ||
| 538 | // transfer the artifacts use, and returns the id the store computed. An empty log | ||
| 539 | // uploads nothing and reports no object, so a run with no output has no log rather | ||
| 540 | // than an empty file. | ||
| 541 | func uploadLog(remote, repo, text string) (oid string, size int64, err error) { | ||
| 542 | if text == "" { | ||
| 543 | return "", 0, nil | ||
| 544 | } | ||
| 545 | url, err := client.RepoURL(remote, repo) | ||
| 546 | if err != nil { | ||
| 547 | return "", 0, err | ||
| 548 | } | ||
| 549 | tx, err := client.DialTransfer(url, "upload") | ||
| 550 | if err != nil { | ||
| 551 | return "", 0, err | ||
| 552 | } | ||
| 553 | defer tx.Close() | ||
| 554 | |||
| 555 | f, err := os.CreateTemp("", "vole-log-*") | ||
| 556 | if err != nil { | ||
| 557 | return "", 0, err | ||
| 558 | } | ||
| 559 | name := f.Name() | ||
| 560 | defer os.Remove(name) | ||
| 561 | if _, err := f.WriteString(text); err != nil { | ||
| 562 | f.Close() | ||
| 563 | return "", 0, err | ||
| 564 | } | ||
| 565 | if err := f.Close(); err != nil { | ||
| 566 | return "", 0, err | ||
| 567 | } | ||
| 568 | ptr, err := tx.Upload(name) | ||
| 569 | if err != nil { | ||
| 570 | return "", 0, err | ||
| 571 | } | ||
| 572 | return ptr.OID, ptr.Size, nil | ||
| 573 | } | ||
| 574 | |||
| 575 | // publishArtifacts expands the job's declared patterns in the checkout and uploads | ||
| 576 | // what they match through the same SSH object transfer git-lfs uses. The bytes go | ||
| 577 | // in before the finish, so the server can refuse a claim it cannot serve. | ||
| 578 | // publishArtifacts uploads every declared output and returns them twice: once as | ||
| 579 | // the run's outputs, once as the subset that belongs to the tag's release. | ||
| 580 | func publishArtifacts(remote, work string, job *runnerJob, log *strings.Builder) (all, release []runnerArtifact, err error) { | ||
| 581 | if len(job.Artifacts) == 0 { | ||
| 582 | return nil, nil, nil | ||
| 583 | } | ||
| 584 | files, err := collectArtifactFiles(work, job.Artifacts) | ||
| 585 | if err != nil { | ||
| 586 | return nil, nil, err | ||
| 587 | } | ||
| 588 | isRelease := map[string]bool{} | ||
| 589 | for _, pat := range job.ReleaseArtifacts { | ||
| 590 | isRelease[pat] = true | ||
| 591 | } | ||
| 592 | url, err := client.RepoURL(remote, job.Repo) | ||
| 593 | if err != nil { | ||
| 594 | return nil, nil, err | ||
| 595 | } | ||
| 596 | tx, err := client.DialTransfer(url, "upload") | ||
| 597 | if err != nil { | ||
| 598 | return nil, nil, fmt.Errorf("connect to publish %d artifact(s): %w", len(files), err) | ||
| 599 | } | ||
| 600 | defer tx.Close() | ||
| 601 | |||
| 602 | all = make([]runnerArtifact, 0, len(files)) | ||
| 603 | for _, f := range files { | ||
| 604 | ptr, err := tx.Upload(filepath.Join(work, filepath.FromSlash(f.Path))) | ||
| 605 | if err != nil { | ||
| 606 | return nil, nil, fmt.Errorf("upload %s: %w", f.Path, err) | ||
| 607 | } | ||
| 608 | art := runnerArtifact{Name: f.Name, OID: ptr.OID, Size: ptr.Size} | ||
| 609 | fmt.Fprintf(log, "\nartifact %s (%s) sha256:%s\n", f.Name, format.Bytes(ptr.Size), ptr.OID) | ||
| 610 | all = append(all, art) | ||
| 611 | if isRelease[f.Pattern] { | ||
| 612 | release = append(release, art) | ||
| 613 | } | ||
| 614 | } | ||
| 615 | return all, release, nil | ||
| 616 | } | ||
| 617 | |||
| 618 | // publishSite deploys the job's declared directory as the repository's site, using | ||
| 619 | // the same snapshot code the pages CLI uses. | ||
| 620 | // | ||
| 621 | // The staleness check first: a site is a snapshot, so a run that finishes after a | ||
| 622 | // newer commit has landed would quietly revert the site to an older build. Refusing | ||
| 623 | // is the honest outcome — the log says why, and the job fails instead of publishing | ||
| 624 | // a stale tree that nobody notices for a week. | ||
| 625 | func publishSite(remote, work string, job *runnerJob, log *strings.Builder) error { | ||
| 626 | if job.Pages == nil || job.Pages.Path == "" { | ||
| 627 | return nil | ||
| 628 | } | ||
| 629 | url, err := client.RepoURL(remote, job.Repo) | ||
| 630 | if err != nil { | ||
| 631 | return err | ||
| 632 | } | ||
| 633 | tip, err := client.LSRemote(work, url, "refs/heads/"+job.Branch) | ||
| 634 | if err != nil { | ||
| 635 | return fmt.Errorf("check %s before deploying the site: %w", job.Branch, err) | ||
| 636 | } | ||
| 637 | switch { | ||
| 638 | case tip == "": | ||
| 639 | return fmt.Errorf("%s no longer exists, so there is no site to deploy to", job.Branch) | ||
| 640 | case tip != job.SHA: | ||
| 641 | return fmt.Errorf("this run built %s but %s is at %s now; the site was not deployed", | ||
| 642 | format.SHA(job.SHA), job.Branch, format.SHA(tip)) | ||
| 643 | } | ||
| 644 | |||
| 645 | gitDir, err := client.GitDirOf(work) | ||
| 646 | if err != nil { | ||
| 647 | return err | ||
| 648 | } | ||
| 649 | c, err := client.OpenBranch(gitDir, url, job.Pages.Ref, "vole-runner") | ||
| 650 | if err != nil { | ||
| 651 | return err | ||
| 652 | } | ||
| 653 | dir := filepath.Join(work, filepath.FromSlash(job.Pages.Path)) | ||
| 654 | msg := fmt.Sprintf("site: %s from run %s (%s)", job.Branch, format.SHA(job.SHA), job.ID) | ||
| 655 | if len(msg) > 200 { | ||
| 656 | msg = fmt.Sprintf("site: %s from run %s", job.Branch, job.ID) | ||
| 657 | } | ||
| 658 | diff, files, deployed, err := client.DeploySite(c, dir, msg, false) | ||
| 659 | if err != nil { | ||
| 660 | return err | ||
| 661 | } | ||
| 662 | if deployed { | ||
| 663 | fmt.Fprintf(log, "\nsite: published %d file(s) to %s (%s)\n", files, job.Pages.Ref, diff) | ||
| 664 | } else { | ||
| 665 | fmt.Fprintf(log, "\nsite: %s already matches %s\n", job.Pages.Ref, job.Pages.Path) | ||
| 666 | } | ||
| 667 | return nil | ||
| 668 | } | ||
| 669 | |||
| 670 | // artifactFile is one matched output. Pattern is the declaration that matched it, | ||
| 671 | // because a pattern can also mean "publish this to the release". | ||
| 672 | type artifactFile struct { | ||
| 673 | Path string // slash-separated, relative to the checkout | ||
| 674 | Name string // the download name: the file's base name | ||
| 675 | Pattern string // the declaration that matched | ||
| 676 | } | ||
| 677 | |||
| 678 | // collectArtifactFiles expands patterns against a checkout. A pattern that matches | ||
| 679 | // nothing is an error rather than an empty artifact list: a build that stopped | ||
| 680 | // producing the file is the failure a declaration is supposed to catch, and a | ||
| 681 | // silent publish of nothing is how that goes unnoticed for a week. | ||
| 682 | func collectArtifactFiles(root string, patterns []string) ([]artifactFile, error) { | ||
| 683 | matched := make([]int, len(patterns)) | ||
| 684 | var files []artifactFile | ||
| 685 | byName := map[string]string{} | ||
| 686 | err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { | ||
| 687 | if err != nil { | ||
| 688 | return err | ||
| 689 | } | ||
| 690 | if d.IsDir() { | ||
| 691 | if d.Name() == ".git" { | ||
| 692 | return filepath.SkipDir | ||
| 693 | } | ||
| 694 | return nil | ||
| 695 | } | ||
| 696 | rel, err := filepath.Rel(root, p) | ||
| 697 | if err != nil { | ||
| 698 | return err | ||
| 699 | } | ||
| 700 | rel = filepath.ToSlash(rel) | ||
| 701 | for i, pat := range patterns { | ||
| 702 | if ok, err := path.Match(pat, rel); err != nil || !ok { | ||
| 703 | continue | ||
| 704 | } | ||
| 705 | matched[i]++ | ||
| 706 | info, err := d.Info() | ||
| 707 | if err != nil { | ||
| 708 | return err | ||
| 709 | } | ||
| 710 | if !info.Mode().IsRegular() { | ||
| 711 | return nil // a symlink or device: not an artifact | ||
| 712 | } | ||
| 713 | name := path.Base(rel) | ||
| 714 | if prev, dup := byName[name]; dup { | ||
| 715 | return fmt.Errorf("two files would be published as %q (%s and %s); rename one "+ | ||
| 716 | "or narrow the patterns", name, prev, rel) | ||
| 717 | } | ||
| 718 | byName[name] = rel | ||
| 719 | files = append(files, artifactFile{Path: rel, Name: name, Pattern: pat}) | ||
| 720 | return nil | ||
| 721 | } | ||
| 722 | return nil | ||
| 723 | }) | ||
| 724 | if err != nil { | ||
| 725 | return nil, err | ||
| 726 | } | ||
| 727 | for i, n := range matched { | ||
| 728 | if n == 0 { | ||
| 729 | return nil, fmt.Errorf("artifact pattern %q matched nothing in the checkout", patterns[i]) | ||
| 730 | } | ||
| 731 | } | ||
| 732 | sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name }) | ||
| 733 | return files, nil | ||
| 734 | } | ||
| 735 | |||
| 736 | // logBuffer collects a step's output. It is a sync buffer because os/exec copies | ||
| 737 | // stdout and stderr in separate goroutines. Nothing is shipped while the step runs: | ||
| 738 | // the whole log is uploaded once, when the attempt is over. | ||
| 739 | type logBuffer struct { | ||
| 740 | mu sync.Mutex | ||
| 741 | buf strings.Builder | ||
| 742 | } | ||
| 743 | |||
| 744 | func (w *logBuffer) Write(p []byte) (int, error) { | ||
| 745 | w.mu.Lock() | ||
| 746 | defer w.mu.Unlock() | ||
| 747 | return w.buf.Write(p) | ||
| 748 | } | ||
| 749 | |||
| 750 | func (w *logBuffer) String() string { | ||
| 751 | w.mu.Lock() | ||
| 752 | defer w.mu.Unlock() | ||
| 753 | return w.buf.String() | ||
| 754 | } | ||
| 755 | |||
| 756 | // execStep runs one shell step, heartbeating while it works. It returns the exit | ||
| 757 | // code, the output produced, and whether the lease was lost. | ||
| 758 | func (s *runnerSession) execStep(runID, work string, step runnerStep, lease time.Duration) (int, string, bool) { | ||
| 759 | timeout := 10 * time.Minute | ||
| 760 | if d, err := time.ParseDuration(step.Timeout); err == nil && d > 0 { | ||
| 761 | timeout = d | ||
| 762 | } | ||
| 763 | ctx, cancel := context.WithTimeout(context.Background(), timeout) | ||
| 764 | defer cancel() | ||
| 765 | |||
| 766 | cmd := exec.CommandContext(ctx, "sh", "-c", step.Run) | ||
| 767 | cmd.Dir = work | ||
| 768 | cmd.Env = append(os.Environ(), "CI=true", "VOLE_CI=1") | ||
| 769 | // Own process group: a step that spawns children must not outlive its timeout. | ||
| 770 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | ||
| 771 | w := &logBuffer{} | ||
| 772 | cmd.Stdout, cmd.Stderr = w, w | ||
| 773 | |||
| 774 | if err := cmd.Start(); err != nil { | ||
| 775 | return 1, fmt.Sprintf("cannot start: %v\n", err), false | ||
| 776 | } | ||
| 777 | done := make(chan error, 1) | ||
| 778 | go func() { done <- cmd.Wait() }() | ||
| 779 | |||
| 780 | interval := lease / 3 | ||
| 781 | if interval < time.Second { | ||
| 782 | interval = time.Second | ||
| 783 | } | ||
| 784 | tick := time.NewTicker(interval) | ||
| 785 | defer tick.Stop() | ||
| 786 | |||
| 787 | for { | ||
| 788 | select { | ||
| 789 | case err := <-done: | ||
| 790 | chunk := w.String() | ||
| 791 | if ctx.Err() == context.DeadlineExceeded { | ||
| 792 | // The heartbeat will have extended the lease this far; report the | ||
| 793 | // timeout as a failure rather than leaving the job hanging. | ||
| 794 | return 1, chunk + fmt.Sprintf("\nstep timed out after %s\n", timeout), false | ||
| 795 | } | ||
| 796 | if err == nil { | ||
| 797 | return 0, chunk, false | ||
| 798 | } | ||
| 799 | var ee *exec.ExitError | ||
| 800 | if errors.As(err, &ee) { | ||
| 801 | return ee.ExitCode(), chunk, false | ||
| 802 | } | ||
| 803 | return 1, chunk + fmt.Sprintf("\n%v\n", err), false | ||
| 804 | case <-ctx.Done(): | ||
| 805 | if cmd.Process != nil { | ||
| 806 | _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) | ||
| 807 | } | ||
| 808 | // Wait for the reader goroutine before returning the output. | ||
| 809 | <-done | ||
| 810 | return 1, w.String() + fmt.Sprintf("\nstep timed out after %s\n", timeout), false | ||
| 811 | case <-tick.C: | ||
| 812 | // The lease is the only thing a heartbeat carries. | ||
| 813 | rep, err := s.call(runnerRequest{Op: "heartbeat", Run: runID}) | ||
| 814 | if err != nil || !rep.OK { | ||
| 815 | return 1, "", true | ||
| 816 | } | ||
| 817 | } | ||
| 818 | } | ||
| 819 | } | ||
| 820 | |||
| 821 | // prepareWorktree fetches the commit to run and checks it out, from a cache that | ||
| 822 | // persists between jobs so a second run of the same repository is cheap. | ||
| 823 | func prepareWorktree(remote, cache, repo, sha string) (string, error) { | ||
| 824 | cloneURL, err := client.RepoURL(remote, repo) | ||
| 825 | if err != nil { | ||
| 826 | return "", err | ||
| 827 | } | ||
| 828 | dir := filepath.Join(cache, repo) | ||
| 829 | |||
| 830 | if _, err := os.Stat(filepath.Join(dir, ".git")); err != nil { | ||
| 831 | if err := os.RemoveAll(dir); err != nil { | ||
| 832 | return "", err | ||
| 833 | } | ||
| 834 | if out, err := exec.Command("git", "clone", "--quiet", cloneURL, dir).CombinedOutput(); err != nil { | ||
| 835 | return "", fmt.Errorf("git clone: %v: %s", err, out) | ||
| 836 | } | ||
| 837 | } else { | ||
| 838 | cmd := exec.Command("git", "-C", dir, "fetch", "--quiet", "--prune", "origin", | ||
| 839 | "+refs/heads/*:refs/remotes/origin/*") | ||
| 840 | if out, err := cmd.CombinedOutput(); err != nil { | ||
| 841 | return "", fmt.Errorf("git fetch: %v: %s", err, out) | ||
| 842 | } | ||
| 843 | } | ||
| 844 | // Start from the exact commit, with no leftovers from a previous run. | ||
| 845 | for _, args := range [][]string{ | ||
| 846 | {"checkout", "--quiet", "--force", "--detach", sha}, | ||
| 847 | {"clean", "-qffdx"}, | ||
| 848 | } { | ||
| 849 | cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) | ||
| 850 | if out, err := cmd.CombinedOutput(); err != nil { | ||
| 851 | // The commit may not be reachable from any branch yet: fetch it by id. | ||
| 852 | if args[0] == "checkout" { | ||
| 853 | if out2, err2 := exec.Command("git", "-C", dir, "fetch", "--quiet", "origin", sha).CombinedOutput(); err2 == nil { | ||
| 854 | if out3, err3 := exec.Command("git", "-C", dir, "checkout", "--quiet", "--force", "--detach", sha).CombinedOutput(); err3 == nil { | ||
| 855 | _ = out2 | ||
| 856 | _ = out3 | ||
| 857 | continue | ||
| 858 | } | ||
| 859 | } | ||
| 860 | } | ||
| 861 | return "", fmt.Errorf("git %s: %v: %s", strings.Join(args, " "), err, out) | ||
| 862 | } | ||
| 863 | } | ||
| 864 | return dir, nil | ||
| 865 | } | ||