cmd/gitbay-runner/main.go
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}