@@ -0,0 +1,250 @@
1+ package main
2+
3+ import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "flag"
9+ "fmt"
10+ "net/http"
11+ "os"
12+ "os/exec"
13+ "path/filepath"
14+ "runtime"
15+ "strconv"
16+ "strings"
17+ "time"
18+ )
19+
20+ // cmdRunner long-polls outward, needing no inbound port, which is what makes pasting it work.
21+ func cmdRunner(ctx context.Context, args []string) error {
22+ if len(args) == 0 {
23+ return errors.New("usage: forge runner <token> [--labels a,b]")
24+ }
25+ tok := args[0]
26+ fs := flag.NewFlagSet("runner", flag.ContinueOnError)
27+ server := fs.String("server", "", "the forge to attach to")
28+ labels := fs.String("labels", "", "what this machine can build, comma separated")
29+ workdir := fs.String("workdir", "", "where to clone, defaults to a temporary directory")
30+ if err := fs.Parse(args[1:]); err != nil {
31+ return err
32+ }
33+ base := strings.TrimRight(*server, "/")
34+ if base == "" {
35+ base = os.Getenv("BAREREPO_SERVER")
36+ }
37+ if base == "" {
38+ // Appendix E's line has no flag, which works because the last attach is remembered.
39+ base = recallServer(tok)
40+ }
41+ if base == "" {
42+ return errors.New("this token has not attached anywhere yet.\n" +
43+ "paste the line from the add-a-runner page, or pass --server https://barerepo.example")
44+ }
45+ host, _ := os.Hostname()
46+ work := *workdir
47+ if work == "" {
48+ var err error
49+ if work, err = os.MkdirTemp("", "barerepo-runner-"); err != nil {
50+ return err
51+ }
52+ defer os.RemoveAll(work)
53+ }
54+
55+ r := &runner{base: base, token: tok, work: work,
56+ labels: splitCSV(*labels), client: &http.Client{Timeout: 2 * time.Minute}}
57+ if err := r.attach(ctx, host); err != nil {
58+ return err
59+ }
60+ // Remembered after it worked, so a wrong address is never the one recalled.
61+ rememberServer(tok, base)
62+ fmt.Printf("attached as %s. waiting for work.\n", host)
63+ for {
64+ if err := r.once(ctx); err != nil {
65+ if ctx.Err() != nil {
66+ return nil
67+ }
68+ fmt.Fprintln(os.Stderr, "forge: "+err.Error())
69+ time.Sleep(5 * time.Second)
70+ }
71+ }
72+ }
73+
74+ type runner struct {
75+ base string
76+ token string
77+ work string
78+ labels []string
79+ id int64
80+ client *http.Client
81+ }
82+
83+ func (r *runner) attach(ctx context.Context, hostname string) error {
84+ var out struct {
85+ RunnerID int64 `json:"runner_id"`
86+ PollInterval int `json:"poll_interval"`
87+ }
88+ err := r.post(ctx, "/runner/attach", map[string]any{
89+ "token": r.token, "hostname": hostname,
90+ "os": runtime.GOOS, "arch": runtime.GOARCH, "labels": r.labels,
91+ }, &out)
92+ if err != nil {
93+ return err
94+ }
95+ r.id = out.RunnerID
96+ return nil
97+ }
98+
99+ // once polls, and builds whatever comes back.
100+ func (r *runner) once(ctx context.Context) error {
101+ req, err := http.NewRequestWithContext(ctx, http.MethodGet,
102+ fmt.Sprintf("%s/runner/poll?id=%d&token=%s", r.base, r.id, r.token), nil)
103+ if err != nil {
104+ return err
105+ }
106+ resp, err := r.client.Do(req)
107+ if err != nil {
108+ return err
109+ }
110+ defer resp.Body.Close()
111+ if resp.StatusCode == http.StatusNoContent {
112+ return nil
113+ }
114+ if resp.StatusCode != http.StatusOK {
115+ return fmt.Errorf("poll: %s", resp.Status)
116+ }
117+ var job jobOffer
118+ if err := json.NewDecoder(resp.Body).Decode(&job); err != nil {
119+ return err
120+ }
121+ fmt.Printf("building %s at %s\n", job.Repo, short(job.SHA))
122+
123+ started := time.Now()
124+ exit, output := r.build(ctx, job)
125+ if err := r.post(ctx, "/runner/log", map[string]any{
126+ "token": r.token, "job_id": job.JobID, "seq": 0, "chunk": output,
127+ }, nil); err != nil {
128+ return err
129+ }
130+ return r.post(ctx, "/runner/done", map[string]any{
131+ "token": r.token, "job_id": job.JobID,
132+ "exit_code": exit, "duration": int(time.Since(started).Seconds()),
133+ }, nil)
134+ }
135+
136+ // jobOffer is what a poll returns, which is everything a build needs and nothing else.
137+ type jobOffer struct {
138+ JobID int64 `json:"job_id"`
139+ Repo string `json:"repo"`
140+ Ref string `json:"ref"`
141+ SHA string `json:"sha"`
142+ Command string `json:"command"`
143+ Image string `json:"image"`
144+ CloneURL string `json:"clone_url"`
145+ JobToken string `json:"job_token"`
146+ }
147+
148+ // build clones at the commit and captures the whole output, which chapter 16 puts on one page.
149+ func (r *runner) build(ctx context.Context, job jobOffer) (int, string) {
150+ cloneURL, jobToken, sha, command := job.CloneURL, job.JobToken, job.SHA, job.Command
151+ dir := filepath.Join(r.work, short(sha))
152+ os.RemoveAll(dir)
153+
154+ if err := os.MkdirAll(dir, 0o750); err != nil {
155+ return 1, err.Error()
156+ }
157+
158+ var log bytes.Buffer
159+ withAuth := strings.Replace(cloneURL, "://", "://x:"+jobToken+"@", 1)
160+ // Every step runs in the clone, so the log shows the command and not a temporary path.
161+ steps := [][]string{
162+ {"git", "init", "-q"},
163+ {"git", "fetch", "-q", "--depth", "1", withAuth, sha},
164+ {"git", "checkout", "-q", "FETCH_HEAD"},
165+ }
166+ for _, step := range steps {
167+ shown := strings.Join(step, " ")
168+ fmt.Fprintf(&log, "$ %s\n", strings.ReplaceAll(shown, jobToken, "<token>"))
169+ cmd := exec.CommandContext(ctx, step[0], step[1:]...)
170+ cmd.Dir = dir
171+ cmd.Stdout = &log
172+ cmd.Stderr = &log
173+ if err := cmd.Run(); err != nil {
174+ fmt.Fprintf(&log, "%v\n", err)
175+ return 1, log.String()
176+ }
177+ }
178+
179+ fmt.Fprintf(&log, "$ %s\n", command)
180+ cmd := exec.CommandContext(ctx, "sh", "-c", command)
181+ cmd.Dir = dir
182+ cmd.Stdout = &log
183+ cmd.Stderr = &log
184+ // A build attaches a release file by speaking the same plain http the runner does. Chapter 22.5.
185+ cmd.Env = append(os.Environ(),
186+ "BAREREPO_URL="+r.base,
187+ "BAREREPO_REPO="+job.Repo,
188+ "BAREREPO_JOB="+strconv.FormatInt(job.JobID, 10),
189+ "BAREREPO_JOB_TOKEN="+jobToken,
190+ )
191+ err := cmd.Run()
192+ code := 0
193+ if err != nil {
194+ code = 1
195+ var ee *exec.ExitError
196+ if errors.As(err, &ee) {
197+ code = ee.ExitCode()
198+ }
199+ fmt.Fprintf(&log, "$ exit %d\n", code)
200+ }
201+ return code, log.String()
202+ }
203+
204+ func (r *runner) post(ctx context.Context, path string, body any, out any) error {
205+ buf, err := json.Marshal(body)
206+ if err != nil {
207+ return err
208+ }
209+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.base+path, bytes.NewReader(buf))
210+ if err != nil {
211+ return err
212+ }
213+ req.Header.Set("Content-Type", "application/json")
214+ resp, err := r.client.Do(req)
215+ if err != nil {
216+ return err
217+ }
218+ defer resp.Body.Close()
219+ if resp.StatusCode >= 400 {
220+ msg, _ := readAll(resp.Body, 1<<12)
221+ return fmt.Errorf("%s: %s: %s", path, resp.Status, strings.TrimSpace(msg))
222+ }
223+ if out != nil {
224+ return json.NewDecoder(resp.Body).Decode(out)
225+ }
226+ return nil
227+ }
228+
229+ func splitCSV(s string) []string {
230+ var out []string
231+ for _, part := range strings.Split(s, ",") {
232+ if part = strings.TrimSpace(part); part != "" {
233+ out = append(out, part)
234+ }
235+ }
236+ return out
237+ }
238+
239+ func short(sha string) string {
240+ if len(sha) > 7 {
241+ return sha[:7]
242+ }
243+ return sha
244+ }
245+
246+ func readAll(r interface{ Read([]byte) (int, error) }, limit int) (string, error) {
247+ buf := make([]byte, limit)
248+ n, _ := r.Read(buf)
249+ return string(buf[:n]), nil
250+ }