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}