Commit 5bce95a7da

5bce95a7da98ac0d7e9be5da444fd24cf31ddeb6

parent: ad0bf9770a

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-09 02:59 UTC

httpd: attach and detach runners on the settings page

Ref #184
e2e/runnerweb_test.go added +48
@@ -0,0 +1,48 @@
1package e2e
2
3import (
4 "net/url"
5 "os"
6 "strings"
7 "testing"
8)
9
10// The settings page attaches and detaches runners through the same
11// commands the CLI uses, and lists what is attached.
12func TestRunnerSettingsWeb(t *testing.T) {
13 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
14 aliceKey := inst.newKey(t, "alice")
15 inst.admin(t, "admin", "user", "create", "alice",
16 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
17 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
18 t.Fatalf("repo create: %s", errOut)
19 }
20 runnerKey := inst.newKey(t, "laptop")
21 pub, _ := os.ReadFile(runnerKey + ".pub")
22
23 alice := inst.login(t, aliceKey)
24 settings := inst.base() + "/alice/app/settings"
25 _, body := browserGet(t, alice, settings)
26 if !strings.Contains(body, "No runners attached") {
27 t.Fatalf("empty state missing:\n%s", body)
28 }
29 if status, _ := browserPost(t, alice, settings, url.Values{"field": {"runner-add"}, "key": {string(pub)}}); status != 200 {
30 t.Fatalf("runner-add post: %d", status)
31 }
32 out, _, _ := inst.ssh(t, aliceKey, "", "repo", "runner", "list", "alice/app", "--json")
33 if !strings.Contains(out, `"fingerprint":"SHA256:`) {
34 t.Fatalf("not attached after the form: %s", out)
35 }
36 fp := out[strings.Index(out, "SHA256:"):]
37 fp = fp[:strings.Index(fp, `"`)]
38 _, body = browserGet(t, alice, settings)
39 if !strings.Contains(body, fp) || !strings.Contains(body, `value="runner-remove"`) {
40 t.Fatalf("attached runner not listed:\n%s", body)
41 }
42 if status, _ := browserPost(t, alice, settings, url.Values{"field": {"runner-remove"}, "fingerprint": {fp}}); status != 200 {
43 t.Fatalf("runner-remove post: %d", status)
44 }
45 if out, _, _ = inst.ssh(t, aliceKey, "", "repo", "runner", "list", "alice/app", "--json"); strings.Contains(out, fp) {
46 t.Fatalf("still attached after remove: %s", out)
47 }
48}
internal/httpd/settings.go +19 −1
@@ -21,6 +21,7 @@ type settingsPage struct {
2121 Branches []gitutil.Ref
2222 DepsEnabled bool
2323 Deps control.DepsOut
24 Runners []store.RepoRunner
2425 Notice string
2526}
2627
@@ -41,10 +42,13 @@ func (s *Server) settingsForm(w http.ResponseWriter, r *http.Request, u store.Us
4142 // prints (#164).
4243 var deps control.DepsOut
4344 s.runControlInto(u, []string{"repo", "deps", "status", repo.Path()}, &deps)
45 var runners []store.RepoRunner
46 s.runControlInto(u, []string{"repo", "runner", "list", repo.Path()}, &runners)
4447 s.render(w, "settings.html", settingsPage{
4548 repoPage: p, Topics: topics, Branches: branches,
4649 DepsEnabled: deps.Enabled, Deps: deps,
47 Notice: s.takeFlash(w, r),
50 Runners: runners,
51 Notice: s.takeFlash(w, r),
4852 })
4953}
5054
@@ -113,6 +117,20 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.
113117 s.settingsRedirect(w, r, "name at least one topic")
114118 return
115119 }
120 case "runner-add":
121 body := v("key")
122 if body == "" {
123 s.settingsRedirect(w, r, "paste the runner's public key")
124 return
125 }
126 msg, ok := s.runControlStdin(u, []string{"repo", "runner", "add", repo}, body+"\n")
127 if ok {
128 msg = ""
129 }
130 s.settingsRedirect(w, r, msg)
131 return
132 case "runner-remove":
133 argv = []string{"repo", "runner", "remove", repo, v("fingerprint")}
116134 default:
117135 s.settingsRedirect(w, r, "unknown setting")
118136 return
internal/web/templates/settings.html +20
@@ -157,6 +157,26 @@ depends on.</p>
157157{{else}}<p class="none">Nothing behind{{if not .Deps.LastCheck}} — the first check has not run yet{{end}}.</p>{{end}}
158158{{end}}
159159
160<h2>Runners</h2>
161{{if .Runners}}
162<ul class="protlist">
163{{range .Runners}}<li><code>{{.Fingerprint}}</code> <span class="meta">{{.Username}}{{if .LastSeen}}, last poll {{.LastSeen}}{{else}}, never polled{{end}}{{if .BuildNumber}}, running {{.BuildRepo}} #{{.BuildNumber}} {{.BuildJob}}{{end}}</span>
164 <form method="post" action="{{$base}}" class="inline">
165 <input type="hidden" name="field" value="runner-remove">
166 <input type="hidden" name="fingerprint" value="{{.Fingerprint}}">
167 <button type="submit" class="linklike">Detach</button>
168 </form></li>
169{{end}}
170</ul>
171{{else}}<p class="meta">No runners attached. Builds for this repository run on the runners attached here; a repository with none queues builds nothing claims.</p>{{end}}
172<form method="post" action="{{$base}}" class="setform">
173 <input type="hidden" name="field" value="runner-add">
174 <label for="runner-key">Attach a runner</label>
175 <textarea id="runner-key" name="key" rows="3" placeholder="ssh-ed25519 AAAA… (from gitbay-runner init)"></textarea>
176 <button type="submit">Attach</button>
177</form>
178<p class="meta">Install <code>gitbay-runner</code>, run <code>gitbay-runner init</code>, and paste the key it prints. The runner builds your commits with the repository's secrets; merge requests from forks wait unless it runs with <code>-untrusted</code>.</p>
179
160180<h2>Lifecycle</h2>
161181<form method="post" action="{{$base}}" class="setform">
162182 <input type="hidden" name="field" value="archive">