cmd/gitbay-runner/main.go

206 lines · 6243 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}
 45
 46func main() {
 47	var (
 48		remote    = flag.String("remote", "git@gitbay.org", "ssh destination of the gitbay server")
 49		sshOpts   = flag.String("ssh-opts", "", "extra ssh options, space-separated (also used for git clone)")
 50		cloneBase = flag.String("clone-base", "", "clone URL prefix (default ssh://<remote>)")
 51		workdir   = flag.String("workdir", filepath.Join(os.TempDir(), "gitbay-runner"), "build workspace root")
 52		poll      = flag.Duration("poll", 5*time.Second, "idle poll interval")
 53		timeout   = flag.Duration("timeout", 30*time.Minute, "per-build time limit")
 54		once      = flag.Bool("once", false, "process at most one build, then exit")
 55		version   = flag.Bool("version", false, "print the commit this binary was built from, then exit")
 56	)
 57	flag.Parse()
 58	if *version {
 59		fmt.Println(buildinfo.String())
 60		return
 61	}
 62	// The runner links internal/store, so it goes stale on changes that never
 63	// touch cmd/gitbay-runner. Say which commit is running.
 64	log.Printf("gitbay-runner %s", buildinfo.String())
 65	r := &runner{
 66		remote:    *remote,
 67		cloneBase: *cloneBase,
 68		workdir:   *workdir,
 69		timeout:   *timeout,
 70	}
 71	if *sshOpts != "" {
 72		r.sshOpts = strings.Fields(*sshOpts)
 73	}
 74	if r.cloneBase == "" {
 75		r.cloneBase = "ssh://" + *remote
 76	}
 77	if err := os.MkdirAll(r.workdir, 0o755); err != nil {
 78		log.Fatal(err)
 79	}
 80	for {
 81		ran, err := r.step()
 82		if err != nil {
 83			log.Printf("runner: %v", err)
 84		}
 85		if *once {
 86			return
 87		}
 88		if !ran {
 89			time.Sleep(*poll)
 90		}
 91	}
 92}
 93
 94// step claims and executes at most one build. ran reports whether there was
 95// one, so the caller knows when to idle.
 96func (r *runner) step() (bool, error) {
 97	out, err := r.ssh(nil, "runner", "next", "--json")
 98	if err != nil {
 99		return false, fmt.Errorf("claiming build: %w (%s)", err, out)
100	}
101	var env struct {
102		Data job `json:"data"`
103	}
104	if err := json.Unmarshal([]byte(out), &env); err != nil {
105		return false, fmt.Errorf("parsing job: %w", err)
106	}
107	if env.Data.ID == 0 {
108		return false, nil
109	}
110	j := env.Data
111	log.Printf("build %d: %s %s @ %.10s", j.ID, j.Repo, j.Job, j.SHA)
112	status := "failure"
113	if r.run(j) {
114		status = "success"
115	}
116	if out, err := r.ssh(nil, "runner", "done", fmt.Sprint(j.ID), status); err != nil {
117		return true, fmt.Errorf("reporting build %d: %w (%s)", j.ID, err, out)
118	}
119	log.Printf("build %d: %s", j.ID, status)
120	return true, nil
121}
122
123// run clones, checks out, and executes the steps, streaming output to the
124// server. Returns whether every step succeeded.
125func (r *runner) run(j job) bool {
126	dir := filepath.Join(r.workdir, fmt.Sprintf("build-%d", j.ID))
127	defer os.RemoveAll(dir)
128
129	// One long-lived `runner log` session receives the whole stream.
130	logCmd := exec.Command("ssh", append(r.sshOpts, r.remote, "runner", "log", fmt.Sprint(j.ID))...)
131	sink, err := logCmd.StdinPipe()
132	if err != nil {
133		log.Printf("build %d: log pipe: %v", j.ID, err)
134		return false
135	}
136	logCmd.Stdout, logCmd.Stderr = io.Discard, io.Discard
137	if err := logCmd.Start(); err != nil {
138		log.Printf("build %d: log stream: %v", j.ID, err)
139		return false
140	}
141	defer func() {
142		sink.Close()
143		logCmd.Wait()
144	}()
145
146	gitSSH := strings.TrimSpace("ssh " + strings.Join(r.sshOpts, " "))
147	cloneURL := r.cloneBase + "/" + j.Repo + ".git"
148	fmt.Fprintf(sink, "$ git clone %s (%.10s)\n", cloneURL, j.SHA)
149	for _, args := range [][]string{
150		{"clone", "-q", cloneURL, dir},
151		{"-C", dir, "checkout", "-q", j.SHA},
152	} {
153		cmd := exec.Command("git", args...)
154		cmd.Env = append(os.Environ(), "GIT_SSH_COMMAND="+gitSSH, "GIT_TERMINAL_PROMPT=0")
155		cmd.Stdout, cmd.Stderr = sink, sink
156		if err := cmd.Run(); err != nil {
157			fmt.Fprintf(sink, "git %s: %v\n", args[0], err)
158			return false
159		}
160	}
161
162	deadline := time.Now().Add(r.timeout)
163	for _, step := range j.Steps {
164		fmt.Fprintf(sink, "$ %s\n", step)
165		cmd := exec.Command("sh", "-c", step)
166		cmd.Dir = dir
167		cmd.Env = append(os.Environ(),
168			"GITBAY_REPO="+j.Repo, "GITBAY_SHA="+j.SHA, "GITBAY_REF="+j.Ref, "GITBAY_JOB="+j.Job, "CI=true")
169		for name, value := range j.Secrets {
170			cmd.Env = append(cmd.Env, name+"="+value)
171		}
172		cmd.Stdout, cmd.Stderr = sink, sink
173		if err := cmd.Start(); err != nil {
174			fmt.Fprintf(sink, "start: %v\n", err)
175			return false
176		}
177		done := make(chan error, 1)
178		go func() { done <- cmd.Wait() }()
179		select {
180		case err := <-done:
181			if err != nil {
182				fmt.Fprintf(sink, "step failed: %v\n", err)
183				return false
184			}
185		case <-time.After(time.Until(deadline)):
186			cmd.Process.Kill()
187			fmt.Fprintf(sink, "build timed out after %s\n", r.timeout)
188			return false
189		}
190	}
191	return true
192}
193
194// ssh runs one control command against the server and returns stdout.
195func (r *runner) ssh(stdin io.Reader, args ...string) (string, error) {
196	cmd := exec.Command("ssh", append(append(r.sshOpts, r.remote), args...)...)
197	if stdin != nil {
198		cmd.Stdin = stdin
199	}
200	var out, errOut strings.Builder
201	cmd.Stdout, cmd.Stderr = &out, &errOut
202	if err := cmd.Run(); err != nil {
203		return out.String() + errOut.String(), err
204	}
205	return out.String(), nil
206}