Single commit page showing message, metadata and full diff.

barerepo / runner / 96966ee
barerepo · 1mo · 11 files · +618 -0 · signed
barerepo
@@ -0,0 +1,7 @@
1+ [repo]
2+ visibility = "public"
3+ description = "the build machine agent"
4+
5+ [access]
6+ allow_force_push = ["master"]
7+ require_signed_commits = true
@@ -0,0 +1,4 @@
1+ /barerepo-runner
2+ .DS_Store
3+ go.work
4+ go.work.sum
@@ -0,0 +1,21 @@
1+ MIT License
2+
3+ Copyright (c) 2026 BareRepo
4+
5+ Permission is hereby granted, free of charge, to any person obtaining a copy
6+ of this software and associated documentation files (the "Software"), to deal
7+ in the Software without restriction, including without limitation the rights
8+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+ copies of the Software, and to permit persons to whom the Software is
10+ furnished to do so, subject to the following conditions:
11+
12+ The above copyright notice and this permission notice shall be included in all
13+ copies or substantial portions of the Software.
14+
15+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+ SOFTWARE.
@@ -0,0 +1,32 @@
1+ # barerepo-runner
2+
3+ The build machine agent for [barerepo](https://barerepo.com/barerepo/server).
4+
5+ go build ./cmd/barerepo-runner
6+ barerepo-runner <token>
7+
8+ The token comes from the add runner page on your barerepo. The server is
9+ remembered after the first attach, so you pass it once and never again.
10+
11+ It long-polls outward, so it needs no inbound port and no port forwarding.
12+ One binary, nothing outside the standard library.
13+
14+ ## Where the token comes from
15+
16+ Runs are your own hardware, which is the whole design: there is no hosted
17+ CI to pay for and no queue to share. A token is scoped to one repository,
18+ so a runner attached to one project cannot read another.
19+
20+ An operator who installs `barerepo-runner` beside the `barerepo` binary
21+ gets a one paste install on the add runner page, because the server hands
22+ out the copy sitting next to it.
23+
24+ ## Running the cross repo tests
25+
26+ The tests that drive a real server live behind a build tag, because they need
27+ all three checkouts side by side. Without the tag this repository builds and
28+ tests on its own and depends on nothing.
29+
30+ git clone https://barerepo.com/barerepo/server ../server
31+ printf 'go 1.24\n\nuse (\n\t./cli\n\t./runner\n\t./server\n)\n' > ../go.work
32+ go test -tags crossrepo ./...
@@ -0,0 +1,67 @@
1+ package main
2+
3+ import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+ )
9+
10+ // Every comment in this tree is one line, which is a standing instruction, so a test holds it.
11+ func TestEveryCommentIsOneLine(t *testing.T) {
12+ root, err := filepath.Abs("../..")
13+ if err != nil {
14+ t.Fatal(err)
15+ }
16+ skip := map[string]bool{".git": true, "plans": true, "misc": true, ".playwright-mcp": true}
17+ err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
18+ if err != nil {
19+ return nil
20+ }
21+ if d.IsDir() {
22+ if skip[d.Name()] {
23+ return filepath.SkipDir
24+ }
25+ return nil
26+ }
27+ switch filepath.Ext(path) {
28+ case ".go", ".js", ".css":
29+ default:
30+ return nil
31+ }
32+ body, err := os.ReadFile(path)
33+ if err != nil {
34+ return nil
35+ }
36+ where := strings.TrimPrefix(path, root+string(filepath.Separator))
37+ run, start := 0, 0
38+ for i, line := range strings.Split(string(body), "\n") {
39+ if strings.HasPrefix(strings.TrimSpace(line), "//") {
40+ if run == 0 {
41+ start = i + 1
42+ }
43+ run++
44+ continue
45+ }
46+ if run > 1 {
47+ t.Errorf("%s:%d has a %d line comment block, and every comment here is one line", where, start, run)
48+ }
49+ run = 0
50+ }
51+ if run > 1 {
52+ t.Errorf("%s:%d has a %d line comment block, and every comment here is one line", where, start, run)
53+ }
54+ if strings.Contains(string(body), "/*") && filepath.Ext(path) == ".css" {
55+ for i, line := range strings.Split(string(body), "\n") {
56+ open := strings.Contains(line, "/*")
57+ if open && !strings.Contains(line[strings.Index(line, "/*"):], "*/") {
58+ t.Errorf("%s:%d opens a css comment it does not close on the same line", where, i+1)
59+ }
60+ }
61+ }
62+ return nil
63+ })
64+ if err != nil {
65+ t.Fatal(err)
66+ }
67+ }
@@ -0,0 +1,28 @@
1+ package main
2+
3+ import (
4+ "context"
5+ "fmt"
6+ "os"
7+ )
8+
9+ const usage = `barerepo-runner <token> [--labels a,b] [--server url]
10+
11+ Attaches this machine to a barerepo as a build runner. It long-polls
12+ outward, so it needs no inbound port and no port forwarding.
13+
14+ The token comes from the add runner page on your barerepo. The server is
15+ remembered after the first attach, so you only pass it once.
16+ `
17+
18+ func main() {
19+ args := os.Args[1:]
20+ if len(args) == 0 || args[0] == "help" || args[0] == "-h" || args[0] == "--help" {
21+ fmt.Print(usage)
22+ return
23+ }
24+ if err := cmdRunner(context.Background(), args); err != nil {
25+ fmt.Fprintln(os.Stderr, "barerepo-runner: "+err.Error())
26+ os.Exit(1)
27+ }
28+ }
@@ -0,0 +1,78 @@
1+ package main
2+
3+ import (
4+ "bufio"
5+ "crypto/sha256"
6+ "encoding/hex"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ )
11+
12+ // serversFile is where a runner remembers what it attached to, so appendix E's line needs no flag.
13+ func serversFile() (string, error) {
14+ dir, err := os.UserConfigDir()
15+ if err != nil {
16+ return "", err
17+ }
18+ return filepath.Join(dir, "forge", "servers"), nil
19+ }
20+
21+ // tokenKey is a hash, because the file names which server a token belongs to and never the token.
22+ func tokenKey(tok string) string {
23+ sum := sha256.Sum256([]byte(tok))
24+ return hex.EncodeToString(sum[:8])
25+ }
26+
27+ // rememberServer records where a token attached, after it worked, so a bad url is never kept.
28+ func rememberServer(tok, server string) {
29+ path, err := serversFile()
30+ if err != nil {
31+ return
32+ }
33+ key := tokenKey(tok)
34+ lines := readServers(path)
35+ out := make([]string, 0, len(lines)+1)
36+ for _, line := range lines {
37+ if k, _, ok := strings.Cut(line, " "); !ok || k != key {
38+ out = append(out, line)
39+ }
40+ }
41+ out = append(out, key+" "+server)
42+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
43+ return
44+ }
45+ // A failure here costs the next run a flag, which is not worth failing a build over.
46+ os.WriteFile(path, []byte(strings.Join(out, "\n")+"\n"), 0o600)
47+ }
48+
49+ // recallServer answers where this token attached last time, or nothing.
50+ func recallServer(tok string) string {
51+ path, err := serversFile()
52+ if err != nil {
53+ return ""
54+ }
55+ key := tokenKey(tok)
56+ for _, line := range readServers(path) {
57+ if k, server, ok := strings.Cut(line, " "); ok && k == key {
58+ return server
59+ }
60+ }
61+ return ""
62+ }
63+
64+ func readServers(path string) []string {
65+ f, err := os.Open(path)
66+ if err != nil {
67+ return nil
68+ }
69+ defer f.Close()
70+ var out []string
71+ sc := bufio.NewScanner(f)
72+ for sc.Scan() {
73+ if line := strings.TrimSpace(sc.Text()); line != "" {
74+ out = append(out, line)
75+ }
76+ }
77+ return out
78+ }
@@ -0,0 +1,53 @@
1+ package main
2+
3+ import (
4+ "os"
5+ "strings"
6+ "testing"
7+ )
8+
9+ // Appendix E's line is `forge runner <token>`, with no address, which needs the last one remembered.
10+ func TestRememberAndRecallAServer(t *testing.T) {
11+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
12+ if os.Getenv("HOME") != "" {
13+ t.Setenv("HOME", t.TempDir())
14+ }
15+
16+ if got := recallServer("rt_live_never_seen"); got != "" {
17+ t.Errorf("a token that never attached recalled %q", got)
18+ }
19+
20+ rememberServer("rt_live_aaa", "https://one.example")
21+ rememberServer("rt_live_bbb", "https://two.example")
22+ if got := recallServer("rt_live_aaa"); got != "https://one.example" {
23+ t.Errorf("recall = %q", got)
24+ }
25+ // Two forges on one machine must not answer for each other.
26+ if got := recallServer("rt_live_bbb"); got != "https://two.example" {
27+ t.Errorf("the second token recalled %q", got)
28+ }
29+
30+ // Re-attaching somewhere else replaces rather than appends.
31+ rememberServer("rt_live_aaa", "https://moved.example")
32+ if got := recallServer("rt_live_aaa"); got != "https://moved.example" {
33+ t.Errorf("a moved server recalled %q", got)
34+ }
35+
36+ // The file names servers, never tokens, so it is not a place secrets sit.
37+ path, err := serversFile()
38+ if err != nil {
39+ t.Fatal(err)
40+ }
41+ body, err := os.ReadFile(path)
42+ if err != nil {
43+ t.Fatal(err)
44+ }
45+ for _, secret := range []string{"rt_live_aaa", "rt_live_bbb"} {
46+ if strings.Contains(string(body), secret) {
47+ t.Errorf("the file holds the token %q", secret)
48+ }
49+ }
50+ if info, err := os.Stat(path); err == nil && info.Mode().Perm() != 0o600 {
51+ t.Errorf("the file is mode %v, and it names where a token works", info.Mode().Perm())
52+ }
53+ }
@@ -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+ }
@@ -0,0 +1,75 @@
1+ //go:build crossrepo
2+
3+ package runner_test
4+
5+ import (
6+ "context"
7+ "os"
8+ "os/exec"
9+ "path/filepath"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/barerepo/server/testserver"
15+ )
16+
17+ // The two halves are separate programs now, so only a test that runs both catches them drifting.
18+ func TestTheRunnerAttachesToARealServer(t *testing.T) {
19+ for _, name := range []string{"git", "ssh-keygen", "go"} {
20+ if _, err := exec.LookPath(name); err != nil {
21+ t.Skipf("%s is not installed", name)
22+ }
23+ }
24+ in := testserver.New(t)
25+ john := in.Account(t, "john")
26+ in.Repo(t, john, "john", "johnbot")
27+ tok := in.RunnerToken(t, "john", "john", "johnbot")
28+
29+ bin := filepath.Join(t.TempDir(), "barerepo-runner")
30+ if out, err := exec.Command("go", "build", "-o", bin, "./cmd/barerepo-runner").CombinedOutput(); err != nil {
31+ t.Fatalf("building the runner: %v\n%s", err, out)
32+ }
33+
34+ ctx, stop := context.WithTimeout(context.Background(), 30*time.Second)
35+ defer stop()
36+ cmd := exec.CommandContext(ctx, bin, tok, "--server", in.URL, "--labels", "build,test")
37+ cmd.Env = append(os.Environ(), "HOME="+t.TempDir())
38+ out, err := cmd.StdoutPipe()
39+ if err != nil {
40+ t.Fatal(err)
41+ }
42+ if err := cmd.Start(); err != nil {
43+ t.Fatal(err)
44+ }
45+ t.Cleanup(func() {
46+ cmd.Process.Kill()
47+ cmd.Wait()
48+ })
49+
50+ said := make(chan string, 1)
51+ go func() {
52+ buf := make([]byte, 256)
53+ n, _ := out.Read(buf)
54+ said <- string(buf[:n])
55+ }()
56+ select {
57+ case line := <-said:
58+ if !strings.Contains(line, "attached as") {
59+ t.Fatalf("the runner said %q, want it to report attaching", line)
60+ }
61+ case <-ctx.Done():
62+ t.Fatal("the runner never reported attaching")
63+ }
64+
65+ runners, err := in.DB.RunnersOf(context.Background(), "john/johnbot")
66+ if err != nil {
67+ t.Fatal(err)
68+ }
69+ if len(runners) != 1 {
70+ t.Fatalf("the server holds %d runners, want the one that just attached", len(runners))
71+ }
72+ if got := runners[0].Labels; !strings.Contains(strings.Join(got, ","), "build") {
73+ t.Errorf("the server recorded labels %v, want the ones the runner sent", got)
74+ }
75+ }
@@ -0,0 +1,3 @@
1+ module github.com/barerepo/runner
2+
3+ go 1.24
reachable from master
barerepo / runnerbarerepo 0.1.0