cmd/gitbay-runner/main.go

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

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