cmd/gitbay-runner/main.go

c7cebb36f27f7d136074c5392f87231786a06d56
gitbay/cmd/gitbay-runner/main.go history · blame · raw

216 lines · 6792 bytes

  1// gitbay-runner executes CI builds queued by a gitbay server. It polls over
  2// SSH — the same authenticated channel everything else uses — claims one
  3// build at a time, clones the repo, runs each step with `sh -c`, streams the
  4// combined output back, and reports success or failure.
  5//
  6// The account behind the runner's key must be an instance admin: a runner
  7// executes arbitrary repo code, so handing out jobs is the operator's call.
  8// v1 runs steps directly on the host under this process's user; run it as a
  9// dedicated unprivileged user.
 10package main
 11
 12import (
 13	"encoding/json"
 14	"flag"
 15	"fmt"
 16	"io"
 17	"log"
 18	"os"
 19	"os/exec"
 20	"path/filepath"
 21	"strings"
 22	"time"
 23
 24	"gitbay.org/gitbay/internal/buildinfo"
 25)
 26
 27type job struct {
 28	ID      int64             `json:"id"`
 29	Repo    string            `json:"repo"`
 30	Number  int64             `json:"number"`
 31	Job     string            `json:"job"`
 32	SHA     string            `json:"sha"`
 33	Ref     string            `json:"ref"`
 34	Steps   []string          `json:"steps"`
 35	Secrets map[string]string `json:"secrets"`
 36}
 37
 38type runner struct {
 39	remote    string // ssh destination, e.g. git@gitbay.org
 40	sshOpts   []string
 41	cloneBase string // e.g. ssh://git@gitbay.org
 42	workdir   string
 43	timeout   time.Duration
 44	// repos limits which repositories this runner claims builds for. Empty
 45	// means any, which is what a runner on the server itself wants; a runner
 46	// somewhere that should not execute every repository's steps names them.
 47	repos []string
 48}
 49
 50func main() {
 51	var (
 52		remote    = flag.String("remote", "git@gitbay.org", "ssh destination of the gitbay server")
 53		sshOpts   = flag.String("ssh-opts", "", "extra ssh options, space-separated (also used for git clone)")
 54		cloneBase = flag.String("clone-base", "", "clone URL prefix (default ssh://<remote>)")
 55		workdir   = flag.String("workdir", filepath.Join(os.TempDir(), "gitbay-runner"), "build workspace root")
 56		poll      = flag.Duration("poll", 5*time.Second, "idle poll interval")
 57		timeout   = flag.Duration("timeout", 30*time.Minute, "per-build time limit")
 58		repos     = flag.String("repos", "", "only claim builds for these repositories, comma-separated owner/name (default: any)")
 59		once      = flag.Bool("once", false, "process at most one build, then exit")
 60		version   = flag.Bool("version", false, "print the commit this binary was built from, then exit")
 61	)
 62	flag.Parse()
 63	if *version {
 64		fmt.Println(buildinfo.String())
 65		return
 66	}
 67	// The runner links internal/store, so it goes stale on changes that never
 68	// touch cmd/gitbay-runner. Say which commit is running.
 69	log.Printf("gitbay-runner %s", buildinfo.String())
 70	r := &runner{
 71		remote:    *remote,
 72		cloneBase: *cloneBase,
 73		workdir:   *workdir,
 74		timeout:   *timeout,
 75	}
 76	if *sshOpts != "" {
 77		r.sshOpts = strings.Fields(*sshOpts)
 78	}
 79	for _, name := range strings.Split(*repos, ",") {
 80		if name = strings.TrimSpace(name); name != "" {
 81			r.repos = append(r.repos, name)
 82		}
 83	}
 84	if r.cloneBase == "" {
 85		r.cloneBase = "ssh://" + *remote
 86	}
 87	if err := os.MkdirAll(r.workdir, 0o755); err != nil {
 88		log.Fatal(err)
 89	}
 90	for {
 91		ran, err := r.step()
 92		if err != nil {
 93			log.Printf("runner: %v", err)
 94		}
 95		if *once {
 96			return
 97		}
 98		if !ran {
 99			time.Sleep(*poll)
100		}
101	}
102}
103
104// step claims and executes at most one build. ran reports whether there was
105// one, so the caller knows when to idle.
106func (r *runner) step() (bool, error) {
107	out, err := r.ssh(nil, append([]string{"runner", "next"}, append(r.repos, "--json")...)...)
108	if err != nil {
109		return false, fmt.Errorf("claiming build: %w (%s)", err, out)
110	}
111	var env struct {
112		Data job `json:"data"`
113	}
114	if err := json.Unmarshal([]byte(out), &env); err != nil {
115		return false, fmt.Errorf("parsing job: %w", err)
116	}
117	if env.Data.ID == 0 {
118		return false, nil
119	}
120	j := env.Data
121	log.Printf("build %d: %s %s @ %.10s", j.ID, j.Repo, j.Job, j.SHA)
122	status := "failure"
123	if r.run(j) {
124		status = "success"
125	}
126	if out, err := r.ssh(nil, "runner", "done", fmt.Sprint(j.ID), status); err != nil {
127		return true, fmt.Errorf("reporting build %d: %w (%s)", j.ID, err, out)
128	}
129	log.Printf("build %d: %s", j.ID, status)
130	return true, nil
131}
132
133// run clones, checks out, and executes the steps, streaming output to the
134// server. Returns whether every step succeeded.
135func (r *runner) run(j job) bool {
136	dir := filepath.Join(r.workdir, fmt.Sprintf("build-%d", j.ID))
137	defer os.RemoveAll(dir)
138
139	// One long-lived `runner log` session receives the whole stream.
140	logCmd := exec.Command("ssh", append(r.sshOpts, r.remote, "runner", "log", fmt.Sprint(j.ID))...)
141	sink, err := logCmd.StdinPipe()
142	if err != nil {
143		log.Printf("build %d: log pipe: %v", j.ID, err)
144		return false
145	}
146	logCmd.Stdout, logCmd.Stderr = io.Discard, io.Discard
147	if err := logCmd.Start(); err != nil {
148		log.Printf("build %d: log stream: %v", j.ID, err)
149		return false
150	}
151	defer func() {
152		sink.Close()
153		logCmd.Wait()
154	}()
155
156	gitSSH := strings.TrimSpace("ssh " + strings.Join(r.sshOpts, " "))
157	cloneURL := r.cloneBase + "/" + j.Repo + ".git"
158	fmt.Fprintf(sink, "$ git clone %s (%.10s)\n", cloneURL, j.SHA)
159	for _, args := range [][]string{
160		{"clone", "-q", cloneURL, dir},
161		{"-C", dir, "checkout", "-q", j.SHA},
162	} {
163		cmd := exec.Command("git", args...)
164		cmd.Env = append(os.Environ(), "GIT_SSH_COMMAND="+gitSSH, "GIT_TERMINAL_PROMPT=0")
165		cmd.Stdout, cmd.Stderr = sink, sink
166		if err := cmd.Run(); err != nil {
167			fmt.Fprintf(sink, "git %s: %v\n", args[0], err)
168			return false
169		}
170	}
171
172	deadline := time.Now().Add(r.timeout)
173	for _, step := range j.Steps {
174		fmt.Fprintf(sink, "$ %s\n", step)
175		cmd := exec.Command("sh", "-c", step)
176		cmd.Dir = dir
177		cmd.Env = append(os.Environ(),
178			"GITBAY_REPO="+j.Repo, "GITBAY_SHA="+j.SHA, "GITBAY_REF="+j.Ref, "GITBAY_JOB="+j.Job, "CI=true")
179		for name, value := range j.Secrets {
180			cmd.Env = append(cmd.Env, name+"="+value)
181		}
182		cmd.Stdout, cmd.Stderr = sink, sink
183		if err := cmd.Start(); err != nil {
184			fmt.Fprintf(sink, "start: %v\n", err)
185			return false
186		}
187		done := make(chan error, 1)
188		go func() { done <- cmd.Wait() }()
189		select {
190		case err := <-done:
191			if err != nil {
192				fmt.Fprintf(sink, "step failed: %v\n", err)
193				return false
194			}
195		case <-time.After(time.Until(deadline)):
196			cmd.Process.Kill()
197			fmt.Fprintf(sink, "build timed out after %s\n", r.timeout)
198			return false
199		}
200	}
201	return true
202}
203
204// ssh runs one control command against the server and returns stdout.
205func (r *runner) ssh(stdin io.Reader, args ...string) (string, error) {
206	cmd := exec.Command("ssh", append(append(r.sshOpts, r.remote), args...)...)
207	if stdin != nil {
208		cmd.Stdin = stdin
209	}
210	var out, errOut strings.Builder
211	cmd.Stdout, cmd.Stderr = &out, &errOut
212	if err := cmd.Run(); err != nil {
213		return out.String() + errOut.String(), err
214	}
215	return out.String(), nil
216}