docs/plans/2026-09-08-user-runners.md
2378 lines · 85415 bytes
12 symbols in this file
Runners attached to repositories: implementation planGlobal ConstraintsTask 1: Store: ClaimBuild skips untrusted builds unless askedTask 2: Store: migration 0050 and runner attachmentsTask 3: Control: the claim rule, per-key heartbeat, and admin runnersTask 4: Control and CLI: `repo runner add|list|remove`Task 5: Web: Runners on the repository settings pageTask 6: Runner: config file, `-identity`, `-untrusted`Task 7: Runner: `gitbay-runner init`Task 8: e2e: init, attach, build; fork head waitsTask 9: Docs: wiki pagesTask 10: Deploy, release, and the tap
Runners attached to repositories: implementation plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: A gitbay-runner anyone installs, pairs with their repositories on any instance, and runs as a service; the server hands a runner key only the builds of repositories it is attached to.
Architecture: One new table (runner_repos) maps an SSH key to repositories; runner next claims only from a key's attachments and skips untrusted builds unless asked; repo runner add|list|remove manage attachments and render on the settings page. The runner gains init, a config file, its own identity, and -untrusted.
Tech Stack: Go, SQLite via hand-written SQL, github.com/BurntSushi/toml (already a dependency), Go templates, e2e tests against real ssh/git.
Spec: docs/specs/2026-09-08-user-runners-design.md
Global Constraints
- Commit messages:
<area>, <area>: <what>on the first line, body withRef #184. No attribution trailers of any kind (top rule of~/CLAUDE.md). - Never push to
main. Work on branchuser-runners; MR at the end. - Locally:
go build ./... && go vet ./..., the unit tests of the touched packages, and at most the one e2e test being written. The full suite runs in CI on bay1. - Every control command parses argv through
parseFlags(internal/control/flags.go). A command that reads stdin setsReadsStdin: true. A read command setsReadOnly: true. - Every new control command needs a
pass()entry incmd/gitbay/main.go; a coverage test fails otherwise. - Secrets never in argv, never logged. A public key is not a secret.
- Templates:
str/fieldare nil-safe helpers; the whole stylesheet isinternal/web/static/style.css. - Migrations: next number is
0050, both.up.sqland.down.sql. Foreign keys are on. - Comments and docs in plain English, no hype. Wiki is
.gitbay/wiki/*.org.
Task 1: Store: ClaimBuild skips untrusted builds unless asked
Files:
- Modify:
internal/store/builds.go:80-118(ClaimBuild) - Modify:
internal/store/builds_test.go(everyClaimBuild(call gains, false; one new test) - Modify:
internal/store/queues_test.go:27(ClaimBuild(nil, false))
Interfaces:
-
Produces:
Store.ClaimBuild(repoIDs []int64, untrusted bool) (Build, bool, error). Withuntrustedfalse only rows withtrusted = 1are candidates. -
Step 1: Write the failing test
Append to internal/store/builds_test.go:
// A merge request head from a fork is untrusted. A claim skips it unless
// the runner asked for untrusted builds, so a runner on someone's laptop
// never executes a stranger's branch by default.
func TestClaimBuildSkipsUntrustedUnlessAsked(t *testing.T) {
s := open(t)
if err := s.MigrateUp(); err != nil {
t.Fatal(err)
}
uid, err := s.CreateUser("cmc", true)
if err != nil {
t.Fatal(err)
}
repo, err := s.CreateRepo("user", uid, "app", "public")
if err != nil {
t.Fatal(err)
}
// Queued first, so an unfiltered claim would take it.
forkBuild, err := s.CreateBuild(repo, "unit", "abc123", "refs/merge-requests/1/head", `["true"]`, "", "", false)
if err != nil {
t.Fatal(err)
}
own, err := s.CreateBuild(repo, "unit", "def456", "main", `["true"]`, "", "", true)
if err != nil {
t.Fatal(err)
}
b, ok, err := s.ClaimBuild(nil, false)
if err != nil || !ok || b.Number != own {
t.Fatalf("trusted-only claim: err=%v ok=%v number=%d, want %d", err, ok, b.Number, own)
}
if _, ok, _ := s.ClaimBuild(nil, false); ok {
t.Fatal("trusted-only claim took the fork build")
}
b, ok, err = s.ClaimBuild(nil, true)
if err != nil || !ok || b.Number != forkBuild {
t.Fatalf("untrusted claim: err=%v ok=%v number=%d, want %d", err, ok, b.Number, forkBuild)
}
}
- Step 2: Run it to see it fail
Run: go test ./internal/store -run TestClaimBuildSkipsUntrusted 2>&1 | head -5
Expected: compile error, too many arguments to ClaimBuild.
- Step 3: Change
ClaimBuild
Replace the signature, doc comment and query construction in internal/store/builds.go:
// ClaimBuild atomically hands the oldest pending build to a runner and
// marks it running. A non-empty repoIDs restricts the claim to those
// repositories. Untrusted builds — merge request heads from another
// repository — are skipped unless untrusted is set: they run a stranger's
// code, which only a runner that isolates should take.
func (s *Store) ClaimBuild(repoIDs []int64, untrusted bool) (Build, bool, error) {
tx, err := s.DB.Begin()
if err != nil {
return Build{}, false, err
}
defer tx.Rollback()
query := "SELECT id FROM builds WHERE status = 'pending'"
args := []any{}
if !untrusted {
query += " AND trusted = 1"
}
if len(repoIDs) > 0 {
marks := strings.TrimSuffix(strings.Repeat("?,", len(repoIDs)), ",")
query += " AND repo_id IN (" + marks + ")"
for _, id := range repoIDs {
args = append(args, id)
}
}
query += " ORDER BY id LIMIT 1"
var id int64
err = tx.QueryRow(query, args...).Scan(&id)
The rest of the function is unchanged.
- Step 4: Update existing callers in store tests
In internal/store/builds_test.go and internal/store/queues_test.go, every s.ClaimBuild(x) becomes s.ClaimBuild(x, false):
sed -i '' -E 's/ClaimBuild\((nil|\[\]int64\{[a-zA-Z]+\})\)/ClaimBuild(\1, false)/g' internal/store/builds_test.go internal/store/queues_test.go
grep -n "ClaimBuild(" internal/store/*_test.go
Every hit must now show two arguments.
- Step 5: Run the store tests
Run: go test ./internal/store 2>&1 | tail -3
Expected: PASS.
- Step 6: Commit
git add internal/store/builds.go internal/store/builds_test.go internal/store/queues_test.go
git commit -m "store: ClaimBuild skips untrusted builds unless asked
Ref #184"
Task 2: Store: migration 0050 and runner attachments
Files:
- Create:
internal/store/migrations/0050_runner_repos.up.sql - Create:
internal/store/migrations/0050_runner_repos.down.sql - Modify:
internal/store/runners.go(whole file) - Create:
internal/store/runners_test.go
Interfaces:
-
Consumes:
Store.AddSSHKey(userID int64, fingerprint, algo string, blob []byte, scope string) error,Store.SSHKeyByFingerprint(fp) (SSHKey, error),Store.CreateUser(name string, admin bool) (int64, error),Store.CreateRepo(kind string, ownerID int64, name, visibility string) (int64, error),Store.CreateBuild(repoID int64, job, sha, ref, steps, image, tree string, trusted bool) (int64, error). -
Produces:
type RepoRunner struct { Fingerprint, Algo, Username, AddedAt, LastSeen, BuildRepo string; BuildNumber int64; BuildJob, StartedAt string }RunnergainsFingerprint stringandKeyID int64.Store.AttachRunner(keyID, repoID int64) error(idempotent)Store.DetachRunner(repoID int64, fingerprint string) error(ErrNotFoundwhen not attached)Store.RunnerRepoIDs(keyID int64) ([]int64, error)Store.RunnerRepoPaths(keyID int64) ([]string, error)(owner/name, sorted)Store.RunnerAttached(keyID, repoID int64) (bool, error)Store.ListRepoRunners(repoID int64) ([]RepoRunner, error)Store.TouchRunner(keyID, userID int64, scope string, buildID int64) errorStore.RunnerDone(keyID int64) errorStore.ListRunners() ([]Runner, error)unchanged signature.
-
Step 1: Write the migration
internal/store/migrations/0050_runner_repos.up.sql:
-- A runner key is attached to the repositories it may claim builds for
-- (#184). runner_seen is rekeyed by key so two runners on one account
-- are two rows; what it held were heartbeats, so the rows are dropped.
CREATE TABLE runner_repos (
key_id INTEGER NOT NULL REFERENCES ssh_keys(id) ON DELETE CASCADE,
repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
added_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
PRIMARY KEY (key_id, repo_id)
);
CREATE INDEX runner_repos_repo ON runner_repos(repo_id);
DROP TABLE runner_seen;
CREATE TABLE runner_seen (
key_id INTEGER PRIMARY KEY REFERENCES ssh_keys(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
last_seen TEXT NOT NULL,
scope TEXT NOT NULL DEFAULT '',
build_id INTEGER REFERENCES builds(id) ON DELETE SET NULL
);
internal/store/migrations/0050_runner_repos.down.sql:
DROP TABLE runner_repos;
DROP TABLE runner_seen;
CREATE TABLE runner_seen (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
last_seen TEXT NOT NULL,
scope TEXT NOT NULL DEFAULT '',
build_id INTEGER REFERENCES builds(id) ON DELETE SET NULL
);
- Step 2: Write the failing store tests
internal/store/runners_test.go:
package store
import (
"errors"
"testing"
)
// runnerFixture is one user with a runner key and two repositories.
func runnerFixture(t *testing.T) (s *Store, uid, keyID, repoA, repoB int64) {
t.Helper()
s = open(t)
if err := s.MigrateUp(); err != nil {
t.Fatal(err)
}
uid, err := s.CreateUser("alice", false)
if err != nil {
t.Fatal(err)
}
if err := s.AddSSHKey(uid, "SHA256:runnerkey", "ssh-ed25519", []byte("blob"), "runner"); err != nil {
t.Fatal(err)
}
k, err := s.SSHKeyByFingerprint("SHA256:runnerkey")
if err != nil {
t.Fatal(err)
}
repoA, err = s.CreateRepo("user", uid, "a", "public")
if err != nil {
t.Fatal(err)
}
repoB, err = s.CreateRepo("user", uid, "b", "public")
if err != nil {
t.Fatal(err)
}
return s, uid, k.ID, repoA, repoB
}
// Attaching twice is one row; detaching what is not attached is not found.
func TestAttachRunnerIdempotentAndDetach(t *testing.T) {
s, _, keyID, repoA, repoB := runnerFixture(t)
for range 2 {
if err := s.AttachRunner(keyID, repoA); err != nil {
t.Fatal(err)
}
}
ids, err := s.RunnerRepoIDs(keyID)
if err != nil || len(ids) != 1 || ids[0] != repoA {
t.Fatalf("attached repos %v err=%v, want [%d]", ids, err, repoA)
}
if ok, _ := s.RunnerAttached(keyID, repoB); ok {
t.Fatal("attached to a repo it was never attached to")
}
if err := s.DetachRunner(repoB, "SHA256:runnerkey"); !errors.Is(err, ErrNotFound) {
t.Fatalf("detach of an unattached repo: %v, want ErrNotFound", err)
}
if err := s.DetachRunner(repoA, "SHA256:runnerkey"); err != nil {
t.Fatal(err)
}
if ok, _ := s.RunnerAttached(keyID, repoA); ok {
t.Fatal("still attached after detach")
}
}
// Removing the key or the repository removes the attachment with it.
func TestRunnerAttachmentCascades(t *testing.T) {
s, uid, keyID, repoA, repoB := runnerFixture(t)
if err := s.AttachRunner(keyID, repoA); err != nil {
t.Fatal(err)
}
if err := s.AttachRunner(keyID, repoB); err != nil {
t.Fatal(err)
}
if _, err := s.DB.Exec("DELETE FROM repos WHERE id = ?", repoB); err != nil {
t.Fatal(err)
}
if ids, _ := s.RunnerRepoIDs(keyID); len(ids) != 1 {
t.Fatalf("after repo delete: %v, want one attachment", ids)
}
if err := s.RemoveSSHKey(uid, "SHA256:runnerkey"); err != nil {
t.Fatal(err)
}
var n int
if err := s.DB.QueryRow("SELECT count(*) FROM runner_repos").Scan(&n); err != nil || n != 0 {
t.Fatalf("after key delete: %d rows err=%v, want 0", n, err)
}
}
// The heartbeat is per key: two keys on one account are two rows, and a
// repository's runner list shows each key's last poll and the build it holds.
func TestRunnerSeenPerKeyAndRepoList(t *testing.T) {
s, uid, keyID, repoA, _ := runnerFixture(t)
if err := s.AddSSHKey(uid, "SHA256:second", "ssh-ed25519", []byte("blob2"), "runner"); err != nil {
t.Fatal(err)
}
k2, _ := s.SSHKeyByFingerprint("SHA256:second")
for _, id := range []int64{keyID, k2.ID} {
if err := s.AttachRunner(id, repoA); err != nil {
t.Fatal(err)
}
}
if _, err := s.CreateBuild(repoA, "unit", "abc123", "main", `["true"]`, "", "", true); err != nil {
t.Fatal(err)
}
b, ok, err := s.ClaimBuild(nil, false)
if err != nil || !ok {
t.Fatalf("claim: %v ok=%v", err, ok)
}
if err := s.TouchRunner(keyID, uid, "", b.ID); err != nil {
t.Fatal(err)
}
if err := s.TouchRunner(k2.ID, uid, "", 0); err != nil {
t.Fatal(err)
}
runners, err := s.ListRunners()
if err != nil || len(runners) != 2 {
t.Fatalf("ListRunners: %v err=%v, want two rows", runners, err)
}
list, err := s.ListRepoRunners(repoA)
if err != nil || len(list) != 2 {
t.Fatalf("ListRepoRunners: %v err=%v, want two rows", list, err)
}
var held, idle int
for _, r := range list {
if r.Username != "alice" || r.LastSeen == "" || r.AddedAt == "" {
t.Fatalf("row %+v lacks username, last_seen or added_at", r)
}
if r.BuildNumber == b.Number && r.BuildJob == "unit" && r.BuildRepo == "alice/a" {
held++
} else if r.BuildNumber == 0 {
idle++
}
}
if held != 1 || idle != 1 {
t.Fatalf("held=%d idle=%d, want 1 and 1: %+v", held, idle, list)
}
if err := s.RunnerDone(keyID); err != nil {
t.Fatal(err)
}
list, _ = s.ListRepoRunners(repoA)
for _, r := range list {
if r.BuildNumber != 0 {
t.Fatalf("build still held after RunnerDone: %+v", r)
}
}
paths, err := s.RunnerRepoPaths(keyID)
if err != nil || len(paths) != 1 || paths[0] != "alice/a" {
t.Fatalf("RunnerRepoPaths: %v err=%v", paths, err)
}
}
- Step 3: Run the tests to see them fail
Run: go test ./internal/store -run 'TestAttachRunner|TestRunnerAttachment|TestRunnerSeen' 2>&1 | head -20
Expected: compile errors, s.AttachRunner undefined and friends.
- Step 4: Replace
internal/store/runners.go
package store
import "sort"
// Runner is one runner key as the instance admin sees it.
type Runner struct {
Username string `json:"username"`
Fingerprint string `json:"fingerprint"`
KeyID int64 `json:"-"`
LastSeen string `json:"last_seen"`
// Scope is what the runner asked for: comma-joined owner/name, ""
// for any. admin runners replaces it with the attachments for a
// runner key.
Scope string `json:"scope,omitempty"`
// The build it holds, if any.
BuildRepo string `json:"build_repo,omitempty"`
BuildNumber int64 `json:"build_number,omitempty"`
BuildJob string `json:"build_job,omitempty"`
StartedAt string `json:"started_at,omitempty"`
}
// RepoRunner is one key attached to a repository, as repo runner list
// shows it.
type RepoRunner struct {
Fingerprint string `json:"fingerprint"`
Algo string `json:"algo"`
Username string `json:"username"`
AddedAt string `json:"added_at"`
LastSeen string `json:"last_seen,omitempty"`
BuildRepo string `json:"build_repo,omitempty"`
BuildNumber int64 `json:"build_number,omitempty"`
BuildJob string `json:"build_job,omitempty"`
StartedAt string `json:"started_at,omitempty"`
}
// AttachRunner lets a key claim a repository's builds. Attaching twice is
// one row.
func (s *Store) AttachRunner(keyID, repoID int64) error {
_, err := s.DB.Exec("INSERT OR IGNORE INTO runner_repos (key_id, repo_id) VALUES (?, ?)", keyID, repoID)
return err
}
// DetachRunner removes one attachment by fingerprint. The key itself stays.
func (s *Store) DetachRunner(repoID int64, fingerprint string) error {
res, err := s.DB.Exec(`DELETE FROM runner_repos WHERE repo_id = ?
AND key_id = (SELECT id FROM ssh_keys WHERE fingerprint = ?)`, repoID, fingerprint)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// RunnerRepoIDs is every repository a key is attached to.
func (s *Store) RunnerRepoIDs(keyID int64) ([]int64, error) {
rows, err := s.DB.Query("SELECT repo_id FROM runner_repos WHERE key_id = ? ORDER BY repo_id", keyID)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// RunnerRepoPaths is RunnerRepoIDs as owner/name, sorted.
func (s *Store) RunnerRepoPaths(keyID int64) ([]string, error) {
rows, err := s.DB.Query(`SELECT COALESCE(u.username, o.name) || '/' || r.name
FROM runner_repos rr JOIN repos r ON r.id = rr.repo_id
LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id
WHERE rr.key_id = ?`, keyID)
if err != nil {
return nil, err
}
defer rows.Close()
var paths []string
for rows.Next() {
var p string
if err := rows.Scan(&p); err != nil {
return nil, err
}
paths = append(paths, p)
}
sort.Strings(paths)
return paths, rows.Err()
}
// RunnerAttached reports whether a key may claim a repository's builds.
func (s *Store) RunnerAttached(keyID, repoID int64) (bool, error) {
var n int
err := s.DB.QueryRow("SELECT count(*) FROM runner_repos WHERE key_id = ? AND repo_id = ?", keyID, repoID).Scan(&n)
return n > 0, err
}
// ListRepoRunners is every key attached to a repository with its last
// poll and the build it holds, oldest attachment first.
func (s *Store) ListRepoRunners(repoID int64) ([]RepoRunner, error) {
rows, err := s.DB.Query(`SELECT k.fingerprint, k.algo, u.username, rr.added_at,
COALESCE(rs.last_seen, ''),
COALESCE(COALESCE(bu.username, bo.name) || '/' || br.name, ''),
COALESCE(b.number, 0), COALESCE(b.job, ''), COALESCE(b.started_at, '')
FROM runner_repos rr
JOIN ssh_keys k ON k.id = rr.key_id
JOIN users u ON u.id = k.user_id
LEFT JOIN runner_seen rs ON rs.key_id = rr.key_id
LEFT JOIN builds b ON b.id = rs.build_id AND b.status = 'running'
LEFT JOIN repos br ON br.id = b.repo_id
LEFT JOIN users bu ON br.owner_kind = 'user' AND bu.id = br.owner_id
LEFT JOIN orgs bo ON br.owner_kind = 'org' AND bo.id = br.owner_id
WHERE rr.repo_id = ? ORDER BY rr.added_at, k.id`, repoID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RepoRunner
for rows.Next() {
var r RepoRunner
if err := rows.Scan(&r.Fingerprint, &r.Algo, &r.Username, &r.AddedAt, &r.LastSeen,
&r.BuildRepo, &r.BuildNumber, &r.BuildJob, &r.StartedAt); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// TouchRunner records a poll by one key: the time, the scope the runner
// asked for, and the build it just claimed (0 for none).
func (s *Store) TouchRunner(keyID, userID int64, scope string, buildID int64) error {
_, err := s.DB.Exec(`INSERT INTO runner_seen (key_id, user_id, last_seen, scope, build_id)
VALUES (?1, ?2, strftime('%Y-%m-%dT%H:%M:%fZ','now'), ?3, NULLIF(?4, 0))
ON CONFLICT (key_id) DO UPDATE SET
last_seen = excluded.last_seen, scope = excluded.scope,
build_id = COALESCE(excluded.build_id, runner_seen.build_id)`,
keyID, userID, scope, buildID)
return err
}
// RunnerDone records that the key reported and holds nothing now.
func (s *Store) RunnerDone(keyID int64) error {
_, err := s.DB.Exec(`UPDATE runner_seen SET last_seen = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
build_id = NULL WHERE key_id = ?`, keyID)
return err
}
// ListRunners lists every key that has ever polled as a runner, most
// recently seen first.
func (s *Store) ListRunners() ([]Runner, error) {
rows, err := s.DB.Query(`SELECT u.username, k.fingerprint, k.id, r.last_seen, r.scope,
COALESCE(COALESCE(bu.username, bo.name) || '/' || br.name, ''),
COALESCE(b.number, 0), COALESCE(b.job, ''), COALESCE(b.started_at, '')
FROM runner_seen r JOIN users u ON u.id = r.user_id
JOIN ssh_keys k ON k.id = r.key_id
LEFT JOIN builds b ON b.id = r.build_id AND b.status = 'running'
LEFT JOIN repos br ON br.id = b.repo_id
LEFT JOIN users bu ON br.owner_kind = 'user' AND bu.id = br.owner_id
LEFT JOIN orgs bo ON br.owner_kind = 'org' AND bo.id = br.owner_id
ORDER BY r.last_seen DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Runner
for rows.Next() {
var r Runner
if err := rows.Scan(&r.Username, &r.Fingerprint, &r.KeyID, &r.LastSeen, &r.Scope,
&r.BuildRepo, &r.BuildNumber, &r.BuildJob, &r.StartedAt); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
- Step 5: Run the store tests
Run: go test ./internal/store 2>&1 | tail -5
Expected: PASS. Callers in internal/control do not compile yet; that is Task 3. go build ./internal/store must pass here.
- Step 6: Commit
git add internal/store/migrations/0050_runner_repos.up.sql internal/store/migrations/0050_runner_repos.down.sql internal/store/runners.go internal/store/runners_test.go
git commit -m "store: runner keys attach to repositories, heartbeat per key
Migration 0050 adds runner_repos and rekeys runner_seen by ssh key.
Ref #184"
Task 3: Control: the claim rule, per-key heartbeat, and admin runners
Files:
- Modify:
internal/control/build.go(requireRunner,runRunnerNext,runRunnerLog,runRunnerDone, therunner nextregistration) - Modify:
internal/control/admin.go:444-475(runAdminRunners) - Modify:
internal/control/runnernext_test.go:17-30(runnerCtx) - Create:
internal/control/runnerattach_test.go - Modify:
e2e/reap_test.go:112-115(the admin runners row gains a fingerprint column) - Modify:
e2e/mrbuilds_test.go:95,e2e/build_cancel_test.go(claims of a fork head pass--untrusted)
Interfaces:
-
Consumes: the store functions from Tasks 1 and 2;
Ctx.Sourceis the SSH key fingerprint for SSH sessions (internal/sshd/sshd.go:338). -
Produces:
runnerSession(c *Ctx) (store.SSHKey, int): the key behind the session, or an exit code.runnerMayBuild(c *Ctx, key store.SSHKey, repoID int64) (bool, error): admin, or attached.runner next [--untrusted] [<owner/name>...].admin runnersJSON rows carryfingerprint; the text row isusername<TAB>fingerprint<TAB>last_seen<TAB>scope<TAB>held.
-
Step 1: Write the failing control tests
internal/control/runnerattach_test.go:
package control
import (
"bytes"
"strconv"
"strings"
"testing"
"gitbay.org/gitbay/internal/config"
"gitbay.org/gitbay/internal/protocol"
"gitbay.org/gitbay/internal/store"
)
// attachFixture: alice (not admin) owns alice/app with a build queued;
// mallory (not admin) owns mallory/evil with an older build queued. Each
// has a runner-scoped key. The Ctx polls as the given user with the given
// key, which is what the SSH listener produces.
type attachFixture struct {
st *store.Store
alice, mallory int64
aliceKey, malloryKey store.SSHKey
app, evil store.Repo
appBuild, evilBuild int64
}
func newAttachFixture(t *testing.T) attachFixture {
t.Helper()
st, err := store.Open(":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
if err := st.MigrateUp(); err != nil {
t.Fatal(err)
}
var f attachFixture
f.st = st
mk := func(name, fp string) (int64, store.SSHKey, store.Repo, string) {
uid, err := st.CreateUser(name, false)
if err != nil {
t.Fatal(err)
}
if err := st.AddSSHKey(uid, fp, "ssh-ed25519", []byte(fp), "runner"); err != nil {
t.Fatal(err)
}
k, _ := st.SSHKeyByFingerprint(fp)
repoName := map[string]string{"alice": "app", "mallory": "evil"}[name]
rid, err := st.CreateRepo("user", uid, repoName, "public")
if err != nil {
t.Fatal(err)
}
repo, _ := st.RepoByID(rid)
return uid, k, repo, repoName
}
f.mallory, f.malloryKey, f.evil, _ = mk("mallory", "SHA256:mallory")
f.alice, f.aliceKey, f.app, _ = mk("alice", "SHA256:alice")
// mallory's build is older, so an unrestricted claim would take it.
f.evilBuild, err = st.CreateBuild(f.evil.ID, "unit", "aaa111", "main", "[]", "", "", true)
if err != nil {
t.Fatal(err)
}
f.appBuild, err = st.CreateBuild(f.app.ID, "unit", "bbb222", "main", "[]", "", "", true)
if err != nil {
t.Fatal(err)
}
return f
}
func (f attachFixture) ctx(uid int64, key store.SSHKey, admin bool) (*Ctx, *bytes.Buffer) {
var out bytes.Buffer
name := "alice"
if uid == f.mallory {
name = "mallory"
}
return &Ctx{
User: store.User{ID: uid, Username: name, IsAdmin: admin},
Scope: key.Scope,
Source: key.Fingerprint,
Store: f.st,
Cfg: config.Config{Server: config.Server{Root: "/nonexistent", SiteURL: "https://x.test"}},
Stdin: strings.NewReader(""),
Stdout: &out,
Stderr: &out,
}, &out
}
// A runner key with no attachment claims nothing, whatever is queued.
func TestRunnerNextUnattachedClaimsNothing(t *testing.T) {
f := newAttachFixture(t)
c, out := f.ctx(f.alice, f.aliceKey, false)
if code := runRunnerNext(c, nil); code != protocol.ExitOK || !strings.Contains(out.String(), "no pending builds") {
t.Fatalf("exit %d: %s", code, out.String())
}
b, _ := f.st.BuildByNumber(f.evil.ID, f.evilBuild)
if b.Status != "pending" {
t.Fatalf("unattached key claimed a build: %s", b.Status)
}
}
// An attached key claims its repository's build and not the older one
// queued elsewhere; naming a repository outside the attachments is refused.
func TestRunnerNextAttachedClaimsOwnRepoOnly(t *testing.T) {
f := newAttachFixture(t)
if err := f.st.AttachRunner(f.aliceKey.ID, f.app.ID); err != nil {
t.Fatal(err)
}
c, out := f.ctx(f.alice, f.aliceKey, false)
if code := runRunnerNext(c, nil); code != protocol.ExitOK || !strings.Contains(out.String(), "alice/app") {
t.Fatalf("exit %d: %s", code, out.String())
}
if b, _ := f.st.BuildByNumber(f.evil.ID, f.evilBuild); b.Status != "pending" {
t.Fatalf("mallory's build was touched: %s", b.Status)
}
c, out = f.ctx(f.alice, f.aliceKey, false)
if code := runRunnerNext(c, []string{"mallory/evil"}); code != protocol.ExitDenied {
t.Fatalf("naming an unattached repo: exit %d, want %d: %s", code, protocol.ExitDenied, out.String())
}
}
// The heartbeat is recorded against the key, and admin runners shows it
// with its fingerprint and attachments.
func TestAdminRunnersShowsKeyAndAttachments(t *testing.T) {
f := newAttachFixture(t)
if err := f.st.AttachRunner(f.aliceKey.ID, f.app.ID); err != nil {
t.Fatal(err)
}
c, _ := f.ctx(f.alice, f.aliceKey, false)
runRunnerNext(c, nil)
admin, out := f.ctx(f.alice, f.aliceKey, true)
admin.Scope = "full"
if code := runAdminRunners(admin, nil); code != protocol.ExitOK {
t.Fatalf("admin runners: exit %d: %s", code, out.String())
}
if !strings.Contains(out.String(), "alice\tSHA256:alice\t") || !strings.Contains(out.String(), "\talice/app\t") {
t.Fatalf("row lacks fingerprint or attachments:\n%s", out.String())
}
}
// Untrusted builds are skipped unless the runner asks.
func TestRunnerNextUntrustedFlag(t *testing.T) {
f := newAttachFixture(t)
if err := f.st.AttachRunner(f.aliceKey.ID, f.app.ID); err != nil {
t.Fatal(err)
}
c, _ := f.ctx(f.alice, f.aliceKey, false)
runRunnerNext(c, nil) // takes the trusted build
fork, err := f.st.CreateBuild(f.app.ID, "unit", "ccc333", "refs/merge-requests/1/head", "[]", "", "", false)
if err != nil {
t.Fatal(err)
}
c, out := f.ctx(f.alice, f.aliceKey, false)
runRunnerNext(c, nil)
if !strings.Contains(out.String(), "no pending builds") {
t.Fatalf("fork head claimed without --untrusted: %s", out.String())
}
c, out = f.ctx(f.alice, f.aliceKey, false)
if code := runRunnerNext(c, []string{"--untrusted"}); code != protocol.ExitOK || !strings.Contains(out.String(), "alice/app") {
t.Fatalf("--untrusted did not claim the fork head: exit %d %s", code, out.String())
}
if b, _ := f.st.BuildByNumber(f.app.ID, fork); b.Status != "running" {
t.Fatalf("fork build is %s, want running", b.Status)
}
}
// runner done and runner log on a build whose repository is not attached
// to the key are refused.
func TestRunnerDoneRefusedForUnattachedBuild(t *testing.T) {
f := newAttachFixture(t)
if err := f.st.AttachRunner(f.malloryKey.ID, f.evil.ID); err != nil {
t.Fatal(err)
}
c, _ := f.ctx(f.mallory, f.malloryKey, false)
runRunnerNext(c, nil) // mallory holds her own build
evil, _ := f.st.BuildByNumber(f.evil.ID, f.evilBuild)
c, out := f.ctx(f.alice, f.aliceKey, false)
id := strconv.FormatInt(evil.ID, 10)
if code := runRunnerDone(c, []string{id, "success"}); code != protocol.ExitDenied {
t.Fatalf("done on an unattached build: exit %d, want %d: %s", code, protocol.ExitDenied, out.String())
}
c, out = f.ctx(f.alice, f.aliceKey, false)
if code := runRunnerLog(c, []string{id}); code != protocol.ExitDenied {
t.Fatalf("log on an unattached build: exit %d, want %d: %s", code, protocol.ExitDenied, out.String())
}
if b, _ := f.st.BuildByNumber(f.evil.ID, f.evilBuild); b.Status != "running" {
t.Fatalf("build was finished by a foreign key: %s", b.Status)
}
}
- Step 2: Run them to see them fail
Run: go test ./internal/control -run 'TestRunnerNext(Unattached|Attached|Untrusted)|TestAdminRunnersShows|TestRunnerDoneRefused' 2>&1 | head
Expected: compile errors from ClaimBuild, TouchRunner, RunnerDone signature changes in build.go.
- Step 3: Rewrite the runner protocol in
internal/control/build.go
Change the registration:
register(Command{Path: []string{"runner", "next"},
Summary: "claim the oldest pending build this key may run (runner protocol)",
Usage: "runner next [--untrusted] [<owner/name>...]", SSHOnly: true, Run: runRunnerNext})
Replace requireRunner with two helpers:
// runnerSession resolves the key behind a runner-protocol session. The
// runner commands are SSHOnly, so Source is the key's fingerprint. An
// admin key is accepted so an operator can rotate at their own pace; a
// runner host should hold a key added with --scope runner.
func runnerSession(c *Ctx) (store.SSHKey, int) {
if c.Scope != "runner" && !c.User.IsAdmin {
return store.SSHKey{}, c.fail(protocol.ExitDenied, "runner commands need a key added with --scope runner")
}
key, err := c.Store.SSHKeyByFingerprint(c.Source)
if err != nil {
return store.SSHKey{}, c.fail(protocol.ExitDenied, "runner commands need an SSH key session")
}
return key, -1
}
// runnerMayBuild reports whether a runner session may act on a
// repository's builds: an admin user may on any, a runner key on the
// repositories it is attached to (#184).
func runnerMayBuild(c *Ctx, key store.SSHKey, repoID int64) (bool, error) {
if c.User.IsAdmin {
return true, nil
}
return c.Store.RunnerAttached(key.ID, repoID)
}
Rewrite the head of runRunnerNext down to the claim loop:
func runRunnerNext(c *Ctx, args []string) int {
key, code := runnerSession(c)
if code >= 0 {
return code
}
f, err := parseFlags(args, flagSpec{Bools: []string{"--untrusted"}, MaxPos: -1,
Usage: "runner next [--untrusted] [<owner/name>...]"})
if err != nil {
return c.fail(protocol.ExitUsage, "%v", err)
}
// The candidate set. An admin key claims from any repository, narrowed
// by the names given. A runner key claims from the repositories it is
// attached to; a name outside them is refused, not ignored, so a
// misconfigured runner says so instead of idling.
var repoIDs []int64
for _, arg := range f.Pos {
repo, code := resolveRepo(c, arg, policy.CanRead)
if code >= 0 {
return code
}
ok, err := runnerMayBuild(c, key, repo.ID)
if err != nil {
return c.fail(protocol.ExitFailure, "%v", err)
}
if !ok {
return c.fail(protocol.ExitDenied, "this key is not attached to %s", repo.Path())
}
repoIDs = append(repoIDs, repo.ID)
}
if !c.User.IsAdmin && len(repoIDs) == 0 {
repoIDs, err = c.Store.RunnerRepoIDs(key.ID)
if err != nil {
return c.fail(protocol.ExitFailure, "%v", err)
}
if len(repoIDs) == 0 {
// Nothing attached: nothing to claim. Still a heartbeat, so
// admin runners shows the key polling.
c.Store.TouchRunner(key.ID, c.User.ID, "", 0)
return c.emit(map[string]any{}, func(w io.Writer) { fmt.Fprintln(w, "no pending builds") })
}
}
untrusted := f.Has("--untrusted")
var b store.Build
var repo store.Repo
var ok bool
for attempt := 0; attempt < maxOrphanSkip; attempt++ {
b, ok, err = c.Store.ClaimBuild(repoIDs, untrusted)
The rest of the loop is unchanged. Delete the old var err error line, since err now comes from parseFlags. Replace the heartbeat line:
c.Store.TouchRunner(key.ID, c.User.ID, strings.Join(f.Pos, ","), b.ID)
In runRunnerLog, replace if code := requireRunner(c); code >= 0 { return code } with:
key, code := runnerSession(c)
if code >= 0 {
return code
}
and directly after the id, err := strconv.ParseInt(args[0], 10, 64) block, add:
if b, err := c.Store.BuildByID(id); err != nil {
return c.fail(protocol.ExitNotFound, "no build %d", id)
} else if ok, err := runnerMayBuild(c, key, b.RepoID); err != nil {
return c.fail(protocol.ExitFailure, "%v", err)
} else if !ok {
return c.fail(protocol.ExitDenied, "this key is not attached to the build's repository")
}
In runRunnerDone, the same runnerSession replacement; after b, err := c.Store.BuildByID(id) succeeds add:
if ok, err := runnerMayBuild(c, key, b.RepoID); err != nil {
return c.fail(protocol.ExitFailure, "%v", err)
} else if !ok {
return c.fail(protocol.ExitDenied, "this key is not attached to the build's repository")
}
and both c.Store.RunnerDone(c.User.ID) become c.Store.RunnerDone(key.ID).
Delete requireRunner if nothing else references it (grep -n requireRunner internal/).
- Step 4:
admin runnersshows the fingerprint and attachments
In internal/control/admin.go, runAdminRunners, after ListRunners:
for i := range runners {
if runners[i].Scope != "" {
continue
}
key, err := c.Store.SSHKeyByID(runners[i].KeyID)
if err != nil || key.Scope != "runner" {
continue // an admin key with no -repos: any
}
paths, err := c.Store.RunnerRepoPaths(runners[i].KeyID)
if err != nil {
return c.fail(protocol.ExitFailure, "%v", err)
}
runners[i].Scope = strings.Join(paths, ",")
}
A runner key that asked for -repos shows that; one that did not shows its attachments. Both are what the key may claim. Text row:
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Username, r.Fingerprint, r.LastSeen, scope, held)
Add "strings" to admin.go imports if missing.
- Step 5: Fix the existing
runnerCtxtest helper
internal/control/runnernext_test.go, runnerCtx: the session needs a real key. Replace the helper body:
func runnerCtx(st *store.Store, uid int64, root string) (*Ctx, *bytes.Buffer) {
var out bytes.Buffer
fp := fmt.Sprintf("SHA256:runner-%d", uid)
st.AddSSHKey(uid, fp, "ssh-ed25519", []byte(fp), "full") // ErrDuplicateKey on reuse is fine
c := &Ctx{
User: store.User{ID: uid, Username: "ci", IsAdmin: true},
Scope: "full",
Source: fp,
Store: st,
Cfg: config.Config{Server: config.Server{Root: root, SiteURL: "https://x.test"}},
Stdin: strings.NewReader(""),
Stdout: &out,
Stderr: &out,
}
return c, &out
}
Any other control test that builds a Ctx for runRunnerNext, runRunnerLog or runRunnerDone (grep -ln "runRunner" internal/control/*_test.go) needs the same: an AddSSHKey and Source set to its fingerprint.
- Step 6: Run the control tests
Run: go build ./... && go vet ./internal/control && go test ./internal/control 2>&1 | tail -5
Expected: PASS, including TestStdinCommandsReadStdin and TestReadOnlyCommandsWriteNothing.
- Step 7: Update the e2e tests that this changes
e2e/reap_test.go:112-115: the row is now ci<TAB>SHA256:...<TAB>last_seen<TAB>alice/app<TAB>idle. The existing assertions strings.Contains(out, "\nci\t") and strings.Contains(out, "\talice/app\tidle") still hold. No change unless the test fails; run it in Step 8.
e2e/mrbuilds_test.go:95 claims a fork head with an admin key. Add the flag:
out, errOut, code := inst.ssh(t, runnerKey, "", "runner", "next", "--untrusted", "alice/app", "--json")
e2e/build_cancel_test.go: find every "runner", "next" that claims a merge request head from a fork (grep -n 'runner", "next"' e2e/build_cancel_test.go, and read the test around each). Add "--untrusted" right after "next" where the queued build is a fork head. Same-repository branches are trusted and need nothing.
- Step 8: Run those e2e tests
Run: go test ./e2e -run 'TestStaleBuildReapedWithoutRunner|TestForkMRHeadIsBuilt|TestRunnerNextScopedToRepos|TestRunnerScopedKey' -count=1 2>&1 | tail -5
Expected: PASS.
- Step 9: Commit
git add internal/control/build.go internal/control/admin.go internal/control/runnernext_test.go internal/control/runnerattach_test.go e2e/reap_test.go e2e/mrbuilds_test.go e2e/build_cancel_test.go
git commit -m "control: a runner key claims only the repositories it is attached to
runner next takes --untrusted; without it fork heads are skipped. runner
log and runner done refuse a build outside the key's attachments. The
heartbeat and admin runners are per key.
Ref #184"
Task 4: Control and CLI: repo runner add|list|remove
Files:
- Create:
internal/control/runnerrepo.go - Create:
internal/control/runnerrepo_test.go - Modify:
cmd/gitbay/main.go:437-440(agroup("runner", ...)besidedeploy-key)
Interfaces:
-
Consumes: the store functions from Task 2;
resolveRepo(c, path, policy.CanAdmin);c.Store.Audit(userID, action string, fields map[string]any). -
Produces:
repo runner add <owner/name>(stdin: public key) →{"fingerprint": ..., "repo": ...}repo runner list <owner/name>→[]store.RepoRunnerrepo runner remove <owner/name> <fingerprint>→{"removed": fingerprint}
-
Step 1: Write the failing tests
internal/control/runnerrepo_test.go:
package control
import (
"bytes"
"strings"
"testing"
"gitbay.org/gitbay/internal/config"
"gitbay.org/gitbay/internal/protocol"
"gitbay.org/gitbay/internal/store"
)
// Generated once with ssh-keygen -t ed25519; a valid authorized_keys line.
const testRunnerPub = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILAr2r82jFsCJwsEyrEf2wgKy9Dv45xYYici6Ii7NyCS runner@test\n"
func repoRunnerCtx(t *testing.T, st *store.Store, uid int64, admin bool, stdin string) (*Ctx, *bytes.Buffer) {
t.Helper()
var out bytes.Buffer
return &Ctx{
User: store.User{ID: uid, Username: "alice", IsAdmin: admin},
Scope: "full",
Source: "SHA256:session",
Store: st,
Cfg: config.Config{Server: config.Server{SiteURL: "https://x.test"}},
Stdin: strings.NewReader(stdin),
Stdout: &out,
Stderr: &out,
JSON: true,
}, &out
}
// A fresh key is registered on the caller's account with scope runner and
// attached; a second add is a no-op; list shows it; remove detaches and
// leaves the key on the account.
func TestRepoRunnerAddListRemove(t *testing.T) {
st, repo, uid := newQueueTestRepo(t)
c, out := repoRunnerCtx(t, st, uid, false, testRunnerPub)
if code := runRepoRunnerAdd(c, []string{repo.Path()}); code != protocol.ExitOK {
t.Fatalf("add: exit %d %s", code, out.String())
}
if !strings.Contains(out.String(), `"fingerprint":"SHA256:`) {
t.Fatalf("add output: %s", out.String())
}
keys, _ := st.ListSSHKeys(uid)
if len(keys) != 1 || keys[0].Scope != "runner" {
t.Fatalf("key not registered as runner: %+v", keys)
}
fp := keys[0].Fingerprint
c, out = repoRunnerCtx(t, st, uid, false, testRunnerPub)
if code := runRepoRunnerAdd(c, []string{repo.Path()}); code != protocol.ExitOK {
t.Fatalf("second add: exit %d %s", code, out.String())
}
c, out = repoRunnerCtx(t, st, uid, false, "")
if code := runRepoRunnerList(c, []string{repo.Path()}); code != protocol.ExitOK || strings.Count(out.String(), fp) != 1 {
t.Fatalf("list: exit %d %s", code, out.String())
}
c, out = repoRunnerCtx(t, st, uid, false, "")
if code := runRepoRunnerRemove(c, []string{repo.Path(), fp}); code != protocol.ExitOK {
t.Fatalf("remove: exit %d %s", code, out.String())
}
if ok, _ := st.RunnerAttached(keys[0].ID, repo.ID); ok {
t.Fatal("still attached after remove")
}
if keys, _ = st.ListSSHKeys(uid); len(keys) != 1 {
t.Fatal("remove dropped the key from the account")
}
c, out = repoRunnerCtx(t, st, uid, false, "")
if code := runRepoRunnerRemove(c, []string{repo.Path(), fp}); code != protocol.ExitNotFound {
t.Fatalf("remove twice: exit %d, want %d", code, protocol.ExitNotFound)
}
}
// A key that already exists with another scope is never promoted, and
// another account's runner key is refused unless the caller is an admin.
func TestRepoRunnerAddRefusesWrongKeys(t *testing.T) {
st, repo, uid := newQueueTestRepo(t)
c, _ := repoRunnerCtx(t, st, uid, false, testRunnerPub)
// Register the same key as a full key first.
if code := runKeysAdd(c, nil); code != protocol.ExitOK {
t.Fatal("keys add failed")
}
c, out := repoRunnerCtx(t, st, uid, false, testRunnerPub)
if code := runRepoRunnerAdd(c, []string{repo.Path()}); code != protocol.ExitDenied {
t.Fatalf("full key accepted as runner: exit %d %s", code, out.String())
}
keys, _ := st.ListSSHKeys(uid)
if keys[0].Scope != "full" {
t.Fatalf("scope changed to %s", keys[0].Scope)
}
// Someone else's runner key.
bob, _ := st.CreateUser("bob", false)
if err := st.AddSSHKey(bob, "SHA256:bobrunner", "ssh-ed25519", []byte("x"), "runner"); err != nil {
t.Fatal(err)
}
st.RemoveSSHKey(uid, keys[0].Fingerprint)
if err := st.AddSSHKey(bob, keys[0].Fingerprint, "ssh-ed25519", keys[0].Blob, "runner"); err != nil {
t.Fatal(err)
}
c, out = repoRunnerCtx(t, st, uid, false, testRunnerPub)
if code := runRepoRunnerAdd(c, []string{repo.Path()}); code != protocol.ExitDenied {
t.Fatalf("another account's key attached by a non-admin: exit %d %s", code, out.String())
}
c, out = repoRunnerCtx(t, st, uid, true, testRunnerPub)
if code := runRepoRunnerAdd(c, []string{repo.Path()}); code != protocol.ExitOK {
t.Fatalf("admin could not attach another account's runner key: exit %d %s", code, out.String())
}
}
- Step 2: Run them to see them fail
Run: go test ./internal/control -run TestRepoRunner 2>&1 | head -5
Expected: undefined: runRepoRunnerAdd.
- Step 3: Write
internal/control/runnerrepo.go
package control
import (
"errors"
"fmt"
"io"
"golang.org/x/crypto/ssh"
"gitbay.org/gitbay/internal/policy"
"gitbay.org/gitbay/internal/protocol"
"gitbay.org/gitbay/internal/store"
)
// Runners attached to a repository (#184). A runner key claims builds only
// for the repositories it is attached to; a repository admin attaches it
// by pasting the runner's public key. The key lands on the admin's own
// account with scope runner, which confines it to the runner protocol and
// read-only git.
func init() {
register(Command{Path: []string{"repo", "runner", "add"},
Summary: "attach a runner's public key to a repository",
Usage: "repo runner add <owner/name> < key.pub",
ReadsStdin: true, Run: runRepoRunnerAdd})
register(Command{Path: []string{"repo", "runner", "list"},
Summary: "list the runners attached to a repository",
Usage: "repo runner list <owner/name>", ReadOnly: true, Run: runRepoRunnerList})
register(Command{Path: []string{"repo", "runner", "remove"},
Summary: "detach a runner from a repository",
Usage: "repo runner remove <owner/name> <fingerprint>", Run: runRepoRunnerRemove})
}
func runRepoRunnerAdd(c *Ctx, args []string) int {
f, err := parseFlags(args, flagSpec{MaxPos: 1, Usage: "repo runner add <owner/name> < key.pub"})
if err != nil || len(f.Pos) != 1 {
return c.fail(protocol.ExitUsage, "usage: repo runner add <owner/name> < key.pub")
}
repo, code := resolveRepo(c, f.Pos[0], policy.CanAdmin)
if code >= 0 {
return code
}
raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10))
if err != nil {
return c.fail(protocol.ExitFailure, "reading key: %v", err)
}
pub, _, _, _, err := ssh.ParseAuthorizedKey(raw)
if err != nil {
return c.fail(protocol.ExitUsage, "not a valid public key in authorized_keys format: %v", err)
}
fp := ssh.FingerprintSHA256(pub)
key, err := c.Store.SSHKeyByFingerprint(fp)
switch {
case errors.Is(err, store.ErrNotFound):
if err := c.Store.AddSSHKey(c.User.ID, fp, pub.Type(), pub.Marshal(), "runner"); err != nil {
return c.fail(protocol.ExitFailure, "adding key: %v", err)
}
if key, err = c.Store.SSHKeyByFingerprint(fp); err != nil {
return c.fail(protocol.ExitFailure, "%v", err)
}
case err != nil:
return c.fail(protocol.ExitFailure, "%v", err)
case key.Scope != "runner":
// A full key would let a build step administer the account; a
// deploy key is bound elsewhere. A runner gets a key of its own.
return c.fail(protocol.ExitDenied, "%s is a %s key, not a runner key; give the runner a key of its own", fp, key.Scope)
case key.UserID != c.User.ID && !c.User.IsAdmin:
return c.fail(protocol.ExitDenied, "%s belongs to another account", fp)
}
if err := c.Store.AttachRunner(key.ID, repo.ID); err != nil {
return c.fail(protocol.ExitFailure, "%v", err)
}
c.Store.Audit(c.User.ID, "repo.runner.add", map[string]any{"repo": repo.Path(), "fingerprint": fp})
d := map[string]string{"fingerprint": fp, "repo": repo.Path()}
return c.emit(d, func(w io.Writer) {
fmt.Fprintf(w, "runner %s attached to %s\n", fp, repo.Path())
})
}
func runRepoRunnerList(c *Ctx, args []string) int {
if len(args) != 1 {
return c.fail(protocol.ExitUsage, "usage: repo runner list <owner/name>")
}
repo, code := resolveRepo(c, args[0], policy.CanAdmin)
if code >= 0 {
return code
}
runners, err := c.Store.ListRepoRunners(repo.ID)
if err != nil {
return c.fail(protocol.ExitFailure, "%v", err)
}
if runners == nil {
runners = []store.RepoRunner{}
}
return c.emit(runners, func(w io.Writer) {
for _, r := range runners {
seen := r.LastSeen
if seen == "" {
seen = "never"
}
held := "idle"
if r.BuildNumber != 0 {
held = fmt.Sprintf("%s #%d %s since %s", r.BuildRepo, r.BuildNumber, r.BuildJob, r.StartedAt)
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Fingerprint, r.Algo, r.Username, seen, held)
}
})
}
func runRepoRunnerRemove(c *Ctx, args []string) int {
if len(args) != 2 {
return c.fail(protocol.ExitUsage, "usage: repo runner remove <owner/name> <fingerprint>")
}
repo, code := resolveRepo(c, args[0], policy.CanAdmin)
if code >= 0 {
return code
}
if err := c.Store.DetachRunner(repo.ID, args[1]); err != nil {
if errors.Is(err, store.ErrNotFound) {
return c.fail(protocol.ExitNotFound, "no runner %s on %s", args[1], repo.Path())
}
return c.fail(protocol.ExitFailure, "%v", err)
}
c.Store.Audit(c.User.ID, "repo.runner.remove", map[string]any{"repo": repo.Path(), "fingerprint": args[1]})
return c.emit(map[string]string{"removed": args[1]}, func(w io.Writer) {
fmt.Fprintf(w, "runner %s detached from %s\n", args[1], repo.Path())
})
}
Store.Audit(actorID int64, action string, data map[string]any) returns nothing.
- Step 4: Add the CLI table entries
In cmd/gitbay/main.go, directly after the group("deploy-key", ...) block (line 437-440):
group("runner", "runners attached to a repository",
pass("add", "attach a runner's public key: < key.pub", passOpts{server: []string{"repo", "runner", "add"}, needsRepo: true, alwaysStdin: true, stdinWhat: "an SSH public key"}),
pass("list", "list attached runners", passOpts{server: []string{"repo", "runner", "list"}, needsRepo: true}),
pass("remove", "detach a runner: <fingerprint>", passOpts{server: []string{"repo", "runner", "remove"}, needsRepo: true}),
),
- Step 5: Run the tests
Run: go build ./... && go test ./internal/control ./cmd/gitbay 2>&1 | tail -5
Expected: PASS, including the CLI coverage test.
- Step 6: Commit
git add internal/control/runnerrepo.go internal/control/runnerrepo_test.go cmd/gitbay/main.go
git commit -m "control, cli: repo runner add, list, remove
Ref #184"
Task 5: Web: Runners on the repository settings page
Files:
- Modify:
internal/httpd/settings.go:18-49(settingsPage,settingsForm) and theswitchinsettingsSubmit - Modify:
internal/web/templates/settings.html(a section after Dependencies, before Lifecycle) - Create:
e2e/runnerweb_test.go
Interfaces:
-
Consumes:
repo runner list|add|removefrom Task 4;s.runControlInto,s.runControlStdin(u, argv, stdin) (msg string, ok bool),s.runControl. -
Produces: form fields
field=runner-addwithkey, andfield=runner-removewithfingerprint. -
Step 1: Write the failing e2e test
e2e/runnerweb_test.go:
package e2e
import (
"net/url"
"os"
"strings"
"testing"
)
// The settings page attaches and detaches runners through the same
// commands the CLI uses, and lists what is attached.
func TestRunnerSettingsWeb(t *testing.T) {
inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
aliceKey := inst.newKey(t, "alice")
inst.admin(t, "admin", "user", "create", "alice",
"--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
t.Fatalf("repo create: %s", errOut)
}
runnerKey := inst.newKey(t, "laptop")
pub, _ := os.ReadFile(runnerKey + ".pub")
alice := inst.login(t, aliceKey)
settings := inst.base() + "/alice/app/settings"
_, body := browserGet(t, alice, settings)
if !strings.Contains(body, "No runners attached") {
t.Fatalf("empty state missing:\n%s", body)
}
if status, _ := browserPost(t, alice, settings, url.Values{"field": {"runner-add"}, "key": {string(pub)}}); status != 200 {
t.Fatalf("runner-add post: %d", status)
}
out, _, _ := inst.ssh(t, aliceKey, "", "repo", "runner", "list", "alice/app", "--json")
if !strings.Contains(out, `"fingerprint":"SHA256:`) {
t.Fatalf("not attached after the form: %s", out)
}
fp := out[strings.Index(out, "SHA256:"):]
fp = fp[:strings.Index(fp, `"`)]
_, body = browserGet(t, alice, settings)
if !strings.Contains(body, fp) || !strings.Contains(body, `value="runner-remove"`) {
t.Fatalf("attached runner not listed:\n%s", body)
}
if status, _ := browserPost(t, alice, settings, url.Values{"field": {"runner-remove"}, "fingerprint": {fp}}); status != 200 {
t.Fatalf("runner-remove post: %d", status)
}
if out, _, _ = inst.ssh(t, aliceKey, "", "repo", "runner", "list", "alice/app", "--json"); strings.Contains(out, fp) {
t.Fatalf("still attached after remove: %s", out)
}
}
- Step 2: Run it to see it fail
Run: go test ./e2e -run TestRunnerSettingsWeb -count=1 2>&1 | tail -5
Expected: FAIL at "empty state missing".
- Step 3: Handler changes in
internal/httpd/settings.go
settingsPage gains:
Runners []store.RepoRunner
In settingsForm, after the deps read:
var runners []store.RepoRunner
s.runControlInto(u, []string{"repo", "runner", "list", repo.Path()}, &runners)
and pass Runners: runners to the struct literal.
In settingsSubmit's switch, before default::
case "runner-add":
body := v("key")
if body == "" {
s.settingsRedirect(w, r, "paste the runner's public key")
return
}
msg, ok := s.runControlStdin(u, []string{"repo", "runner", "add", repo}, body+"\n")
if ok {
msg = ""
}
s.settingsRedirect(w, r, msg)
return
case "runner-remove":
argv = []string{"repo", "runner", "remove", repo, v("fingerprint")}
- Step 4: Template section
In internal/web/templates/settings.html, before <h2>Lifecycle</h2>:
<h2>Runners</h2>
{{if .Runners}}
<ul class="protlist">
{{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>
<form method="post" action="{{$base}}" class="inline">
<input type="hidden" name="field" value="runner-remove">
<input type="hidden" name="fingerprint" value="{{.Fingerprint}}">
<button type="submit" class="linklike">Detach</button>
</form></li>
{{end}}
</ul>
{{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}}
<form method="post" action="{{$base}}" class="setform">
<input type="hidden" name="field" value="runner-add">
<label for="runner-key">Attach a runner</label>
<textarea id="runner-key" name="key" rows="3" placeholder="ssh-ed25519 AAAA… (from gitbay-runner init)"></textarea>
<button type="submit">Attach</button>
</form>
<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>
- Step 5: Run the test
Run: go test ./e2e -run TestRunnerSettingsWeb -count=1 2>&1 | tail -5
Expected: PASS.
- Step 6: Commit
git add internal/httpd/settings.go internal/web/templates/settings.html e2e/runnerweb_test.go
git commit -m "httpd: attach and detach runners on the settings page
Ref #184"
Task 6: Runner: config file, -identity, -untrusted
Files:
- Create:
cmd/gitbay-runner/config.go - Create:
cmd/gitbay-runner/config_test.go - Modify:
cmd/gitbay-runner/main.go(flag block,runnerstruct,step, ssh option assembly)
Interfaces:
-
Produces:
configDir() string:$XDG_CONFIG_HOME/gitbay-runneror$HOME/.config/gitbay-runner.defaultConfigPath() string:configDir()/config.toml.configPathFromArgs(args []string, def string) string: honours-config X,--config X,-config=X.loadConfig(path string) (map[string]string, bool, error): flag name to value, false when the file is absent.applyConfig(fs *flag.FlagSet, values map[string]string) error:fs.Seteach.identityOpts(path string) []string:["-i", path, "-o", "IdentitiesOnly=yes"]or nil.- Flags
-config,-identity,-untrusted.
-
Step 1: Write the failing tests
cmd/gitbay-runner/config_test.go:
package main
import (
"flag"
"os"
"path/filepath"
"testing"
)
// A config file sets the flags' values; a flag on the command line wins.
func TestConfigFileFeedsFlagsAndFlagsOverride(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
os.WriteFile(path, []byte("remote = \"git@example.test\"\npoll = \"9s\"\nuntrusted = true\nidentity = \"/k\"\njobs = 2\n"), 0o600)
values, found, err := loadConfig(path)
if err != nil || !found {
t.Fatalf("loadConfig: found=%v err=%v", found, err)
}
fs := flag.NewFlagSet("t", flag.ContinueOnError)
remote := fs.String("remote", "git@gitbay.org", "")
poll := fs.Duration("poll", 0, "")
untrusted := fs.Bool("untrusted", false, "")
identity := fs.String("identity", "", "")
jobs := fs.Int("jobs", 1, "")
if err := applyConfig(fs, values); err != nil {
t.Fatal(err)
}
if err := fs.Parse([]string{"-poll", "3s"}); err != nil {
t.Fatal(err)
}
if *remote != "git@example.test" || poll.String() != "3s" || !*untrusted || *identity != "/k" || *jobs != 2 {
t.Fatalf("remote=%s poll=%s untrusted=%v identity=%s jobs=%d", *remote, poll, *untrusted, *identity, *jobs)
}
if _, found, err := loadConfig(filepath.Join(dir, "missing.toml")); found || err != nil {
t.Fatalf("missing file: found=%v err=%v", found, err)
}
if _, _, err := loadConfig(path); err != nil {
t.Fatal(err)
}
os.WriteFile(path, []byte("nonsense = \"x\"\n"), 0o600)
if _, _, err := loadConfig(path); err == nil {
t.Fatal("an unknown key was accepted")
}
}
func TestConfigPathFromArgs(t *testing.T) {
for _, tc := range []struct {
args []string
want string
}{
{nil, "/def"},
{[]string{"-once"}, "/def"},
{[]string{"-config", "/a"}, "/a"},
{[]string{"--config", "/b", "-once"}, "/b"},
{[]string{"-config=/c"}, "/c"},
} {
if got := configPathFromArgs(tc.args, "/def"); got != tc.want {
t.Errorf("%v: got %s want %s", tc.args, got, tc.want)
}
}
}
func TestConfigDirHonoursXDG(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", "/x")
if got := configDir(); got != "/x/gitbay-runner" {
t.Fatalf("got %s", got)
}
t.Setenv("XDG_CONFIG_HOME", "")
t.Setenv("HOME", "/h")
if got := configDir(); got != "/h/.config/gitbay-runner" {
t.Fatalf("got %s", got)
}
}
func TestIdentityOpts(t *testing.T) {
if got := identityOpts(""); got != nil {
t.Fatalf("empty identity produced %v", got)
}
got := identityOpts("/k")
if len(got) != 4 || got[0] != "-i" || got[1] != "/k" || got[3] != "IdentitiesOnly=yes" {
t.Fatalf("got %v", got)
}
}
- Step 2: Run them to see them fail
Run: go test ./cmd/gitbay-runner -run 'TestConfig|TestIdentity' 2>&1 | head -5
Expected: undefined: loadConfig and friends.
- Step 3: Write
cmd/gitbay-runner/config.go
package main
import (
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/BurntSushi/toml"
)
// The runner takes everything as flags, which does not work under a
// service manager. config.toml in the config directory carries the same
// names; a flag on the command line overrides it (#184).
func configDir() string {
if x := os.Getenv("XDG_CONFIG_HOME"); x != "" {
return filepath.Join(x, "gitbay-runner")
}
return filepath.Join(os.Getenv("HOME"), ".config", "gitbay-runner")
}
func defaultConfigPath() string { return filepath.Join(configDir(), "config.toml") }
// configPathFromArgs finds -config before the flag set is parsed, since
// the file's values must be set before parsing for flags to override them.
func configPathFromArgs(args []string, def string) string {
for i, a := range args {
a = strings.TrimPrefix(a, "-")
if a == "-config" || a == "config" {
if i+1 < len(args) {
return args[i+1]
}
}
if v, ok := strings.CutPrefix(a, "config="); ok {
return v
}
if v, ok := strings.CutPrefix(a, "-config="); ok {
return v
}
}
return def
}
// configKeys is every key the file may carry: the flag names.
var configKeys = map[string]bool{"remote": true, "ssh-opts": true, "clone-base": true, "workdir": true,
"poll": true, "timeout": true, "repos": true, "jobs": true, "image": true, "isolation": true,
"memory": true, "cpus": true, "untrusted": true, "identity": true}
// loadConfig reads path into flag name → value. Absent file: found is
// false and there is no error. An unknown key is an error, not a typo
// the runner silently ignores.
func loadConfig(path string) (values map[string]string, found bool, err error) {
var raw map[string]any
if _, err := toml.DecodeFile(path, &raw); errors.Is(err, os.ErrNotExist) {
return nil, false, nil
} else if err != nil {
return nil, true, fmt.Errorf("%s: %w", path, err)
}
values = map[string]string{}
for k, v := range raw {
if !configKeys[k] {
return nil, true, fmt.Errorf("%s: unknown key %s", path, k)
}
values[k] = fmt.Sprint(v)
}
return values, true, nil
}
// applyConfig sets each value on the flag set, which is what parsing the
// command line would do; parse afterwards and the command line wins.
func applyConfig(fs *flag.FlagSet, values map[string]string) error {
for k, v := range values {
if fs.Lookup(k) == nil {
return fmt.Errorf("config: unknown key %s", k)
}
if err := fs.Set(k, v); err != nil {
return fmt.Errorf("config: %s: %w", k, err)
}
}
return nil
}
// identityOpts is what makes ssh and git use the runner's own key and no
// other: on a laptop the ambient key is the user's full-scope one, which
// the runner protocol refuses.
func identityOpts(path string) []string {
if path == "" {
return nil
}
return []string{"-i", path, "-o", "IdentitiesOnly=yes"}
}
- Step 4: Wire it into
main.go
In main(), replace flag.Parse() and the flag block with a flag set fed by the config file. Add three flags and keep the others as they are:
var (
configPath = flag.String("config", defaultConfigPath(), "config file; keys are these flag names, flags override it")
identity = flag.String("identity", "", "ssh private key to poll and clone with (default: the key gitbay-runner init generated, if present)")
untrusted = flag.Bool("untrusted", false, "also claim untrusted builds: merge request heads from forks (needs -isolation podman to be safe)")
// ... existing flags unchanged ...
)
path := configPathFromArgs(os.Args[1:], *configPath)
if values, found, err := loadConfig(path); err != nil {
log.Fatal(err)
} else if found {
if err := applyConfig(flag.CommandLine, values); err != nil {
log.Fatal(err)
}
log.Printf("config: %s", path)
}
flag.Parse()
After if *sshOpts != "" { r.sshOpts = strings.Fields(*sshOpts) }:
if *identity == "" {
if p := filepath.Join(configDir(), "id_ed25519"); fileExists(p) {
*identity = p
}
}
r.sshOpts = append(identityOpts(*identity), r.sshOpts...)
r.untrusted = *untrusted
with
func fileExists(p string) bool { _, err := os.Stat(p); return err == nil }
runner struct gains untrusted bool. In step():
args := []string{"runner", "next"}
if r.untrusted {
args = append(args, "--untrusted")
}
args = append(append(args, r.repos...), "--json")
out, err := r.ssh(nil, args...)
Both ssh() and the gitSSH line already use r.sshOpts, so the identity reaches both.
Move the init dispatch hook in now so Task 7 has a place to land, at the top of main():
if len(os.Args) > 1 && os.Args[1] == "init" {
os.Exit(runInit(os.Args[2:]))
}
and a stub in config.go until Task 7 replaces it:
func runInit(args []string) int { fmt.Fprintln(os.Stderr, "init: not implemented"); return 2 }
- Step 5: Run the tests
Run: go build ./... && go vet ./cmd/gitbay-runner && go test ./cmd/gitbay-runner 2>&1 | tail -3
Expected: PASS.
- Step 6: Commit
git add cmd/gitbay-runner/config.go cmd/gitbay-runner/config_test.go cmd/gitbay-runner/main.go
git commit -m "runner: config.toml, -identity, -untrusted
Ref #184"
Task 7: Runner: gitbay-runner init
Files:
- Create:
cmd/gitbay-runner/init.go(replaces the stubrunInitinconfig.go; delete the stub) - Create:
cmd/gitbay-runner/init_test.go
Interfaces:
-
Consumes:
configDir(),defaultWorkdir(),toolpath.Look("ssh-keygen"). -
Produces:
runInit(args []string) int; files<configDir>/id_ed25519,id_ed25519.pub,config.toml. -
Step 1: Write the failing test
cmd/gitbay-runner/init_test.go:
package main
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// init creates the key and config once, prints the key and the attach
// command, and running it again changes nothing.
func TestInitWritesKeyAndConfigOnce(t *testing.T) {
if _, err := exec.LookPath("ssh-keygen"); err != nil {
t.Skip("ssh-keygen not on PATH")
}
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
var out bytes.Buffer
initOut = &out
defer func() { initOut = os.Stdout }()
if code := runInit([]string{"-remote", "git@example.test"}); code != 0 {
t.Fatalf("init: exit %d\n%s", code, out.String())
}
cdir := filepath.Join(dir, "gitbay-runner")
key := filepath.Join(cdir, "id_ed25519")
pub, err := os.ReadFile(key + ".pub")
if err != nil || !strings.HasPrefix(string(pub), "ssh-ed25519 ") {
t.Fatalf("public key: %v %q", err, pub)
}
if fi, _ := os.Stat(key); fi.Mode().Perm() != 0o600 {
t.Fatalf("private key mode %o", fi.Mode().Perm())
}
if fi, _ := os.Stat(cdir); fi.Mode().Perm() != 0o700 {
t.Fatalf("config dir mode %o", fi.Mode().Perm())
}
cfg, _ := os.ReadFile(filepath.Join(cdir, "config.toml"))
for _, want := range []string{"remote = \"git@example.test\"", "isolation = \"none\"", "untrusted = false", "identity = \"" + key + "\""} {
if !strings.Contains(string(cfg), want) {
t.Fatalf("config lacks %q:\n%s", want, cfg)
}
}
for _, want := range []string{strings.TrimSpace(string(pub)), "gitbay repo runner add owner/name < " + key + ".pub", "https://example.test/owner/name/settings"} {
if !strings.Contains(out.String(), want) {
t.Fatalf("output lacks %q:\n%s", want, out.String())
}
}
out.Reset()
if code := runInit([]string{"-remote", "git@other.test"}); code != 0 {
t.Fatalf("second init: exit %d\n%s", code, out.String())
}
if pub2, _ := os.ReadFile(key + ".pub"); string(pub2) != string(pub) {
t.Fatal("second init replaced the key")
}
if cfg2, _ := os.ReadFile(filepath.Join(cdir, "config.toml")); string(cfg2) != string(cfg) {
t.Fatal("second init rewrote the config")
}
}
// podman needs an image; init refuses to write a config the runner would
// refuse to start with.
func TestInitPodmanNeedsImage(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
var out bytes.Buffer
initOut = &out
defer func() { initOut = os.Stdout }()
if code := runInit([]string{"-isolation", "podman"}); code != 2 {
t.Fatalf("exit %d, want 2:\n%s", code, out.String())
}
}
- Step 2: Run it to see it fail
Run: go test ./cmd/gitbay-runner -run TestInit 2>&1 | head -5
Expected: undefined: initOut.
- Step 3: Write
cmd/gitbay-runner/init.go
package main
import (
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"gitbay.org/gitbay/internal/toolpath"
)
// initOut is where init prints; tests capture it.
var initOut io.Writer = os.Stdout
// runInit makes a fresh install ready to attach: a key of its own, a
// config file the service reads, and the one command to run next. It never
// overwrites a key or a config that exists, so running it twice is safe.
func runInit(args []string) int {
fs := flag.NewFlagSet("init", flag.ContinueOnError)
fs.SetOutput(initOut)
remote := fs.String("remote", "git@gitbay.org", "ssh destination of the gitbay server")
workdir := fs.String("workdir", defaultWorkdir(), "build workspace root")
isolation := fs.String("isolation", isolationNone, "how steps run: none, or podman with -image")
image := fs.String("image", "", "container image for -isolation podman")
if err := fs.Parse(args); err != nil {
return 2
}
if *isolation == isolationPodman && *image == "" {
fmt.Fprintln(initOut, "-isolation podman needs -image <ref>: the runner refuses to start without one, and there is no image to guess")
return 2
}
if *isolation != isolationPodman && *isolation != isolationNone {
fmt.Fprintf(initOut, "unknown isolation %q\n", *isolation)
return 2
}
dir := configDir()
if err := os.MkdirAll(dir, 0o700); err != nil {
fmt.Fprintln(initOut, err)
return 1
}
os.Chmod(dir, 0o700)
key := filepath.Join(dir, "id_ed25519")
if !fileExists(key) {
cmd := exec.Command(toolpath.Look("ssh-keygen"), "-q", "-t", "ed25519", "-N", "", "-C", "gitbay-runner", "-f", key)
if out, err := cmd.CombinedOutput(); err != nil {
fmt.Fprintf(initOut, "ssh-keygen: %v\n%s", err, out)
return 1
}
}
os.Chmod(key, 0o600)
cfgPath := filepath.Join(dir, "config.toml")
if !fileExists(cfgPath) {
var b strings.Builder
fmt.Fprintf(&b, "remote = %q\n", *remote)
fmt.Fprintf(&b, "workdir = %q\n", *workdir)
fmt.Fprintf(&b, "isolation = %q\n", *isolation)
if *image != "" {
fmt.Fprintf(&b, "image = %q\n", *image)
}
fmt.Fprintf(&b, "untrusted = false\n")
fmt.Fprintf(&b, "identity = %q\n", key)
if err := os.WriteFile(cfgPath, []byte(b.String()), 0o600); err != nil {
fmt.Fprintln(initOut, err)
return 1
}
}
pub, err := os.ReadFile(key + ".pub")
if err != nil {
fmt.Fprintln(initOut, err)
return 1
}
host := *remote
if i := strings.LastIndex(host, "@"); i >= 0 {
host = host[i+1:]
}
fmt.Fprintf(initOut, "config: %s\nkey: %s\n\n", cfgPath, key)
if *isolation == isolationNone {
fmt.Fprintln(initOut, "Steps run on this machine as your user, with no container. Untrusted builds\n(merge requests from forks) are excluded unless the runner is started with\n-untrusted, so that means your own commits.\n")
}
fmt.Fprintf(initOut, "This runner's public key:\n\n %s\nAttach it to each repository it should build, as a repository admin:\n\n gitbay repo runner add owner/name < %s.pub\n\nor paste it under Runners at https://%s/owner/name/settings\n\nThen start it:\n\n brew services start krz/tap/gitbay-runner\n\nor run gitbay-runner with no arguments.\n",
strings.TrimSpace(string(pub)), key, host)
return 0
}
isolationNone and isolationPodman are the constants in isolate.go:20-21. Delete the stub runInit from config.go.
- Step 4: Run the tests
Run: go build ./... && go vet ./cmd/gitbay-runner && go test ./cmd/gitbay-runner 2>&1 | tail -3
Expected: PASS.
- Step 5: Commit
git add cmd/gitbay-runner/init.go cmd/gitbay-runner/init_test.go cmd/gitbay-runner/config.go
git commit -m "runner: init generates the key and config and prints the attach step
Ref #184"
Task 8: e2e: init, attach, build; fork head waits
Files:
- Create:
e2e/runnerattach_test.go
Interfaces:
-
Consumes:
buildRunner(t),inst.newKey,inst.admin,inst.ssh(t, key, stdin, argv...),inst.gitEnv,inst.sshURL,mustGit,inst.port,inst.sshDir(all ine2e/ci_test.goand the instance helpers). -
Step 1: Write the test
e2e/runnerattach_test.go:
package e2e
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// The whole flow a user goes through: init on their machine, attach the
// printed key to their repository, start the runner from the config init
// wrote. The runner builds their push and leaves a fork's merge request
// head alone until started with -untrusted.
func TestAttachedRunnerBuildsOwnRepo(t *testing.T) {
inst := startInstance(t)
inst.runner = buildRunner(t)
aliceKey := inst.newKey(t, "alice")
inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
bobKey := inst.newKey(t, "bob")
inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
t.Fatalf("repo create: %s", errOut)
}
// init on "alice's laptop".
xdg := t.TempDir()
initCmd := exec.Command(inst.runner, "init", "-remote", "git@127.0.0.1")
initCmd.Env = append(os.Environ(), "XDG_CONFIG_HOME="+xdg)
initOut, err := initCmd.CombinedOutput()
if err != nil {
t.Fatalf("init: %v\n%s", err, initOut)
}
cdir := filepath.Join(xdg, "gitbay-runner")
pub, err := os.ReadFile(filepath.Join(cdir, "id_ed25519.pub"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(initOut), strings.TrimSpace(string(pub))) {
t.Fatalf("init did not print the key:\n%s", initOut)
}
// The unattached key claims nothing, even with a build queued.
work := t.TempDir()
env := inst.gitEnv(aliceKey)
mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
dir := filepath.Join(work, "w")
os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte("jobs:\n unit:\n steps:\n - echo built\n"), 0o644)
mustGit(t, dir, env, "checkout", "-q", "-b", "main")
mustGit(t, dir, env, "add", ".")
mustGit(t, dir, env, "commit", "-q", "-m", "ci")
mustGit(t, dir, env, "push", "-q", "origin", "main")
run := func(extra ...string) string {
t.Helper()
// No -i in ssh-opts: the identity from the config is what
// authenticates, which is the point.
opts := fmt.Sprintf("-p %d -o StrictHostKeyChecking=no -o UserKnownHostsFile=%s -o BatchMode=yes",
inst.port, filepath.Join(inst.sshDir, "known_hosts"))
args := append([]string{"-config", filepath.Join(cdir, "config.toml"), "-once",
"-ssh-opts", opts,
"-clone-base", fmt.Sprintf("ssh://git@127.0.0.1:%d", inst.port),
"-workdir", t.TempDir()}, extra...)
cmd := exec.Command(inst.runner, args...)
cmd.Env = append(os.Environ(), "XDG_CONFIG_HOME="+xdg, "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("runner: %v\n%s", err, out)
}
return string(out)
}
if out, _, _ := inst.ssh(t, aliceKey, "", "build", "list", "alice/app"); !strings.Contains(out, "unit\tpending") {
t.Fatalf("build not pending before attach: %s", out)
}
// Attach with the printed key.
if _, errOut, code := inst.ssh(t, aliceKey, string(pub), "repo", "runner", "add", "alice/app"); code != 0 {
t.Fatalf("repo runner add: %s", errOut)
}
out, _, _ := inst.ssh(t, aliceKey, "", "repo", "runner", "list", "alice/app", "--json")
if !strings.Contains(out, `"username":"alice"`) || strings.Contains(out, `"last_seen":"20`) {
t.Fatalf("list after attach: %s", out)
}
// The runner builds it.
run()
if out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app"); !strings.Contains(out, "unit\tsuccess") {
t.Fatalf("build not built by the attached runner: %s", out)
}
if out, _, _ = inst.ssh(t, aliceKey, "", "repo", "runner", "list", "alice/app", "--json"); !strings.Contains(out, `"last_seen":"20`) {
t.Fatalf("no heartbeat after a poll: %s", out)
}
// bob forks and opens a merge request: an untrusted build in the target.
if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "fork", "alice/app"); code != 0 {
t.Fatalf("fork: %s", errOut)
}
bwork := t.TempDir()
benv := inst.gitEnv(bobKey)
mustGit(t, bwork, benv, "clone", inst.sshURL("bob/app"), "w")
bdir := filepath.Join(bwork, "w")
mustGit(t, bdir, benv, "checkout", "-q", "-b", "feat")
os.WriteFile(filepath.Join(bdir, "f.txt"), []byte("y\n"), 0o644)
mustGit(t, bdir, benv, "add", ".")
mustGit(t, bdir, benv, "commit", "-q", "-m", "change")
mustGit(t, bdir, benv, "push", "-q", "origin", "feat")
if _, _, code := inst.ssh(t, bobKey, "", "build", "cancel", "bob/app", "1"); code != 0 {
t.Fatal("cancel bob's own build")
}
if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/app",
"--source", "bob/app:feat", "--target", "main", "--title", "change"); code != 0 {
t.Fatalf("mr create: %s", errOut)
}
run()
if out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app"); strings.Count(out, "pending") != 1 {
t.Fatalf("fork head was claimed without -untrusted:\n%s", out)
}
run("-untrusted")
if out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app"); strings.Contains(out, "pending") {
t.Fatalf("fork head not built with -untrusted:\n%s", out)
}
}
- Step 2: Run it
Run: go test ./e2e -run TestAttachedRunnerBuildsOwnRepo -count=1 -v 2>&1 | tail -20
Expected: PASS. If the fork's build numbering differs (two jobs, or the fork's push queues more than one build), adjust the build cancel bob/app loop to cancel every pending build listed by build list bob/app --json.
- Step 3: Commit
git add e2e/runnerattach_test.go
git commit -m "e2e: init, attach, and an attached runner building its repository
Ref #184"
Task 9: Docs: wiki pages
Files:
-
Modify:
.gitbay/wiki/Users.org(after the "CI builds" section's last paragraph, before "* Large files (LFS)") -
Modify:
.gitbay/wiki/Admin.org:329-340and:395-402 -
Modify:
.gitbay/wiki/Threat-Model.org:120-126 -
Modify:
.gitbay/wiki/Parity.org(repo table, after thewebhooksrow; and the "SSH only, by design" paragraph is unchanged) -
Modify:
.gitbay/wiki/FAQ.org:20-25 -
Modify:
.gitbay/wiki/CI.org(one sentence after the three mechanisms list) -
Step 1: Users: "Your own runner"
Insert before * Large files (LFS):
** Your own runner
Builds run on runners attached to the repository. An instance need not
offer any: install =gitbay-runner= on a machine of yours and attach it.
#+begin_src sh
brew install krz/tap/gitbay-runner # or a binary from the release
gitbay-runner init -remote git@gitbay.org
#+end_src
=init= generates a key under =~/.config/gitbay-runner/=, writes
=config.toml= beside it, and prints the public key with the command to
attach it:
#+begin_src sh
gitbay repo runner add owner/name < ~/.config/gitbay-runner/id_ed25519.pub
#+end_src
or paste the key under Runners on the repository's settings page. Then
=brew services start krz/tap/gitbay-runner=, or run =gitbay-runner= with
no arguments; it reads the config file, and any flag overrides it.
What it builds: every build for the repositories it is attached to,
with the repository's secrets, and nothing else. Merge requests from
forks are untrusted and wait unless the runner runs with =-untrusted=,
which is only sensible with =-isolation podman -image <ref>= (see
[[Admin][Admin]]). Attach one runner to several repositories by repeating
=repo runner add=; run several runners on one account by running =init=
on each machine. =repo runner list= shows each attached key, when it
last polled and the build it holds; =repo runner remove <fingerprint>=
detaches one (the key stays on your account; =keys remove= drops it).
A runner key reaches only the runner protocol and read-only git, so a
build step that reads it off disk cannot administer your account.
- Step 2: Admin
Replace lines 329-340's opening paragraph ("=gitbay-runner= executes builds ... then removed:") with:
=gitbay-runner= executes builds queued by pushes and merge requests. It
polls over SSH with a key of scope =runner=, which reaches only the
runner protocol and read-only git (a runner executes arbitrary
repository code, so the key it holds must not do more). A runner key
claims builds only for the repositories it is attached to, by =repo
runner add= from a repository admin or an instance admin; an admin key
claims any. Users attach their own runners: see the Users page. For an
instance runner, run it as a dedicated unprivileged user on a non-admin
account. =admin user create --key= registers a full-scope key, so the
runner key is added afterwards through a bootstrap key that is then
removed, and attached to each repository it should build:
After the existing bootstrap code block, add:
#+begin_src sh
gitbay repo runner add krz/site < /var/lib/gitbay-runner/.ssh/id_ed25519.pub
#+end_src
Replace lines 395-402 (from "and the isolation canary, nothing else" to "the boundary is you choosing how to start it.") with:
and the isolation canary, nothing else, because it shares the host with
the forge; any other repository builds on a runner its owner attaches.
=-repos= narrows an admin runner; for a runner key the attachments are
the boundary, held by the server, and =-repos= may only name
repositories among them. =-untrusted= makes a runner claim merge
request heads from forks; the bay1 unit sets it because it isolates in
podman. A runner without it builds trusted commits only.
Add -untrusted to the gitbay-runner -remote git@gitbay.org -repos krz/site,krz/docs example only if that runner isolates; leave the example as is and note under it: "Add =-untrusted= only with =-isolation podman=."
- Step 3: Threat-Model
Replace the "What the runner holds" bullet (lines 120-126) with:
- *What the runner holds.* A key of scope =runner=, which the dispatcher
confines to =runner next=, =runner log= and =runner done= and to
read-only git, and which claims, logs and finishes builds only for
the repositories it is attached to (=repo runner add=). A step that
reads the key off the disk gets exactly that: it cannot administer
the instance, push, read a repository the runner's account cannot, or
touch another repository's builds. An admin key still works for the
runner protocol so an operator can rotate at their own pace; a runner
host should not hold one. Untrusted builds are skipped unless the
runner asks with =-untrusted=, so a runner on a user's machine never
executes a stranger's branch by default.
- Step 4: Parity, FAQ, CI
Parity, repo table, after the webhooks row:
| runners attach, list, detach | yes | yes | no |
The columns are cli, web, ios. iOS is no: outstanding, not intended.
FAQ, replace the "Does CI run for my repository on gitbay.org?" answer:
- Does CI run for my repository on gitbay.org? :: On a runner you
attach. The instance's own runner builds the forge's repositories and
its isolation canary, since it shares the host with the forge.
Install =gitbay-runner= on a machine of yours, run =gitbay-runner
init=, and attach the key it prints with =repo runner add= or on the
repository's settings page; see the Users page. A self-hosted
instance can do the same, or run one runner for whichever
repositories its operator attaches it to.
CI.org, after the three-mechanism list:
Which runner takes a build is the fourth: a build is claimed only by a
runner attached to its repository (or an instance admin's runner), and
an untrusted build only by one started with =-untrusted=. A repository
with no runner attached queues builds nothing claims. See the Users
page.
- Step 5: Check the wiki tests
Run: go test ./internal/hookd ./internal/ci -run 'Wiki|Parity' 2>&1 | tail -3
Expected: PASS (nothing here changes the push-shape table).
- Step 6: Commit
git add .gitbay/wiki/Users.org .gitbay/wiki/Admin.org .gitbay/wiki/Threat-Model.org .gitbay/wiki/Parity.org .gitbay/wiki/FAQ.org .gitbay/wiki/CI.org
git commit -m "wiki: runners attached to repositories
Ref #184"
Task 10: Deploy, release, and the tap
Files:
-
Modify:
deploy/gitbay-runner.override.conf(theExecStartline) -
Modify:
deploy/release.sh:22(the binary list) -
Create in
krz/homebrew-tap(separate clone, after the release is tagged):Formula/gitbay-runner.rb; modifyFormula/gitbay.rb -
Step 1: The bay1 unit claims fork heads
In deploy/gitbay-runner.override.conf, the ExecStart= line gains -untrusted at the end, and the comment above ExecStart gains:
# -untrusted: this runner isolates in podman, so it takes merge request
# heads from forks; a runner without a container must not.
- Step 2: Release binaries include the runner
deploy/release.sh: for bin in gitbay gitbayd; do becomes for bin in gitbay gitbayd gitbay-runner; do, and the header comment's "gitbay and gitbayd" becomes "gitbay, gitbayd and gitbay-runner".
- Step 3: Build, vet, and the touched unit tests
Run: go build ./... && go vet ./... && go test ./internal/store ./internal/control ./cmd/gitbay ./cmd/gitbay-runner ./internal/httpd 2>&1 | tail -8
Expected: all PASS.
- Step 4: Commit and open the MR
git add deploy/gitbay-runner.override.conf deploy/release.sh
git commit -m "deploy: the bay1 runner claims untrusted builds; release ships gitbay-runner
Ref #184"
git push -u origin user-runners
gitbay mr create --source user-runners --target main --title "Runners attached to repositories" --file - <<'MR'
A runner key claims builds only for the repositories it is attached to
(`repo runner add|list|remove`, also on the settings page). `runner next`
skips untrusted builds unless `--untrusted`. Migration 0050.
`gitbay-runner init`, `config.toml`, `-identity`, `-untrusted`.
After deploy, attach the bay1 runner to krz/gitbay and cmc/ci-smoke as
the admin; until then it claims nothing.
Ref #184
MR
Wait for CI on bay1 (gitbay build list on the MR head) before merging: gitbay mr merge <n> --strategy ff.
- Step 5: Deploy and attach (operator, after merge)
make deploy && make deploy-runner
ssh -p 2222 root@46.232.248.67 cat /var/lib/gitbay-runner/.ssh/id_ed25519.pub | gitbay repo runner add krz/gitbay
ssh -p 2222 root@46.232.248.67 cat /var/lib/gitbay-runner/.ssh/id_ed25519.pub | gitbay repo runner add cmc/ci-smoke
gitbay admin runners
The last line must show the ci row with its fingerprint and krz/gitbay,cmc/ci-smoke.
- Step 6: The tap, after the release is tagged
In a clone of https://gitbay.org/krz/homebrew-tap.git, Formula/gitbay-runner.rb:
class GitbayRunner < Formula
desc "CI runner for gitbay: builds the repositories you attach it to"
homepage "https://gitbay.org/krz/gitbay"
url "https://gitbay.org/krz/gitbay.git",
tag: "v1.17.0",
revision: "<commit of the tag>"
license "0BSD"
head "https://gitbay.org/krz/gitbay.git", branch: "main"
depends_on "go" => :build
def install
system "go", "build", *std_go_args(ldflags: "-s -w"), "./cmd/gitbay-runner"
end
service do
run [opt_bin/"gitbay-runner"]
keep_alive true
log_path var/"log/gitbay-runner.log"
error_log_path var/"log/gitbay-runner.err.log"
end
def caveats
<<~EOS
Generate this machine's key and config, and print the key to attach:
gitbay-runner init -remote git@gitbay.org
Attach it to each repository it should build (as a repository admin):
gitbay repo runner add owner/name < ~/.config/gitbay-runner/id_ed25519.pub
Then:
brew services start krz/tap/gitbay-runner
EOS
end
test do
assert_match "gitbay-runner", shell_output("#{bin}/gitbay-runner -version")
end
end
In Formula/gitbay.rb, set tag: and revision: to the same release. Check with brew install --build-from-source krz/tap/gitbay-runner && brew test krz/tap/gitbay-runner, then commit on a branch of the tap and merge with an MR there.