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