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/agent.go +77−70

@@ -15,6 +15,7 @@ import (
15 15 "encoding/json"
16 16 "errors"
17 17 "fmt"
18 "io"
18 19 "net/url"
19 20 "os"
20 21 "os/exec"
@@ -461,7 +462,12 @@ func (s *runnerSession) close() {
461 462 // run executes a leased job and reports the outcome. A lost lease ends the job
462 463 // without reporting a result: another runner owns it now.
463 464 func (s *runnerSession) run(remote, cache string, job *runnerJob, lease time.Duration, box runnerBox) {
464 log := &strings.Builder{}
465 log, err := newSpool(MaxLogBytes)
466 if err != nil {
467 fmt.Fprintf(os.Stderr, "vole runner: %v\n", err)
468 return
469 }
470 defer log.Close()
465 471 co, err := prepareWorktree(remote, cache, job.Repo, job.SHA, job.ID, job.Attempt)
466 472 if err != nil {
467 473 // No checkout means no branch to commit a record to, so there is nothing to
@@ -532,14 +538,13 @@ func (s *runnerSession) run(remote, cache string, job *runnerJob, lease time.Dur
532 538 for _, step := range job.Steps {
533 539 // The step's section of the log starts at its header, so a reader can open
534 540 // one step without holding the image build that ran before it.
535 logStart := int64(log.Len())
541 logStart := log.Len()
536 542 started := time.Now().UTC()
537 543 fmt.Fprintf(log, "\n--- %s\n", step.Name)
538 code, chunk, lost := s.execStep(job.ID, sandbox, step, lease)
539 log.WriteString(chunk)
544 code, lost := s.execStep(job.ID, sandbox, step, lease, log)
540 545 result := runs.StepResult{
541 546 Name: step.Name, Started: started, Finished: time.Now().UTC(), Exit: code,
542 LogOffset: logStart, LogSize: int64(log.Len()) - logStart,
547 LogOffset: logStart, LogSize: log.Len() - logStart,
543 548 }
544 549 if lost {
545 550 fmt.Fprintf(os.Stderr, "vole runner: lease lost while running %q; stopping\n", step.Name)
@@ -547,7 +552,7 @@ func (s *runnerSession) run(remote, cache string, job *runnerJob, lease time.Dur
547 552 }
548 553 if code != 0 {
549 554 fmt.Fprintf(log, "\nstep %q exited %d\n", step.Name, code)
550 result.LogSize = int64(log.Len()) - logStart
555 result.LogSize = log.Len() - logStart
551 556 result.Finished = time.Now().UTC()
552 557 steps = append(steps, result)
553 558 sandbox.Stop()
@@ -592,9 +597,13 @@ func (s *runnerSession) cacheFor(job *runnerJob) []container.Mount {
592 597 if s.cacheRoot == "" {
593 598 return nil
594 599 }
600 // A contributor branch is untrusted code, and a cache is shared mutable state:
601 // one contributor's branch must not be able to seed another's cache or the one a
602 // protected branch builds into. Namespacing by account means a contributor's
603 // cache follows them across their branches and nobody else can write it.
595 604 trust := "direct"
596 if _, contributor := access.ContributorRefName(job.Ref); contributor {
597 trust = "contrib"
605 if name, contributor := access.ContributorRefName(job.Ref); contributor {
606 trust = "contrib-" + name
598 607 }
599 608 ns := jobcache.Namespace(s.cacheRoot, job.Repo, trust, jobcache.ImageKey(job.ImageSHA, job.Image))
600 609 if err := os.MkdirAll(ns, 0o755); err != nil {
@@ -617,8 +626,8 @@ func (s *runnerSession) cacheFor(job *runnerJob) []container.Mount {
617 626 // A log that cannot be stored does not lose the result: the record then names no log
618 627 // and the failure is reported on stderr rather than swallowed. A record that cannot be
619 628 // pushed is not reported at all, because there is nothing for the server to read.
620 func (s *runnerSession) finish(remote string, co checkout, job *runnerJob, log *strings.Builder, exit int, steps []runs.StepResult, arts, releaseArts []runnerArtifact, box runnerBox) {
621 oid, size, err := uploadLog(remote, job.Repo, log.String())
629 func (s *runnerSession) finish(remote string, co checkout, job *runnerJob, log *spool, exit int, steps []runs.StepResult, arts, releaseArts []runnerArtifact, box runnerBox) {
630 oid, size, err := uploadLog(remote, job.Repo, log)
622 631 if err != nil {
623 632 fmt.Fprintf(os.Stderr, "vole runner: could not upload the log for %s: %v\n", job.ID, err)
624 633 }
@@ -682,7 +691,7 @@ func (s *runnerSession) finish(remote string, co checkout, job *runnerJob, log *
682 691 // commit, so a release cannot carry bytes whose build has no record and a run does not
683 692 // pay a push per output. A lost race against somebody else's issue is re-applied on
684 693 // the new tip, the same way every other metadata write is.
685 func publishRecord(remote string, co checkout, job *runnerJob, rec runs.Record, rel []runnerArtifact, log *strings.Builder) error {
694 func publishRecord(remote string, co checkout, job *runnerJob, rec runs.Record, rel []runnerArtifact, log io.Writer) error {
686 695 url, err := client.RepoURL(remote, job.Repo)
687 696 if err != nil {
688 697 return err
@@ -733,14 +742,18 @@ func checkReleaseJob(job *runnerJob) error {
733 742 return nil
734 743 }
735 744
736 // uploadLog puts the log's bytes in the instance's object store over the same SSH
737 // transfer the artifacts use, and returns the id the store computed. An empty log
738 // uploads nothing and reports no object, so a run with no output has no log rather
739 // than an empty file.
740 func uploadLog(remote, repo, text string) (oid string, size int64, err error) {
741 if text == "" {
745 // uploadLog puts the attempt's log in the instance's object store over the same
746 // SSH transfer the artifacts use, and returns the id the store computed. The spool
747 // is already a file, so it is sent as it is: no copy through memory and no second
748 // pass to hash it. An empty log uploads nothing and reports no object, so a run
749 // with no output has no log rather than an empty file.
750 func uploadLog(remote, repo string, log *spool) (oid string, size int64, err error) {
751 if log == nil || log.Len() == 0 {
742 752 return "", 0, nil
743 753 }
754 if err := log.seal(); err != nil {
755 return "", 0, err
756 }
744 757 url, err := client.RepoURL(remote, repo)
745 758 if err != nil {
746 759 return "", 0, err
@@ -750,21 +763,7 @@ func uploadLog(remote, repo, text string) (oid string, size int64, err error) {
750 763 return "", 0, err
751 764 }
752 765 defer tx.Close()
753
754 f, err := os.CreateTemp("", "vole-log-*")
755 if err != nil {
756 return "", 0, err
757 }
758 name := f.Name()
759 defer os.Remove(name)
760 if _, err := f.WriteString(text); err != nil {
761 f.Close()
762 return "", 0, err
763 }
764 if err := f.Close(); err != nil {
765 return "", 0, err
766 }
767 ptr, err := tx.Upload(name)
766 ptr, err := tx.Upload(log.Path())
768 767 if err != nil {
769 768 return "", 0, err
770 769 }
@@ -776,7 +775,7 @@ func uploadLog(remote, repo, text string) (oid string, size int64, err error) {
776 775 // in before the finish, so the server can refuse a claim it cannot serve.
777 776 // publishArtifacts uploads every declared output and returns them twice: once as
778 777 // the run's outputs, once as the subset that belongs to the tag's release.
779 func publishArtifacts(remote, work string, job *runnerJob, log *strings.Builder) (all, release []runnerArtifact, err error) {
778 func publishArtifacts(remote, work string, job *runnerJob, log io.Writer) (all, release []runnerArtifact, err error) {
780 779 if len(job.Artifacts) == 0 {
781 780 return nil, nil, nil
782 781 }
@@ -784,6 +783,9 @@ func publishArtifacts(remote, work string, job *runnerJob, log *strings.Builder)
784 783 if err != nil {
785 784 return nil, nil, err
786 785 }
786 if err := checkArtifactLimits(work, files); err != nil {
787 return nil, nil, err
788 }
787 789 isRelease := map[string]bool{}
788 790 for _, pat := range job.ReleaseArtifacts {
789 791 isRelease[pat] = true
@@ -821,7 +823,7 @@ func publishArtifacts(remote, work string, job *runnerJob, log *strings.Builder)
821 823 // newer commit has landed would quietly revert the site to an older build. Refusing
822 824 // is the honest outcome — the log says why, and the job fails instead of publishing
823 825 // a stale tree that nobody notices for a week.
824 func publishSite(remote string, co checkout, job *runnerJob, log *strings.Builder) error {
826 func publishSite(remote string, co checkout, job *runnerJob, log io.Writer) error {
825 827 if job.Pages == nil || job.Pages.Path == "" {
826 828 return nil
827 829 }
@@ -874,6 +876,31 @@ type artifactFile struct {
874 876 // nothing is an error rather than an empty artifact list: a build that stopped
875 877 // producing the file is the failure a declaration is supposed to catch, and a
876 878 // silent publish of nothing is how that goes unnoticed for a week.
879 // checkArtifactLimits bounds what one run may publish. The workflow caps the number
880 // of declarations; a declaration is a pattern, and a pattern can match a directory
881 // tree, so the bound on what they expand to has to be here. An artifact set is
882 // uploaded into the instance's object store, and a job choosing its size is a job
883 // choosing the instance's disk.
884 func checkArtifactLimits(work string, files []artifactFile) error {
885 if len(files) > MaxRunArtifactFiles {
886 return fmt.Errorf("this run publishes %d artifact files, limit %d; narrow the patterns",
887 len(files), MaxRunArtifactFiles)
888 }
889 var total int64
890 for _, f := range files {
891 info, err := os.Stat(filepath.Join(work, filepath.FromSlash(f.Path)))
892 if err != nil {
893 continue
894 }
895 total += info.Size()
896 }
897 if total > MaxRunArtifactBytes {
898 return fmt.Errorf("this run publishes %s of artifacts, limit %s; narrow the patterns",
899 format.Bytes(total), format.Bytes(MaxRunArtifactBytes))
900 }
901 return nil
902 }
903
877 904 func collectArtifactFiles(root string, patterns []string) ([]artifactFile, error) {
878 905 matched := make([]int, len(patterns))
879 906 var files []artifactFile
@@ -928,30 +955,9 @@ func collectArtifactFiles(root string, patterns []string) ([]artifactFile, error
928 955 return files, nil
929 956 }
930 957
931 // logBuffer collects a step's output. It is a sync buffer because os/exec copies
932 // stdout and stderr in separate goroutines. Nothing is shipped while the step runs:
933 // the whole attempt's log is uploaded once, when the attempt is over, and the step
934 // records byte ranges into it rather than uploading a second object.
935 type logBuffer struct {
936 mu sync.Mutex
937 buf strings.Builder
938 }
939
940 func (w *logBuffer) Write(p []byte) (int, error) {
941 w.mu.Lock()
942 defer w.mu.Unlock()
943 return w.buf.Write(p)
944 }
945
946 func (w *logBuffer) String() string {
947 w.mu.Lock()
948 defer w.mu.Unlock()
949 return w.buf.String()
950 }
951
952 958 // execStep runs one shell step, heartbeating while it works. It returns the exit
953 959 // code, the output produced, and whether the lease was lost.
954 func (s *runnerSession) execStep(runID string, sandbox *container.Container, step runnerStep, lease time.Duration) (int, string, bool) {
960 func (s *runnerSession) execStep(runID string, sandbox *container.Container, step runnerStep, lease time.Duration, out io.Writer) (int, bool) {
955 961 timeout := 10 * time.Minute
956 962 if d, err := time.ParseDuration(step.Timeout); err == nil && d > 0 {
957 963 timeout = d
@@ -960,26 +966,27 @@ func (s *runnerSession) execStep(runID string, sandbox *container.Container, ste
960 966 defer cancel()
961 967
962 968 cmd := sandbox.Exec(ctx, step.Run)
963 code, chunk, lost := s.runCommand(ctx, cmd, runID, lease, sandbox.Stop)
969 code, lost := s.runCommand(ctx, cmd, runID, lease, sandbox.Stop, out)
964 970 if ctx.Err() == context.DeadlineExceeded {
965 971 // The heartbeat will have extended the lease this far; report the timeout as
966 972 // a failure rather than leaving the job hanging.
967 return 1, chunk + fmt.Sprintf("\nstep timed out after %s\n", timeout), lost
973 fmt.Fprintf(out, "\nstep timed out after %s\n", timeout)
974 return 1, lost
968 975 }
969 return code, chunk, lost
976 return code, lost
970 977 }
971 978
972 979 // runCommand starts one host-side command and heartbeats while it runs. It is used
973 980 // for docker exec and for building a custom image; onTimeout is what closes the
974 981 // sandbox when the command runs out of time.
975 func (s *runnerSession) runCommand(ctx context.Context, cmd *exec.Cmd, runID string, lease time.Duration, onTimeout func()) (int, string, bool) {
982 func (s *runnerSession) runCommand(ctx context.Context, cmd *exec.Cmd, runID string, lease time.Duration, onTimeout func(), out io.Writer) (int, bool) {
976 983 // Own process group: a command that spawns children must not outlive its timeout.
977 984 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
978 w := &logBuffer{}
979 cmd.Stdout, cmd.Stderr = w, w
985 cmd.Stdout, cmd.Stderr = out, out
980 986
981 987 if err := cmd.Start(); err != nil {
982 return 1, fmt.Sprintf("cannot start: %v\n", err), false
988 fmt.Fprintf(out, "cannot start: %v\n", err)
989 return 1, false
983 990 }
984 991 done := make(chan error, 1)
985 992 go func() { done <- cmd.Wait() }()
@@ -994,15 +1001,15 @@ func (s *runnerSession) runCommand(ctx context.Context, cmd *exec.Cmd, runID str
994 1001 for {
995 1002 select {
996 1003 case err := <-done:
997 chunk := w.String()
998 1004 if err == nil {
999 return 0, chunk, false
1005 return 0, false
1000 1006 }
1001 1007 var ee *exec.ExitError
1002 1008 if errors.As(err, &ee) {
1003 return ee.ExitCode(), chunk, false
1009 return ee.ExitCode(), false
1004 1010 }
1005 return 1, chunk + fmt.Sprintf("\n%v\n", err), false
1011 fmt.Fprintf(out, "\n%v\n", err)
1012 return 1, false
1006 1013 case <-ctx.Done():
1007 1014 if onTimeout != nil {
1008 1015 onTimeout()
@@ -1010,14 +1017,14 @@ func (s *runnerSession) runCommand(ctx context.Context, cmd *exec.Cmd, runID str
1010 1017 if cmd.Process != nil {
1011 1018 _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
1012 1019 }
1013 // Wait for the reader goroutine before returning the output.
1020 // Wait for the copy goroutines before the caller closes the log.
1014 1021 <-done
1015 return 1, w.String(), false
1022 return 1, false
1016 1023 case <-tick.C:
1017 1024 // The lease is the only thing a heartbeat carries.
1018 1025 rep, err := s.call(runnerRequest{Op: "heartbeat", Run: runID})
1019 1026 if err != nil || !rep.OK {
1020 return 1, "", true
1027 return 1, true
1021 1028 }
1022 1029 }
1023 1030 }