package main import ( "bytes" "context" "encoding/json" "errors" "flag" "fmt" "net/http" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "time" ) // cmdRunner long-polls outward, needing no inbound port, which is what makes pasting it work. func cmdRunner(ctx context.Context, args []string) error { if len(args) == 0 { return errors.New("usage: forge runner [--labels a,b]") } tok := args[0] fs := flag.NewFlagSet("runner", flag.ContinueOnError) server := fs.String("server", "", "the forge to attach to") labels := fs.String("labels", "", "what this machine can build, comma separated") workdir := fs.String("workdir", "", "where to clone, defaults to a temporary directory") if err := fs.Parse(args[1:]); err != nil { return err } base := strings.TrimRight(*server, "/") if base == "" { base = os.Getenv("BAREREPO_SERVER") } if base == "" { // Appendix E's line has no flag, which works because the last attach is remembered. base = recallServer(tok) } if base == "" { return errors.New("this token has not attached anywhere yet.\n" + "paste the line from the add-a-runner page, or pass --server https://barerepo.example") } host, _ := os.Hostname() work := *workdir if work == "" { var err error if work, err = os.MkdirTemp("", "barerepo-runner-"); err != nil { return err } defer os.RemoveAll(work) } r := &runner{base: base, token: tok, work: work, labels: splitCSV(*labels), client: &http.Client{Timeout: 2 * time.Minute}} if err := r.attach(ctx, host); err != nil { return err } // Remembered after it worked, so a wrong address is never the one recalled. rememberServer(tok, base) fmt.Printf("attached as %s. waiting for work.\n", host) for { if err := r.once(ctx); err != nil { if ctx.Err() != nil { return nil } fmt.Fprintln(os.Stderr, "forge: "+err.Error()) time.Sleep(5 * time.Second) } } } type runner struct { base string token string work string labels []string id int64 client *http.Client } func (r *runner) attach(ctx context.Context, hostname string) error { var out struct { RunnerID int64 `json:"runner_id"` PollInterval int `json:"poll_interval"` } err := r.post(ctx, "/runner/attach", map[string]any{ "token": r.token, "hostname": hostname, "os": runtime.GOOS, "arch": runtime.GOARCH, "labels": r.labels, }, &out) if err != nil { return err } r.id = out.RunnerID return nil } // once polls, and builds whatever comes back. func (r *runner) once(ctx context.Context) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/runner/poll?id=%d&token=%s", r.base, r.id, r.token), nil) if err != nil { return err } resp, err := r.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode == http.StatusNoContent { return nil } if resp.StatusCode != http.StatusOK { return fmt.Errorf("poll: %s", resp.Status) } var job jobOffer if err := json.NewDecoder(resp.Body).Decode(&job); err != nil { return err } fmt.Printf("building %s at %s\n", job.Repo, short(job.SHA)) started := time.Now() exit, output := r.build(ctx, job) if err := r.post(ctx, "/runner/log", map[string]any{ "token": r.token, "job_id": job.JobID, "seq": 0, "chunk": output, }, nil); err != nil { return err } return r.post(ctx, "/runner/done", map[string]any{ "token": r.token, "job_id": job.JobID, "exit_code": exit, "duration": int(time.Since(started).Seconds()), }, nil) } // jobOffer is what a poll returns, which is everything a build needs and nothing else. type jobOffer struct { JobID int64 `json:"job_id"` Repo string `json:"repo"` Ref string `json:"ref"` SHA string `json:"sha"` Command string `json:"command"` Image string `json:"image"` CloneURL string `json:"clone_url"` JobToken string `json:"job_token"` } // build clones at the commit and captures the whole output, which chapter 16 puts on one page. func (r *runner) build(ctx context.Context, job jobOffer) (int, string) { cloneURL, jobToken, sha, command := job.CloneURL, job.JobToken, job.SHA, job.Command dir := filepath.Join(r.work, short(sha)) os.RemoveAll(dir) if err := os.MkdirAll(dir, 0o750); err != nil { return 1, err.Error() } var log bytes.Buffer withAuth := strings.Replace(cloneURL, "://", "://x:"+jobToken+"@", 1) // Every step runs in the clone, so the log shows the command and not a temporary path. steps := [][]string{ {"git", "init", "-q"}, {"git", "fetch", "-q", "--depth", "1", withAuth, sha}, {"git", "checkout", "-q", "FETCH_HEAD"}, } for _, step := range steps { shown := strings.Join(step, " ") fmt.Fprintf(&log, "$ %s\n", strings.ReplaceAll(shown, jobToken, "")) cmd := exec.CommandContext(ctx, step[0], step[1:]...) cmd.Dir = dir cmd.Stdout = &log cmd.Stderr = &log if err := cmd.Run(); err != nil { fmt.Fprintf(&log, "%v\n", err) return 1, log.String() } } fmt.Fprintf(&log, "$ %s\n", command) cmd := exec.CommandContext(ctx, "sh", "-c", command) cmd.Dir = dir cmd.Stdout = &log cmd.Stderr = &log // A build attaches a release file by speaking the same plain http the runner does. Chapter 22.5. cmd.Env = append(os.Environ(), "BAREREPO_URL="+r.base, "BAREREPO_REPO="+job.Repo, "BAREREPO_JOB="+strconv.FormatInt(job.JobID, 10), "BAREREPO_JOB_TOKEN="+jobToken, ) err := cmd.Run() code := 0 if err != nil { code = 1 var ee *exec.ExitError if errors.As(err, &ee) { code = ee.ExitCode() } fmt.Fprintf(&log, "$ exit %d\n", code) } return code, log.String() } func (r *runner) post(ctx context.Context, path string, body any, out any) error { buf, err := json.Marshal(body) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.base+path, bytes.NewReader(buf)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") resp, err := r.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 400 { msg, _ := readAll(resp.Body, 1<<12) return fmt.Errorf("%s: %s: %s", path, resp.Status, strings.TrimSpace(msg)) } if out != nil { return json.NewDecoder(resp.Body).Decode(out) } return nil } func splitCSV(s string) []string { var out []string for _, part := range strings.Split(s, ",") { if part = strings.TrimSpace(part); part != "" { out = append(out, part) } } return out } func short(sha string) string { if len(sha) > 7 { return sha[:7] } return sha } func readAll(r interface{ Read([]byte) (int, error) }, limit int) (string, error) { buf := make([]byte, limit) n, _ := r.Read(buf) return string(buf[:n]), nil }