| @@ -0,0 +1,2378 @@ |
| 1 | # Runners attached to repositories: implementation plan |
| 2 | |
| 3 | > **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. |
| 4 | |
| 5 | **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. |
| 6 | |
| 7 | **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`. |
| 8 | |
| 9 | **Tech Stack:** Go, SQLite via hand-written SQL, `github.com/BurntSushi/toml` (already a dependency), Go templates, e2e tests against real ssh/git. |
| 10 | |
| 11 | **Spec:** `docs/specs/2026-09-08-user-runners-design.md` |
| 12 | |
| 13 | ## Global Constraints |
| 14 | |
| 15 | - Commit messages: `<area>, <area>: <what>` on the first line, body with `Ref #184`. No attribution trailers of any kind (top rule of `~/CLAUDE.md`). |
| 16 | - Never push to `main`. Work on branch `user-runners`; MR at the end. |
| 17 | - 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. |
| 18 | - Every control command parses argv through `parseFlags` (`internal/control/flags.go`). A command that reads stdin sets `ReadsStdin: true`. A read command sets `ReadOnly: true`. |
| 19 | - Every new control command needs a `pass()` entry in `cmd/gitbay/main.go`; a coverage test fails otherwise. |
| 20 | - Secrets never in argv, never logged. A public key is not a secret. |
| 21 | - Templates: `str`/`field` are nil-safe helpers; the whole stylesheet is `internal/web/static/style.css`. |
| 22 | - Migrations: next number is `0050`, both `.up.sql` and `.down.sql`. Foreign keys are on. |
| 23 | - Comments and docs in plain English, no hype. Wiki is `.gitbay/wiki/*.org`. |
| 24 | |
| 25 | --- |
| 26 | |
| 27 | ### Task 1: Store: ClaimBuild skips untrusted builds unless asked |
| 28 | |
| 29 | **Files:** |
| 30 | - Modify: `internal/store/builds.go:80-118` (`ClaimBuild`) |
| 31 | - Modify: `internal/store/builds_test.go` (every `ClaimBuild(` call gains `, false`; one new test) |
| 32 | - Modify: `internal/store/queues_test.go:27` (`ClaimBuild(nil, false)`) |
| 33 | |
| 34 | **Interfaces:** |
| 35 | - Produces: `Store.ClaimBuild(repoIDs []int64, untrusted bool) (Build, bool, error)`. With `untrusted` false only rows with `trusted = 1` are candidates. |
| 36 | |
| 37 | - [ ] **Step 1: Write the failing test** |
| 38 | |
| 39 | Append to `internal/store/builds_test.go`: |
| 40 | |
| 41 | ```go |
| 42 | // A merge request head from a fork is untrusted. A claim skips it unless |
| 43 | // the runner asked for untrusted builds, so a runner on someone's laptop |
| 44 | // never executes a stranger's branch by default. |
| 45 | func TestClaimBuildSkipsUntrustedUnlessAsked(t *testing.T) { |
| 46 | s := open(t) |
| 47 | if err := s.MigrateUp(); err != nil { |
| 48 | t.Fatal(err) |
| 49 | } |
| 50 | uid, err := s.CreateUser("cmc", true) |
| 51 | if err != nil { |
| 52 | t.Fatal(err) |
| 53 | } |
| 54 | repo, err := s.CreateRepo("user", uid, "app", "public") |
| 55 | if err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | // Queued first, so an unfiltered claim would take it. |
| 59 | forkBuild, err := s.CreateBuild(repo, "unit", "abc123", "refs/merge-requests/1/head", `["true"]`, "", "", false) |
| 60 | if err != nil { |
| 61 | t.Fatal(err) |
| 62 | } |
| 63 | own, err := s.CreateBuild(repo, "unit", "def456", "main", `["true"]`, "", "", true) |
| 64 | if err != nil { |
| 65 | t.Fatal(err) |
| 66 | } |
| 67 | b, ok, err := s.ClaimBuild(nil, false) |
| 68 | if err != nil || !ok || b.Number != own { |
| 69 | t.Fatalf("trusted-only claim: err=%v ok=%v number=%d, want %d", err, ok, b.Number, own) |
| 70 | } |
| 71 | if _, ok, _ := s.ClaimBuild(nil, false); ok { |
| 72 | t.Fatal("trusted-only claim took the fork build") |
| 73 | } |
| 74 | b, ok, err = s.ClaimBuild(nil, true) |
| 75 | if err != nil || !ok || b.Number != forkBuild { |
| 76 | t.Fatalf("untrusted claim: err=%v ok=%v number=%d, want %d", err, ok, b.Number, forkBuild) |
| 77 | } |
| 78 | } |
| 79 | ``` |
| 80 | |
| 81 | - [ ] **Step 2: Run it to see it fail** |
| 82 | |
| 83 | Run: `go test ./internal/store -run TestClaimBuildSkipsUntrusted 2>&1 | head -5` |
| 84 | Expected: compile error, too many arguments to `ClaimBuild`. |
| 85 | |
| 86 | - [ ] **Step 3: Change `ClaimBuild`** |
| 87 | |
| 88 | Replace the signature, doc comment and query construction in `internal/store/builds.go`: |
| 89 | |
| 90 | ```go |
| 91 | // ClaimBuild atomically hands the oldest pending build to a runner and |
| 92 | // marks it running. A non-empty repoIDs restricts the claim to those |
| 93 | // repositories. Untrusted builds — merge request heads from another |
| 94 | // repository — are skipped unless untrusted is set: they run a stranger's |
| 95 | // code, which only a runner that isolates should take. |
| 96 | func (s *Store) ClaimBuild(repoIDs []int64, untrusted bool) (Build, bool, error) { |
| 97 | tx, err := s.DB.Begin() |
| 98 | if err != nil { |
| 99 | return Build{}, false, err |
| 100 | } |
| 101 | defer tx.Rollback() |
| 102 | query := "SELECT id FROM builds WHERE status = 'pending'" |
| 103 | args := []any{} |
| 104 | if !untrusted { |
| 105 | query += " AND trusted = 1" |
| 106 | } |
| 107 | if len(repoIDs) > 0 { |
| 108 | marks := strings.TrimSuffix(strings.Repeat("?,", len(repoIDs)), ",") |
| 109 | query += " AND repo_id IN (" + marks + ")" |
| 110 | for _, id := range repoIDs { |
| 111 | args = append(args, id) |
| 112 | } |
| 113 | } |
| 114 | query += " ORDER BY id LIMIT 1" |
| 115 | var id int64 |
| 116 | err = tx.QueryRow(query, args...).Scan(&id) |
| 117 | ``` |
| 118 | |
| 119 | The rest of the function is unchanged. |
| 120 | |
| 121 | - [ ] **Step 4: Update existing callers in store tests** |
| 122 | |
| 123 | In `internal/store/builds_test.go` and `internal/store/queues_test.go`, every `s.ClaimBuild(x)` becomes `s.ClaimBuild(x, false)`: |
| 124 | |
| 125 | ```bash |
| 126 | sed -i '' -E 's/ClaimBuild\((nil|\[\]int64\{[a-zA-Z]+\})\)/ClaimBuild(\1, false)/g' internal/store/builds_test.go internal/store/queues_test.go |
| 127 | grep -n "ClaimBuild(" internal/store/*_test.go |
| 128 | ``` |
| 129 | |
| 130 | Every hit must now show two arguments. |
| 131 | |
| 132 | - [ ] **Step 5: Run the store tests** |
| 133 | |
| 134 | Run: `go test ./internal/store 2>&1 | tail -3` |
| 135 | Expected: PASS. |
| 136 | |
| 137 | - [ ] **Step 6: Commit** |
| 138 | |
| 139 | ```bash |
| 140 | git add internal/store/builds.go internal/store/builds_test.go internal/store/queues_test.go |
| 141 | git commit -m "store: ClaimBuild skips untrusted builds unless asked |
| 142 | |
| 143 | Ref #184" |
| 144 | ``` |
| 145 | |
| 146 | --- |
| 147 | |
| 148 | ### Task 2: Store: migration 0050 and runner attachments |
| 149 | |
| 150 | **Files:** |
| 151 | - Create: `internal/store/migrations/0050_runner_repos.up.sql` |
| 152 | - Create: `internal/store/migrations/0050_runner_repos.down.sql` |
| 153 | - Modify: `internal/store/runners.go` (whole file) |
| 154 | - Create: `internal/store/runners_test.go` |
| 155 | |
| 156 | **Interfaces:** |
| 157 | - 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)`. |
| 158 | - Produces: |
| 159 | - `type RepoRunner struct { Fingerprint, Algo, Username, AddedAt, LastSeen, BuildRepo string; BuildNumber int64; BuildJob, StartedAt string }` |
| 160 | - `Runner` gains `Fingerprint string` and `KeyID int64`. |
| 161 | - `Store.AttachRunner(keyID, repoID int64) error` (idempotent) |
| 162 | - `Store.DetachRunner(repoID int64, fingerprint string) error` (`ErrNotFound` when not attached) |
| 163 | - `Store.RunnerRepoIDs(keyID int64) ([]int64, error)` |
| 164 | - `Store.RunnerRepoPaths(keyID int64) ([]string, error)` (owner/name, sorted) |
| 165 | - `Store.RunnerAttached(keyID, repoID int64) (bool, error)` |
| 166 | - `Store.ListRepoRunners(repoID int64) ([]RepoRunner, error)` |
| 167 | - `Store.TouchRunner(keyID, userID int64, scope string, buildID int64) error` |
| 168 | - `Store.RunnerDone(keyID int64) error` |
| 169 | - `Store.ListRunners() ([]Runner, error)` unchanged signature. |
| 170 | |
| 171 | - [ ] **Step 1: Write the migration** |
| 172 | |
| 173 | `internal/store/migrations/0050_runner_repos.up.sql`: |
| 174 | |
| 175 | ```sql |
| 176 | -- A runner key is attached to the repositories it may claim builds for |
| 177 | -- (#184). runner_seen is rekeyed by key so two runners on one account |
| 178 | -- are two rows; what it held were heartbeats, so the rows are dropped. |
| 179 | CREATE TABLE runner_repos ( |
| 180 | key_id INTEGER NOT NULL REFERENCES ssh_keys(id) ON DELETE CASCADE, |
| 181 | repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, |
| 182 | added_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), |
| 183 | PRIMARY KEY (key_id, repo_id) |
| 184 | ); |
| 185 | CREATE INDEX runner_repos_repo ON runner_repos(repo_id); |
| 186 | |
| 187 | DROP TABLE runner_seen; |
| 188 | CREATE TABLE runner_seen ( |
| 189 | key_id INTEGER PRIMARY KEY REFERENCES ssh_keys(id) ON DELETE CASCADE, |
| 190 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, |
| 191 | last_seen TEXT NOT NULL, |
| 192 | scope TEXT NOT NULL DEFAULT '', |
| 193 | build_id INTEGER REFERENCES builds(id) ON DELETE SET NULL |
| 194 | ); |
| 195 | ``` |
| 196 | |
| 197 | `internal/store/migrations/0050_runner_repos.down.sql`: |
| 198 | |
| 199 | ```sql |
| 200 | DROP TABLE runner_repos; |
| 201 | DROP TABLE runner_seen; |
| 202 | CREATE TABLE runner_seen ( |
| 203 | user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, |
| 204 | last_seen TEXT NOT NULL, |
| 205 | scope TEXT NOT NULL DEFAULT '', |
| 206 | build_id INTEGER REFERENCES builds(id) ON DELETE SET NULL |
| 207 | ); |
| 208 | ``` |
| 209 | |
| 210 | - [ ] **Step 2: Write the failing store tests** |
| 211 | |
| 212 | `internal/store/runners_test.go`: |
| 213 | |
| 214 | ```go |
| 215 | package store |
| 216 | |
| 217 | import ( |
| 218 | "errors" |
| 219 | "testing" |
| 220 | ) |
| 221 | |
| 222 | // runnerFixture is one user with a runner key and two repositories. |
| 223 | func runnerFixture(t *testing.T) (s *Store, uid, keyID, repoA, repoB int64) { |
| 224 | t.Helper() |
| 225 | s = open(t) |
| 226 | if err := s.MigrateUp(); err != nil { |
| 227 | t.Fatal(err) |
| 228 | } |
| 229 | uid, err := s.CreateUser("alice", false) |
| 230 | if err != nil { |
| 231 | t.Fatal(err) |
| 232 | } |
| 233 | if err := s.AddSSHKey(uid, "SHA256:runnerkey", "ssh-ed25519", []byte("blob"), "runner"); err != nil { |
| 234 | t.Fatal(err) |
| 235 | } |
| 236 | k, err := s.SSHKeyByFingerprint("SHA256:runnerkey") |
| 237 | if err != nil { |
| 238 | t.Fatal(err) |
| 239 | } |
| 240 | repoA, err = s.CreateRepo("user", uid, "a", "public") |
| 241 | if err != nil { |
| 242 | t.Fatal(err) |
| 243 | } |
| 244 | repoB, err = s.CreateRepo("user", uid, "b", "public") |
| 245 | if err != nil { |
| 246 | t.Fatal(err) |
| 247 | } |
| 248 | return s, uid, k.ID, repoA, repoB |
| 249 | } |
| 250 | |
| 251 | // Attaching twice is one row; detaching what is not attached is not found. |
| 252 | func TestAttachRunnerIdempotentAndDetach(t *testing.T) { |
| 253 | s, _, keyID, repoA, repoB := runnerFixture(t) |
| 254 | for range 2 { |
| 255 | if err := s.AttachRunner(keyID, repoA); err != nil { |
| 256 | t.Fatal(err) |
| 257 | } |
| 258 | } |
| 259 | ids, err := s.RunnerRepoIDs(keyID) |
| 260 | if err != nil || len(ids) != 1 || ids[0] != repoA { |
| 261 | t.Fatalf("attached repos %v err=%v, want [%d]", ids, err, repoA) |
| 262 | } |
| 263 | if ok, _ := s.RunnerAttached(keyID, repoB); ok { |
| 264 | t.Fatal("attached to a repo it was never attached to") |
| 265 | } |
| 266 | if err := s.DetachRunner(repoB, "SHA256:runnerkey"); !errors.Is(err, ErrNotFound) { |
| 267 | t.Fatalf("detach of an unattached repo: %v, want ErrNotFound", err) |
| 268 | } |
| 269 | if err := s.DetachRunner(repoA, "SHA256:runnerkey"); err != nil { |
| 270 | t.Fatal(err) |
| 271 | } |
| 272 | if ok, _ := s.RunnerAttached(keyID, repoA); ok { |
| 273 | t.Fatal("still attached after detach") |
| 274 | } |
| 275 | } |
| 276 | |
| 277 | // Removing the key or the repository removes the attachment with it. |
| 278 | func TestRunnerAttachmentCascades(t *testing.T) { |
| 279 | s, uid, keyID, repoA, repoB := runnerFixture(t) |
| 280 | if err := s.AttachRunner(keyID, repoA); err != nil { |
| 281 | t.Fatal(err) |
| 282 | } |
| 283 | if err := s.AttachRunner(keyID, repoB); err != nil { |
| 284 | t.Fatal(err) |
| 285 | } |
| 286 | if _, err := s.DB.Exec("DELETE FROM repos WHERE id = ?", repoB); err != nil { |
| 287 | t.Fatal(err) |
| 288 | } |
| 289 | if ids, _ := s.RunnerRepoIDs(keyID); len(ids) != 1 { |
| 290 | t.Fatalf("after repo delete: %v, want one attachment", ids) |
| 291 | } |
| 292 | if err := s.RemoveSSHKey(uid, "SHA256:runnerkey"); err != nil { |
| 293 | t.Fatal(err) |
| 294 | } |
| 295 | var n int |
| 296 | if err := s.DB.QueryRow("SELECT count(*) FROM runner_repos").Scan(&n); err != nil || n != 0 { |
| 297 | t.Fatalf("after key delete: %d rows err=%v, want 0", n, err) |
| 298 | } |
| 299 | } |
| 300 | |
| 301 | // The heartbeat is per key: two keys on one account are two rows, and a |
| 302 | // repository's runner list shows each key's last poll and the build it holds. |
| 303 | func TestRunnerSeenPerKeyAndRepoList(t *testing.T) { |
| 304 | s, uid, keyID, repoA, _ := runnerFixture(t) |
| 305 | if err := s.AddSSHKey(uid, "SHA256:second", "ssh-ed25519", []byte("blob2"), "runner"); err != nil { |
| 306 | t.Fatal(err) |
| 307 | } |
| 308 | k2, _ := s.SSHKeyByFingerprint("SHA256:second") |
| 309 | for _, id := range []int64{keyID, k2.ID} { |
| 310 | if err := s.AttachRunner(id, repoA); err != nil { |
| 311 | t.Fatal(err) |
| 312 | } |
| 313 | } |
| 314 | if _, err := s.CreateBuild(repoA, "unit", "abc123", "main", `["true"]`, "", "", true); err != nil { |
| 315 | t.Fatal(err) |
| 316 | } |
| 317 | b, ok, err := s.ClaimBuild(nil, false) |
| 318 | if err != nil || !ok { |
| 319 | t.Fatalf("claim: %v ok=%v", err, ok) |
| 320 | } |
| 321 | if err := s.TouchRunner(keyID, uid, "", b.ID); err != nil { |
| 322 | t.Fatal(err) |
| 323 | } |
| 324 | if err := s.TouchRunner(k2.ID, uid, "", 0); err != nil { |
| 325 | t.Fatal(err) |
| 326 | } |
| 327 | runners, err := s.ListRunners() |
| 328 | if err != nil || len(runners) != 2 { |
| 329 | t.Fatalf("ListRunners: %v err=%v, want two rows", runners, err) |
| 330 | } |
| 331 | list, err := s.ListRepoRunners(repoA) |
| 332 | if err != nil || len(list) != 2 { |
| 333 | t.Fatalf("ListRepoRunners: %v err=%v, want two rows", list, err) |
| 334 | } |
| 335 | var held, idle int |
| 336 | for _, r := range list { |
| 337 | if r.Username != "alice" || r.LastSeen == "" || r.AddedAt == "" { |
| 338 | t.Fatalf("row %+v lacks username, last_seen or added_at", r) |
| 339 | } |
| 340 | if r.BuildNumber == b.Number && r.BuildJob == "unit" && r.BuildRepo == "alice/a" { |
| 341 | held++ |
| 342 | } else if r.BuildNumber == 0 { |
| 343 | idle++ |
| 344 | } |
| 345 | } |
| 346 | if held != 1 || idle != 1 { |
| 347 | t.Fatalf("held=%d idle=%d, want 1 and 1: %+v", held, idle, list) |
| 348 | } |
| 349 | if err := s.RunnerDone(keyID); err != nil { |
| 350 | t.Fatal(err) |
| 351 | } |
| 352 | list, _ = s.ListRepoRunners(repoA) |
| 353 | for _, r := range list { |
| 354 | if r.BuildNumber != 0 { |
| 355 | t.Fatalf("build still held after RunnerDone: %+v", r) |
| 356 | } |
| 357 | } |
| 358 | paths, err := s.RunnerRepoPaths(keyID) |
| 359 | if err != nil || len(paths) != 1 || paths[0] != "alice/a" { |
| 360 | t.Fatalf("RunnerRepoPaths: %v err=%v", paths, err) |
| 361 | } |
| 362 | } |
| 363 | ``` |
| 364 | |
| 365 | - [ ] **Step 3: Run the tests to see them fail** |
| 366 | |
| 367 | Run: `go test ./internal/store -run 'TestAttachRunner|TestRunnerAttachment|TestRunnerSeen' 2>&1 | head -20` |
| 368 | Expected: compile errors, `s.AttachRunner undefined` and friends. |
| 369 | |
| 370 | - [ ] **Step 4: Replace `internal/store/runners.go`** |
| 371 | |
| 372 | ```go |
| 373 | package store |
| 374 | |
| 375 | import "sort" |
| 376 | |
| 377 | // Runner is one runner key as the instance admin sees it. |
| 378 | type Runner struct { |
| 379 | Username string `json:"username"` |
| 380 | Fingerprint string `json:"fingerprint"` |
| 381 | KeyID int64 `json:"-"` |
| 382 | LastSeen string `json:"last_seen"` |
| 383 | // Scope is what the runner asked for: comma-joined owner/name, "" |
| 384 | // for any. admin runners replaces it with the attachments for a |
| 385 | // runner key. |
| 386 | Scope string `json:"scope,omitempty"` |
| 387 | // The build it holds, if any. |
| 388 | BuildRepo string `json:"build_repo,omitempty"` |
| 389 | BuildNumber int64 `json:"build_number,omitempty"` |
| 390 | BuildJob string `json:"build_job,omitempty"` |
| 391 | StartedAt string `json:"started_at,omitempty"` |
| 392 | } |
| 393 | |
| 394 | // RepoRunner is one key attached to a repository, as repo runner list |
| 395 | // shows it. |
| 396 | type RepoRunner struct { |
| 397 | Fingerprint string `json:"fingerprint"` |
| 398 | Algo string `json:"algo"` |
| 399 | Username string `json:"username"` |
| 400 | AddedAt string `json:"added_at"` |
| 401 | LastSeen string `json:"last_seen,omitempty"` |
| 402 | BuildRepo string `json:"build_repo,omitempty"` |
| 403 | BuildNumber int64 `json:"build_number,omitempty"` |
| 404 | BuildJob string `json:"build_job,omitempty"` |
| 405 | StartedAt string `json:"started_at,omitempty"` |
| 406 | } |
| 407 | |
| 408 | // AttachRunner lets a key claim a repository's builds. Attaching twice is |
| 409 | // one row. |
| 410 | func (s *Store) AttachRunner(keyID, repoID int64) error { |
| 411 | _, err := s.DB.Exec("INSERT OR IGNORE INTO runner_repos (key_id, repo_id) VALUES (?, ?)", keyID, repoID) |
| 412 | return err |
| 413 | } |
| 414 | |
| 415 | // DetachRunner removes one attachment by fingerprint. The key itself stays. |
| 416 | func (s *Store) DetachRunner(repoID int64, fingerprint string) error { |
| 417 | res, err := s.DB.Exec(`DELETE FROM runner_repos WHERE repo_id = ? |
| 418 | AND key_id = (SELECT id FROM ssh_keys WHERE fingerprint = ?)`, repoID, fingerprint) |
| 419 | if err != nil { |
| 420 | return err |
| 421 | } |
| 422 | if n, _ := res.RowsAffected(); n == 0 { |
| 423 | return ErrNotFound |
| 424 | } |
| 425 | return nil |
| 426 | } |
| 427 | |
| 428 | // RunnerRepoIDs is every repository a key is attached to. |
| 429 | func (s *Store) RunnerRepoIDs(keyID int64) ([]int64, error) { |
| 430 | rows, err := s.DB.Query("SELECT repo_id FROM runner_repos WHERE key_id = ? ORDER BY repo_id", keyID) |
| 431 | if err != nil { |
| 432 | return nil, err |
| 433 | } |
| 434 | defer rows.Close() |
| 435 | var ids []int64 |
| 436 | for rows.Next() { |
| 437 | var id int64 |
| 438 | if err := rows.Scan(&id); err != nil { |
| 439 | return nil, err |
| 440 | } |
| 441 | ids = append(ids, id) |
| 442 | } |
| 443 | return ids, rows.Err() |
| 444 | } |
| 445 | |
| 446 | // RunnerRepoPaths is RunnerRepoIDs as owner/name, sorted. |
| 447 | func (s *Store) RunnerRepoPaths(keyID int64) ([]string, error) { |
| 448 | rows, err := s.DB.Query(`SELECT COALESCE(u.username, o.name) || '/' || r.name |
| 449 | FROM runner_repos rr JOIN repos r ON r.id = rr.repo_id |
| 450 | LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id |
| 451 | LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id |
| 452 | WHERE rr.key_id = ?`, keyID) |
| 453 | if err != nil { |
| 454 | return nil, err |
| 455 | } |
| 456 | defer rows.Close() |
| 457 | var paths []string |
| 458 | for rows.Next() { |
| 459 | var p string |
| 460 | if err := rows.Scan(&p); err != nil { |
| 461 | return nil, err |
| 462 | } |
| 463 | paths = append(paths, p) |
| 464 | } |
| 465 | sort.Strings(paths) |
| 466 | return paths, rows.Err() |
| 467 | } |
| 468 | |
| 469 | // RunnerAttached reports whether a key may claim a repository's builds. |
| 470 | func (s *Store) RunnerAttached(keyID, repoID int64) (bool, error) { |
| 471 | var n int |
| 472 | err := s.DB.QueryRow("SELECT count(*) FROM runner_repos WHERE key_id = ? AND repo_id = ?", keyID, repoID).Scan(&n) |
| 473 | return n > 0, err |
| 474 | } |
| 475 | |
| 476 | // ListRepoRunners is every key attached to a repository with its last |
| 477 | // poll and the build it holds, oldest attachment first. |
| 478 | func (s *Store) ListRepoRunners(repoID int64) ([]RepoRunner, error) { |
| 479 | rows, err := s.DB.Query(`SELECT k.fingerprint, k.algo, u.username, rr.added_at, |
| 480 | COALESCE(rs.last_seen, ''), |
| 481 | COALESCE(COALESCE(bu.username, bo.name) || '/' || br.name, ''), |
| 482 | COALESCE(b.number, 0), COALESCE(b.job, ''), COALESCE(b.started_at, '') |
| 483 | FROM runner_repos rr |
| 484 | JOIN ssh_keys k ON k.id = rr.key_id |
| 485 | JOIN users u ON u.id = k.user_id |
| 486 | LEFT JOIN runner_seen rs ON rs.key_id = rr.key_id |
| 487 | LEFT JOIN builds b ON b.id = rs.build_id AND b.status = 'running' |
| 488 | LEFT JOIN repos br ON br.id = b.repo_id |
| 489 | LEFT JOIN users bu ON br.owner_kind = 'user' AND bu.id = br.owner_id |
| 490 | LEFT JOIN orgs bo ON br.owner_kind = 'org' AND bo.id = br.owner_id |
| 491 | WHERE rr.repo_id = ? ORDER BY rr.added_at, k.id`, repoID) |
| 492 | if err != nil { |
| 493 | return nil, err |
| 494 | } |
| 495 | defer rows.Close() |
| 496 | var out []RepoRunner |
| 497 | for rows.Next() { |
| 498 | var r RepoRunner |
| 499 | if err := rows.Scan(&r.Fingerprint, &r.Algo, &r.Username, &r.AddedAt, &r.LastSeen, |
| 500 | &r.BuildRepo, &r.BuildNumber, &r.BuildJob, &r.StartedAt); err != nil { |
| 501 | return nil, err |
| 502 | } |
| 503 | out = append(out, r) |
| 504 | } |
| 505 | return out, rows.Err() |
| 506 | } |
| 507 | |
| 508 | // TouchRunner records a poll by one key: the time, the scope the runner |
| 509 | // asked for, and the build it just claimed (0 for none). |
| 510 | func (s *Store) TouchRunner(keyID, userID int64, scope string, buildID int64) error { |
| 511 | _, err := s.DB.Exec(`INSERT INTO runner_seen (key_id, user_id, last_seen, scope, build_id) |
| 512 | VALUES (?1, ?2, strftime('%Y-%m-%dT%H:%M:%fZ','now'), ?3, NULLIF(?4, 0)) |
| 513 | ON CONFLICT (key_id) DO UPDATE SET |
| 514 | last_seen = excluded.last_seen, scope = excluded.scope, |
| 515 | build_id = COALESCE(excluded.build_id, runner_seen.build_id)`, |
| 516 | keyID, userID, scope, buildID) |
| 517 | return err |
| 518 | } |
| 519 | |
| 520 | // RunnerDone records that the key reported and holds nothing now. |
| 521 | func (s *Store) RunnerDone(keyID int64) error { |
| 522 | _, err := s.DB.Exec(`UPDATE runner_seen SET last_seen = strftime('%Y-%m-%dT%H:%M:%fZ','now'), |
| 523 | build_id = NULL WHERE key_id = ?`, keyID) |
| 524 | return err |
| 525 | } |
| 526 | |
| 527 | // ListRunners lists every key that has ever polled as a runner, most |
| 528 | // recently seen first. |
| 529 | func (s *Store) ListRunners() ([]Runner, error) { |
| 530 | rows, err := s.DB.Query(`SELECT u.username, k.fingerprint, k.id, r.last_seen, r.scope, |
| 531 | COALESCE(COALESCE(bu.username, bo.name) || '/' || br.name, ''), |
| 532 | COALESCE(b.number, 0), COALESCE(b.job, ''), COALESCE(b.started_at, '') |
| 533 | FROM runner_seen r JOIN users u ON u.id = r.user_id |
| 534 | JOIN ssh_keys k ON k.id = r.key_id |
| 535 | LEFT JOIN builds b ON b.id = r.build_id AND b.status = 'running' |
| 536 | LEFT JOIN repos br ON br.id = b.repo_id |
| 537 | LEFT JOIN users bu ON br.owner_kind = 'user' AND bu.id = br.owner_id |
| 538 | LEFT JOIN orgs bo ON br.owner_kind = 'org' AND bo.id = br.owner_id |
| 539 | ORDER BY r.last_seen DESC`) |
| 540 | if err != nil { |
| 541 | return nil, err |
| 542 | } |
| 543 | defer rows.Close() |
| 544 | var out []Runner |
| 545 | for rows.Next() { |
| 546 | var r Runner |
| 547 | if err := rows.Scan(&r.Username, &r.Fingerprint, &r.KeyID, &r.LastSeen, &r.Scope, |
| 548 | &r.BuildRepo, &r.BuildNumber, &r.BuildJob, &r.StartedAt); err != nil { |
| 549 | return nil, err |
| 550 | } |
| 551 | out = append(out, r) |
| 552 | } |
| 553 | return out, rows.Err() |
| 554 | } |
| 555 | ``` |
| 556 | |
| 557 | - [ ] **Step 5: Run the store tests** |
| 558 | |
| 559 | Run: `go test ./internal/store 2>&1 | tail -5` |
| 560 | Expected: PASS. Callers in `internal/control` do not compile yet; that is Task 3. `go build ./internal/store` must pass here. |
| 561 | |
| 562 | - [ ] **Step 6: Commit** |
| 563 | |
| 564 | ```bash |
| 565 | 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 |
| 566 | git commit -m "store: runner keys attach to repositories, heartbeat per key |
| 567 | |
| 568 | Migration 0050 adds runner_repos and rekeys runner_seen by ssh key. |
| 569 | |
| 570 | Ref #184" |
| 571 | ``` |
| 572 | |
| 573 | --- |
| 574 | |
| 575 | ### Task 3: Control: the claim rule, per-key heartbeat, and admin runners |
| 576 | |
| 577 | **Files:** |
| 578 | - Modify: `internal/control/build.go` (`requireRunner`, `runRunnerNext`, `runRunnerLog`, `runRunnerDone`, the `runner next` registration) |
| 579 | - Modify: `internal/control/admin.go:444-475` (`runAdminRunners`) |
| 580 | - Modify: `internal/control/runnernext_test.go:17-30` (`runnerCtx`) |
| 581 | - Create: `internal/control/runnerattach_test.go` |
| 582 | - Modify: `e2e/reap_test.go:112-115` (the admin runners row gains a fingerprint column) |
| 583 | - Modify: `e2e/mrbuilds_test.go:95`, `e2e/build_cancel_test.go` (claims of a fork head pass `--untrusted`) |
| 584 | |
| 585 | **Interfaces:** |
| 586 | - Consumes: the store functions from Tasks 1 and 2; `Ctx.Source` is the SSH key fingerprint for SSH sessions (`internal/sshd/sshd.go:338`). |
| 587 | - Produces: |
| 588 | - `runnerSession(c *Ctx) (store.SSHKey, int)`: the key behind the session, or an exit code. |
| 589 | - `runnerMayBuild(c *Ctx, key store.SSHKey, repoID int64) (bool, error)`: admin, or attached. |
| 590 | - `runner next [--untrusted] [<owner/name>...]`. |
| 591 | - `admin runners` JSON rows carry `fingerprint`; the text row is `username<TAB>fingerprint<TAB>last_seen<TAB>scope<TAB>held`. |
| 592 | |
| 593 | - [ ] **Step 1: Write the failing control tests** |
| 594 | |
| 595 | `internal/control/runnerattach_test.go`: |
| 596 | |
| 597 | ```go |
| 598 | package control |
| 599 | |
| 600 | import ( |
| 601 | "bytes" |
| 602 | "strconv" |
| 603 | "strings" |
| 604 | "testing" |
| 605 | |
| 606 | "gitbay.org/gitbay/internal/config" |
| 607 | "gitbay.org/gitbay/internal/protocol" |
| 608 | "gitbay.org/gitbay/internal/store" |
| 609 | ) |
| 610 | |
| 611 | // attachFixture: alice (not admin) owns alice/app with a build queued; |
| 612 | // mallory (not admin) owns mallory/evil with an older build queued. Each |
| 613 | // has a runner-scoped key. The Ctx polls as the given user with the given |
| 614 | // key, which is what the SSH listener produces. |
| 615 | type attachFixture struct { |
| 616 | st *store.Store |
| 617 | alice, mallory int64 |
| 618 | aliceKey, malloryKey store.SSHKey |
| 619 | app, evil store.Repo |
| 620 | appBuild, evilBuild int64 |
| 621 | } |
| 622 | |
| 623 | func newAttachFixture(t *testing.T) attachFixture { |
| 624 | t.Helper() |
| 625 | st, err := store.Open(":memory:") |
| 626 | if err != nil { |
| 627 | t.Fatal(err) |
| 628 | } |
| 629 | t.Cleanup(func() { st.Close() }) |
| 630 | if err := st.MigrateUp(); err != nil { |
| 631 | t.Fatal(err) |
| 632 | } |
| 633 | var f attachFixture |
| 634 | f.st = st |
| 635 | mk := func(name, fp string) (int64, store.SSHKey, store.Repo, string) { |
| 636 | uid, err := st.CreateUser(name, false) |
| 637 | if err != nil { |
| 638 | t.Fatal(err) |
| 639 | } |
| 640 | if err := st.AddSSHKey(uid, fp, "ssh-ed25519", []byte(fp), "runner"); err != nil { |
| 641 | t.Fatal(err) |
| 642 | } |
| 643 | k, _ := st.SSHKeyByFingerprint(fp) |
| 644 | repoName := map[string]string{"alice": "app", "mallory": "evil"}[name] |
| 645 | rid, err := st.CreateRepo("user", uid, repoName, "public") |
| 646 | if err != nil { |
| 647 | t.Fatal(err) |
| 648 | } |
| 649 | repo, _ := st.RepoByID(rid) |
| 650 | return uid, k, repo, repoName |
| 651 | } |
| 652 | f.mallory, f.malloryKey, f.evil, _ = mk("mallory", "SHA256:mallory") |
| 653 | f.alice, f.aliceKey, f.app, _ = mk("alice", "SHA256:alice") |
| 654 | // mallory's build is older, so an unrestricted claim would take it. |
| 655 | f.evilBuild, err = st.CreateBuild(f.evil.ID, "unit", "aaa111", "main", "[]", "", "", true) |
| 656 | if err != nil { |
| 657 | t.Fatal(err) |
| 658 | } |
| 659 | f.appBuild, err = st.CreateBuild(f.app.ID, "unit", "bbb222", "main", "[]", "", "", true) |
| 660 | if err != nil { |
| 661 | t.Fatal(err) |
| 662 | } |
| 663 | return f |
| 664 | } |
| 665 | |
| 666 | func (f attachFixture) ctx(uid int64, key store.SSHKey, admin bool) (*Ctx, *bytes.Buffer) { |
| 667 | var out bytes.Buffer |
| 668 | name := "alice" |
| 669 | if uid == f.mallory { |
| 670 | name = "mallory" |
| 671 | } |
| 672 | return &Ctx{ |
| 673 | User: store.User{ID: uid, Username: name, IsAdmin: admin}, |
| 674 | Scope: key.Scope, |
| 675 | Source: key.Fingerprint, |
| 676 | Store: f.st, |
| 677 | Cfg: config.Config{Server: config.Server{Root: "/nonexistent", SiteURL: "https://x.test"}}, |
| 678 | Stdin: strings.NewReader(""), |
| 679 | Stdout: &out, |
| 680 | Stderr: &out, |
| 681 | }, &out |
| 682 | } |
| 683 | |
| 684 | // A runner key with no attachment claims nothing, whatever is queued. |
| 685 | func TestRunnerNextUnattachedClaimsNothing(t *testing.T) { |
| 686 | f := newAttachFixture(t) |
| 687 | c, out := f.ctx(f.alice, f.aliceKey, false) |
| 688 | if code := runRunnerNext(c, nil); code != protocol.ExitOK || !strings.Contains(out.String(), "no pending builds") { |
| 689 | t.Fatalf("exit %d: %s", code, out.String()) |
| 690 | } |
| 691 | b, _ := f.st.BuildByNumber(f.evil.ID, f.evilBuild) |
| 692 | if b.Status != "pending" { |
| 693 | t.Fatalf("unattached key claimed a build: %s", b.Status) |
| 694 | } |
| 695 | } |
| 696 | |
| 697 | // An attached key claims its repository's build and not the older one |
| 698 | // queued elsewhere; naming a repository outside the attachments is refused. |
| 699 | func TestRunnerNextAttachedClaimsOwnRepoOnly(t *testing.T) { |
| 700 | f := newAttachFixture(t) |
| 701 | if err := f.st.AttachRunner(f.aliceKey.ID, f.app.ID); err != nil { |
| 702 | t.Fatal(err) |
| 703 | } |
| 704 | c, out := f.ctx(f.alice, f.aliceKey, false) |
| 705 | if code := runRunnerNext(c, nil); code != protocol.ExitOK || !strings.Contains(out.String(), "alice/app") { |
| 706 | t.Fatalf("exit %d: %s", code, out.String()) |
| 707 | } |
| 708 | if b, _ := f.st.BuildByNumber(f.evil.ID, f.evilBuild); b.Status != "pending" { |
| 709 | t.Fatalf("mallory's build was touched: %s", b.Status) |
| 710 | } |
| 711 | c, out = f.ctx(f.alice, f.aliceKey, false) |
| 712 | if code := runRunnerNext(c, []string{"mallory/evil"}); code != protocol.ExitDenied { |
| 713 | t.Fatalf("naming an unattached repo: exit %d, want %d: %s", code, protocol.ExitDenied, out.String()) |
| 714 | } |
| 715 | } |
| 716 | |
| 717 | // The heartbeat is recorded against the key, and admin runners shows it |
| 718 | // with its fingerprint and attachments. |
| 719 | func TestAdminRunnersShowsKeyAndAttachments(t *testing.T) { |
| 720 | f := newAttachFixture(t) |
| 721 | if err := f.st.AttachRunner(f.aliceKey.ID, f.app.ID); err != nil { |
| 722 | t.Fatal(err) |
| 723 | } |
| 724 | c, _ := f.ctx(f.alice, f.aliceKey, false) |
| 725 | runRunnerNext(c, nil) |
| 726 | admin, out := f.ctx(f.alice, f.aliceKey, true) |
| 727 | admin.Scope = "full" |
| 728 | if code := runAdminRunners(admin, nil); code != protocol.ExitOK { |
| 729 | t.Fatalf("admin runners: exit %d: %s", code, out.String()) |
| 730 | } |
| 731 | if !strings.Contains(out.String(), "alice\tSHA256:alice\t") || !strings.Contains(out.String(), "\talice/app\t") { |
| 732 | t.Fatalf("row lacks fingerprint or attachments:\n%s", out.String()) |
| 733 | } |
| 734 | } |
| 735 | |
| 736 | // Untrusted builds are skipped unless the runner asks. |
| 737 | func TestRunnerNextUntrustedFlag(t *testing.T) { |
| 738 | f := newAttachFixture(t) |
| 739 | if err := f.st.AttachRunner(f.aliceKey.ID, f.app.ID); err != nil { |
| 740 | t.Fatal(err) |
| 741 | } |
| 742 | c, _ := f.ctx(f.alice, f.aliceKey, false) |
| 743 | runRunnerNext(c, nil) // takes the trusted build |
| 744 | fork, err := f.st.CreateBuild(f.app.ID, "unit", "ccc333", "refs/merge-requests/1/head", "[]", "", "", false) |
| 745 | if err != nil { |
| 746 | t.Fatal(err) |
| 747 | } |
| 748 | c, out := f.ctx(f.alice, f.aliceKey, false) |
| 749 | runRunnerNext(c, nil) |
| 750 | if !strings.Contains(out.String(), "no pending builds") { |
| 751 | t.Fatalf("fork head claimed without --untrusted: %s", out.String()) |
| 752 | } |
| 753 | c, out = f.ctx(f.alice, f.aliceKey, false) |
| 754 | if code := runRunnerNext(c, []string{"--untrusted"}); code != protocol.ExitOK || !strings.Contains(out.String(), "alice/app") { |
| 755 | t.Fatalf("--untrusted did not claim the fork head: exit %d %s", code, out.String()) |
| 756 | } |
| 757 | if b, _ := f.st.BuildByNumber(f.app.ID, fork); b.Status != "running" { |
| 758 | t.Fatalf("fork build is %s, want running", b.Status) |
| 759 | } |
| 760 | } |
| 761 | |
| 762 | // runner done and runner log on a build whose repository is not attached |
| 763 | // to the key are refused. |
| 764 | func TestRunnerDoneRefusedForUnattachedBuild(t *testing.T) { |
| 765 | f := newAttachFixture(t) |
| 766 | if err := f.st.AttachRunner(f.malloryKey.ID, f.evil.ID); err != nil { |
| 767 | t.Fatal(err) |
| 768 | } |
| 769 | c, _ := f.ctx(f.mallory, f.malloryKey, false) |
| 770 | runRunnerNext(c, nil) // mallory holds her own build |
| 771 | evil, _ := f.st.BuildByNumber(f.evil.ID, f.evilBuild) |
| 772 | c, out := f.ctx(f.alice, f.aliceKey, false) |
| 773 | id := strconv.FormatInt(evil.ID, 10) |
| 774 | if code := runRunnerDone(c, []string{id, "success"}); code != protocol.ExitDenied { |
| 775 | t.Fatalf("done on an unattached build: exit %d, want %d: %s", code, protocol.ExitDenied, out.String()) |
| 776 | } |
| 777 | c, out = f.ctx(f.alice, f.aliceKey, false) |
| 778 | if code := runRunnerLog(c, []string{id}); code != protocol.ExitDenied { |
| 779 | t.Fatalf("log on an unattached build: exit %d, want %d: %s", code, protocol.ExitDenied, out.String()) |
| 780 | } |
| 781 | if b, _ := f.st.BuildByNumber(f.evil.ID, f.evilBuild); b.Status != "running" { |
| 782 | t.Fatalf("build was finished by a foreign key: %s", b.Status) |
| 783 | } |
| 784 | } |
| 785 | ``` |
| 786 | |
| 787 | - [ ] **Step 2: Run them to see them fail** |
| 788 | |
| 789 | Run: `go test ./internal/control -run 'TestRunnerNext(Unattached|Attached|Untrusted)|TestAdminRunnersShows|TestRunnerDoneRefused' 2>&1 | head` |
| 790 | Expected: compile errors from `ClaimBuild`, `TouchRunner`, `RunnerDone` signature changes in `build.go`. |
| 791 | |
| 792 | - [ ] **Step 3: Rewrite the runner protocol in `internal/control/build.go`** |
| 793 | |
| 794 | Change the registration: |
| 795 | |
| 796 | ```go |
| 797 | register(Command{Path: []string{"runner", "next"}, |
| 798 | Summary: "claim the oldest pending build this key may run (runner protocol)", |
| 799 | Usage: "runner next [--untrusted] [<owner/name>...]", SSHOnly: true, Run: runRunnerNext}) |
| 800 | ``` |
| 801 | |
| 802 | Replace `requireRunner` with two helpers: |
| 803 | |
| 804 | ```go |
| 805 | // runnerSession resolves the key behind a runner-protocol session. The |
| 806 | // runner commands are SSHOnly, so Source is the key's fingerprint. An |
| 807 | // admin key is accepted so an operator can rotate at their own pace; a |
| 808 | // runner host should hold a key added with --scope runner. |
| 809 | func runnerSession(c *Ctx) (store.SSHKey, int) { |
| 810 | if c.Scope != "runner" && !c.User.IsAdmin { |
| 811 | return store.SSHKey{}, c.fail(protocol.ExitDenied, "runner commands need a key added with --scope runner") |
| 812 | } |
| 813 | key, err := c.Store.SSHKeyByFingerprint(c.Source) |
| 814 | if err != nil { |
| 815 | return store.SSHKey{}, c.fail(protocol.ExitDenied, "runner commands need an SSH key session") |
| 816 | } |
| 817 | return key, -1 |
| 818 | } |
| 819 | |
| 820 | // runnerMayBuild reports whether a runner session may act on a |
| 821 | // repository's builds: an admin user may on any, a runner key on the |
| 822 | // repositories it is attached to (#184). |
| 823 | func runnerMayBuild(c *Ctx, key store.SSHKey, repoID int64) (bool, error) { |
| 824 | if c.User.IsAdmin { |
| 825 | return true, nil |
| 826 | } |
| 827 | return c.Store.RunnerAttached(key.ID, repoID) |
| 828 | } |
| 829 | ``` |
| 830 | |
| 831 | Rewrite the head of `runRunnerNext` down to the claim loop: |
| 832 | |
| 833 | ```go |
| 834 | func runRunnerNext(c *Ctx, args []string) int { |
| 835 | key, code := runnerSession(c) |
| 836 | if code >= 0 { |
| 837 | return code |
| 838 | } |
| 839 | f, err := parseFlags(args, flagSpec{Bools: []string{"--untrusted"}, MaxPos: -1, |
| 840 | Usage: "runner next [--untrusted] [<owner/name>...]"}) |
| 841 | if err != nil { |
| 842 | return c.fail(protocol.ExitUsage, "%v", err) |
| 843 | } |
| 844 | // The candidate set. An admin key claims from any repository, narrowed |
| 845 | // by the names given. A runner key claims from the repositories it is |
| 846 | // attached to; a name outside them is refused, not ignored, so a |
| 847 | // misconfigured runner says so instead of idling. |
| 848 | var repoIDs []int64 |
| 849 | for _, arg := range f.Pos { |
| 850 | repo, code := resolveRepo(c, arg, policy.CanRead) |
| 851 | if code >= 0 { |
| 852 | return code |
| 853 | } |
| 854 | ok, err := runnerMayBuild(c, key, repo.ID) |
| 855 | if err != nil { |
| 856 | return c.fail(protocol.ExitFailure, "%v", err) |
| 857 | } |
| 858 | if !ok { |
| 859 | return c.fail(protocol.ExitDenied, "this key is not attached to %s", repo.Path()) |
| 860 | } |
| 861 | repoIDs = append(repoIDs, repo.ID) |
| 862 | } |
| 863 | if !c.User.IsAdmin && len(repoIDs) == 0 { |
| 864 | repoIDs, err = c.Store.RunnerRepoIDs(key.ID) |
| 865 | if err != nil { |
| 866 | return c.fail(protocol.ExitFailure, "%v", err) |
| 867 | } |
| 868 | if len(repoIDs) == 0 { |
| 869 | // Nothing attached: nothing to claim. Still a heartbeat, so |
| 870 | // admin runners shows the key polling. |
| 871 | c.Store.TouchRunner(key.ID, c.User.ID, "", 0) |
| 872 | return c.emit(map[string]any{}, func(w io.Writer) { fmt.Fprintln(w, "no pending builds") }) |
| 873 | } |
| 874 | } |
| 875 | untrusted := f.Has("--untrusted") |
| 876 | var b store.Build |
| 877 | var repo store.Repo |
| 878 | var ok bool |
| 879 | for attempt := 0; attempt < maxOrphanSkip; attempt++ { |
| 880 | b, ok, err = c.Store.ClaimBuild(repoIDs, untrusted) |
| 881 | ``` |
| 882 | |
| 883 | The rest of the loop is unchanged. Delete the old `var err error` line, since `err` now comes from `parseFlags`. Replace the heartbeat line: |
| 884 | |
| 885 | ```go |
| 886 | c.Store.TouchRunner(key.ID, c.User.ID, strings.Join(f.Pos, ","), b.ID) |
| 887 | ``` |
| 888 | |
| 889 | In `runRunnerLog`, replace `if code := requireRunner(c); code >= 0 { return code }` with: |
| 890 | |
| 891 | ```go |
| 892 | key, code := runnerSession(c) |
| 893 | if code >= 0 { |
| 894 | return code |
| 895 | } |
| 896 | ``` |
| 897 | |
| 898 | and directly after the `id, err := strconv.ParseInt(args[0], 10, 64)` block, add: |
| 899 | |
| 900 | ```go |
| 901 | if b, err := c.Store.BuildByID(id); err != nil { |
| 902 | return c.fail(protocol.ExitNotFound, "no build %d", id) |
| 903 | } else if ok, err := runnerMayBuild(c, key, b.RepoID); err != nil { |
| 904 | return c.fail(protocol.ExitFailure, "%v", err) |
| 905 | } else if !ok { |
| 906 | return c.fail(protocol.ExitDenied, "this key is not attached to the build's repository") |
| 907 | } |
| 908 | ``` |
| 909 | |
| 910 | In `runRunnerDone`, the same `runnerSession` replacement; after `b, err := c.Store.BuildByID(id)` succeeds add: |
| 911 | |
| 912 | ```go |
| 913 | if ok, err := runnerMayBuild(c, key, b.RepoID); err != nil { |
| 914 | return c.fail(protocol.ExitFailure, "%v", err) |
| 915 | } else if !ok { |
| 916 | return c.fail(protocol.ExitDenied, "this key is not attached to the build's repository") |
| 917 | } |
| 918 | ``` |
| 919 | |
| 920 | and both `c.Store.RunnerDone(c.User.ID)` become `c.Store.RunnerDone(key.ID)`. |
| 921 | |
| 922 | Delete `requireRunner` if nothing else references it (`grep -n requireRunner internal/`). |
| 923 | |
| 924 | - [ ] **Step 4: `admin runners` shows the fingerprint and attachments** |
| 925 | |
| 926 | In `internal/control/admin.go`, `runAdminRunners`, after `ListRunners`: |
| 927 | |
| 928 | ```go |
| 929 | for i := range runners { |
| 930 | if runners[i].Scope != "" { |
| 931 | continue |
| 932 | } |
| 933 | key, err := c.Store.SSHKeyByID(runners[i].KeyID) |
| 934 | if err != nil || key.Scope != "runner" { |
| 935 | continue // an admin key with no -repos: any |
| 936 | } |
| 937 | paths, err := c.Store.RunnerRepoPaths(runners[i].KeyID) |
| 938 | if err != nil { |
| 939 | return c.fail(protocol.ExitFailure, "%v", err) |
| 940 | } |
| 941 | runners[i].Scope = strings.Join(paths, ",") |
| 942 | } |
| 943 | ``` |
| 944 | |
| 945 | 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: |
| 946 | |
| 947 | ```go |
| 948 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Username, r.Fingerprint, r.LastSeen, scope, held) |
| 949 | ``` |
| 950 | |
| 951 | Add `"strings"` to admin.go imports if missing. |
| 952 | |
| 953 | - [ ] **Step 5: Fix the existing `runnerCtx` test helper** |
| 954 | |
| 955 | `internal/control/runnernext_test.go`, `runnerCtx`: the session needs a real key. Replace the helper body: |
| 956 | |
| 957 | ```go |
| 958 | func runnerCtx(st *store.Store, uid int64, root string) (*Ctx, *bytes.Buffer) { |
| 959 | var out bytes.Buffer |
| 960 | fp := fmt.Sprintf("SHA256:runner-%d", uid) |
| 961 | st.AddSSHKey(uid, fp, "ssh-ed25519", []byte(fp), "full") // ErrDuplicateKey on reuse is fine |
| 962 | c := &Ctx{ |
| 963 | User: store.User{ID: uid, Username: "ci", IsAdmin: true}, |
| 964 | Scope: "full", |
| 965 | Source: fp, |
| 966 | Store: st, |
| 967 | Cfg: config.Config{Server: config.Server{Root: root, SiteURL: "https://x.test"}}, |
| 968 | Stdin: strings.NewReader(""), |
| 969 | Stdout: &out, |
| 970 | Stderr: &out, |
| 971 | } |
| 972 | return c, &out |
| 973 | } |
| 974 | ``` |
| 975 | |
| 976 | 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. |
| 977 | |
| 978 | - [ ] **Step 6: Run the control tests** |
| 979 | |
| 980 | Run: `go build ./... && go vet ./internal/control && go test ./internal/control 2>&1 | tail -5` |
| 981 | Expected: PASS, including `TestStdinCommandsReadStdin` and `TestReadOnlyCommandsWriteNothing`. |
| 982 | |
| 983 | - [ ] **Step 7: Update the e2e tests that this changes** |
| 984 | |
| 985 | `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. |
| 986 | |
| 987 | `e2e/mrbuilds_test.go:95` claims a fork head with an admin key. Add the flag: |
| 988 | |
| 989 | ```go |
| 990 | out, errOut, code := inst.ssh(t, runnerKey, "", "runner", "next", "--untrusted", "alice/app", "--json") |
| 991 | ``` |
| 992 | |
| 993 | `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. |
| 994 | |
| 995 | - [ ] **Step 8: Run those e2e tests** |
| 996 | |
| 997 | Run: `go test ./e2e -run 'TestStaleBuildReapedWithoutRunner|TestForkMRHeadIsBuilt|TestRunnerNextScopedToRepos|TestRunnerScopedKey' -count=1 2>&1 | tail -5` |
| 998 | Expected: PASS. |
| 999 | |
| 1000 | - [ ] **Step 9: Commit** |
| 1001 | |
| 1002 | ```bash |
| 1003 | 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 |
| 1004 | git commit -m "control: a runner key claims only the repositories it is attached to |
| 1005 | |
| 1006 | runner next takes --untrusted; without it fork heads are skipped. runner |
| 1007 | log and runner done refuse a build outside the key's attachments. The |
| 1008 | heartbeat and admin runners are per key. |
| 1009 | |
| 1010 | Ref #184" |
| 1011 | ``` |
| 1012 | |
| 1013 | --- |
| 1014 | |
| 1015 | ### Task 4: Control and CLI: `repo runner add|list|remove` |
| 1016 | |
| 1017 | **Files:** |
| 1018 | - Create: `internal/control/runnerrepo.go` |
| 1019 | - Create: `internal/control/runnerrepo_test.go` |
| 1020 | - Modify: `cmd/gitbay/main.go:437-440` (a `group("runner", ...)` beside `deploy-key`) |
| 1021 | |
| 1022 | **Interfaces:** |
| 1023 | - Consumes: the store functions from Task 2; `resolveRepo(c, path, policy.CanAdmin)`; `c.Store.Audit(userID, action string, fields map[string]any)`. |
| 1024 | - Produces: |
| 1025 | - `repo runner add <owner/name>` (stdin: public key) → `{"fingerprint": ..., "repo": ...}` |
| 1026 | - `repo runner list <owner/name>` → `[]store.RepoRunner` |
| 1027 | - `repo runner remove <owner/name> <fingerprint>` → `{"removed": fingerprint}` |
| 1028 | |
| 1029 | - [ ] **Step 1: Write the failing tests** |
| 1030 | |
| 1031 | `internal/control/runnerrepo_test.go`: |
| 1032 | |
| 1033 | ```go |
| 1034 | package control |
| 1035 | |
| 1036 | import ( |
| 1037 | "bytes" |
| 1038 | "strings" |
| 1039 | "testing" |
| 1040 | |
| 1041 | "gitbay.org/gitbay/internal/config" |
| 1042 | "gitbay.org/gitbay/internal/protocol" |
| 1043 | "gitbay.org/gitbay/internal/store" |
| 1044 | ) |
| 1045 | |
| 1046 | // Generated once with ssh-keygen -t ed25519; a valid authorized_keys line. |
| 1047 | const testRunnerPub = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILAr2r82jFsCJwsEyrEf2wgKy9Dv45xYYici6Ii7NyCS runner@test\n" |
| 1048 | |
| 1049 | func repoRunnerCtx(t *testing.T, st *store.Store, uid int64, admin bool, stdin string) (*Ctx, *bytes.Buffer) { |
| 1050 | t.Helper() |
| 1051 | var out bytes.Buffer |
| 1052 | return &Ctx{ |
| 1053 | User: store.User{ID: uid, Username: "alice", IsAdmin: admin}, |
| 1054 | Scope: "full", |
| 1055 | Source: "SHA256:session", |
| 1056 | Store: st, |
| 1057 | Cfg: config.Config{Server: config.Server{SiteURL: "https://x.test"}}, |
| 1058 | Stdin: strings.NewReader(stdin), |
| 1059 | Stdout: &out, |
| 1060 | Stderr: &out, |
| 1061 | JSON: true, |
| 1062 | }, &out |
| 1063 | } |
| 1064 | |
| 1065 | // A fresh key is registered on the caller's account with scope runner and |
| 1066 | // attached; a second add is a no-op; list shows it; remove detaches and |
| 1067 | // leaves the key on the account. |
| 1068 | func TestRepoRunnerAddListRemove(t *testing.T) { |
| 1069 | st, repo, uid := newQueueTestRepo(t) |
| 1070 | c, out := repoRunnerCtx(t, st, uid, false, testRunnerPub) |
| 1071 | if code := runRepoRunnerAdd(c, []string{repo.Path()}); code != protocol.ExitOK { |
| 1072 | t.Fatalf("add: exit %d %s", code, out.String()) |
| 1073 | } |
| 1074 | if !strings.Contains(out.String(), `"fingerprint":"SHA256:`) { |
| 1075 | t.Fatalf("add output: %s", out.String()) |
| 1076 | } |
| 1077 | keys, _ := st.ListSSHKeys(uid) |
| 1078 | if len(keys) != 1 || keys[0].Scope != "runner" { |
| 1079 | t.Fatalf("key not registered as runner: %+v", keys) |
| 1080 | } |
| 1081 | fp := keys[0].Fingerprint |
| 1082 | c, out = repoRunnerCtx(t, st, uid, false, testRunnerPub) |
| 1083 | if code := runRepoRunnerAdd(c, []string{repo.Path()}); code != protocol.ExitOK { |
| 1084 | t.Fatalf("second add: exit %d %s", code, out.String()) |
| 1085 | } |
| 1086 | c, out = repoRunnerCtx(t, st, uid, false, "") |
| 1087 | if code := runRepoRunnerList(c, []string{repo.Path()}); code != protocol.ExitOK || strings.Count(out.String(), fp) != 1 { |
| 1088 | t.Fatalf("list: exit %d %s", code, out.String()) |
| 1089 | } |
| 1090 | c, out = repoRunnerCtx(t, st, uid, false, "") |
| 1091 | if code := runRepoRunnerRemove(c, []string{repo.Path(), fp}); code != protocol.ExitOK { |
| 1092 | t.Fatalf("remove: exit %d %s", code, out.String()) |
| 1093 | } |
| 1094 | if ok, _ := st.RunnerAttached(keys[0].ID, repo.ID); ok { |
| 1095 | t.Fatal("still attached after remove") |
| 1096 | } |
| 1097 | if keys, _ = st.ListSSHKeys(uid); len(keys) != 1 { |
| 1098 | t.Fatal("remove dropped the key from the account") |
| 1099 | } |
| 1100 | c, out = repoRunnerCtx(t, st, uid, false, "") |
| 1101 | if code := runRepoRunnerRemove(c, []string{repo.Path(), fp}); code != protocol.ExitNotFound { |
| 1102 | t.Fatalf("remove twice: exit %d, want %d", code, protocol.ExitNotFound) |
| 1103 | } |
| 1104 | } |
| 1105 | |
| 1106 | // A key that already exists with another scope is never promoted, and |
| 1107 | // another account's runner key is refused unless the caller is an admin. |
| 1108 | func TestRepoRunnerAddRefusesWrongKeys(t *testing.T) { |
| 1109 | st, repo, uid := newQueueTestRepo(t) |
| 1110 | c, _ := repoRunnerCtx(t, st, uid, false, testRunnerPub) |
| 1111 | // Register the same key as a full key first. |
| 1112 | if code := runKeysAdd(c, nil); code != protocol.ExitOK { |
| 1113 | t.Fatal("keys add failed") |
| 1114 | } |
| 1115 | c, out := repoRunnerCtx(t, st, uid, false, testRunnerPub) |
| 1116 | if code := runRepoRunnerAdd(c, []string{repo.Path()}); code != protocol.ExitDenied { |
| 1117 | t.Fatalf("full key accepted as runner: exit %d %s", code, out.String()) |
| 1118 | } |
| 1119 | keys, _ := st.ListSSHKeys(uid) |
| 1120 | if keys[0].Scope != "full" { |
| 1121 | t.Fatalf("scope changed to %s", keys[0].Scope) |
| 1122 | } |
| 1123 | // Someone else's runner key. |
| 1124 | bob, _ := st.CreateUser("bob", false) |
| 1125 | if err := st.AddSSHKey(bob, "SHA256:bobrunner", "ssh-ed25519", []byte("x"), "runner"); err != nil { |
| 1126 | t.Fatal(err) |
| 1127 | } |
| 1128 | st.RemoveSSHKey(uid, keys[0].Fingerprint) |
| 1129 | if err := st.AddSSHKey(bob, keys[0].Fingerprint, "ssh-ed25519", keys[0].Blob, "runner"); err != nil { |
| 1130 | t.Fatal(err) |
| 1131 | } |
| 1132 | c, out = repoRunnerCtx(t, st, uid, false, testRunnerPub) |
| 1133 | if code := runRepoRunnerAdd(c, []string{repo.Path()}); code != protocol.ExitDenied { |
| 1134 | t.Fatalf("another account's key attached by a non-admin: exit %d %s", code, out.String()) |
| 1135 | } |
| 1136 | c, out = repoRunnerCtx(t, st, uid, true, testRunnerPub) |
| 1137 | if code := runRepoRunnerAdd(c, []string{repo.Path()}); code != protocol.ExitOK { |
| 1138 | t.Fatalf("admin could not attach another account's runner key: exit %d %s", code, out.String()) |
| 1139 | } |
| 1140 | } |
| 1141 | ``` |
| 1142 | |
| 1143 | - [ ] **Step 2: Run them to see them fail** |
| 1144 | |
| 1145 | Run: `go test ./internal/control -run TestRepoRunner 2>&1 | head -5` |
| 1146 | Expected: `undefined: runRepoRunnerAdd`. |
| 1147 | |
| 1148 | - [ ] **Step 3: Write `internal/control/runnerrepo.go`** |
| 1149 | |
| 1150 | ```go |
| 1151 | package control |
| 1152 | |
| 1153 | import ( |
| 1154 | "errors" |
| 1155 | "fmt" |
| 1156 | "io" |
| 1157 | |
| 1158 | "golang.org/x/crypto/ssh" |
| 1159 | |
| 1160 | "gitbay.org/gitbay/internal/policy" |
| 1161 | "gitbay.org/gitbay/internal/protocol" |
| 1162 | "gitbay.org/gitbay/internal/store" |
| 1163 | ) |
| 1164 | |
| 1165 | // Runners attached to a repository (#184). A runner key claims builds only |
| 1166 | // for the repositories it is attached to; a repository admin attaches it |
| 1167 | // by pasting the runner's public key. The key lands on the admin's own |
| 1168 | // account with scope runner, which confines it to the runner protocol and |
| 1169 | // read-only git. |
| 1170 | func init() { |
| 1171 | register(Command{Path: []string{"repo", "runner", "add"}, |
| 1172 | Summary: "attach a runner's public key to a repository", |
| 1173 | Usage: "repo runner add <owner/name> < key.pub", |
| 1174 | ReadsStdin: true, Run: runRepoRunnerAdd}) |
| 1175 | register(Command{Path: []string{"repo", "runner", "list"}, |
| 1176 | Summary: "list the runners attached to a repository", |
| 1177 | Usage: "repo runner list <owner/name>", ReadOnly: true, Run: runRepoRunnerList}) |
| 1178 | register(Command{Path: []string{"repo", "runner", "remove"}, |
| 1179 | Summary: "detach a runner from a repository", |
| 1180 | Usage: "repo runner remove <owner/name> <fingerprint>", Run: runRepoRunnerRemove}) |
| 1181 | } |
| 1182 | |
| 1183 | func runRepoRunnerAdd(c *Ctx, args []string) int { |
| 1184 | f, err := parseFlags(args, flagSpec{MaxPos: 1, Usage: "repo runner add <owner/name> < key.pub"}) |
| 1185 | if err != nil || len(f.Pos) != 1 { |
| 1186 | return c.fail(protocol.ExitUsage, "usage: repo runner add <owner/name> < key.pub") |
| 1187 | } |
| 1188 | repo, code := resolveRepo(c, f.Pos[0], policy.CanAdmin) |
| 1189 | if code >= 0 { |
| 1190 | return code |
| 1191 | } |
| 1192 | raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10)) |
| 1193 | if err != nil { |
| 1194 | return c.fail(protocol.ExitFailure, "reading key: %v", err) |
| 1195 | } |
| 1196 | pub, _, _, _, err := ssh.ParseAuthorizedKey(raw) |
| 1197 | if err != nil { |
| 1198 | return c.fail(protocol.ExitUsage, "not a valid public key in authorized_keys format: %v", err) |
| 1199 | } |
| 1200 | fp := ssh.FingerprintSHA256(pub) |
| 1201 | key, err := c.Store.SSHKeyByFingerprint(fp) |
| 1202 | switch { |
| 1203 | case errors.Is(err, store.ErrNotFound): |
| 1204 | if err := c.Store.AddSSHKey(c.User.ID, fp, pub.Type(), pub.Marshal(), "runner"); err != nil { |
| 1205 | return c.fail(protocol.ExitFailure, "adding key: %v", err) |
| 1206 | } |
| 1207 | if key, err = c.Store.SSHKeyByFingerprint(fp); err != nil { |
| 1208 | return c.fail(protocol.ExitFailure, "%v", err) |
| 1209 | } |
| 1210 | case err != nil: |
| 1211 | return c.fail(protocol.ExitFailure, "%v", err) |
| 1212 | case key.Scope != "runner": |
| 1213 | // A full key would let a build step administer the account; a |
| 1214 | // deploy key is bound elsewhere. A runner gets a key of its own. |
| 1215 | 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) |
| 1216 | case key.UserID != c.User.ID && !c.User.IsAdmin: |
| 1217 | return c.fail(protocol.ExitDenied, "%s belongs to another account", fp) |
| 1218 | } |
| 1219 | if err := c.Store.AttachRunner(key.ID, repo.ID); err != nil { |
| 1220 | return c.fail(protocol.ExitFailure, "%v", err) |
| 1221 | } |
| 1222 | c.Store.Audit(c.User.ID, "repo.runner.add", map[string]any{"repo": repo.Path(), "fingerprint": fp}) |
| 1223 | d := map[string]string{"fingerprint": fp, "repo": repo.Path()} |
| 1224 | return c.emit(d, func(w io.Writer) { |
| 1225 | fmt.Fprintf(w, "runner %s attached to %s\n", fp, repo.Path()) |
| 1226 | }) |
| 1227 | } |
| 1228 | |
| 1229 | func runRepoRunnerList(c *Ctx, args []string) int { |
| 1230 | if len(args) != 1 { |
| 1231 | return c.fail(protocol.ExitUsage, "usage: repo runner list <owner/name>") |
| 1232 | } |
| 1233 | repo, code := resolveRepo(c, args[0], policy.CanAdmin) |
| 1234 | if code >= 0 { |
| 1235 | return code |
| 1236 | } |
| 1237 | runners, err := c.Store.ListRepoRunners(repo.ID) |
| 1238 | if err != nil { |
| 1239 | return c.fail(protocol.ExitFailure, "%v", err) |
| 1240 | } |
| 1241 | if runners == nil { |
| 1242 | runners = []store.RepoRunner{} |
| 1243 | } |
| 1244 | return c.emit(runners, func(w io.Writer) { |
| 1245 | for _, r := range runners { |
| 1246 | seen := r.LastSeen |
| 1247 | if seen == "" { |
| 1248 | seen = "never" |
| 1249 | } |
| 1250 | held := "idle" |
| 1251 | if r.BuildNumber != 0 { |
| 1252 | held = fmt.Sprintf("%s #%d %s since %s", r.BuildRepo, r.BuildNumber, r.BuildJob, r.StartedAt) |
| 1253 | } |
| 1254 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Fingerprint, r.Algo, r.Username, seen, held) |
| 1255 | } |
| 1256 | }) |
| 1257 | } |
| 1258 | |
| 1259 | func runRepoRunnerRemove(c *Ctx, args []string) int { |
| 1260 | if len(args) != 2 { |
| 1261 | return c.fail(protocol.ExitUsage, "usage: repo runner remove <owner/name> <fingerprint>") |
| 1262 | } |
| 1263 | repo, code := resolveRepo(c, args[0], policy.CanAdmin) |
| 1264 | if code >= 0 { |
| 1265 | return code |
| 1266 | } |
| 1267 | if err := c.Store.DetachRunner(repo.ID, args[1]); err != nil { |
| 1268 | if errors.Is(err, store.ErrNotFound) { |
| 1269 | return c.fail(protocol.ExitNotFound, "no runner %s on %s", args[1], repo.Path()) |
| 1270 | } |
| 1271 | return c.fail(protocol.ExitFailure, "%v", err) |
| 1272 | } |
| 1273 | c.Store.Audit(c.User.ID, "repo.runner.remove", map[string]any{"repo": repo.Path(), "fingerprint": args[1]}) |
| 1274 | return c.emit(map[string]string{"removed": args[1]}, func(w io.Writer) { |
| 1275 | fmt.Fprintf(w, "runner %s detached from %s\n", args[1], repo.Path()) |
| 1276 | }) |
| 1277 | } |
| 1278 | ``` |
| 1279 | |
| 1280 | `Store.Audit(actorID int64, action string, data map[string]any)` returns nothing. |
| 1281 | |
| 1282 | - [ ] **Step 4: Add the CLI table entries** |
| 1283 | |
| 1284 | In `cmd/gitbay/main.go`, directly after the `group("deploy-key", ...)` block (line 437-440): |
| 1285 | |
| 1286 | ```go |
| 1287 | group("runner", "runners attached to a repository", |
| 1288 | 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"}), |
| 1289 | pass("list", "list attached runners", passOpts{server: []string{"repo", "runner", "list"}, needsRepo: true}), |
| 1290 | pass("remove", "detach a runner: <fingerprint>", passOpts{server: []string{"repo", "runner", "remove"}, needsRepo: true}), |
| 1291 | ), |
| 1292 | ``` |
| 1293 | |
| 1294 | - [ ] **Step 5: Run the tests** |
| 1295 | |
| 1296 | Run: `go build ./... && go test ./internal/control ./cmd/gitbay 2>&1 | tail -5` |
| 1297 | Expected: PASS, including the CLI coverage test. |
| 1298 | |
| 1299 | - [ ] **Step 6: Commit** |
| 1300 | |
| 1301 | ```bash |
| 1302 | git add internal/control/runnerrepo.go internal/control/runnerrepo_test.go cmd/gitbay/main.go |
| 1303 | git commit -m "control, cli: repo runner add, list, remove |
| 1304 | |
| 1305 | Ref #184" |
| 1306 | ``` |
| 1307 | |
| 1308 | --- |
| 1309 | |
| 1310 | ### Task 5: Web: Runners on the repository settings page |
| 1311 | |
| 1312 | **Files:** |
| 1313 | - Modify: `internal/httpd/settings.go:18-49` (`settingsPage`, `settingsForm`) and the `switch` in `settingsSubmit` |
| 1314 | - Modify: `internal/web/templates/settings.html` (a section after Dependencies, before Lifecycle) |
| 1315 | - Create: `e2e/runnerweb_test.go` |
| 1316 | |
| 1317 | **Interfaces:** |
| 1318 | - Consumes: `repo runner list|add|remove` from Task 4; `s.runControlInto`, `s.runControlStdin(u, argv, stdin) (msg string, ok bool)`, `s.runControl`. |
| 1319 | - Produces: form fields `field=runner-add` with `key`, and `field=runner-remove` with `fingerprint`. |
| 1320 | |
| 1321 | - [ ] **Step 1: Write the failing e2e test** |
| 1322 | |
| 1323 | `e2e/runnerweb_test.go`: |
| 1324 | |
| 1325 | ```go |
| 1326 | package e2e |
| 1327 | |
| 1328 | import ( |
| 1329 | "net/url" |
| 1330 | "os" |
| 1331 | "strings" |
| 1332 | "testing" |
| 1333 | ) |
| 1334 | |
| 1335 | // The settings page attaches and detaches runners through the same |
| 1336 | // commands the CLI uses, and lists what is attached. |
| 1337 | func TestRunnerSettingsWeb(t *testing.T) { |
| 1338 | inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n") |
| 1339 | aliceKey := inst.newKey(t, "alice") |
| 1340 | inst.admin(t, "admin", "user", "create", "alice", |
| 1341 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") |
| 1342 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { |
| 1343 | t.Fatalf("repo create: %s", errOut) |
| 1344 | } |
| 1345 | runnerKey := inst.newKey(t, "laptop") |
| 1346 | pub, _ := os.ReadFile(runnerKey + ".pub") |
| 1347 | |
| 1348 | alice := inst.login(t, aliceKey) |
| 1349 | settings := inst.base() + "/alice/app/settings" |
| 1350 | _, body := browserGet(t, alice, settings) |
| 1351 | if !strings.Contains(body, "No runners attached") { |
| 1352 | t.Fatalf("empty state missing:\n%s", body) |
| 1353 | } |
| 1354 | if status, _ := browserPost(t, alice, settings, url.Values{"field": {"runner-add"}, "key": {string(pub)}}); status != 200 { |
| 1355 | t.Fatalf("runner-add post: %d", status) |
| 1356 | } |
| 1357 | out, _, _ := inst.ssh(t, aliceKey, "", "repo", "runner", "list", "alice/app", "--json") |
| 1358 | if !strings.Contains(out, `"fingerprint":"SHA256:`) { |
| 1359 | t.Fatalf("not attached after the form: %s", out) |
| 1360 | } |
| 1361 | fp := out[strings.Index(out, "SHA256:"):] |
| 1362 | fp = fp[:strings.Index(fp, `"`)] |
| 1363 | _, body = browserGet(t, alice, settings) |
| 1364 | if !strings.Contains(body, fp) || !strings.Contains(body, `value="runner-remove"`) { |
| 1365 | t.Fatalf("attached runner not listed:\n%s", body) |
| 1366 | } |
| 1367 | if status, _ := browserPost(t, alice, settings, url.Values{"field": {"runner-remove"}, "fingerprint": {fp}}); status != 200 { |
| 1368 | t.Fatalf("runner-remove post: %d", status) |
| 1369 | } |
| 1370 | if out, _, _ = inst.ssh(t, aliceKey, "", "repo", "runner", "list", "alice/app", "--json"); strings.Contains(out, fp) { |
| 1371 | t.Fatalf("still attached after remove: %s", out) |
| 1372 | } |
| 1373 | } |
| 1374 | ``` |
| 1375 | |
| 1376 | - [ ] **Step 2: Run it to see it fail** |
| 1377 | |
| 1378 | Run: `go test ./e2e -run TestRunnerSettingsWeb -count=1 2>&1 | tail -5` |
| 1379 | Expected: FAIL at "empty state missing". |
| 1380 | |
| 1381 | - [ ] **Step 3: Handler changes in `internal/httpd/settings.go`** |
| 1382 | |
| 1383 | `settingsPage` gains: |
| 1384 | |
| 1385 | ```go |
| 1386 | Runners []store.RepoRunner |
| 1387 | ``` |
| 1388 | |
| 1389 | In `settingsForm`, after the deps read: |
| 1390 | |
| 1391 | ```go |
| 1392 | var runners []store.RepoRunner |
| 1393 | s.runControlInto(u, []string{"repo", "runner", "list", repo.Path()}, &runners) |
| 1394 | ``` |
| 1395 | |
| 1396 | and pass `Runners: runners` to the struct literal. |
| 1397 | |
| 1398 | In `settingsSubmit`'s switch, before `default:`: |
| 1399 | |
| 1400 | ```go |
| 1401 | case "runner-add": |
| 1402 | body := v("key") |
| 1403 | if body == "" { |
| 1404 | s.settingsRedirect(w, r, "paste the runner's public key") |
| 1405 | return |
| 1406 | } |
| 1407 | msg, ok := s.runControlStdin(u, []string{"repo", "runner", "add", repo}, body+"\n") |
| 1408 | if ok { |
| 1409 | msg = "" |
| 1410 | } |
| 1411 | s.settingsRedirect(w, r, msg) |
| 1412 | return |
| 1413 | case "runner-remove": |
| 1414 | argv = []string{"repo", "runner", "remove", repo, v("fingerprint")} |
| 1415 | ``` |
| 1416 | |
| 1417 | - [ ] **Step 4: Template section** |
| 1418 | |
| 1419 | In `internal/web/templates/settings.html`, before `<h2>Lifecycle</h2>`: |
| 1420 | |
| 1421 | ```html |
| 1422 | <h2>Runners</h2> |
| 1423 | {{if .Runners}} |
| 1424 | <ul class="protlist"> |
| 1425 | {{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> |
| 1426 | <form method="post" action="{{$base}}" class="inline"> |
| 1427 | <input type="hidden" name="field" value="runner-remove"> |
| 1428 | <input type="hidden" name="fingerprint" value="{{.Fingerprint}}"> |
| 1429 | <button type="submit" class="linklike">Detach</button> |
| 1430 | </form></li> |
| 1431 | {{end}} |
| 1432 | </ul> |
| 1433 | {{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}} |
| 1434 | <form method="post" action="{{$base}}" class="setform"> |
| 1435 | <input type="hidden" name="field" value="runner-add"> |
| 1436 | <label for="runner-key">Attach a runner</label> |
| 1437 | <textarea id="runner-key" name="key" rows="3" placeholder="ssh-ed25519 AAAA… (from gitbay-runner init)"></textarea> |
| 1438 | <button type="submit">Attach</button> |
| 1439 | </form> |
| 1440 | <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> |
| 1441 | ``` |
| 1442 | |
| 1443 | - [ ] **Step 5: Run the test** |
| 1444 | |
| 1445 | Run: `go test ./e2e -run TestRunnerSettingsWeb -count=1 2>&1 | tail -5` |
| 1446 | Expected: PASS. |
| 1447 | |
| 1448 | - [ ] **Step 6: Commit** |
| 1449 | |
| 1450 | ```bash |
| 1451 | git add internal/httpd/settings.go internal/web/templates/settings.html e2e/runnerweb_test.go |
| 1452 | git commit -m "httpd: attach and detach runners on the settings page |
| 1453 | |
| 1454 | Ref #184" |
| 1455 | ``` |
| 1456 | |
| 1457 | --- |
| 1458 | |
| 1459 | ### Task 6: Runner: config file, `-identity`, `-untrusted` |
| 1460 | |
| 1461 | **Files:** |
| 1462 | - Create: `cmd/gitbay-runner/config.go` |
| 1463 | - Create: `cmd/gitbay-runner/config_test.go` |
| 1464 | - Modify: `cmd/gitbay-runner/main.go` (flag block, `runner` struct, `step`, ssh option assembly) |
| 1465 | |
| 1466 | **Interfaces:** |
| 1467 | - Produces: |
| 1468 | - `configDir() string`: `$XDG_CONFIG_HOME/gitbay-runner` or `$HOME/.config/gitbay-runner`. |
| 1469 | - `defaultConfigPath() string`: `configDir()/config.toml`. |
| 1470 | - `configPathFromArgs(args []string, def string) string`: honours `-config X`, `--config X`, `-config=X`. |
| 1471 | - `loadConfig(path string) (map[string]string, bool, error)`: flag name to value, false when the file is absent. |
| 1472 | - `applyConfig(fs *flag.FlagSet, values map[string]string) error`: `fs.Set` each. |
| 1473 | - `identityOpts(path string) []string`: `["-i", path, "-o", "IdentitiesOnly=yes"]` or nil. |
| 1474 | - Flags `-config`, `-identity`, `-untrusted`. |
| 1475 | |
| 1476 | - [ ] **Step 1: Write the failing tests** |
| 1477 | |
| 1478 | `cmd/gitbay-runner/config_test.go`: |
| 1479 | |
| 1480 | ```go |
| 1481 | package main |
| 1482 | |
| 1483 | import ( |
| 1484 | "flag" |
| 1485 | "os" |
| 1486 | "path/filepath" |
| 1487 | "testing" |
| 1488 | ) |
| 1489 | |
| 1490 | // A config file sets the flags' values; a flag on the command line wins. |
| 1491 | func TestConfigFileFeedsFlagsAndFlagsOverride(t *testing.T) { |
| 1492 | dir := t.TempDir() |
| 1493 | path := filepath.Join(dir, "config.toml") |
| 1494 | os.WriteFile(path, []byte("remote = \"git@example.test\"\npoll = \"9s\"\nuntrusted = true\nidentity = \"/k\"\njobs = 2\n"), 0o600) |
| 1495 | |
| 1496 | values, found, err := loadConfig(path) |
| 1497 | if err != nil || !found { |
| 1498 | t.Fatalf("loadConfig: found=%v err=%v", found, err) |
| 1499 | } |
| 1500 | fs := flag.NewFlagSet("t", flag.ContinueOnError) |
| 1501 | remote := fs.String("remote", "git@gitbay.org", "") |
| 1502 | poll := fs.Duration("poll", 0, "") |
| 1503 | untrusted := fs.Bool("untrusted", false, "") |
| 1504 | identity := fs.String("identity", "", "") |
| 1505 | jobs := fs.Int("jobs", 1, "") |
| 1506 | if err := applyConfig(fs, values); err != nil { |
| 1507 | t.Fatal(err) |
| 1508 | } |
| 1509 | if err := fs.Parse([]string{"-poll", "3s"}); err != nil { |
| 1510 | t.Fatal(err) |
| 1511 | } |
| 1512 | if *remote != "git@example.test" || poll.String() != "3s" || !*untrusted || *identity != "/k" || *jobs != 2 { |
| 1513 | t.Fatalf("remote=%s poll=%s untrusted=%v identity=%s jobs=%d", *remote, poll, *untrusted, *identity, *jobs) |
| 1514 | } |
| 1515 | if _, found, err := loadConfig(filepath.Join(dir, "missing.toml")); found || err != nil { |
| 1516 | t.Fatalf("missing file: found=%v err=%v", found, err) |
| 1517 | } |
| 1518 | if _, _, err := loadConfig(path); err != nil { |
| 1519 | t.Fatal(err) |
| 1520 | } |
| 1521 | os.WriteFile(path, []byte("nonsense = \"x\"\n"), 0o600) |
| 1522 | if _, _, err := loadConfig(path); err == nil { |
| 1523 | t.Fatal("an unknown key was accepted") |
| 1524 | } |
| 1525 | } |
| 1526 | |
| 1527 | func TestConfigPathFromArgs(t *testing.T) { |
| 1528 | for _, tc := range []struct { |
| 1529 | args []string |
| 1530 | want string |
| 1531 | }{ |
| 1532 | {nil, "/def"}, |
| 1533 | {[]string{"-once"}, "/def"}, |
| 1534 | {[]string{"-config", "/a"}, "/a"}, |
| 1535 | {[]string{"--config", "/b", "-once"}, "/b"}, |
| 1536 | {[]string{"-config=/c"}, "/c"}, |
| 1537 | } { |
| 1538 | if got := configPathFromArgs(tc.args, "/def"); got != tc.want { |
| 1539 | t.Errorf("%v: got %s want %s", tc.args, got, tc.want) |
| 1540 | } |
| 1541 | } |
| 1542 | } |
| 1543 | |
| 1544 | func TestConfigDirHonoursXDG(t *testing.T) { |
| 1545 | t.Setenv("XDG_CONFIG_HOME", "/x") |
| 1546 | if got := configDir(); got != "/x/gitbay-runner" { |
| 1547 | t.Fatalf("got %s", got) |
| 1548 | } |
| 1549 | t.Setenv("XDG_CONFIG_HOME", "") |
| 1550 | t.Setenv("HOME", "/h") |
| 1551 | if got := configDir(); got != "/h/.config/gitbay-runner" { |
| 1552 | t.Fatalf("got %s", got) |
| 1553 | } |
| 1554 | } |
| 1555 | |
| 1556 | func TestIdentityOpts(t *testing.T) { |
| 1557 | if got := identityOpts(""); got != nil { |
| 1558 | t.Fatalf("empty identity produced %v", got) |
| 1559 | } |
| 1560 | got := identityOpts("/k") |
| 1561 | if len(got) != 4 || got[0] != "-i" || got[1] != "/k" || got[3] != "IdentitiesOnly=yes" { |
| 1562 | t.Fatalf("got %v", got) |
| 1563 | } |
| 1564 | } |
| 1565 | ``` |
| 1566 | |
| 1567 | - [ ] **Step 2: Run them to see them fail** |
| 1568 | |
| 1569 | Run: `go test ./cmd/gitbay-runner -run 'TestConfig|TestIdentity' 2>&1 | head -5` |
| 1570 | Expected: `undefined: loadConfig` and friends. |
| 1571 | |
| 1572 | - [ ] **Step 3: Write `cmd/gitbay-runner/config.go`** |
| 1573 | |
| 1574 | ```go |
| 1575 | package main |
| 1576 | |
| 1577 | import ( |
| 1578 | "errors" |
| 1579 | "flag" |
| 1580 | "fmt" |
| 1581 | "os" |
| 1582 | "path/filepath" |
| 1583 | "strings" |
| 1584 | |
| 1585 | "github.com/BurntSushi/toml" |
| 1586 | ) |
| 1587 | |
| 1588 | // The runner takes everything as flags, which does not work under a |
| 1589 | // service manager. config.toml in the config directory carries the same |
| 1590 | // names; a flag on the command line overrides it (#184). |
| 1591 | |
| 1592 | func configDir() string { |
| 1593 | if x := os.Getenv("XDG_CONFIG_HOME"); x != "" { |
| 1594 | return filepath.Join(x, "gitbay-runner") |
| 1595 | } |
| 1596 | return filepath.Join(os.Getenv("HOME"), ".config", "gitbay-runner") |
| 1597 | } |
| 1598 | |
| 1599 | func defaultConfigPath() string { return filepath.Join(configDir(), "config.toml") } |
| 1600 | |
| 1601 | // configPathFromArgs finds -config before the flag set is parsed, since |
| 1602 | // the file's values must be set before parsing for flags to override them. |
| 1603 | func configPathFromArgs(args []string, def string) string { |
| 1604 | for i, a := range args { |
| 1605 | a = strings.TrimPrefix(a, "-") |
| 1606 | if a == "-config" || a == "config" { |
| 1607 | if i+1 < len(args) { |
| 1608 | return args[i+1] |
| 1609 | } |
| 1610 | } |
| 1611 | if v, ok := strings.CutPrefix(a, "config="); ok { |
| 1612 | return v |
| 1613 | } |
| 1614 | if v, ok := strings.CutPrefix(a, "-config="); ok { |
| 1615 | return v |
| 1616 | } |
| 1617 | } |
| 1618 | return def |
| 1619 | } |
| 1620 | |
| 1621 | // configKeys is every key the file may carry: the flag names. |
| 1622 | var configKeys = map[string]bool{"remote": true, "ssh-opts": true, "clone-base": true, "workdir": true, |
| 1623 | "poll": true, "timeout": true, "repos": true, "jobs": true, "image": true, "isolation": true, |
| 1624 | "memory": true, "cpus": true, "untrusted": true, "identity": true} |
| 1625 | |
| 1626 | // loadConfig reads path into flag name → value. Absent file: found is |
| 1627 | // false and there is no error. An unknown key is an error, not a typo |
| 1628 | // the runner silently ignores. |
| 1629 | func loadConfig(path string) (values map[string]string, found bool, err error) { |
| 1630 | var raw map[string]any |
| 1631 | if _, err := toml.DecodeFile(path, &raw); errors.Is(err, os.ErrNotExist) { |
| 1632 | return nil, false, nil |
| 1633 | } else if err != nil { |
| 1634 | return nil, true, fmt.Errorf("%s: %w", path, err) |
| 1635 | } |
| 1636 | values = map[string]string{} |
| 1637 | for k, v := range raw { |
| 1638 | if !configKeys[k] { |
| 1639 | return nil, true, fmt.Errorf("%s: unknown key %s", path, k) |
| 1640 | } |
| 1641 | values[k] = fmt.Sprint(v) |
| 1642 | } |
| 1643 | return values, true, nil |
| 1644 | } |
| 1645 | |
| 1646 | // applyConfig sets each value on the flag set, which is what parsing the |
| 1647 | // command line would do; parse afterwards and the command line wins. |
| 1648 | func applyConfig(fs *flag.FlagSet, values map[string]string) error { |
| 1649 | for k, v := range values { |
| 1650 | if fs.Lookup(k) == nil { |
| 1651 | return fmt.Errorf("config: unknown key %s", k) |
| 1652 | } |
| 1653 | if err := fs.Set(k, v); err != nil { |
| 1654 | return fmt.Errorf("config: %s: %w", k, err) |
| 1655 | } |
| 1656 | } |
| 1657 | return nil |
| 1658 | } |
| 1659 | |
| 1660 | // identityOpts is what makes ssh and git use the runner's own key and no |
| 1661 | // other: on a laptop the ambient key is the user's full-scope one, which |
| 1662 | // the runner protocol refuses. |
| 1663 | func identityOpts(path string) []string { |
| 1664 | if path == "" { |
| 1665 | return nil |
| 1666 | } |
| 1667 | return []string{"-i", path, "-o", "IdentitiesOnly=yes"} |
| 1668 | } |
| 1669 | ``` |
| 1670 | |
| 1671 | - [ ] **Step 4: Wire it into `main.go`** |
| 1672 | |
| 1673 | 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: |
| 1674 | |
| 1675 | ```go |
| 1676 | var ( |
| 1677 | configPath = flag.String("config", defaultConfigPath(), "config file; keys are these flag names, flags override it") |
| 1678 | identity = flag.String("identity", "", "ssh private key to poll and clone with (default: the key gitbay-runner init generated, if present)") |
| 1679 | untrusted = flag.Bool("untrusted", false, "also claim untrusted builds: merge request heads from forks (needs -isolation podman to be safe)") |
| 1680 | // ... existing flags unchanged ... |
| 1681 | ) |
| 1682 | path := configPathFromArgs(os.Args[1:], *configPath) |
| 1683 | if values, found, err := loadConfig(path); err != nil { |
| 1684 | log.Fatal(err) |
| 1685 | } else if found { |
| 1686 | if err := applyConfig(flag.CommandLine, values); err != nil { |
| 1687 | log.Fatal(err) |
| 1688 | } |
| 1689 | log.Printf("config: %s", path) |
| 1690 | } |
| 1691 | flag.Parse() |
| 1692 | ``` |
| 1693 | |
| 1694 | After `if *sshOpts != "" { r.sshOpts = strings.Fields(*sshOpts) }`: |
| 1695 | |
| 1696 | ```go |
| 1697 | if *identity == "" { |
| 1698 | if p := filepath.Join(configDir(), "id_ed25519"); fileExists(p) { |
| 1699 | *identity = p |
| 1700 | } |
| 1701 | } |
| 1702 | r.sshOpts = append(identityOpts(*identity), r.sshOpts...) |
| 1703 | r.untrusted = *untrusted |
| 1704 | ``` |
| 1705 | |
| 1706 | with |
| 1707 | |
| 1708 | ```go |
| 1709 | func fileExists(p string) bool { _, err := os.Stat(p); return err == nil } |
| 1710 | ``` |
| 1711 | |
| 1712 | `runner` struct gains `untrusted bool`. In `step()`: |
| 1713 | |
| 1714 | ```go |
| 1715 | args := []string{"runner", "next"} |
| 1716 | if r.untrusted { |
| 1717 | args = append(args, "--untrusted") |
| 1718 | } |
| 1719 | args = append(append(args, r.repos...), "--json") |
| 1720 | out, err := r.ssh(nil, args...) |
| 1721 | ``` |
| 1722 | |
| 1723 | Both `ssh()` and the `gitSSH` line already use `r.sshOpts`, so the identity reaches both. |
| 1724 | |
| 1725 | Move the `init` dispatch hook in now so Task 7 has a place to land, at the top of `main()`: |
| 1726 | |
| 1727 | ```go |
| 1728 | if len(os.Args) > 1 && os.Args[1] == "init" { |
| 1729 | os.Exit(runInit(os.Args[2:])) |
| 1730 | } |
| 1731 | ``` |
| 1732 | |
| 1733 | and a stub in `config.go` until Task 7 replaces it: |
| 1734 | |
| 1735 | ```go |
| 1736 | func runInit(args []string) int { fmt.Fprintln(os.Stderr, "init: not implemented"); return 2 } |
| 1737 | ``` |
| 1738 | |
| 1739 | - [ ] **Step 5: Run the tests** |
| 1740 | |
| 1741 | Run: `go build ./... && go vet ./cmd/gitbay-runner && go test ./cmd/gitbay-runner 2>&1 | tail -3` |
| 1742 | Expected: PASS. |
| 1743 | |
| 1744 | - [ ] **Step 6: Commit** |
| 1745 | |
| 1746 | ```bash |
| 1747 | git add cmd/gitbay-runner/config.go cmd/gitbay-runner/config_test.go cmd/gitbay-runner/main.go |
| 1748 | git commit -m "runner: config.toml, -identity, -untrusted |
| 1749 | |
| 1750 | Ref #184" |
| 1751 | ``` |
| 1752 | |
| 1753 | --- |
| 1754 | |
| 1755 | ### Task 7: Runner: `gitbay-runner init` |
| 1756 | |
| 1757 | **Files:** |
| 1758 | - Create: `cmd/gitbay-runner/init.go` (replaces the stub `runInit` in `config.go`; delete the stub) |
| 1759 | - Create: `cmd/gitbay-runner/init_test.go` |
| 1760 | |
| 1761 | **Interfaces:** |
| 1762 | - Consumes: `configDir()`, `defaultWorkdir()`, `toolpath.Look("ssh-keygen")`. |
| 1763 | - Produces: `runInit(args []string) int`; files `<configDir>/id_ed25519`, `id_ed25519.pub`, `config.toml`. |
| 1764 | |
| 1765 | - [ ] **Step 1: Write the failing test** |
| 1766 | |
| 1767 | `cmd/gitbay-runner/init_test.go`: |
| 1768 | |
| 1769 | ```go |
| 1770 | package main |
| 1771 | |
| 1772 | import ( |
| 1773 | "bytes" |
| 1774 | "os" |
| 1775 | "os/exec" |
| 1776 | "path/filepath" |
| 1777 | "strings" |
| 1778 | "testing" |
| 1779 | ) |
| 1780 | |
| 1781 | // init creates the key and config once, prints the key and the attach |
| 1782 | // command, and running it again changes nothing. |
| 1783 | func TestInitWritesKeyAndConfigOnce(t *testing.T) { |
| 1784 | if _, err := exec.LookPath("ssh-keygen"); err != nil { |
| 1785 | t.Skip("ssh-keygen not on PATH") |
| 1786 | } |
| 1787 | dir := t.TempDir() |
| 1788 | t.Setenv("XDG_CONFIG_HOME", dir) |
| 1789 | var out bytes.Buffer |
| 1790 | initOut = &out |
| 1791 | defer func() { initOut = os.Stdout }() |
| 1792 | |
| 1793 | if code := runInit([]string{"-remote", "git@example.test"}); code != 0 { |
| 1794 | t.Fatalf("init: exit %d\n%s", code, out.String()) |
| 1795 | } |
| 1796 | cdir := filepath.Join(dir, "gitbay-runner") |
| 1797 | key := filepath.Join(cdir, "id_ed25519") |
| 1798 | pub, err := os.ReadFile(key + ".pub") |
| 1799 | if err != nil || !strings.HasPrefix(string(pub), "ssh-ed25519 ") { |
| 1800 | t.Fatalf("public key: %v %q", err, pub) |
| 1801 | } |
| 1802 | if fi, _ := os.Stat(key); fi.Mode().Perm() != 0o600 { |
| 1803 | t.Fatalf("private key mode %o", fi.Mode().Perm()) |
| 1804 | } |
| 1805 | if fi, _ := os.Stat(cdir); fi.Mode().Perm() != 0o700 { |
| 1806 | t.Fatalf("config dir mode %o", fi.Mode().Perm()) |
| 1807 | } |
| 1808 | cfg, _ := os.ReadFile(filepath.Join(cdir, "config.toml")) |
| 1809 | for _, want := range []string{"remote = \"git@example.test\"", "isolation = \"none\"", "untrusted = false", "identity = \"" + key + "\""} { |
| 1810 | if !strings.Contains(string(cfg), want) { |
| 1811 | t.Fatalf("config lacks %q:\n%s", want, cfg) |
| 1812 | } |
| 1813 | } |
| 1814 | for _, want := range []string{strings.TrimSpace(string(pub)), "gitbay repo runner add owner/name < " + key + ".pub", "https://example.test/owner/name/settings"} { |
| 1815 | if !strings.Contains(out.String(), want) { |
| 1816 | t.Fatalf("output lacks %q:\n%s", want, out.String()) |
| 1817 | } |
| 1818 | } |
| 1819 | |
| 1820 | out.Reset() |
| 1821 | if code := runInit([]string{"-remote", "git@other.test"}); code != 0 { |
| 1822 | t.Fatalf("second init: exit %d\n%s", code, out.String()) |
| 1823 | } |
| 1824 | if pub2, _ := os.ReadFile(key + ".pub"); string(pub2) != string(pub) { |
| 1825 | t.Fatal("second init replaced the key") |
| 1826 | } |
| 1827 | if cfg2, _ := os.ReadFile(filepath.Join(cdir, "config.toml")); string(cfg2) != string(cfg) { |
| 1828 | t.Fatal("second init rewrote the config") |
| 1829 | } |
| 1830 | } |
| 1831 | |
| 1832 | // podman needs an image; init refuses to write a config the runner would |
| 1833 | // refuse to start with. |
| 1834 | func TestInitPodmanNeedsImage(t *testing.T) { |
| 1835 | t.Setenv("XDG_CONFIG_HOME", t.TempDir()) |
| 1836 | var out bytes.Buffer |
| 1837 | initOut = &out |
| 1838 | defer func() { initOut = os.Stdout }() |
| 1839 | if code := runInit([]string{"-isolation", "podman"}); code != 2 { |
| 1840 | t.Fatalf("exit %d, want 2:\n%s", code, out.String()) |
| 1841 | } |
| 1842 | } |
| 1843 | ``` |
| 1844 | |
| 1845 | - [ ] **Step 2: Run it to see it fail** |
| 1846 | |
| 1847 | Run: `go test ./cmd/gitbay-runner -run TestInit 2>&1 | head -5` |
| 1848 | Expected: `undefined: initOut`. |
| 1849 | |
| 1850 | - [ ] **Step 3: Write `cmd/gitbay-runner/init.go`** |
| 1851 | |
| 1852 | ```go |
| 1853 | package main |
| 1854 | |
| 1855 | import ( |
| 1856 | "flag" |
| 1857 | "fmt" |
| 1858 | "io" |
| 1859 | "os" |
| 1860 | "os/exec" |
| 1861 | "path/filepath" |
| 1862 | "strings" |
| 1863 | |
| 1864 | "gitbay.org/gitbay/internal/toolpath" |
| 1865 | ) |
| 1866 | |
| 1867 | // initOut is where init prints; tests capture it. |
| 1868 | var initOut io.Writer = os.Stdout |
| 1869 | |
| 1870 | // runInit makes a fresh install ready to attach: a key of its own, a |
| 1871 | // config file the service reads, and the one command to run next. It never |
| 1872 | // overwrites a key or a config that exists, so running it twice is safe. |
| 1873 | func runInit(args []string) int { |
| 1874 | fs := flag.NewFlagSet("init", flag.ContinueOnError) |
| 1875 | fs.SetOutput(initOut) |
| 1876 | remote := fs.String("remote", "git@gitbay.org", "ssh destination of the gitbay server") |
| 1877 | workdir := fs.String("workdir", defaultWorkdir(), "build workspace root") |
| 1878 | isolation := fs.String("isolation", isolationNone, "how steps run: none, or podman with -image") |
| 1879 | image := fs.String("image", "", "container image for -isolation podman") |
| 1880 | if err := fs.Parse(args); err != nil { |
| 1881 | return 2 |
| 1882 | } |
| 1883 | if *isolation == isolationPodman && *image == "" { |
| 1884 | fmt.Fprintln(initOut, "-isolation podman needs -image <ref>: the runner refuses to start without one, and there is no image to guess") |
| 1885 | return 2 |
| 1886 | } |
| 1887 | if *isolation != isolationPodman && *isolation != isolationNone { |
| 1888 | fmt.Fprintf(initOut, "unknown isolation %q\n", *isolation) |
| 1889 | return 2 |
| 1890 | } |
| 1891 | |
| 1892 | dir := configDir() |
| 1893 | if err := os.MkdirAll(dir, 0o700); err != nil { |
| 1894 | fmt.Fprintln(initOut, err) |
| 1895 | return 1 |
| 1896 | } |
| 1897 | os.Chmod(dir, 0o700) |
| 1898 | key := filepath.Join(dir, "id_ed25519") |
| 1899 | if !fileExists(key) { |
| 1900 | cmd := exec.Command(toolpath.Look("ssh-keygen"), "-q", "-t", "ed25519", "-N", "", "-C", "gitbay-runner", "-f", key) |
| 1901 | if out, err := cmd.CombinedOutput(); err != nil { |
| 1902 | fmt.Fprintf(initOut, "ssh-keygen: %v\n%s", err, out) |
| 1903 | return 1 |
| 1904 | } |
| 1905 | } |
| 1906 | os.Chmod(key, 0o600) |
| 1907 | |
| 1908 | cfgPath := filepath.Join(dir, "config.toml") |
| 1909 | if !fileExists(cfgPath) { |
| 1910 | var b strings.Builder |
| 1911 | fmt.Fprintf(&b, "remote = %q\n", *remote) |
| 1912 | fmt.Fprintf(&b, "workdir = %q\n", *workdir) |
| 1913 | fmt.Fprintf(&b, "isolation = %q\n", *isolation) |
| 1914 | if *image != "" { |
| 1915 | fmt.Fprintf(&b, "image = %q\n", *image) |
| 1916 | } |
| 1917 | fmt.Fprintf(&b, "untrusted = false\n") |
| 1918 | fmt.Fprintf(&b, "identity = %q\n", key) |
| 1919 | if err := os.WriteFile(cfgPath, []byte(b.String()), 0o600); err != nil { |
| 1920 | fmt.Fprintln(initOut, err) |
| 1921 | return 1 |
| 1922 | } |
| 1923 | } |
| 1924 | |
| 1925 | pub, err := os.ReadFile(key + ".pub") |
| 1926 | if err != nil { |
| 1927 | fmt.Fprintln(initOut, err) |
| 1928 | return 1 |
| 1929 | } |
| 1930 | host := *remote |
| 1931 | if i := strings.LastIndex(host, "@"); i >= 0 { |
| 1932 | host = host[i+1:] |
| 1933 | } |
| 1934 | fmt.Fprintf(initOut, "config: %s\nkey: %s\n\n", cfgPath, key) |
| 1935 | if *isolation == isolationNone { |
| 1936 | 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") |
| 1937 | } |
| 1938 | 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", |
| 1939 | strings.TrimSpace(string(pub)), key, host) |
| 1940 | return 0 |
| 1941 | } |
| 1942 | ``` |
| 1943 | |
| 1944 | `isolationNone` and `isolationPodman` are the constants in `isolate.go:20-21`. Delete the stub `runInit` from `config.go`. |
| 1945 | |
| 1946 | - [ ] **Step 4: Run the tests** |
| 1947 | |
| 1948 | Run: `go build ./... && go vet ./cmd/gitbay-runner && go test ./cmd/gitbay-runner 2>&1 | tail -3` |
| 1949 | Expected: PASS. |
| 1950 | |
| 1951 | - [ ] **Step 5: Commit** |
| 1952 | |
| 1953 | ```bash |
| 1954 | git add cmd/gitbay-runner/init.go cmd/gitbay-runner/init_test.go cmd/gitbay-runner/config.go |
| 1955 | git commit -m "runner: init generates the key and config and prints the attach step |
| 1956 | |
| 1957 | Ref #184" |
| 1958 | ``` |
| 1959 | |
| 1960 | --- |
| 1961 | |
| 1962 | ### Task 8: e2e: init, attach, build; fork head waits |
| 1963 | |
| 1964 | **Files:** |
| 1965 | - Create: `e2e/runnerattach_test.go` |
| 1966 | |
| 1967 | **Interfaces:** |
| 1968 | - Consumes: `buildRunner(t)`, `inst.newKey`, `inst.admin`, `inst.ssh(t, key, stdin, argv...)`, `inst.gitEnv`, `inst.sshURL`, `mustGit`, `inst.port`, `inst.sshDir` (all in `e2e/ci_test.go` and the instance helpers). |
| 1969 | |
| 1970 | - [ ] **Step 1: Write the test** |
| 1971 | |
| 1972 | `e2e/runnerattach_test.go`: |
| 1973 | |
| 1974 | ```go |
| 1975 | package e2e |
| 1976 | |
| 1977 | import ( |
| 1978 | "fmt" |
| 1979 | "os" |
| 1980 | "os/exec" |
| 1981 | "path/filepath" |
| 1982 | "strings" |
| 1983 | "testing" |
| 1984 | ) |
| 1985 | |
| 1986 | // The whole flow a user goes through: init on their machine, attach the |
| 1987 | // printed key to their repository, start the runner from the config init |
| 1988 | // wrote. The runner builds their push and leaves a fork's merge request |
| 1989 | // head alone until started with -untrusted. |
| 1990 | func TestAttachedRunnerBuildsOwnRepo(t *testing.T) { |
| 1991 | inst := startInstance(t) |
| 1992 | inst.runner = buildRunner(t) |
| 1993 | aliceKey := inst.newKey(t, "alice") |
| 1994 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") |
| 1995 | bobKey := inst.newKey(t, "bob") |
| 1996 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") |
| 1997 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { |
| 1998 | t.Fatalf("repo create: %s", errOut) |
| 1999 | } |
| 2000 | |
| 2001 | // init on "alice's laptop". |
| 2002 | xdg := t.TempDir() |
| 2003 | initCmd := exec.Command(inst.runner, "init", "-remote", "git@127.0.0.1") |
| 2004 | initCmd.Env = append(os.Environ(), "XDG_CONFIG_HOME="+xdg) |
| 2005 | initOut, err := initCmd.CombinedOutput() |
| 2006 | if err != nil { |
| 2007 | t.Fatalf("init: %v\n%s", err, initOut) |
| 2008 | } |
| 2009 | cdir := filepath.Join(xdg, "gitbay-runner") |
| 2010 | pub, err := os.ReadFile(filepath.Join(cdir, "id_ed25519.pub")) |
| 2011 | if err != nil { |
| 2012 | t.Fatal(err) |
| 2013 | } |
| 2014 | if !strings.Contains(string(initOut), strings.TrimSpace(string(pub))) { |
| 2015 | t.Fatalf("init did not print the key:\n%s", initOut) |
| 2016 | } |
| 2017 | |
| 2018 | // The unattached key claims nothing, even with a build queued. |
| 2019 | work := t.TempDir() |
| 2020 | env := inst.gitEnv(aliceKey) |
| 2021 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") |
| 2022 | dir := filepath.Join(work, "w") |
| 2023 | os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755) |
| 2024 | os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte("jobs:\n unit:\n steps:\n - echo built\n"), 0o644) |
| 2025 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") |
| 2026 | mustGit(t, dir, env, "add", ".") |
| 2027 | mustGit(t, dir, env, "commit", "-q", "-m", "ci") |
| 2028 | mustGit(t, dir, env, "push", "-q", "origin", "main") |
| 2029 | |
| 2030 | run := func(extra ...string) string { |
| 2031 | t.Helper() |
| 2032 | // No -i in ssh-opts: the identity from the config is what |
| 2033 | // authenticates, which is the point. |
| 2034 | opts := fmt.Sprintf("-p %d -o StrictHostKeyChecking=no -o UserKnownHostsFile=%s -o BatchMode=yes", |
| 2035 | inst.port, filepath.Join(inst.sshDir, "known_hosts")) |
| 2036 | args := append([]string{"-config", filepath.Join(cdir, "config.toml"), "-once", |
| 2037 | "-ssh-opts", opts, |
| 2038 | "-clone-base", fmt.Sprintf("ssh://git@127.0.0.1:%d", inst.port), |
| 2039 | "-workdir", t.TempDir()}, extra...) |
| 2040 | cmd := exec.Command(inst.runner, args...) |
| 2041 | cmd.Env = append(os.Environ(), "XDG_CONFIG_HOME="+xdg, "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null") |
| 2042 | out, err := cmd.CombinedOutput() |
| 2043 | if err != nil { |
| 2044 | t.Fatalf("runner: %v\n%s", err, out) |
| 2045 | } |
| 2046 | return string(out) |
| 2047 | } |
| 2048 | if out, _, _ := inst.ssh(t, aliceKey, "", "build", "list", "alice/app"); !strings.Contains(out, "unit\tpending") { |
| 2049 | t.Fatalf("build not pending before attach: %s", out) |
| 2050 | } |
| 2051 | |
| 2052 | // Attach with the printed key. |
| 2053 | if _, errOut, code := inst.ssh(t, aliceKey, string(pub), "repo", "runner", "add", "alice/app"); code != 0 { |
| 2054 | t.Fatalf("repo runner add: %s", errOut) |
| 2055 | } |
| 2056 | out, _, _ := inst.ssh(t, aliceKey, "", "repo", "runner", "list", "alice/app", "--json") |
| 2057 | if !strings.Contains(out, `"username":"alice"`) || strings.Contains(out, `"last_seen":"20`) { |
| 2058 | t.Fatalf("list after attach: %s", out) |
| 2059 | } |
| 2060 | |
| 2061 | // The runner builds it. |
| 2062 | run() |
| 2063 | if out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app"); !strings.Contains(out, "unit\tsuccess") { |
| 2064 | t.Fatalf("build not built by the attached runner: %s", out) |
| 2065 | } |
| 2066 | if out, _, _ = inst.ssh(t, aliceKey, "", "repo", "runner", "list", "alice/app", "--json"); !strings.Contains(out, `"last_seen":"20`) { |
| 2067 | t.Fatalf("no heartbeat after a poll: %s", out) |
| 2068 | } |
| 2069 | |
| 2070 | // bob forks and opens a merge request: an untrusted build in the target. |
| 2071 | if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "fork", "alice/app"); code != 0 { |
| 2072 | t.Fatalf("fork: %s", errOut) |
| 2073 | } |
| 2074 | bwork := t.TempDir() |
| 2075 | benv := inst.gitEnv(bobKey) |
| 2076 | mustGit(t, bwork, benv, "clone", inst.sshURL("bob/app"), "w") |
| 2077 | bdir := filepath.Join(bwork, "w") |
| 2078 | mustGit(t, bdir, benv, "checkout", "-q", "-b", "feat") |
| 2079 | os.WriteFile(filepath.Join(bdir, "f.txt"), []byte("y\n"), 0o644) |
| 2080 | mustGit(t, bdir, benv, "add", ".") |
| 2081 | mustGit(t, bdir, benv, "commit", "-q", "-m", "change") |
| 2082 | mustGit(t, bdir, benv, "push", "-q", "origin", "feat") |
| 2083 | if _, _, code := inst.ssh(t, bobKey, "", "build", "cancel", "bob/app", "1"); code != 0 { |
| 2084 | t.Fatal("cancel bob's own build") |
| 2085 | } |
| 2086 | if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/app", |
| 2087 | "--source", "bob/app:feat", "--target", "main", "--title", "change"); code != 0 { |
| 2088 | t.Fatalf("mr create: %s", errOut) |
| 2089 | } |
| 2090 | run() |
| 2091 | if out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app"); strings.Count(out, "pending") != 1 { |
| 2092 | t.Fatalf("fork head was claimed without -untrusted:\n%s", out) |
| 2093 | } |
| 2094 | run("-untrusted") |
| 2095 | if out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app"); strings.Contains(out, "pending") { |
| 2096 | t.Fatalf("fork head not built with -untrusted:\n%s", out) |
| 2097 | } |
| 2098 | } |
| 2099 | ``` |
| 2100 | |
| 2101 | - [ ] **Step 2: Run it** |
| 2102 | |
| 2103 | Run: `go test ./e2e -run TestAttachedRunnerBuildsOwnRepo -count=1 -v 2>&1 | tail -20` |
| 2104 | 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`. |
| 2105 | |
| 2106 | - [ ] **Step 3: Commit** |
| 2107 | |
| 2108 | ```bash |
| 2109 | git add e2e/runnerattach_test.go |
| 2110 | git commit -m "e2e: init, attach, and an attached runner building its repository |
| 2111 | |
| 2112 | Ref #184" |
| 2113 | ``` |
| 2114 | |
| 2115 | --- |
| 2116 | |
| 2117 | ### Task 9: Docs: wiki pages |
| 2118 | |
| 2119 | **Files:** |
| 2120 | - Modify: `.gitbay/wiki/Users.org` (after the "CI builds" section's last paragraph, before "* Large files (LFS)") |
| 2121 | - Modify: `.gitbay/wiki/Admin.org:329-340` and `:395-402` |
| 2122 | - Modify: `.gitbay/wiki/Threat-Model.org:120-126` |
| 2123 | - Modify: `.gitbay/wiki/Parity.org` (repo table, after the `webhooks` row; and the "SSH only, by design" paragraph is unchanged) |
| 2124 | - Modify: `.gitbay/wiki/FAQ.org:20-25` |
| 2125 | - Modify: `.gitbay/wiki/CI.org` (one sentence after the three mechanisms list) |
| 2126 | |
| 2127 | - [ ] **Step 1: Users: "Your own runner"** |
| 2128 | |
| 2129 | Insert before `* Large files (LFS)`: |
| 2130 | |
| 2131 | ```org |
| 2132 | ** Your own runner |
| 2133 | |
| 2134 | Builds run on runners attached to the repository. An instance need not |
| 2135 | offer any: install =gitbay-runner= on a machine of yours and attach it. |
| 2136 | |
| 2137 | #+begin_src sh |
| 2138 | brew install krz/tap/gitbay-runner # or a binary from the release |
| 2139 | gitbay-runner init -remote git@gitbay.org |
| 2140 | #+end_src |
| 2141 | |
| 2142 | =init= generates a key under =~/.config/gitbay-runner/=, writes |
| 2143 | =config.toml= beside it, and prints the public key with the command to |
| 2144 | attach it: |
| 2145 | |
| 2146 | #+begin_src sh |
| 2147 | gitbay repo runner add owner/name < ~/.config/gitbay-runner/id_ed25519.pub |
| 2148 | #+end_src |
| 2149 | |
| 2150 | or paste the key under Runners on the repository's settings page. Then |
| 2151 | =brew services start krz/tap/gitbay-runner=, or run =gitbay-runner= with |
| 2152 | no arguments; it reads the config file, and any flag overrides it. |
| 2153 | |
| 2154 | What it builds: every build for the repositories it is attached to, |
| 2155 | with the repository's secrets, and nothing else. Merge requests from |
| 2156 | forks are untrusted and wait unless the runner runs with =-untrusted=, |
| 2157 | which is only sensible with =-isolation podman -image <ref>= (see |
| 2158 | [[Admin][Admin]]). Attach one runner to several repositories by repeating |
| 2159 | =repo runner add=; run several runners on one account by running =init= |
| 2160 | on each machine. =repo runner list= shows each attached key, when it |
| 2161 | last polled and the build it holds; =repo runner remove <fingerprint>= |
| 2162 | detaches one (the key stays on your account; =keys remove= drops it). |
| 2163 | A runner key reaches only the runner protocol and read-only git, so a |
| 2164 | build step that reads it off disk cannot administer your account. |
| 2165 | ``` |
| 2166 | |
| 2167 | - [ ] **Step 2: Admin** |
| 2168 | |
| 2169 | Replace lines 329-340's opening paragraph ("=gitbay-runner= executes builds ... then removed:") with: |
| 2170 | |
| 2171 | ```org |
| 2172 | =gitbay-runner= executes builds queued by pushes and merge requests. It |
| 2173 | polls over SSH with a key of scope =runner=, which reaches only the |
| 2174 | runner protocol and read-only git (a runner executes arbitrary |
| 2175 | repository code, so the key it holds must not do more). A runner key |
| 2176 | claims builds only for the repositories it is attached to, by =repo |
| 2177 | runner add= from a repository admin or an instance admin; an admin key |
| 2178 | claims any. Users attach their own runners: see the Users page. For an |
| 2179 | instance runner, run it as a dedicated unprivileged user on a non-admin |
| 2180 | account. =admin user create --key= registers a full-scope key, so the |
| 2181 | runner key is added afterwards through a bootstrap key that is then |
| 2182 | removed, and attached to each repository it should build: |
| 2183 | ``` |
| 2184 | |
| 2185 | After the existing bootstrap code block, add: |
| 2186 | |
| 2187 | ```org |
| 2188 | #+begin_src sh |
| 2189 | gitbay repo runner add krz/site < /var/lib/gitbay-runner/.ssh/id_ed25519.pub |
| 2190 | #+end_src |
| 2191 | ``` |
| 2192 | |
| 2193 | Replace lines 395-402 (from "and the isolation canary, nothing else" to "the boundary is you choosing how to start it.") with: |
| 2194 | |
| 2195 | ```org |
| 2196 | and the isolation canary, nothing else, because it shares the host with |
| 2197 | the forge; any other repository builds on a runner its owner attaches. |
| 2198 | |
| 2199 | =-repos= narrows an admin runner; for a runner key the attachments are |
| 2200 | the boundary, held by the server, and =-repos= may only name |
| 2201 | repositories among them. =-untrusted= makes a runner claim merge |
| 2202 | request heads from forks; the bay1 unit sets it because it isolates in |
| 2203 | podman. A runner without it builds trusted commits only. |
| 2204 | ``` |
| 2205 | |
| 2206 | 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=." |
| 2207 | |
| 2208 | - [ ] **Step 3: Threat-Model** |
| 2209 | |
| 2210 | Replace the "What the runner holds" bullet (lines 120-126) with: |
| 2211 | |
| 2212 | ```org |
| 2213 | - *What the runner holds.* A key of scope =runner=, which the dispatcher |
| 2214 | confines to =runner next=, =runner log= and =runner done= and to |
| 2215 | read-only git, and which claims, logs and finishes builds only for |
| 2216 | the repositories it is attached to (=repo runner add=). A step that |
| 2217 | reads the key off the disk gets exactly that: it cannot administer |
| 2218 | the instance, push, read a repository the runner's account cannot, or |
| 2219 | touch another repository's builds. An admin key still works for the |
| 2220 | runner protocol so an operator can rotate at their own pace; a runner |
| 2221 | host should not hold one. Untrusted builds are skipped unless the |
| 2222 | runner asks with =-untrusted=, so a runner on a user's machine never |
| 2223 | executes a stranger's branch by default. |
| 2224 | ``` |
| 2225 | |
| 2226 | - [ ] **Step 4: Parity, FAQ, CI** |
| 2227 | |
| 2228 | Parity, repo table, after the `webhooks` row: |
| 2229 | |
| 2230 | ```org |
| 2231 | | runners attach, list, detach | yes | yes | no | |
| 2232 | ``` |
| 2233 | |
| 2234 | The columns are cli, web, ios. iOS is `no`: outstanding, not intended. |
| 2235 | |
| 2236 | FAQ, replace the "Does CI run for my repository on gitbay.org?" answer: |
| 2237 | |
| 2238 | ```org |
| 2239 | - Does CI run for my repository on gitbay.org? :: On a runner you |
| 2240 | attach. The instance's own runner builds the forge's repositories and |
| 2241 | its isolation canary, since it shares the host with the forge. |
| 2242 | Install =gitbay-runner= on a machine of yours, run =gitbay-runner |
| 2243 | init=, and attach the key it prints with =repo runner add= or on the |
| 2244 | repository's settings page; see the Users page. A self-hosted |
| 2245 | instance can do the same, or run one runner for whichever |
| 2246 | repositories its operator attaches it to. |
| 2247 | ``` |
| 2248 | |
| 2249 | CI.org, after the three-mechanism list: |
| 2250 | |
| 2251 | ```org |
| 2252 | Which runner takes a build is the fourth: a build is claimed only by a |
| 2253 | runner attached to its repository (or an instance admin's runner), and |
| 2254 | an untrusted build only by one started with =-untrusted=. A repository |
| 2255 | with no runner attached queues builds nothing claims. See the Users |
| 2256 | page. |
| 2257 | ``` |
| 2258 | |
| 2259 | - [ ] **Step 5: Check the wiki tests** |
| 2260 | |
| 2261 | Run: `go test ./internal/hookd ./internal/ci -run 'Wiki|Parity' 2>&1 | tail -3` |
| 2262 | Expected: PASS (nothing here changes the push-shape table). |
| 2263 | |
| 2264 | - [ ] **Step 6: Commit** |
| 2265 | |
| 2266 | ```bash |
| 2267 | 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 |
| 2268 | git commit -m "wiki: runners attached to repositories |
| 2269 | |
| 2270 | Ref #184" |
| 2271 | ``` |
| 2272 | |
| 2273 | --- |
| 2274 | |
| 2275 | ### Task 10: Deploy, release, and the tap |
| 2276 | |
| 2277 | **Files:** |
| 2278 | - Modify: `deploy/gitbay-runner.override.conf` (the `ExecStart` line) |
| 2279 | - Modify: `deploy/release.sh:22` (the binary list) |
| 2280 | - Create in `krz/homebrew-tap` (separate clone, after the release is tagged): `Formula/gitbay-runner.rb`; modify `Formula/gitbay.rb` |
| 2281 | |
| 2282 | - [ ] **Step 1: The bay1 unit claims fork heads** |
| 2283 | |
| 2284 | In `deploy/gitbay-runner.override.conf`, the `ExecStart=` line gains ` -untrusted` at the end, and the comment above `ExecStart` gains: |
| 2285 | |
| 2286 | ``` |
| 2287 | # -untrusted: this runner isolates in podman, so it takes merge request |
| 2288 | # heads from forks; a runner without a container must not. |
| 2289 | ``` |
| 2290 | |
| 2291 | - [ ] **Step 2: Release binaries include the runner** |
| 2292 | |
| 2293 | `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". |
| 2294 | |
| 2295 | - [ ] **Step 3: Build, vet, and the touched unit tests** |
| 2296 | |
| 2297 | Run: `go build ./... && go vet ./... && go test ./internal/store ./internal/control ./cmd/gitbay ./cmd/gitbay-runner ./internal/httpd 2>&1 | tail -8` |
| 2298 | Expected: all PASS. |
| 2299 | |
| 2300 | - [ ] **Step 4: Commit and open the MR** |
| 2301 | |
| 2302 | ```bash |
| 2303 | git add deploy/gitbay-runner.override.conf deploy/release.sh |
| 2304 | git commit -m "deploy: the bay1 runner claims untrusted builds; release ships gitbay-runner |
| 2305 | |
| 2306 | Ref #184" |
| 2307 | git push -u origin user-runners |
| 2308 | gitbay mr create --source user-runners --target main --title "Runners attached to repositories" --file - <<'MR' |
| 2309 | A runner key claims builds only for the repositories it is attached to |
| 2310 | (`repo runner add|list|remove`, also on the settings page). `runner next` |
| 2311 | skips untrusted builds unless `--untrusted`. Migration 0050. |
| 2312 | `gitbay-runner init`, `config.toml`, `-identity`, `-untrusted`. |
| 2313 | |
| 2314 | After deploy, attach the bay1 runner to krz/gitbay and cmc/ci-smoke as |
| 2315 | the admin; until then it claims nothing. |
| 2316 | |
| 2317 | Ref #184 |
| 2318 | MR |
| 2319 | ``` |
| 2320 | |
| 2321 | Wait for CI on bay1 (`gitbay build list` on the MR head) before merging: `gitbay mr merge <n> --strategy ff`. |
| 2322 | |
| 2323 | - [ ] **Step 5: Deploy and attach (operator, after merge)** |
| 2324 | |
| 2325 | ```bash |
| 2326 | make deploy && make deploy-runner |
| 2327 | ssh -p 2222 root@46.232.248.67 cat /var/lib/gitbay-runner/.ssh/id_ed25519.pub | gitbay repo runner add krz/gitbay |
| 2328 | ssh -p 2222 root@46.232.248.67 cat /var/lib/gitbay-runner/.ssh/id_ed25519.pub | gitbay repo runner add cmc/ci-smoke |
| 2329 | gitbay admin runners |
| 2330 | ``` |
| 2331 | |
| 2332 | The last line must show the `ci` row with its fingerprint and `krz/gitbay,cmc/ci-smoke`. |
| 2333 | |
| 2334 | - [ ] **Step 6: The tap, after the release is tagged** |
| 2335 | |
| 2336 | In a clone of `https://gitbay.org/krz/homebrew-tap.git`, `Formula/gitbay-runner.rb`: |
| 2337 | |
| 2338 | ```ruby |
| 2339 | class GitbayRunner < Formula |
| 2340 | desc "CI runner for gitbay: builds the repositories you attach it to" |
| 2341 | homepage "https://gitbay.org/krz/gitbay" |
| 2342 | url "https://gitbay.org/krz/gitbay.git", |
| 2343 | tag: "v1.17.0", |
| 2344 | revision: "<commit of the tag>" |
| 2345 | license "0BSD" |
| 2346 | head "https://gitbay.org/krz/gitbay.git", branch: "main" |
| 2347 | |
| 2348 | depends_on "go" => :build |
| 2349 | |
| 2350 | def install |
| 2351 | system "go", "build", *std_go_args(ldflags: "-s -w"), "./cmd/gitbay-runner" |
| 2352 | end |
| 2353 | |
| 2354 | service do |
| 2355 | run [opt_bin/"gitbay-runner"] |
| 2356 | keep_alive true |
| 2357 | log_path var/"log/gitbay-runner.log" |
| 2358 | error_log_path var/"log/gitbay-runner.err.log" |
| 2359 | end |
| 2360 | |
| 2361 | def caveats |
| 2362 | <<~EOS |
| 2363 | Generate this machine's key and config, and print the key to attach: |
| 2364 | gitbay-runner init -remote git@gitbay.org |
| 2365 | Attach it to each repository it should build (as a repository admin): |
| 2366 | gitbay repo runner add owner/name < ~/.config/gitbay-runner/id_ed25519.pub |
| 2367 | Then: |
| 2368 | brew services start krz/tap/gitbay-runner |
| 2369 | EOS |
| 2370 | end |
| 2371 | |
| 2372 | test do |
| 2373 | assert_match "gitbay-runner", shell_output("#{bin}/gitbay-runner -version") |
| 2374 | end |
| 2375 | end |
| 2376 | ``` |
| 2377 | |
| 2378 | 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. |