Runners attached to repositories !354
42 files changed, +4356 −142
Layout: unified · split
.gitbay/wiki/Admin.org +44 −33
| @@ -329,13 +329,16 @@ startup to point at the current binary path. | ||
| 329 | 329 | * CI runner |
| 330 | 330 | |
| 331 | 331 | =gitbay-runner= executes builds queued by pushes and merge requests. It |
| 332 | polls over SSH with a key added by =keys add --scope runner=, which | |
| 333 | reaches only the runner protocol and read-only git (a runner executes | |
| 334 | arbitrary repository code, so the key it holds must not do more), then | |
| 335 | clones, runs the steps, streams the log back and resolves the commit | |
| 336 | status. Run it as a dedicated unprivileged user on a non-admin account. | |
| 337 | =admin user create --key= registers a full-scope key, so the runner key | |
| 338 | is added afterwards through a bootstrap key that is then removed: | |
| 332 | polls over SSH with a key of scope =runner=, which reaches only the | |
| 333 | runner protocol and read-only git (a runner executes arbitrary | |
| 334 | repository code, so the key it holds must not do more). A runner key | |
| 335 | claims builds only for the repositories it is attached to, by =repo | |
| 336 | runner add= from a repository admin or an instance admin; an admin key | |
| 337 | claims any. Users attach their own runners: see the Users page. For an | |
| 338 | instance runner, run it as a dedicated unprivileged user on a non-admin | |
| 339 | account. =admin user create --key= registers a full-scope key, so the | |
| 340 | runner key is added afterwards through a bootstrap key that is then | |
| 341 | removed, and attached to each repository it should build: | |
| 339 | 342 | |
| 340 | 343 | #+begin_src sh |
| 341 | 344 | useradd --system --create-home --home-dir /var/lib/gitbay-runner ci-runner |
| @@ -348,6 +351,10 @@ rm /tmp/ci-bootstrap /tmp/ci-bootstrap.pub | ||
| 348 | 351 | gitbay-runner -remote git@127.0.0.1 -workdir /var/lib/gitbay-runner/work |
| 349 | 352 | #+end_src |
| 350 | 353 | |
| 354 | #+begin_src sh | |
| 355 | gitbay repo runner add krz/site < /var/lib/gitbay-runner/.ssh/id_ed25519.pub | |
| 356 | #+end_src | |
| 357 | ||
| 351 | 358 | =-jobs N= runs N builds at once. Claiming is one transaction that |
| 352 | 359 | selects and updates, and each build works in its own =build-<id>= |
| 353 | 360 | directory, so workers do not collide; idle polls are staggered across |
| @@ -356,13 +363,14 @@ below are per service, not per build, so raising =-jobs= divides them | ||
| 356 | 363 | rather than multiplying the host's load. |
| 357 | 364 | |
| 358 | 365 | =admin runners= shows which account each runner polls as, and what each |
| 359 | is scoped to. A runner with no scope claims builds for *any* | |
| 360 | repository, which on an instance with open registration means running a | |
| 361 | stranger's steps; scope one with =-repos owner/name=. An admin key | |
| 362 | still works for the protocol during a rotation. A merge request head | |
| 363 | from a fork is built in the target repository as untrusted: the claim | |
| 364 | carries no secrets. Same-repository heads were built by their branch | |
| 365 | push and are not built again. | |
| 366 | may claim. A runner key claims builds only for the repositories it is | |
| 367 | attached to: with none attached it claims nothing, and =-repos= may only | |
| 368 | narrow within them. An admin's full-scope key claims any repository — | |
| 369 | that is what =-repos= was for — and still works for the protocol during | |
| 370 | a rotation. A merge request head from a fork is built in the target | |
| 371 | repository as untrusted: the claim carries no secrets, and only a runner | |
| 372 | started with =-untrusted= takes it. Same-repository heads were built by | |
| 373 | their branch push and are not built again. | |
| 366 | 374 | |
| 367 | 375 | =make deploy-runner= also installs |
| 368 | 376 | =deploy/gitbay-runner.override.conf= as a systemd drop-in: =Nice=10=, |
| @@ -379,33 +387,36 @@ v1 runs steps directly on the host — no containers — so treat the | ||
| 379 | 387 | runner machine as executing whatever your users push. Install the |
| 380 | 388 | toolchains your builds need on it. |
| 381 | 389 | |
| 382 | A runner claims the oldest pending build in the queue, whichever | |
| 383 | repository it belongs to. =-repos= narrows that to named repositories, | |
| 384 | which is what makes a runner outside the server practical — one on a | |
| 385 | machine that should build a single project, or that holds credentials for | |
| 386 | one deployment, no longer picks up a build belonging to someone else. With | |
| 387 | open registration that someone need not be anyone you know. | |
| 390 | A runner claims the oldest pending build among the repositories its key | |
| 391 | is attached to — for an admin key, the oldest in the instance. =-repos= | |
| 392 | narrows within that set, which is what makes a runner outside the server | |
| 393 | practical: one on a machine that should build a single project, or that | |
| 394 | holds credentials for one deployment, stays on it. | |
| 388 | 395 | |
| 389 | 396 | #+begin_src sh |
| 390 | 397 | gitbay-runner -remote git@gitbay.org -repos krz/site,krz/docs \ |
| 391 | 398 | -workdir /var/lib/gitbay-runner/work |
| 392 | 399 | #+end_src |
| 393 | 400 | |
| 401 | Add =-untrusted= only with =-isolation podman=. | |
| 402 | ||
| 394 | 403 | gitbay.org's runner is scoped: it builds the forge's own repositories |
| 395 | 404 | and the isolation canary, nothing else, because it shares the host with |
| 396 | the forge. A =.gitbay/ci.yml= in another repository there queues builds | |
| 397 | no runner claims. Whether that changes is krz/gitbay#184. | |
| 398 | ||
| 399 | Naming no repositories is the old behaviour and stays the right choice for | |
| 400 | the runner on the server itself. The scoping is what the runner asks for, | |
| 401 | not an ACL the server holds over it: a runner account is admin by | |
| 402 | necessity, so the boundary is you choosing how to start it. | |
| 403 | ||
| 404 | =gitbay dashboard= and =ssh git@<host> admin runners= list every account | |
| 405 | that has polled as a runner: when it last polled, the =-repos= scope it | |
| 406 | asked for, and the build it holds. =admin runners= also heads the list | |
| 407 | with the queue: builds pending now, and over the last day how many were | |
| 408 | claimed, how long they waited to be claimed (average and worst), and | |
| 405 | the forge; any other repository builds on a runner its owner attaches. | |
| 406 | ||
| 407 | =-repos= narrows an admin runner; for a runner key the attachments are | |
| 408 | the boundary, held by the server, and =-repos= may only name | |
| 409 | repositories among them. =-untrusted= makes a runner claim merge | |
| 410 | request heads from forks; the bay1 unit sets it because it isolates in | |
| 411 | podman. A runner without it builds trusted commits only. | |
| 412 | ||
| 413 | =gitbay dashboard= and =ssh git@<host> admin runners= list every key | |
| 414 | that has polled as a runner: the account, the key's fingerprint, when it | |
| 415 | last polled, the repositories it may claim — its attachments for a runner | |
| 416 | key, the =-repos= it asked for or =any= for an admin key — and the build | |
| 417 | it holds. =admin runners= also heads the list with the queue: builds | |
| 418 | pending now, and over the last day how many were claimed, how long they | |
| 419 | waited to be claimed (average and worst), and | |
| 409 | 420 | how many the reaper ended instead of a runner reporting them. A build a runner claimed and never |
| 410 | 421 | reported is failed by the scheduler's minute tick, whether or not any |
| 411 | 422 | runner is still alive: within about two minutes of its log stream ending |
.gitbay/wiki/CI.org +6
| @@ -20,6 +20,12 @@ Three mechanisms decide what a push does to CI, and they interact: | ||
| 20 | 20 | the build deadline if no stream was ever seen (#179). Its status reads |
| 21 | 21 | =build abandoned=. |
| 22 | 22 | |
| 23 | Which runner takes a build is the fourth: a build is claimed only by a | |
| 24 | runner attached to its repository (or an instance admin's runner), and | |
| 25 | an untrusted build only by one started with =-untrusted=. A repository | |
| 26 | with no runner attached queues builds nothing claims. See the Users | |
| 27 | page. | |
| 28 | ||
| 23 | 29 | Scheduled jobs run on their cron against the default branch, never on |
| 24 | 30 | push; a default-branch push registers or updates them. Tag jobs run on |
| 25 | 31 | a matching tag push and nothing else. |
.gitbay/wiki/FAQ.org +8 −6
| @@ -17,9 +17,11 @@ | ||
| 17 | 17 | email patch flow (revisit only if sourcehut-style demand appears), |
| 18 | 18 | federation and Postgres (no need at this scale). Recorded so the |
| 19 | 19 | absence reads as a decision, not an oversight. |
| 20 | - Does CI run for my repository on gitbay.org? :: Not yet. The runner | |
| 21 | there is scoped to the forge's own repositories and its isolation | |
| 22 | canary, since it shares the host with the forge. A =.gitbay/ci.yml= | |
| 23 | in your repository queues builds nothing claims. krz/gitbay#184 is | |
| 24 | where that gets decided. A self-hosted instance runs the same runner | |
| 25 | for whichever repositories its operator names. | |
| 20 | - Does CI run for my repository on gitbay.org? :: On a runner you | |
| 21 | attach. The instance's own runner builds the forge's repositories and | |
| 22 | its isolation canary, since it shares the host with the forge. | |
| 23 | Install =gitbay-runner= on a machine of yours, run =gitbay-runner | |
| 24 | init=, and attach the key it prints with =repo runner add= or on the | |
| 25 | repository's settings page; see the Users page. A self-hosted | |
| 26 | instance can do the same, or run one runner for whichever | |
| 27 | repositories its operator attaches it to. | |
.gitbay/wiki/Parity.org +1
| @@ -162,6 +162,7 @@ always markdown. | ||
| 162 | 162 | | access grants | yes | no | yes | |
| 163 | 163 | | effective access | yes | no | yes | |
| 164 | 164 | | webhooks | yes | no | yes | |
| 165 | | runners attach, list, detach| yes | yes | no | | |
| 165 | 166 | | import from a remote | yes | no | yes | |
| 166 | 167 | | topics, website | yes | yes | yes | |
| 167 | 168 | | visibility | yes | yes | yes | |
.gitbay/wiki/Threat-Model.org +11 −7
| @@ -117,13 +117,17 @@ JavaScript, so =script-src 'none'= costs nothing. | ||
| 117 | 117 | gitbayd never does: it reads =.gitbay/ci.yml= and queues a build, and a |
| 118 | 118 | runner, polling over SSH, clones the commit and runs its steps. |
| 119 | 119 | |
| 120 | - *What the runner holds.* A key added with =keys add --scope runner=, | |
| 121 | which the dispatcher confines to =runner next=, =runner log= and | |
| 122 | =runner done= and to read-only git. A step that reads the key off the | |
| 123 | disk gets exactly that: it cannot administer the instance, push, or | |
| 124 | read a repository the runner's account cannot. An admin key still | |
| 125 | works for the runner protocol so an operator can rotate at their own | |
| 126 | pace; a runner host should not hold one. | |
| 120 | - *What the runner holds.* A key of scope =runner=, which the dispatcher | |
| 121 | confines to =runner next=, =runner log= and =runner done= and to | |
| 122 | read-only git, and which claims, logs and finishes builds only for | |
| 123 | the repositories it is attached to (=repo runner add=). A step that | |
| 124 | reads the key off the disk gets exactly that: it cannot administer | |
| 125 | the instance, push, read a repository the runner's account cannot, or | |
| 126 | touch another repository's builds. An admin key still works for the | |
| 127 | runner protocol so an operator can rotate at their own pace; a runner | |
| 128 | host should not hold one. Untrusted builds are skipped unless the | |
| 129 | runner asks with =-untrusted=, so a runner on a user's machine never | |
| 130 | executes a stranger's branch by default. | |
| 127 | 131 | - *What a build sees.* The commit, the =GITBAY_*= variables and the |
| 128 | 132 | repository's secrets — unless the head came from another repository. |
| 129 | 133 | A merge request from a fork is built in the target as untrusted, with |
.gitbay/wiki/Users.org +34
| @@ -489,6 +489,40 @@ log says who cancelled it. Both need write access. | ||
| 489 | 489 | What each push shape queues, with dedupe, path filters, schedules and |
| 490 | 490 | the reaper together, is one table on [[CI][CI]]. |
| 491 | 491 | |
| 492 | ** Your own runner | |
| 493 | ||
| 494 | Builds run on runners attached to the repository. An instance need not | |
| 495 | offer any: install =gitbay-runner= on a machine of yours and attach it. | |
| 496 | ||
| 497 | #+begin_src sh | |
| 498 | brew install krz/tap/gitbay-runner # or a binary from the release | |
| 499 | gitbay-runner init -remote git@gitbay.org | |
| 500 | #+end_src | |
| 501 | ||
| 502 | =init= generates a key under =~/.config/gitbay-runner/=, writes | |
| 503 | =config.toml= beside it, and prints the public key with the command to | |
| 504 | attach it: | |
| 505 | ||
| 506 | #+begin_src sh | |
| 507 | gitbay repo runner add owner/name < ~/.config/gitbay-runner/id_ed25519.pub | |
| 508 | #+end_src | |
| 509 | ||
| 510 | or paste the key under Runners on the repository's settings page. Then | |
| 511 | =brew services start krz/tap/gitbay-runner=, or run =gitbay-runner= with | |
| 512 | no arguments; it reads the config file, and any flag overrides it. | |
| 513 | ||
| 514 | What it builds: every build for the repositories it is attached to, | |
| 515 | with the repository's secrets, and nothing else. Merge requests from | |
| 516 | forks are untrusted and wait unless the runner runs with =-untrusted=, | |
| 517 | which is only sensible with =-isolation podman -image <ref>= (see | |
| 518 | [[Admin][Admin]]). Attach one runner to several repositories by repeating | |
| 519 | =repo runner add=; run several runners on one account by running =init= | |
| 520 | on each machine. =repo runner list= shows each attached key, when it | |
| 521 | last polled and the build it holds; =repo runner remove <fingerprint>= | |
| 522 | detaches one (the key stays on your account; =keys remove= drops it). | |
| 523 | A runner key reaches only the runner protocol and read-only git, so a | |
| 524 | build step that reads it off disk cannot administer your account. | |
| 525 | ||
| 492 | 526 | * Large files (LFS) |
| 493 | 527 | |
| 494 | 528 | Standard Git LFS works over both transports with no setup beyond the |
cmd/gitbay-runner/config.go added +94
| @@ -0,0 +1,94 @@ | ||
| 1 | package main | |
| 2 | ||
| 3 | import ( | |
| 4 | "errors" | |
| 5 | "flag" | |
| 6 | "fmt" | |
| 7 | "os" | |
| 8 | "path/filepath" | |
| 9 | "strings" | |
| 10 | ||
| 11 | "github.com/BurntSushi/toml" | |
| 12 | ) | |
| 13 | ||
| 14 | // The runner takes everything as flags, which does not work under a | |
| 15 | // service manager. config.toml in the config directory carries the same | |
| 16 | // names; a flag on the command line overrides it (#184). | |
| 17 | ||
| 18 | func configDir() string { | |
| 19 | if x := os.Getenv("XDG_CONFIG_HOME"); x != "" { | |
| 20 | return filepath.Join(x, "gitbay-runner") | |
| 21 | } | |
| 22 | return filepath.Join(os.Getenv("HOME"), ".config", "gitbay-runner") | |
| 23 | } | |
| 24 | ||
| 25 | func defaultConfigPath() string { return filepath.Join(configDir(), "config.toml") } | |
| 26 | ||
| 27 | // configPathFromArgs finds -config before the flag set is parsed, since | |
| 28 | // the file's values must be set before parsing for flags to override them. | |
| 29 | func configPathFromArgs(args []string, def string) string { | |
| 30 | for i, a := range args { | |
| 31 | a = strings.TrimPrefix(a, "-") | |
| 32 | if a == "-config" || a == "config" { | |
| 33 | if i+1 < len(args) { | |
| 34 | return args[i+1] | |
| 35 | } | |
| 36 | } | |
| 37 | if v, ok := strings.CutPrefix(a, "config="); ok { | |
| 38 | return v | |
| 39 | } | |
| 40 | if v, ok := strings.CutPrefix(a, "-config="); ok { | |
| 41 | return v | |
| 42 | } | |
| 43 | } | |
| 44 | return def | |
| 45 | } | |
| 46 | ||
| 47 | // configKeys is every key the file may carry: the flag names. | |
| 48 | var configKeys = map[string]bool{"remote": true, "ssh-opts": true, "clone-base": true, "workdir": true, | |
| 49 | "poll": true, "timeout": true, "repos": true, "jobs": true, "image": true, "isolation": true, | |
| 50 | "memory": true, "cpus": true, "untrusted": true, "identity": true} | |
| 51 | ||
| 52 | // loadConfig reads path into flag name → value. Absent file: found is | |
| 53 | // false and there is no error. An unknown key is an error, not a typo | |
| 54 | // the runner silently ignores. | |
| 55 | func loadConfig(path string) (values map[string]string, found bool, err error) { | |
| 56 | var raw map[string]any | |
| 57 | if _, err := toml.DecodeFile(path, &raw); errors.Is(err, os.ErrNotExist) { | |
| 58 | return nil, false, nil | |
| 59 | } else if err != nil { | |
| 60 | return nil, true, fmt.Errorf("%s: %w", path, err) | |
| 61 | } | |
| 62 | values = map[string]string{} | |
| 63 | for k, v := range raw { | |
| 64 | if !configKeys[k] { | |
| 65 | return nil, true, fmt.Errorf("%s: unknown key %s", path, k) | |
| 66 | } | |
| 67 | values[k] = fmt.Sprint(v) | |
| 68 | } | |
| 69 | return values, true, nil | |
| 70 | } | |
| 71 | ||
| 72 | // applyConfig sets each value on the flag set, which is what parsing the | |
| 73 | // command line would do; parse afterwards and the command line wins. | |
| 74 | func applyConfig(fs *flag.FlagSet, values map[string]string) error { | |
| 75 | for k, v := range values { | |
| 76 | if fs.Lookup(k) == nil { | |
| 77 | return fmt.Errorf("config: unknown key %s", k) | |
| 78 | } | |
| 79 | if err := fs.Set(k, v); err != nil { | |
| 80 | return fmt.Errorf("config: %s: %w", k, err) | |
| 81 | } | |
| 82 | } | |
| 83 | return nil | |
| 84 | } | |
| 85 | ||
| 86 | // identityOpts is what makes ssh and git use the runner's own key and no | |
| 87 | // other: on a laptop the ambient key is the user's full-scope one, which | |
| 88 | // the runner protocol refuses. | |
| 89 | func identityOpts(path string) []string { | |
| 90 | if path == "" { | |
| 91 | return nil | |
| 92 | } | |
| 93 | return []string{"-i", path, "-o", "IdentitiesOnly=yes"} | |
| 94 | } | |
cmd/gitbay-runner/config_test.go added +84
| @@ -0,0 +1,84 @@ | ||
| 1 | package main | |
| 2 | ||
| 3 | import ( | |
| 4 | "flag" | |
| 5 | "os" | |
| 6 | "path/filepath" | |
| 7 | "testing" | |
| 8 | ) | |
| 9 | ||
| 10 | // A config file sets the flags' values; a flag on the command line wins. | |
| 11 | func TestConfigFileFeedsFlagsAndFlagsOverride(t *testing.T) { | |
| 12 | dir := t.TempDir() | |
| 13 | path := filepath.Join(dir, "config.toml") | |
| 14 | os.WriteFile(path, []byte("remote = \"git@example.test\"\npoll = \"9s\"\nuntrusted = true\nidentity = \"/k\"\njobs = 2\n"), 0o600) | |
| 15 | ||
| 16 | values, found, err := loadConfig(path) | |
| 17 | if err != nil || !found { | |
| 18 | t.Fatalf("loadConfig: found=%v err=%v", found, err) | |
| 19 | } | |
| 20 | fs := flag.NewFlagSet("t", flag.ContinueOnError) | |
| 21 | remote := fs.String("remote", "git@gitbay.org", "") | |
| 22 | poll := fs.Duration("poll", 0, "") | |
| 23 | untrusted := fs.Bool("untrusted", false, "") | |
| 24 | identity := fs.String("identity", "", "") | |
| 25 | jobs := fs.Int("jobs", 1, "") | |
| 26 | if err := applyConfig(fs, values); err != nil { | |
| 27 | t.Fatal(err) | |
| 28 | } | |
| 29 | if err := fs.Parse([]string{"-poll", "3s"}); err != nil { | |
| 30 | t.Fatal(err) | |
| 31 | } | |
| 32 | if *remote != "git@example.test" || poll.String() != "3s" || !*untrusted || *identity != "/k" || *jobs != 2 { | |
| 33 | t.Fatalf("remote=%s poll=%s untrusted=%v identity=%s jobs=%d", *remote, poll, *untrusted, *identity, *jobs) | |
| 34 | } | |
| 35 | if _, found, err := loadConfig(filepath.Join(dir, "missing.toml")); found || err != nil { | |
| 36 | t.Fatalf("missing file: found=%v err=%v", found, err) | |
| 37 | } | |
| 38 | if _, _, err := loadConfig(path); err != nil { | |
| 39 | t.Fatal(err) | |
| 40 | } | |
| 41 | os.WriteFile(path, []byte("nonsense = \"x\"\n"), 0o600) | |
| 42 | if _, _, err := loadConfig(path); err == nil { | |
| 43 | t.Fatal("an unknown key was accepted") | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | func TestConfigPathFromArgs(t *testing.T) { | |
| 48 | for _, tc := range []struct { | |
| 49 | args []string | |
| 50 | want string | |
| 51 | }{ | |
| 52 | {nil, "/def"}, | |
| 53 | {[]string{"-once"}, "/def"}, | |
| 54 | {[]string{"-config", "/a"}, "/a"}, | |
| 55 | {[]string{"--config", "/b", "-once"}, "/b"}, | |
| 56 | {[]string{"-config=/c"}, "/c"}, | |
| 57 | } { | |
| 58 | if got := configPathFromArgs(tc.args, "/def"); got != tc.want { | |
| 59 | t.Errorf("%v: got %s want %s", tc.args, got, tc.want) | |
| 60 | } | |
| 61 | } | |
| 62 | } | |
| 63 | ||
| 64 | func TestConfigDirHonoursXDG(t *testing.T) { | |
| 65 | t.Setenv("XDG_CONFIG_HOME", "/x") | |
| 66 | if got := configDir(); got != "/x/gitbay-runner" { | |
| 67 | t.Fatalf("got %s", got) | |
| 68 | } | |
| 69 | t.Setenv("XDG_CONFIG_HOME", "") | |
| 70 | t.Setenv("HOME", "/h") | |
| 71 | if got := configDir(); got != "/h/.config/gitbay-runner" { | |
| 72 | t.Fatalf("got %s", got) | |
| 73 | } | |
| 74 | } | |
| 75 | ||
| 76 | func TestIdentityOpts(t *testing.T) { | |
| 77 | if got := identityOpts(""); got != nil { | |
| 78 | t.Fatalf("empty identity produced %v", got) | |
| 79 | } | |
| 80 | got := identityOpts("/k") | |
| 81 | if len(got) != 4 || got[0] != "-i" || got[1] != "/k" || got[3] != "IdentitiesOnly=yes" { | |
| 82 | t.Fatalf("got %v", got) | |
| 83 | } | |
| 84 | } | |
cmd/gitbay-runner/init.go added +89
| @@ -0,0 +1,89 @@ | ||
| 1 | package main | |
| 2 | ||
| 3 | import ( | |
| 4 | "flag" | |
| 5 | "fmt" | |
| 6 | "io" | |
| 7 | "os" | |
| 8 | "os/exec" | |
| 9 | "path/filepath" | |
| 10 | "strings" | |
| 11 | ||
| 12 | "gitbay.org/gitbay/internal/toolpath" | |
| 13 | ) | |
| 14 | ||
| 15 | // initOut is where init prints; tests capture it. | |
| 16 | var initOut io.Writer = os.Stdout | |
| 17 | ||
| 18 | // runInit makes a fresh install ready to attach: a key of its own, a | |
| 19 | // config file the service reads, and the one command to run next. It never | |
| 20 | // overwrites a key or a config that exists, so running it twice is safe. | |
| 21 | func runInit(args []string) int { | |
| 22 | fs := flag.NewFlagSet("init", flag.ContinueOnError) | |
| 23 | fs.SetOutput(initOut) | |
| 24 | remote := fs.String("remote", "git@gitbay.org", "ssh destination of the gitbay server") | |
| 25 | workdir := fs.String("workdir", defaultWorkdir(), "build workspace root") | |
| 26 | isolation := fs.String("isolation", isolationNone, "how steps run: none, or podman with -image") | |
| 27 | image := fs.String("image", "", "container image for -isolation podman") | |
| 28 | if err := fs.Parse(args); err != nil { | |
| 29 | return 2 | |
| 30 | } | |
| 31 | if *isolation == isolationPodman && *image == "" { | |
| 32 | fmt.Fprintln(initOut, "-isolation podman needs -image <ref>: the runner refuses to start without one, and there is no image to guess") | |
| 33 | return 2 | |
| 34 | } | |
| 35 | if *isolation != isolationPodman && *isolation != isolationNone { | |
| 36 | fmt.Fprintf(initOut, "unknown isolation %q\n", *isolation) | |
| 37 | return 2 | |
| 38 | } | |
| 39 | ||
| 40 | dir := configDir() | |
| 41 | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 42 | fmt.Fprintln(initOut, err) | |
| 43 | return 1 | |
| 44 | } | |
| 45 | os.Chmod(dir, 0o700) | |
| 46 | key := filepath.Join(dir, "id_ed25519") | |
| 47 | if !fileExists(key) { | |
| 48 | cmd := exec.Command(toolpath.Look("ssh-keygen"), "-q", "-t", "ed25519", "-N", "", "-C", "gitbay-runner", "-f", key) | |
| 49 | if out, err := cmd.CombinedOutput(); err != nil { | |
| 50 | fmt.Fprintf(initOut, "ssh-keygen: %v\n%s", err, out) | |
| 51 | return 1 | |
| 52 | } | |
| 53 | } | |
| 54 | os.Chmod(key, 0o600) | |
| 55 | ||
| 56 | cfgPath := filepath.Join(dir, "config.toml") | |
| 57 | if !fileExists(cfgPath) { | |
| 58 | var b strings.Builder | |
| 59 | fmt.Fprintf(&b, "remote = %q\n", *remote) | |
| 60 | fmt.Fprintf(&b, "workdir = %q\n", *workdir) | |
| 61 | fmt.Fprintf(&b, "isolation = %q\n", *isolation) | |
| 62 | if *image != "" { | |
| 63 | fmt.Fprintf(&b, "image = %q\n", *image) | |
| 64 | } | |
| 65 | fmt.Fprintf(&b, "untrusted = false\n") | |
| 66 | fmt.Fprintf(&b, "identity = %q\n", key) | |
| 67 | if err := os.WriteFile(cfgPath, []byte(b.String()), 0o600); err != nil { | |
| 68 | fmt.Fprintln(initOut, err) | |
| 69 | return 1 | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| 73 | pub, err := os.ReadFile(key + ".pub") | |
| 74 | if err != nil { | |
| 75 | fmt.Fprintln(initOut, err) | |
| 76 | return 1 | |
| 77 | } | |
| 78 | host := *remote | |
| 79 | if i := strings.LastIndex(host, "@"); i >= 0 { | |
| 80 | host = host[i+1:] | |
| 81 | } | |
| 82 | fmt.Fprintf(initOut, "config: %s\nkey: %s\n\n", cfgPath, key) | |
| 83 | if *isolation == isolationNone { | |
| 84 | 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.") | |
| 85 | } | |
| 86 | 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", | |
| 87 | strings.TrimSpace(string(pub)), key, host) | |
| 88 | return 0 | |
| 89 | } | |
cmd/gitbay-runner/init_test.go added +73
| @@ -0,0 +1,73 @@ | ||
| 1 | package main | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "os" | |
| 6 | "os/exec" | |
| 7 | "path/filepath" | |
| 8 | "strings" | |
| 9 | "testing" | |
| 10 | ) | |
| 11 | ||
| 12 | // init creates the key and config once, prints the key and the attach | |
| 13 | // command, and running it again changes nothing. | |
| 14 | func TestInitWritesKeyAndConfigOnce(t *testing.T) { | |
| 15 | if _, err := exec.LookPath("ssh-keygen"); err != nil { | |
| 16 | t.Skip("ssh-keygen not on PATH") | |
| 17 | } | |
| 18 | dir := t.TempDir() | |
| 19 | t.Setenv("XDG_CONFIG_HOME", dir) | |
| 20 | var out bytes.Buffer | |
| 21 | initOut = &out | |
| 22 | defer func() { initOut = os.Stdout }() | |
| 23 | ||
| 24 | if code := runInit([]string{"-remote", "git@example.test"}); code != 0 { | |
| 25 | t.Fatalf("init: exit %d\n%s", code, out.String()) | |
| 26 | } | |
| 27 | cdir := filepath.Join(dir, "gitbay-runner") | |
| 28 | key := filepath.Join(cdir, "id_ed25519") | |
| 29 | pub, err := os.ReadFile(key + ".pub") | |
| 30 | if err != nil || !strings.HasPrefix(string(pub), "ssh-ed25519 ") { | |
| 31 | t.Fatalf("public key: %v %q", err, pub) | |
| 32 | } | |
| 33 | if fi, _ := os.Stat(key); fi.Mode().Perm() != 0o600 { | |
| 34 | t.Fatalf("private key mode %o", fi.Mode().Perm()) | |
| 35 | } | |
| 36 | if fi, _ := os.Stat(cdir); fi.Mode().Perm() != 0o700 { | |
| 37 | t.Fatalf("config dir mode %o", fi.Mode().Perm()) | |
| 38 | } | |
| 39 | cfg, _ := os.ReadFile(filepath.Join(cdir, "config.toml")) | |
| 40 | for _, want := range []string{"remote = \"git@example.test\"", "isolation = \"none\"", "untrusted = false", "identity = \"" + key + "\""} { | |
| 41 | if !strings.Contains(string(cfg), want) { | |
| 42 | t.Fatalf("config lacks %q:\n%s", want, cfg) | |
| 43 | } | |
| 44 | } | |
| 45 | for _, want := range []string{strings.TrimSpace(string(pub)), "gitbay repo runner add owner/name < " + key + ".pub", "https://example.test/owner/name/settings"} { | |
| 46 | if !strings.Contains(out.String(), want) { | |
| 47 | t.Fatalf("output lacks %q:\n%s", want, out.String()) | |
| 48 | } | |
| 49 | } | |
| 50 | ||
| 51 | out.Reset() | |
| 52 | if code := runInit([]string{"-remote", "git@other.test"}); code != 0 { | |
| 53 | t.Fatalf("second init: exit %d\n%s", code, out.String()) | |
| 54 | } | |
| 55 | if pub2, _ := os.ReadFile(key + ".pub"); string(pub2) != string(pub) { | |
| 56 | t.Fatal("second init replaced the key") | |
| 57 | } | |
| 58 | if cfg2, _ := os.ReadFile(filepath.Join(cdir, "config.toml")); string(cfg2) != string(cfg) { | |
| 59 | t.Fatal("second init rewrote the config") | |
| 60 | } | |
| 61 | } | |
| 62 | ||
| 63 | // podman needs an image; init refuses to write a config the runner would | |
| 64 | // refuse to start with. | |
| 65 | func TestInitPodmanNeedsImage(t *testing.T) { | |
| 66 | t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |
| 67 | var out bytes.Buffer | |
| 68 | initOut = &out | |
| 69 | defer func() { initOut = os.Stdout }() | |
| 70 | if code := runInit([]string{"-isolation", "podman"}); code != 2 { | |
| 71 | t.Fatalf("exit %d, want 2:\n%s", code, out.String()) | |
| 72 | } | |
| 73 | } | |
cmd/gitbay-runner/main.go +49 −15
| @@ -19,6 +19,7 @@ import ( | ||
| 19 | 19 | "os/exec" |
| 20 | 20 | "os/signal" |
| 21 | 21 | "path/filepath" |
| 22 | "slices" | |
| 22 | 23 | "strings" |
| 23 | 24 | "sync" |
| 24 | 25 | "syscall" |
| @@ -62,25 +63,42 @@ type runner struct { | ||
| 62 | 63 | // means any, which is what a runner on the server itself wants; a runner |
| 63 | 64 | // somewhere that should not execute every repository's steps names them. |
| 64 | 65 | repos []string |
| 66 | // untrusted also claims merge request heads from forks. | |
| 67 | untrusted bool | |
| 65 | 68 | } |
| 66 | 69 | |
| 67 | 70 | func main() { |
| 71 | if len(os.Args) > 1 && os.Args[1] == "init" { | |
| 72 | os.Exit(runInit(os.Args[2:])) | |
| 73 | } | |
| 68 | 74 | var ( |
| 69 | remote = flag.String("remote", "git@gitbay.org", "ssh destination of the gitbay server") | |
| 70 | sshOpts = flag.String("ssh-opts", "", "extra ssh options, space-separated (also used for git clone)") | |
| 71 | cloneBase = flag.String("clone-base", "", "clone URL prefix (default ssh://<remote>)") | |
| 72 | workdir = flag.String("workdir", defaultWorkdir(), "build workspace root") | |
| 73 | poll = flag.Duration("poll", 5*time.Second, "idle poll interval") | |
| 74 | timeout = flag.Duration("timeout", 30*time.Minute, "per-build time limit") | |
| 75 | repos = flag.String("repos", "", "only claim builds for these repositories, comma-separated owner/name (default: any)") | |
| 76 | once = flag.Bool("once", false, "process at most one build, then exit") | |
| 77 | jobs = flag.Int("jobs", 1, "builds to run at once") | |
| 78 | image = flag.String("image", "", "default container image for jobs that name none") | |
| 79 | isolation = flag.String("isolation", "podman", "how steps run: podman, or none for no container") | |
| 80 | memory = flag.String("memory", "", "memory limit per build, e.g. 4g (podman only, needs a delegated cgroup; default unlimited)") | |
| 81 | cpus = flag.String("cpus", "", "CPU limit per build, e.g. 2 (podman only, needs a delegated cgroup; default unlimited)") | |
| 82 | version = flag.Bool("version", false, "print the commit this binary was built from, then exit") | |
| 75 | configPath = flag.String("config", defaultConfigPath(), "config file; keys are these flag names, flags override it") | |
| 76 | identity = flag.String("identity", "", "ssh private key to poll and clone with (default: the key gitbay-runner init generated, if present)") | |
| 77 | untrusted = flag.Bool("untrusted", false, "also claim untrusted builds: merge request heads from forks (needs -isolation podman to be safe)") | |
| 78 | remote = flag.String("remote", "git@gitbay.org", "ssh destination of the gitbay server") | |
| 79 | sshOpts = flag.String("ssh-opts", "", "extra ssh options, space-separated (also used for git clone)") | |
| 80 | cloneBase = flag.String("clone-base", "", "clone URL prefix (default ssh://<remote>)") | |
| 81 | workdir = flag.String("workdir", defaultWorkdir(), "build workspace root") | |
| 82 | poll = flag.Duration("poll", 5*time.Second, "idle poll interval") | |
| 83 | timeout = flag.Duration("timeout", 30*time.Minute, "per-build time limit") | |
| 84 | repos = flag.String("repos", "", "only claim builds for these repositories, comma-separated owner/name (default: any)") | |
| 85 | once = flag.Bool("once", false, "process at most one build, then exit") | |
| 86 | jobs = flag.Int("jobs", 1, "builds to run at once") | |
| 87 | image = flag.String("image", "", "default container image for jobs that name none") | |
| 88 | isolation = flag.String("isolation", "podman", "how steps run: podman, or none for no container") | |
| 89 | memory = flag.String("memory", "", "memory limit per build, e.g. 4g (podman only, needs a delegated cgroup; default unlimited)") | |
| 90 | cpus = flag.String("cpus", "", "CPU limit per build, e.g. 2 (podman only, needs a delegated cgroup; default unlimited)") | |
| 91 | version = flag.Bool("version", false, "print the commit this binary was built from, then exit") | |
| 83 | 92 | ) |
| 93 | path := configPathFromArgs(os.Args[1:], *configPath) | |
| 94 | if values, found, err := loadConfig(path); err != nil { | |
| 95 | log.Fatal(err) | |
| 96 | } else if found { | |
| 97 | if err := applyConfig(flag.CommandLine, values); err != nil { | |
| 98 | log.Fatal(err) | |
| 99 | } | |
| 100 | log.Printf("config: %s", path) | |
| 101 | } | |
| 84 | 102 | flag.Parse() |
| 85 | 103 | if *version { |
| 86 | 104 | fmt.Println(buildinfo.String()) |
| @@ -127,6 +145,15 @@ func main() { | ||
| 127 | 145 | if *sshOpts != "" { |
| 128 | 146 | r.sshOpts = strings.Fields(*sshOpts) |
| 129 | 147 | } |
| 148 | if *identity == "" { | |
| 149 | if p := filepath.Join(configDir(), "id_ed25519"); fileExists(p) { | |
| 150 | *identity = p | |
| 151 | } | |
| 152 | } | |
| 153 | // Clipped: the later appends run from concurrent workers, and spare | |
| 154 | // capacity here would have them writing the same backing array. | |
| 155 | r.sshOpts = slices.Clip(append(identityOpts(*identity), r.sshOpts...)) | |
| 156 | r.untrusted = *untrusted | |
| 130 | 157 | for _, name := range strings.Split(*repos, ",") { |
| 131 | 158 | if name = strings.TrimSpace(name); name != "" { |
| 132 | 159 | r.repos = append(r.repos, name) |
| @@ -218,7 +245,12 @@ func (r *runner) serve(n int, once bool, poll time.Duration, stop <-chan struct{ | ||
| 218 | 245 | // step claims and executes at most one build. ran reports whether there was |
| 219 | 246 | // one, so the caller knows when to idle. |
| 220 | 247 | func (r *runner) step() (bool, error) { |
| 221 | out, err := r.ssh(nil, append([]string{"runner", "next"}, append(r.repos, "--json")...)...) | |
| 248 | args := []string{"runner", "next"} | |
| 249 | if r.untrusted { | |
| 250 | args = append(args, "--untrusted") | |
| 251 | } | |
| 252 | args = append(append(args, r.repos...), "--json") | |
| 253 | out, err := r.ssh(nil, args...) | |
| 222 | 254 | if err != nil { |
| 223 | 255 | return false, fmt.Errorf("claiming build: %w (%s)", err, out) |
| 224 | 256 | } |
| @@ -468,6 +500,8 @@ func (r *runner) ssh(stdin io.Reader, args ...string) (string, error) { | ||
| 468 | 500 | return out.String(), nil |
| 469 | 501 | } |
| 470 | 502 | |
| 503 | func fileExists(p string) bool { _, err := os.Stat(p); return err == nil } | |
| 504 | ||
| 471 | 505 | // defaultWorkdir picks a build workspace that another local user cannot |
| 472 | 506 | // have created first. |
| 473 | 507 | // |
cmd/gitbay/main.go +5
| @@ -439,6 +439,11 @@ func repoCmd() *cobra.Command { | ||
| 439 | 439 | pass("list", "list deploy keys", passOpts{server: []string{"repo", "deploy-key", "list"}, needsRepo: true}), |
| 440 | 440 | pass("remove", "remove a deploy key: <fingerprint>", passOpts{server: []string{"repo", "deploy-key", "remove"}, needsRepo: true}), |
| 441 | 441 | ), |
| 442 | group("runner", "runners attached to a repository", | |
| 443 | 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"}), | |
| 444 | pass("list", "list attached runners", passOpts{server: []string{"repo", "runner", "list"}, needsRepo: true}), | |
| 445 | pass("remove", "detach a runner: <fingerprint>", passOpts{server: []string{"repo", "runner", "remove"}, needsRepo: true}), | |
| 446 | ), | |
| 442 | 447 | group("mirror", "sync with a foreign remote", |
| 443 | 448 | pass("add", "add a mirror: <https-url> --direction push|pull [--username <u>] [--token-stdin]", |
| 444 | 449 | passOpts{server: []string{"repo", "mirror", "add"}, needsRepo: true, stdinOK: true}), |
deploy/gitbay-runner.override.conf +3 −1
| @@ -70,8 +70,10 @@ TimeoutStopSec=50min | ||
| 70 | 70 | # already dead. mixed signals the main process only; whatever is left |
| 71 | 71 | # when it exits is killed. |
| 72 | 72 | KillMode=mixed |
| 73 | # -untrusted: this runner isolates in podman, so it takes merge request | |
| 74 | # heads from forks; a runner without a container must not. | |
| 73 | 75 | ExecStart= |
| 74 | ExecStart=/usr/local/bin/gitbay-runner -remote git@127.0.0.1 -workdir /var/lib/gitbay-runner/work -poll 5s -timeout 45m -repos krz/gitbay,cmc/ci-smoke -isolation podman -image localhost/gitbay-ci:1 -cpus 3 -memory 6g | |
| 76 | ExecStart=/usr/local/bin/gitbay-runner -remote git@127.0.0.1 -workdir /var/lib/gitbay-runner/work -poll 5s -timeout 45m -repos krz/gitbay,cmc/ci-smoke -isolation podman -image localhost/gitbay-ci:1 -cpus 3 -memory 6g -untrusted | |
| 75 | 77 | Nice=10 |
| 76 | 78 | CPUWeight=30 |
| 77 | 79 | IOWeight=30 |
deploy/release.sh +3 −3
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | #!/bin/sh |
| 2 | # Build release binaries for a tag: reproducible cross-compiled gitbay and | |
| 3 | # gitbayd with a checksum manifest. | |
| 2 | # Build release binaries for a tag: reproducible cross-compiled gitbay, | |
| 3 | # gitbayd and gitbay-runner with a checksum manifest. | |
| 4 | 4 | # |
| 5 | 5 | # git checkout v0.2.0 && ./deploy/release.sh v0.2.0 |
| 6 | 6 | # |
| @@ -19,7 +19,7 @@ mkdir -p "$out" | ||
| 19 | 19 | for target in linux/amd64 linux/arm64 darwin/arm64; do |
| 20 | 20 | goos="${target%/*}" |
| 21 | 21 | goarch="${target#*/}" |
| 22 | for bin in gitbay gitbayd; do | |
| 22 | for bin in gitbay gitbayd gitbay-runner; do | |
| 23 | 23 | name="${bin}-${V}-${goos}-${goarch}" |
| 24 | 24 | echo "building $name" |
| 25 | 25 | CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \ |
docs/plans/2026-09-08-user-runners.md added +2378
| @@ -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. | |
docs/specs/2026-09-08-user-runners-design.md added +261
| @@ -0,0 +1,261 @@ | ||
| 1 | # Runners attached to repositories | |
| 2 | ||
| 3 | Ref #184 (option B). A `gitbay-runner` anyone installs on their own machine | |
| 4 | and attaches to their repositories on any instance, so an instance offers CI | |
| 5 | without offering compute. | |
| 6 | ||
| 7 | ## Problem | |
| 8 | ||
| 9 | CI on gitbay.org builds the forge's own repositories and nothing else: the one | |
| 10 | runner shares the host with the forge and is scoped with `-repos`. A | |
| 11 | `.gitbay/ci.yml` in anyone else's repository queues builds nothing claims. | |
| 12 | Widening that runner's scope is the second machine and the tier-per-trust-level | |
| 13 | design in #184, which costs compute and storage the operator pays for. | |
| 14 | ||
| 15 | The runner already polls over SSH from anywhere with a key of scope `runner`, | |
| 16 | and `admin runners` already lists several. What is missing is the server-side | |
| 17 | rule that says which builds a given key may claim. Today there is none: | |
| 18 | ||
| 19 | - `keys add --scope runner` is self-service for any account. | |
| 20 | - `runner next` checks only the key's scope (`requireRunner`). With no | |
| 21 | repository arguments it claims the oldest pending build on the instance, | |
| 22 | whichever repository it belongs to, and the claim carries the repository's | |
| 23 | secrets when the build is trusted. | |
| 24 | - `runner log` and `runner done` accept any build id. | |
| 25 | ||
| 26 | With `registration = "open"` a stranger can run a runner against gitbay.org | |
| 27 | today and receive builds and secrets for repositories they cannot read. The | |
| 28 | wiki says a runner account is admin by necessity; the code does not enforce | |
| 29 | it. | |
| 30 | ||
| 31 | ## Decision | |
| 32 | ||
| 33 | A runner key is attached to repositories by a repository admin, and claims | |
| 34 | builds only for the repositories it is attached to. A user who wants builds | |
| 35 | installs `gitbay-runner`, runs `gitbay-runner init`, attaches the printed | |
| 36 | public key to their repository, and starts the service. Admin keys keep | |
| 37 | today's behaviour. Untrusted builds (merge request heads from forks) are | |
| 38 | excluded from every claim unless the runner asks for them. | |
| 39 | ||
| 40 | Decisions taken on the way, with the alternatives rejected: | |
| 41 | ||
| 42 | - **Repository-level attachment**, not "a user's runner builds the user's | |
| 43 | repositories" and not "repositories the user can admin". One attachment | |
| 44 | row answers "who executes this repository's code" exactly. | |
| 45 | - **The runner prints its key and an admin attaches it**, not a | |
| 46 | registration token. No secret crosses the wire, and it is the deploy-key | |
| 47 | motion the forge already has. | |
| 48 | - **An attachment table keyed by ssh key**, not a `runner:<repo>` scope on | |
| 49 | the key. Fingerprints are unique per instance, so a scope binds one key | |
| 50 | to one repository; a table lets one key serve many. | |
| 51 | - **Untrusted builds skipped by default**, enforced by the server, with a | |
| 52 | runner flag to opt in. Not "build everything" and not "the runner refuses | |
| 53 | without podman", which would be the runner's word. | |
| 54 | - **Homebrew formula in `krz/homebrew-tap` on gitbay.org**, built from | |
| 55 | source at the tag like `gitbay.rb`. Not a GitHub tap, not an installer | |
| 56 | script. | |
| 57 | ||
| 58 | Not in scope: per-account build limits, claim order, a second operator | |
| 59 | machine. Those stay on #184. | |
| 60 | ||
| 61 | ## Server | |
| 62 | ||
| 63 | ### Data | |
| 64 | ||
| 65 | Migration 0050: | |
| 66 | ||
| 67 | ```sql | |
| 68 | CREATE TABLE runner_repos ( | |
| 69 | key_id INTEGER NOT NULL REFERENCES ssh_keys(id) ON DELETE CASCADE, | |
| 70 | repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 71 | added_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 72 | PRIMARY KEY (key_id, repo_id) | |
| 73 | ); | |
| 74 | ``` | |
| 75 | ||
| 76 | `runner_seen` is rekeyed from `user_id` to `key_id` (SQLite: recreate the | |
| 77 | table; existing rows are dropped, they are heartbeats). `user_id` stays as a | |
| 78 | plain column for the listing. Two runners on one account are two rows. | |
| 79 | ||
| 80 | `control.Ctx` gains `KeyID int64`, the id of the key that authenticated the | |
| 81 | session, set by `internal/sshd`. Web and API sessions leave it zero; every | |
| 82 | command that reads it is `SSHOnly`. | |
| 83 | ||
| 84 | ### Commands | |
| 85 | ||
| 86 | One new noun under `repo`. Each requires `policy.CanAdmin` on the repository. | |
| 87 | ||
| 88 | | Command | Flags | Notes | | |
| 89 | |---|---|---| | |
| 90 | | `repo runner add <owner/name> < key.pub` | `ReadsStdin` | Unknown fingerprint: added to the caller's account with scope `runner`, then attached. Known fingerprint: must already have scope `runner`, and must belong to the caller unless the caller is an instance admin, else exit 4. A full-scope or deploy key is never promoted. Attaching an already-attached key is exit 0 and idempotent. The key's account must be able to read the repository, or the runner cannot clone it. Output `{fingerprint, repo}`. | | |
| 91 | | `repo runner list <owner/name>` | `ReadOnly` | Per key: fingerprint, algo, owner username, `added_at`, `last_seen`, and the build it holds (`build_number`, `build_job`, `started_at`) when it holds one. | | |
| 92 | | `repo runner remove <owner/name> <fingerprint>` | | Drops the attachment. The key stays on the account; `keys remove` drops the key and cascades. Exit 3 when not attached. | | |
| 93 | ||
| 94 | ### Claim rule | |
| 95 | ||
| 96 | `runner next [--untrusted] [<owner/name>...]`: | |
| 97 | ||
| 98 | - Scope `runner`: the candidate set is the key's attachments. No attachments | |
| 99 | claims nothing and returns "no pending builds". Each `<owner/name>` on the | |
| 100 | command line must be among the attachments, else exit 4 naming it; the | |
| 101 | named set narrows the candidates. | |
| 102 | - Admin key: unchanged. Any repository, narrowed by the arguments. | |
| 103 | - Without `--untrusted`, builds with `trusted = 0` are never claimed, for | |
| 104 | every key. `store.ClaimBuild` gains the parameter. The bay1 unit passes | |
| 105 | `-untrusted` to keep building fork heads in podman. | |
| 106 | - Secrets ride the claim as today (`b.Trusted`). An attached runner was | |
| 107 | attached by a repository admin and is trusted with the repository's | |
| 108 | secrets. | |
| 109 | - The orphan skip loop, the reachability check and the heartbeat are | |
| 110 | unchanged. The heartbeat records `c.KeyID`. | |
| 111 | ||
| 112 | `runner log <id>` and `runner done <id> ...` on a scope-`runner` key: the | |
| 113 | build's repository must be attached to the key, else exit 4. Admin keys are | |
| 114 | unchanged. | |
| 115 | ||
| 116 | ### Listings | |
| 117 | ||
| 118 | `admin runners` rows carry `fingerprint` beside `username`. `scope` becomes | |
| 119 | the attached repositories for a runner key, joined with commas; for an admin | |
| 120 | key it stays the repositories the runner asked for, or `any`. `dashboard` reads the same rows. | |
| 121 | ||
| 122 | ## Runner | |
| 123 | ||
| 124 | ### `gitbay-runner init` | |
| 125 | ||
| 126 | A subcommand, `gitbay-runner init [-remote git@host] [-isolation podman|none]`. | |
| 127 | It: | |
| 128 | ||
| 129 | 1. Creates the config directory: `$XDG_CONFIG_HOME/gitbay-runner`, else | |
| 130 | `~/.config/gitbay-runner`. Mode 0700. | |
| 131 | 2. Generates `id_ed25519` and `id_ed25519.pub` there unless present. Mode | |
| 132 | 0600 on the private key. The runner never overwrites a key. | |
| 133 | 3. Writes `config.toml` unless present: | |
| 134 | ||
| 135 | ```toml | |
| 136 | remote = "git@gitbay.org" | |
| 137 | workdir = "/Users/x/Library/Caches/gitbay-runner" # defaultWorkdir() | |
| 138 | isolation = "podman" | |
| 139 | untrusted = false | |
| 140 | ``` | |
| 141 | ||
| 142 | `isolation` is `none` unless `-isolation podman -image <ref>` are both | |
| 143 | given: the runner refuses podman without an image, and there is no | |
| 144 | image to guess. With `none` it prints one line saying so: steps run as | |
| 145 | this user, and untrusted builds are excluded by default so that means | |
| 146 | your own commits. | |
| 147 | 4. Prints the public key and the next step: | |
| 148 | ||
| 149 | ``` | |
| 150 | gitbay repo runner add owner/name < /Users/x/.config/gitbay-runner/id_ed25519.pub | |
| 151 | ``` | |
| 152 | ||
| 153 | and the URL to paste it at, `https://<host>/<owner>/<name>/settings`, | |
| 154 | with `<host>` taken from the remote. Then `brew services start | |
| 155 | krz/tap/gitbay-runner`, or the binary with no arguments. | |
| 156 | ||
| 157 | Re-running `init` is safe and prints the same key. | |
| 158 | ||
| 159 | ### Config file | |
| 160 | ||
| 161 | The daemon reads `config.toml` from the config directory when it exists. | |
| 162 | Keys are the flag names (`remote`, `ssh-opts`, `clone-base`, `workdir`, | |
| 163 | `poll`, `timeout`, `repos`, `jobs`, `image`, `isolation`, `memory`, `cpus`, | |
| 164 | `untrusted`, `identity`). A flag given on the command line overrides the | |
| 165 | file. `-config <path>` names another file. No other configuration source. | |
| 166 | ||
| 167 | ### Own identity | |
| 168 | ||
| 169 | `-identity <path>`, default the generated key when it exists, else empty. | |
| 170 | When set, ssh and git clone get `-i <path>` and `-o IdentitiesOnly=yes`, so | |
| 171 | a laptop's ambient full-scope key is never offered. Under podman the clone | |
| 172 | already happens outside the container; the identity stays outside with it. | |
| 173 | ||
| 174 | ### `-untrusted` | |
| 175 | ||
| 176 | Adds `--untrusted` to `runner next`. Default off. | |
| 177 | ||
| 178 | ### Packaging | |
| 179 | ||
| 180 | - `Formula/gitbay-runner.rb` in `krz/homebrew-tap`: source build at the | |
| 181 | tag, `go build ./cmd/gitbay-runner`, a `service do` block running | |
| 182 | `opt_bin/"gitbay-runner"` with no arguments, `keep_alive true`, logs under | |
| 183 | `var/"log"`, and caveats naming the two steps. `test do` asserts | |
| 184 | `-version`. | |
| 185 | - `gitbay.rb` in the same tap moves from v0.4.0 to the current tag in the | |
| 186 | same commit. | |
| 187 | - `deploy/release.sh` builds `gitbay-runner` for the three targets alongside | |
| 188 | `gitbay` and `gitbayd`. | |
| 189 | ||
| 190 | Unchanged: poll interval, workdir layout, the per-repository build home, | |
| 191 | SIGTERM drain, podman isolation, log cap. | |
| 192 | ||
| 193 | ## Web | |
| 194 | ||
| 195 | The repository settings page gains a Runners section: the `repo runner list` | |
| 196 | rows with a remove button each, and a textarea that posts a public key. Both | |
| 197 | go through `settingsSubmit` into the control commands; the paste is stdin | |
| 198 | via `dispatchIntoStdin`. No new route, so no reserved name. The account page | |
| 199 | already lists keys with their scope; a runner key shows as `runner`. | |
| 200 | ||
| 201 | ## Docs | |
| 202 | ||
| 203 | - `Users`: a "Your own runner" section under CI builds: install, `init`, | |
| 204 | attach (CLI and web), start, what it builds (your commits, with secrets) | |
| 205 | and what it does not (fork heads, unless `-untrusted`), several | |
| 206 | repositories on one runner, several runners on one account. | |
| 207 | - `Admin` and `Threat-Model`: replace "a runner account is admin by | |
| 208 | necessity" and "the scoping is what the runner asks for, not an ACL the | |
| 209 | server holds" with the attachment rule. The bay1 example gains | |
| 210 | `-untrusted`. | |
| 211 | - `Parity`: one row in the repository table, runners attach/list/detach, | |
| 212 | yes on CLI and web, no on iOS. | |
| 213 | - `CI` and `FAQ`: one line each pointing at the Users section. The FAQ's | |
| 214 | "CI builds only the repositories the operator names" becomes "and any | |
| 215 | repository with a runner attached". | |
| 216 | ||
| 217 | ## Tests | |
| 218 | ||
| 219 | - Store: `ClaimBuild` skips untrusted builds unless asked; a claim limited to | |
| 220 | attached repositories; attachment rows go with the key and with the | |
| 221 | repository; `runner_seen` per key. | |
| 222 | - Control: a scope-`runner` key with no attachment claims nothing; an | |
| 223 | attached key claims its repository and not another user's pending build; | |
| 224 | a named repository outside the attachments is exit 4; `runner log` and | |
| 225 | `runner done` refused for an unattached build; `repo runner add` refuses | |
| 226 | a full-scope key and a deploy key; add is idempotent. The existing | |
| 227 | `TestStdinCommandsReadStdin`, `TestReadOnlyCommandsWriteNothing`, the CLI | |
| 228 | table coverage test and the Parity test cover the new commands without | |
| 229 | changes. The e2e tests that add a scope-`runner` key today | |
| 230 | (`buildcancelweb_test.go`, `reap_test.go`, `runner_scope_test.go`) gain an | |
| 231 | attach step, since an unattached key no longer claims. | |
| 232 | - Runner: `init` writes key and config with the right modes and never | |
| 233 | overwrites; config values are overridden by flags; `-identity` reaches the | |
| 234 | ssh and git command lines. | |
| 235 | - e2e, one test: `init` against the test instance, attach over SSH with the | |
| 236 | printed key, start the runner with the generated config, a push builds | |
| 237 | and succeeds, a merge request head from a fork stays pending; with | |
| 238 | `-untrusted` it builds. | |
| 239 | ||
| 240 | ## Rollout | |
| 241 | ||
| 242 | 1. Server, store, control, web, docs, tests: one MR against `main`, with | |
| 243 | migration 0050. Deployed, the change closes the claim hole for every | |
| 244 | existing scope-`runner` key on the instance: none has attachments, so | |
| 245 | none claims. That includes the bay1 runner, which polls as the | |
| 246 | non-admin account `ci` with a scope-`runner` key. Right after the | |
| 247 | deploy, attach it as the admin: | |
| 248 | ||
| 249 | ``` | |
| 250 | ssh -p 2222 root@bay1 cat /var/lib/gitbay-runner/.ssh/id_ed25519.pub \ | |
| 251 | | gitbay repo runner add krz/gitbay | |
| 252 | ssh -p 2222 root@bay1 cat /var/lib/gitbay-runner/.ssh/id_ed25519.pub \ | |
| 253 | | gitbay repo runner add cmc/ci-smoke | |
| 254 | ``` | |
| 255 | ||
| 256 | Builds queued between the deploy and the attach wait; none is lost. | |
| 257 | `-untrusted` goes into the unit in the same deploy. | |
| 258 | 2. Runner: `init`, config file, `-identity`, `-untrusted`. Same MR or the | |
| 259 | next; the server change does not depend on it. | |
| 260 | 3. Tap: formula and the `gitbay.rb` bump, after the release that carries | |
| 261 | the runner change is tagged. | |
e2e/build_cancel_test.go +1 −1
| @@ -124,7 +124,7 @@ func TestBuildCancelRunning(t *testing.T) { | ||
| 124 | 124 | runner := exec.Command(inst.runner, "-once", "-remote", "git@127.0.0.1", "-ssh-opts", opts, |
| 125 | 125 | "-isolation", "none", |
| 126 | 126 | "-clone-base", fmt.Sprintf("ssh://git@127.0.0.1:%d", inst.port), "-workdir", t.TempDir()) |
| 127 | runner.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null") | |
| 127 | runner.Env = append(os.Environ(), "XDG_CONFIG_HOME="+t.TempDir(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null") | |
| 128 | 128 | var runnerOut strings.Builder |
| 129 | 129 | runner.Stdout, runner.Stderr = &runnerOut, &runnerOut |
| 130 | 130 | if err := runner.Start(); err != nil { |
e2e/buildcancelweb_test.go +6 −8
| @@ -19,19 +19,17 @@ func TestBuildCancelWeb(t *testing.T) { | ||
| 19 | 19 | inst.admin(t, "admin", "user", "create", "alice", |
| 20 | 20 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") |
| 21 | 21 | |
| 22 | // ci is an ordinary account; its runner key is self-added with | |
| 23 | // --scope runner, which confines it to the runner protocol and | |
| 24 | // read-only git rather than reaching for admin. | |
| 25 | ciKey := inst.newKey(t, "ci") | |
| 26 | inst.admin(t, "admin", "user", "create", "ci", "--key", ciKey+".pub") | |
| 27 | 22 | runnerKey := inst.newKey(t, "ci-runner") |
| 28 | 23 | pub, _ := os.ReadFile(runnerKey + ".pub") |
| 29 | if _, errOut, code := inst.ssh(t, ciKey, string(pub), "keys", "add", "--scope", "runner"); code != 0 { | |
| 30 | t.Fatalf("keys add --scope runner: %s", errOut) | |
| 31 | } | |
| 32 | 24 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { |
| 33 | 25 | t.Fatalf("repo create: %s", errOut) |
| 34 | 26 | } |
| 27 | // The runner key is attached by alice through repo runner add, which | |
| 28 | // registers it on her account with scope runner, confining it to the | |
| 29 | // runner protocol and read-only git rather than reaching for admin. | |
| 30 | if _, errOut, code := inst.ssh(t, aliceKey, string(pub), "repo", "runner", "add", "alice/app"); code != 0 { | |
| 31 | t.Fatalf("repo runner add: %s", errOut) | |
| 32 | } | |
| 35 | 33 | work := t.TempDir() |
| 36 | 34 | env := inst.gitEnv(aliceKey) |
| 37 | 35 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") |
e2e/ci_test.go +7 −5
| @@ -23,7 +23,7 @@ func buildRunner(t *testing.T) string { | ||
| 23 | 23 | } |
| 24 | 24 | |
| 25 | 25 | // runnerOnce processes at most one pending build with the given key. |
| 26 | func (i *instance) runnerOnce(t *testing.T, key string) string { | |
| 26 | func (i *instance) runnerOnce(t *testing.T, key string, extra ...string) string { | |
| 27 | 27 | t.Helper() |
| 28 | 28 | opts := fmt.Sprintf("-p %d -i %s -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=%s -o BatchMode=yes", |
| 29 | 29 | i.port, key, filepath.Join(i.sshDir, "known_hosts")) |
| @@ -31,13 +31,15 @@ func (i *instance) runnerOnce(t *testing.T, key string) string { | ||
| 31 | 31 | // cancellation, not the sandbox, and the suite must run on a machine |
| 32 | 32 | // without podman. The isolation tests are in isolation_podman_test.go |
| 33 | 33 | // and skip visibly when it is absent (#144). |
| 34 | cmd := exec.Command(i.runner, "-once", | |
| 34 | args := []string{"-once", | |
| 35 | 35 | "-remote", "git@127.0.0.1", |
| 36 | 36 | "-ssh-opts", opts, |
| 37 | 37 | "-isolation", "none", |
| 38 | 38 | "-clone-base", fmt.Sprintf("ssh://git@127.0.0.1:%d", i.port), |
| 39 | "-workdir", t.TempDir()) | |
| 40 | cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null") | |
| 39 | "-workdir", t.TempDir()} | |
| 40 | args = append(args, extra...) | |
| 41 | cmd := exec.Command(i.runner, args...) | |
| 42 | cmd.Env = append(os.Environ(), "XDG_CONFIG_HOME="+t.TempDir(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null") | |
| 41 | 43 | out, err := cmd.CombinedOutput() |
| 42 | 44 | if err != nil { |
| 43 | 45 | t.Fatalf("runner: %v\n%s", err, out) |
| @@ -291,7 +293,7 @@ func (i *instance) runnerJobs(t *testing.T, key, repo string, jobs int) string { | ||
| 291 | 293 | "-ssh-opts", opts, |
| 292 | 294 | "-clone-base", fmt.Sprintf("ssh://git@127.0.0.1:%d", i.port), |
| 293 | 295 | "-workdir", t.TempDir()) |
| 294 | cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null") | |
| 296 | cmd.Env = append(os.Environ(), "XDG_CONFIG_HOME="+t.TempDir(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null") | |
| 295 | 297 | var buf bytes.Buffer |
| 296 | 298 | cmd.Stdout, cmd.Stderr = &buf, &buf |
| 297 | 299 | if err := cmd.Start(); err != nil { |
e2e/isolation_podman_test.go +1 −1
| @@ -182,7 +182,7 @@ func runnerPodmanOnce(t *testing.T, inst *instance, key string) { | ||
| 182 | 182 | "-image", "localhost/gitbay-ci:1", |
| 183 | 183 | "-clone-base", fmt.Sprintf("ssh://git@127.0.0.1:%d", inst.port), |
| 184 | 184 | "-workdir", t.TempDir()) |
| 185 | cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null") | |
| 185 | cmd.Env = append(os.Environ(), "XDG_CONFIG_HOME="+t.TempDir(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null") | |
| 186 | 186 | if out, err := cmd.CombinedOutput(); err != nil { |
| 187 | 187 | t.Fatalf("runner: %v\n%s", err, out) |
| 188 | 188 | } |
e2e/mrbuilds_test.go +2 −2
| @@ -92,7 +92,7 @@ func TestForkMRHeadIsBuilt(t *testing.T) { | ||
| 92 | 92 | } |
| 93 | 93 | |
| 94 | 94 | // The claim carries no secrets for a head from another repository. |
| 95 | out, errOut, code := inst.ssh(t, runnerKey, "", "runner", "next", "alice/app", "--json") | |
| 95 | out, errOut, code := inst.ssh(t, runnerKey, "", "runner", "next", "--untrusted", "alice/app", "--json") | |
| 96 | 96 | if code != 0 { |
| 97 | 97 | t.Fatalf("runner next: %s", errOut) |
| 98 | 98 | } |
| @@ -112,7 +112,7 @@ func TestForkMRHeadIsBuilt(t *testing.T) { | ||
| 112 | 112 | } |
| 113 | 113 | |
| 114 | 114 | // The real runner fetches the merge request ref and runs the second job. |
| 115 | log := inst.runnerOnce(t, runnerKey) | |
| 115 | log := inst.runnerOnce(t, runnerKey, "-untrusted") | |
| 116 | 116 | if !strings.Contains(log, "two") { |
| 117 | 117 | t.Fatalf("runner did not run the second job:\n%s", log) |
| 118 | 118 | } |
e2e/readonly_test.go +1
| @@ -122,6 +122,7 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) { | ||
| 122 | 122 | "repo commit": {"alice/app", sha}, |
| 123 | 123 | "repo download": {"alice/app"}, |
| 124 | 124 | "repo deploy-key list": {"alice/app"}, |
| 125 | "repo runner list": {"alice/app"}, | |
| 125 | 126 | "repo secret list": {"alice/app"}, |
| 126 | 127 | "repo mirror list": {"alice/app"}, |
| 127 | 128 | "repo domain list": {"alice/app"}, |
e2e/runnerattach_test.go added +128
| @@ -0,0 +1,128 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | "os" | |
| 6 | "os/exec" | |
| 7 | "path/filepath" | |
| 8 | "strings" | |
| 9 | "testing" | |
| 10 | ) | |
| 11 | ||
| 12 | // The whole flow a user goes through: init on their machine, attach the | |
| 13 | // printed key to their repository, start the runner from the config init | |
| 14 | // wrote. The runner builds their push and leaves a fork's merge request | |
| 15 | // head alone until started with -untrusted. | |
| 16 | func TestAttachedRunnerBuildsOwnRepo(t *testing.T) { | |
| 17 | inst := startInstance(t) | |
| 18 | inst.runner = buildRunner(t) | |
| 19 | aliceKey := inst.newKey(t, "alice") | |
| 20 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | |
| 21 | bobKey := inst.newKey(t, "bob") | |
| 22 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | |
| 23 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | |
| 24 | t.Fatalf("repo create: %s", errOut) | |
| 25 | } | |
| 26 | ||
| 27 | // init on "alice's laptop". | |
| 28 | xdg := t.TempDir() | |
| 29 | initCmd := exec.Command(inst.runner, "init", "-remote", "git@127.0.0.1") | |
| 30 | initCmd.Env = append(os.Environ(), "XDG_CONFIG_HOME="+xdg) | |
| 31 | initOut, err := initCmd.CombinedOutput() | |
| 32 | if err != nil { | |
| 33 | t.Fatalf("init: %v\n%s", err, initOut) | |
| 34 | } | |
| 35 | cdir := filepath.Join(xdg, "gitbay-runner") | |
| 36 | pub, err := os.ReadFile(filepath.Join(cdir, "id_ed25519.pub")) | |
| 37 | if err != nil { | |
| 38 | t.Fatal(err) | |
| 39 | } | |
| 40 | if !strings.Contains(string(initOut), strings.TrimSpace(string(pub))) { | |
| 41 | t.Fatalf("init did not print the key:\n%s", initOut) | |
| 42 | } | |
| 43 | ||
| 44 | // The unattached key claims nothing, even with a build queued. | |
| 45 | work := t.TempDir() | |
| 46 | env := inst.gitEnv(aliceKey) | |
| 47 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") | |
| 48 | dir := filepath.Join(work, "w") | |
| 49 | os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755) | |
| 50 | os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte("jobs:\n unit:\n steps:\n - echo built\n"), 0o644) | |
| 51 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | |
| 52 | mustGit(t, dir, env, "add", ".") | |
| 53 | mustGit(t, dir, env, "commit", "-q", "-m", "ci") | |
| 54 | mustGit(t, dir, env, "push", "-q", "origin", "main") | |
| 55 | ||
| 56 | run := func(extra ...string) string { | |
| 57 | t.Helper() | |
| 58 | // No -i in ssh-opts: the identity from the config is what | |
| 59 | // authenticates, which is the point. | |
| 60 | opts := fmt.Sprintf("-p %d -o StrictHostKeyChecking=no -o UserKnownHostsFile=%s -o BatchMode=yes", | |
| 61 | inst.port, filepath.Join(inst.sshDir, "known_hosts")) | |
| 62 | args := append([]string{"-config", filepath.Join(cdir, "config.toml"), "-once", | |
| 63 | "-ssh-opts", opts, | |
| 64 | "-clone-base", fmt.Sprintf("ssh://git@127.0.0.1:%d", inst.port), | |
| 65 | "-workdir", t.TempDir()}, extra...) | |
| 66 | cmd := exec.Command(inst.runner, args...) | |
| 67 | cmd.Env = append(os.Environ(), "XDG_CONFIG_HOME="+xdg, "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null") | |
| 68 | out, err := cmd.CombinedOutput() | |
| 69 | if err != nil { | |
| 70 | t.Fatalf("runner: %v\n%s", err, out) | |
| 71 | } | |
| 72 | return string(out) | |
| 73 | } | |
| 74 | if out, _, _ := inst.ssh(t, aliceKey, "", "build", "list", "alice/app"); !strings.Contains(out, "unit\tpending") { | |
| 75 | t.Fatalf("build not pending before attach: %s", out) | |
| 76 | } | |
| 77 | run() | |
| 78 | if out, _, _ := inst.ssh(t, aliceKey, "", "build", "list", "alice/app"); !strings.Contains(out, "unit\tpending") { | |
| 79 | t.Fatalf("unattached runner claimed the build: %s", out) | |
| 80 | } | |
| 81 | ||
| 82 | // Attach with the printed key. | |
| 83 | if _, errOut, code := inst.ssh(t, aliceKey, string(pub), "repo", "runner", "add", "alice/app"); code != 0 { | |
| 84 | t.Fatalf("repo runner add: %s", errOut) | |
| 85 | } | |
| 86 | out, _, _ := inst.ssh(t, aliceKey, "", "repo", "runner", "list", "alice/app", "--json") | |
| 87 | if !strings.Contains(out, `"username":"alice"`) || strings.Contains(out, `"last_seen":"20`) { | |
| 88 | t.Fatalf("list after attach: %s", out) | |
| 89 | } | |
| 90 | ||
| 91 | // The runner builds it. | |
| 92 | run() | |
| 93 | if out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app"); !strings.Contains(out, "unit\tsuccess") { | |
| 94 | t.Fatalf("build not built by the attached runner: %s", out) | |
| 95 | } | |
| 96 | if out, _, _ = inst.ssh(t, aliceKey, "", "repo", "runner", "list", "alice/app", "--json"); !strings.Contains(out, `"last_seen":"20`) { | |
| 97 | t.Fatalf("no heartbeat after a poll: %s", out) | |
| 98 | } | |
| 99 | ||
| 100 | // bob forks and opens a merge request: an untrusted build in the target. | |
| 101 | if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "fork", "alice/app"); code != 0 { | |
| 102 | t.Fatalf("fork: %s", errOut) | |
| 103 | } | |
| 104 | bwork := t.TempDir() | |
| 105 | benv := inst.gitEnv(bobKey) | |
| 106 | mustGit(t, bwork, benv, "clone", inst.sshURL("bob/app"), "w") | |
| 107 | bdir := filepath.Join(bwork, "w") | |
| 108 | mustGit(t, bdir, benv, "checkout", "-q", "-b", "feat") | |
| 109 | os.WriteFile(filepath.Join(bdir, "f.txt"), []byte("y\n"), 0o644) | |
| 110 | mustGit(t, bdir, benv, "add", ".") | |
| 111 | mustGit(t, bdir, benv, "commit", "-q", "-m", "change") | |
| 112 | mustGit(t, bdir, benv, "push", "-q", "origin", "feat") | |
| 113 | if _, _, code := inst.ssh(t, bobKey, "", "build", "cancel", "bob/app", "1"); code != 0 { | |
| 114 | t.Fatal("cancel bob's own build") | |
| 115 | } | |
| 116 | if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/app", | |
| 117 | "--source", "bob/app:feat", "--target", "main", "--title", "change"); code != 0 { | |
| 118 | t.Fatalf("mr create: %s", errOut) | |
| 119 | } | |
| 120 | run() | |
| 121 | if out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app"); strings.Count(out, "pending") != 1 { | |
| 122 | t.Fatalf("fork head was claimed without -untrusted:\n%s", out) | |
| 123 | } | |
| 124 | run("-untrusted") | |
| 125 | if out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app"); strings.Contains(out, "pending") { | |
| 126 | t.Fatalf("fork head not built with -untrusted:\n%s", out) | |
| 127 | } | |
| 128 | } | |
e2e/runnerstop_test.go +1 −1
| @@ -81,7 +81,7 @@ func (i *instance) buildStatus(t *testing.T, key string) string { | ||
| 81 | 81 | func TestRunnerDrainsOnSIGTERM(t *testing.T) { |
| 82 | 82 | inst, key := stopFixture(t) |
| 83 | 83 | cmd := exec.Command(inst.runner, inst.runnerArgs(t, key)...) |
| 84 | cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null") | |
| 84 | cmd.Env = append(os.Environ(), "XDG_CONFIG_HOME="+t.TempDir(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null") | |
| 85 | 85 | var buf bytes.Buffer |
| 86 | 86 | cmd.Stdout, cmd.Stderr = &buf, &buf |
| 87 | 87 | if err := cmd.Start(); err != nil { |
e2e/runnerweb_test.go added +51
| @@ -0,0 +1,51 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "net/url" | |
| 5 | "os" | |
| 6 | "strings" | |
| 7 | "testing" | |
| 8 | ) | |
| 9 | ||
| 10 | // The settings page attaches and detaches runners through the same | |
| 11 | // commands the CLI uses, and lists what is attached. | |
| 12 | func TestRunnerSettingsWeb(t *testing.T) { | |
| 13 | inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n") | |
| 14 | aliceKey := inst.newKey(t, "alice") | |
| 15 | inst.admin(t, "admin", "user", "create", "alice", | |
| 16 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | |
| 17 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | |
| 18 | t.Fatalf("repo create: %s", errOut) | |
| 19 | } | |
| 20 | runnerKey := inst.newKey(t, "laptop") | |
| 21 | pub, _ := os.ReadFile(runnerKey + ".pub") | |
| 22 | ||
| 23 | alice := inst.login(t, aliceKey) | |
| 24 | settings := inst.base() + "/alice/app/settings" | |
| 25 | _, body := browserGet(t, alice, settings) | |
| 26 | if !strings.Contains(body, "No runners attached") { | |
| 27 | t.Fatalf("empty state missing:\n%s", body) | |
| 28 | } | |
| 29 | if status, _ := browserPost(t, alice, settings, url.Values{"field": {"runner-add"}, "key": {string(pub)}}); status != 200 { | |
| 30 | t.Fatalf("runner-add post: %d", status) | |
| 31 | } | |
| 32 | out, _, _ := inst.ssh(t, aliceKey, "", "repo", "runner", "list", "alice/app", "--json") | |
| 33 | if !strings.Contains(out, `"fingerprint":"SHA256:`) { | |
| 34 | t.Fatalf("not attached after the form: %s", out) | |
| 35 | } | |
| 36 | fp := out[strings.Index(out, "SHA256:"):] | |
| 37 | fp = fp[:strings.Index(fp, `"`)] | |
| 38 | // html/template writes + as + in text and attributes, and a | |
| 39 | // fingerprint is base64, so the page shows the escaped form. | |
| 40 | shown := strings.ReplaceAll(fp, "+", "+") | |
| 41 | _, body = browserGet(t, alice, settings) | |
| 42 | if !strings.Contains(body, shown) || !strings.Contains(body, `value="runner-remove"`) { | |
| 43 | t.Fatalf("attached runner not listed:\n%s", body) | |
| 44 | } | |
| 45 | if status, _ := browserPost(t, alice, settings, url.Values{"field": {"runner-remove"}, "fingerprint": {fp}}); status != 200 { | |
| 46 | t.Fatalf("runner-remove post: %d", status) | |
| 47 | } | |
| 48 | if out, _, _ = inst.ssh(t, aliceKey, "", "repo", "runner", "list", "alice/app", "--json"); strings.Contains(out, fp) { | |
| 49 | t.Fatalf("still attached after remove: %s", out) | |
| 50 | } | |
| 51 | } | |
internal/control/admin.go +19 −1
| @@ -459,6 +459,24 @@ func runAdminRunners(c *Ctx, args []string) int { | ||
| 459 | 459 | if runners == nil { |
| 460 | 460 | runners = []store.Runner{} |
| 461 | 461 | } |
| 462 | // The scope column is what the key may claim, not what it asked for. A | |
| 463 | // runner key is confined to its attachments, so they replace whatever | |
| 464 | // -repos it polled with, and none of them means none. Any other key | |
| 465 | // keeps the repositories it asked for, or the whole instance. | |
| 466 | for i := range runners { | |
| 467 | key, err := c.Store.SSHKeyByID(runners[i].KeyID) | |
| 468 | if err != nil || key.Scope != "runner" { | |
| 469 | continue | |
| 470 | } | |
| 471 | paths, err := c.Store.RunnerRepoPaths(runners[i].KeyID) | |
| 472 | if err != nil { | |
| 473 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 474 | } | |
| 475 | runners[i].Scope = "none" | |
| 476 | if len(paths) > 0 { | |
| 477 | runners[i].Scope = strings.Join(paths, ",") | |
| 478 | } | |
| 479 | } | |
| 462 | 480 | d := map[string]any{"queue": queue, "runners": runners} |
| 463 | 481 | return c.emit(d, func(w io.Writer) { |
| 464 | 482 | fmt.Fprintf(w, "queue: %d pending; last 24h: %d claimed, wait avg %ds max %ds, %d reaped\n", |
| @@ -472,7 +490,7 @@ func runAdminRunners(c *Ctx, args []string) int { | ||
| 472 | 490 | if r.BuildNumber != 0 { |
| 473 | 491 | held = fmt.Sprintf("%s #%d %s since %s", r.BuildRepo, r.BuildNumber, r.BuildJob, r.StartedAt) |
| 474 | 492 | } |
| 475 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", r.Username, r.LastSeen, scope, held) | |
| 493 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Username, r.Fingerprint, r.LastSeen, scope, held) | |
| 476 | 494 | } |
| 477 | 495 | }) |
| 478 | 496 | } |
internal/control/build.go +82 −17
| @@ -59,8 +59,8 @@ func init() { | ||
| 59 | 59 | // dispatcher confines to these three commands and read-only git, or |
| 60 | 60 | // an admin key, which a runner host should not hold (#92). |
| 61 | 61 | register(Command{Path: []string{"runner", "next"}, |
| 62 | Summary: "claim the oldest pending build (runner protocol)", | |
| 63 | Usage: "runner next [<owner/name>...]", SSHOnly: true, Run: runRunnerNext}) | |
| 62 | Summary: "claim the oldest pending build this key may run (runner protocol)", | |
| 63 | Usage: "runner next [--untrusted] [<owner/name>...]", SSHOnly: true, Run: runRunnerNext}) | |
| 64 | 64 | register(Command{Path: []string{"runner", "log"}, |
| 65 | 65 | Summary: "append a build's log from stdin", |
| 66 | 66 | Usage: "runner log <build-id>", SSHOnly: true, ReadsStdin: true, Run: runRunnerLog}) |
| @@ -309,11 +309,36 @@ func runSecretList(c *Ctx, args []string) int { | ||
| 309 | 309 | }) |
| 310 | 310 | } |
| 311 | 311 | |
| 312 | func requireRunner(c *Ctx) int { | |
| 312 | // runnerSession resolves the key behind a runner-protocol session. The | |
| 313 | // runner commands are SSHOnly, so Source is the key's fingerprint. An | |
| 314 | // admin key is accepted so an operator can rotate at their own pace; a | |
| 315 | // runner host should hold a key added with --scope runner. | |
| 316 | func runnerSession(c *Ctx) (store.SSHKey, int) { | |
| 313 | 317 | if c.Scope != "runner" && !c.User.IsAdmin { |
| 314 | return c.fail(protocol.ExitDenied, "runner commands need a key added with --scope runner") | |
| 318 | return store.SSHKey{}, c.fail(protocol.ExitDenied, "runner commands need a key added with --scope runner") | |
| 315 | 319 | } |
| 316 | return -1 | |
| 320 | key, err := c.Store.SSHKeyByFingerprint(c.Source) | |
| 321 | if err != nil { | |
| 322 | return store.SSHKey{}, c.fail(protocol.ExitDenied, "runner commands need an SSH key session") | |
| 323 | } | |
| 324 | return key, -1 | |
| 325 | } | |
| 326 | ||
| 327 | // runnerAdmin reports whether a session claims builds instance-wide. The | |
| 328 | // bypass is the key, not the account: a scope-runner key is confined to | |
| 329 | // its attachments whoever owns it, including an instance admin. | |
| 330 | func runnerAdmin(c *Ctx) bool { | |
| 331 | return c.User.IsAdmin && c.Scope != "runner" | |
| 332 | } | |
| 333 | ||
| 334 | // runnerMayBuild reports whether a runner session may act on a | |
| 335 | // repository's builds: an admin key may on any, a runner key on the | |
| 336 | // repositories it is attached to (#184). | |
| 337 | func runnerMayBuild(c *Ctx, key store.SSHKey, repoID int64) (bool, error) { | |
| 338 | if runnerAdmin(c) { | |
| 339 | return true, nil | |
| 340 | } | |
| 341 | return c.Store.RunnerAttached(key.ID, repoID) | |
| 317 | 342 | } |
| 318 | 343 | |
| 319 | 344 | // maxOrphanSkip bounds how many claimed builds runRunnerNext will find |
| @@ -327,26 +352,52 @@ func requireRunner(c *Ctx) int { | ||
| 327 | 352 | const maxOrphanSkip = 50 |
| 328 | 353 | |
| 329 | 354 | func runRunnerNext(c *Ctx, args []string) int { |
| 330 | if code := requireRunner(c); code >= 0 { | |
| 355 | key, code := runnerSession(c) | |
| 356 | if code >= 0 { | |
| 331 | 357 | return code |
| 332 | 358 | } |
| 333 | // A runner may limit itself to named repositories. The operator chooses | |
| 334 | // what a given runner executes by how they start it; this is scoping the | |
| 335 | // runner asks for, not an ACL the server holds over it. | |
| 359 | f, err := parseFlags(args, flagSpec{Bools: []string{"--untrusted"}, MaxPos: -1, | |
| 360 | Usage: "runner next [--untrusted] [<owner/name>...]"}) | |
| 361 | if err != nil { | |
| 362 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 363 | } | |
| 364 | // The candidate set. An admin key claims from any repository, narrowed | |
| 365 | // by the names given. A runner key claims from the repositories it is | |
| 366 | // attached to; a name outside them is refused, not ignored, so a | |
| 367 | // misconfigured runner says so instead of idling. | |
| 336 | 368 | var repoIDs []int64 |
| 337 | for _, arg := range args { | |
| 369 | for _, arg := range f.Pos { | |
| 338 | 370 | repo, code := resolveRepo(c, arg, policy.CanRead) |
| 339 | 371 | if code >= 0 { |
| 340 | 372 | return code |
| 341 | 373 | } |
| 374 | ok, err := runnerMayBuild(c, key, repo.ID) | |
| 375 | if err != nil { | |
| 376 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 377 | } | |
| 378 | if !ok { | |
| 379 | return c.fail(protocol.ExitDenied, "this key is not attached to %s", repo.Path()) | |
| 380 | } | |
| 342 | 381 | repoIDs = append(repoIDs, repo.ID) |
| 343 | 382 | } |
| 383 | if !runnerAdmin(c) && len(repoIDs) == 0 { | |
| 384 | repoIDs, err = c.Store.RunnerRepoIDs(key.ID) | |
| 385 | if err != nil { | |
| 386 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 387 | } | |
| 388 | if len(repoIDs) == 0 { | |
| 389 | // Nothing attached: nothing to claim. Still a heartbeat, so | |
| 390 | // admin runners shows the key polling. | |
| 391 | c.Store.TouchRunner(key.ID, c.User.ID, "", 0) | |
| 392 | return c.emit(map[string]any{}, func(w io.Writer) { fmt.Fprintln(w, "no pending builds") }) | |
| 393 | } | |
| 394 | } | |
| 395 | untrusted := f.Has("--untrusted") | |
| 344 | 396 | var b store.Build |
| 345 | 397 | var repo store.Repo |
| 346 | 398 | var ok bool |
| 347 | var err error | |
| 348 | 399 | for attempt := 0; attempt < maxOrphanSkip; attempt++ { |
| 349 | b, ok, err = c.Store.ClaimBuild(repoIDs) | |
| 400 | b, ok, err = c.Store.ClaimBuild(repoIDs, untrusted) | |
| 350 | 401 | if err != nil { |
| 351 | 402 | return c.fail(protocol.ExitFailure, "%v", err) |
| 352 | 403 | } |
| @@ -376,7 +427,7 @@ func runRunnerNext(c *Ctx, args []string) int { | ||
| 376 | 427 | b, ok = store.Build{}, false |
| 377 | 428 | } |
| 378 | 429 | // The poll itself is the runner's heartbeat: admin runners reads it. |
| 379 | c.Store.TouchRunner(c.User.ID, strings.Join(args, ","), b.ID) | |
| 430 | c.Store.TouchRunner(key.ID, c.User.ID, strings.Join(f.Pos, ","), b.ID) | |
| 380 | 431 | if !ok { |
| 381 | 432 | return c.emit(map[string]any{}, func(w io.Writer) { fmt.Fprintln(w, "no pending builds") }) |
| 382 | 433 | } |
| @@ -408,7 +459,8 @@ func runRunnerNext(c *Ctx, args []string) int { | ||
| 408 | 459 | } |
| 409 | 460 | |
| 410 | 461 | func runRunnerLog(c *Ctx, args []string) int { |
| 411 | if code := requireRunner(c); code >= 0 { | |
| 462 | key, code := runnerSession(c) | |
| 463 | if code >= 0 { | |
| 412 | 464 | return code |
| 413 | 465 | } |
| 414 | 466 | if len(args) != 1 { |
| @@ -418,6 +470,13 @@ func runRunnerLog(c *Ctx, args []string) int { | ||
| 418 | 470 | if err != nil { |
| 419 | 471 | return c.fail(protocol.ExitUsage, "bad build id %q", args[0]) |
| 420 | 472 | } |
| 473 | if b, err := c.Store.BuildByID(id); err != nil { | |
| 474 | return c.fail(protocol.ExitNotFound, "no build %d", id) | |
| 475 | } else if ok, err := runnerMayBuild(c, key, b.RepoID); err != nil { | |
| 476 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 477 | } else if !ok { | |
| 478 | return c.fail(protocol.ExitDenied, "this key is not attached to the build's repository") | |
| 479 | } | |
| 421 | 480 | // Stream stdin into the log in chunks so long builds appear live. An |
| 422 | 481 | // append that fails drops its chunk and the loop keeps draining: ending |
| 423 | 482 | // the session here breaks the runner's pipe, and a broken pipe is how a |
| @@ -478,7 +537,8 @@ func runRunnerLog(c *Ctx, args []string) int { | ||
| 478 | 537 | } |
| 479 | 538 | |
| 480 | 539 | func runRunnerDone(c *Ctx, args []string) int { |
| 481 | if code := requireRunner(c); code >= 0 { | |
| 540 | key, code := runnerSession(c) | |
| 541 | if code >= 0 { | |
| 482 | 542 | return code |
| 483 | 543 | } |
| 484 | 544 | if len(args) != 2 || (args[1] != "success" && args[1] != "failure") { |
| @@ -492,10 +552,15 @@ func runRunnerDone(c *Ctx, args []string) int { | ||
| 492 | 552 | if err != nil { |
| 493 | 553 | return c.fail(protocol.ExitNotFound, "no build %d", id) |
| 494 | 554 | } |
| 555 | if ok, err := runnerMayBuild(c, key, b.RepoID); err != nil { | |
| 556 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 557 | } else if !ok { | |
| 558 | return c.fail(protocol.ExitDenied, "this key is not attached to the build's repository") | |
| 559 | } | |
| 495 | 560 | // Cancelled underneath the runner: its report is late, not wrong. |
| 496 | 561 | // The row, the status and the log were settled by the cancel. |
| 497 | 562 | if b.Status == "cancelled" { |
| 498 | c.Store.RunnerDone(c.User.ID) | |
| 563 | c.Store.RunnerDone(key.ID) | |
| 499 | 564 | return c.emit(map[string]any{"build": b.Number, "status": "cancelled"}, func(w io.Writer) { |
| 500 | 565 | fmt.Fprintf(w, "build %d was cancelled\n", b.Number) |
| 501 | 566 | }) |
| @@ -503,7 +568,7 @@ func runRunnerDone(c *Ctx, args []string) int { | ||
| 503 | 568 | if err := c.Store.FinishBuild(id, args[1]); err != nil { |
| 504 | 569 | return c.fail(protocol.ExitFailure, "finishing build %d: %v", id, err) |
| 505 | 570 | } |
| 506 | c.Store.RunnerDone(c.User.ID) | |
| 571 | c.Store.RunnerDone(key.ID) | |
| 507 | 572 | repo, err := c.Store.RepoByID(b.RepoID) |
| 508 | 573 | if err != nil { |
| 509 | 574 | return c.fail(protocol.ExitFailure, "%v", err) |
internal/control/build_test.go +1 −1
| @@ -582,7 +582,7 @@ func TestQueueBranchBuildsSameTreeReusesSuccess(t *testing.T) { | ||
| 582 | 582 | if len(builds) != 1 { |
| 583 | 583 | t.Fatalf("first commit queued %d builds, want 1", len(builds)) |
| 584 | 584 | } |
| 585 | if _, ok, err := st.ClaimBuild([]int64{repo.ID}); err != nil || !ok { | |
| 585 | if _, ok, err := st.ClaimBuild([]int64{repo.ID}, false); err != nil || !ok { | |
| 586 | 586 | t.Fatalf("claim: ok=%v err=%v", ok, err) |
| 587 | 587 | } |
| 588 | 588 | if err := st.FinishBuild(builds[0].ID, "success"); err != nil { |
internal/control/runnerattach_test.go added +225
| @@ -0,0 +1,225 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "strconv" | |
| 6 | "strings" | |
| 7 | "testing" | |
| 8 | ||
| 9 | "gitbay.org/gitbay/internal/config" | |
| 10 | "gitbay.org/gitbay/internal/protocol" | |
| 11 | "gitbay.org/gitbay/internal/store" | |
| 12 | ) | |
| 13 | ||
| 14 | // attachFixture: alice (not admin) owns alice/app with a build queued; | |
| 15 | // mallory (not admin) owns mallory/evil with an older build queued. Each | |
| 16 | // has a runner-scoped key. The Ctx polls as the given user with the given | |
| 17 | // key, which is what the SSH listener produces. | |
| 18 | type attachFixture struct { | |
| 19 | st *store.Store | |
| 20 | alice, mallory int64 | |
| 21 | aliceKey, malloryKey store.SSHKey | |
| 22 | app, evil store.Repo | |
| 23 | appBuild, evilBuild int64 | |
| 24 | } | |
| 25 | ||
| 26 | func newAttachFixture(t *testing.T) attachFixture { | |
| 27 | t.Helper() | |
| 28 | st, err := store.Open(":memory:") | |
| 29 | if err != nil { | |
| 30 | t.Fatal(err) | |
| 31 | } | |
| 32 | t.Cleanup(func() { st.Close() }) | |
| 33 | if err := st.MigrateUp(); err != nil { | |
| 34 | t.Fatal(err) | |
| 35 | } | |
| 36 | var f attachFixture | |
| 37 | f.st = st | |
| 38 | mk := func(name, fp string) (int64, store.SSHKey, store.Repo, string) { | |
| 39 | uid, err := st.CreateUser(name, false) | |
| 40 | if err != nil { | |
| 41 | t.Fatal(err) | |
| 42 | } | |
| 43 | if err := st.AddSSHKey(uid, fp, "ssh-ed25519", []byte(fp), "runner"); err != nil { | |
| 44 | t.Fatal(err) | |
| 45 | } | |
| 46 | k, _ := st.SSHKeyByFingerprint(fp) | |
| 47 | repoName := map[string]string{"alice": "app", "mallory": "evil"}[name] | |
| 48 | rid, err := st.CreateRepo("user", uid, repoName, "public") | |
| 49 | if err != nil { | |
| 50 | t.Fatal(err) | |
| 51 | } | |
| 52 | repo, _ := st.RepoByID(rid) | |
| 53 | return uid, k, repo, repoName | |
| 54 | } | |
| 55 | f.mallory, f.malloryKey, f.evil, _ = mk("mallory", "SHA256:mallory") | |
| 56 | f.alice, f.aliceKey, f.app, _ = mk("alice", "SHA256:alice") | |
| 57 | // mallory's build is older, so an unrestricted claim would take it. | |
| 58 | f.evilBuild, err = st.CreateBuild(f.evil.ID, "unit", "aaa111", "main", "[]", "", "", true) | |
| 59 | if err != nil { | |
| 60 | t.Fatal(err) | |
| 61 | } | |
| 62 | f.appBuild, err = st.CreateBuild(f.app.ID, "unit", "bbb222", "main", "[]", "", "", true) | |
| 63 | if err != nil { | |
| 64 | t.Fatal(err) | |
| 65 | } | |
| 66 | return f | |
| 67 | } | |
| 68 | ||
| 69 | func (f attachFixture) ctx(uid int64, key store.SSHKey, admin bool) (*Ctx, *bytes.Buffer) { | |
| 70 | var out bytes.Buffer | |
| 71 | name := "alice" | |
| 72 | if uid == f.mallory { | |
| 73 | name = "mallory" | |
| 74 | } | |
| 75 | return &Ctx{ | |
| 76 | User: store.User{ID: uid, Username: name, IsAdmin: admin}, | |
| 77 | Scope: key.Scope, | |
| 78 | Source: key.Fingerprint, | |
| 79 | Store: f.st, | |
| 80 | Cfg: config.Config{Server: config.Server{Root: "/nonexistent", SiteURL: "https://x.test"}}, | |
| 81 | Stdin: strings.NewReader(""), | |
| 82 | Stdout: &out, | |
| 83 | Stderr: &out, | |
| 84 | }, &out | |
| 85 | } | |
| 86 | ||
| 87 | // A runner key with no attachment claims nothing, whatever is queued. | |
| 88 | func TestRunnerNextUnattachedClaimsNothing(t *testing.T) { | |
| 89 | f := newAttachFixture(t) | |
| 90 | c, out := f.ctx(f.alice, f.aliceKey, false) | |
| 91 | if code := runRunnerNext(c, nil); code != protocol.ExitOK || !strings.Contains(out.String(), "no pending builds") { | |
| 92 | t.Fatalf("exit %d: %s", code, out.String()) | |
| 93 | } | |
| 94 | b, _ := f.st.BuildByNumber(f.evil.ID, f.evilBuild) | |
| 95 | if b.Status != "pending" { | |
| 96 | t.Fatalf("unattached key claimed a build: %s", b.Status) | |
| 97 | } | |
| 98 | } | |
| 99 | ||
| 100 | // An attached key claims its repository's build and not the older one | |
| 101 | // queued elsewhere; naming a repository outside the attachments is refused. | |
| 102 | func TestRunnerNextAttachedClaimsOwnRepoOnly(t *testing.T) { | |
| 103 | f := newAttachFixture(t) | |
| 104 | if err := f.st.AttachRunner(f.aliceKey.ID, f.app.ID); err != nil { | |
| 105 | t.Fatal(err) | |
| 106 | } | |
| 107 | c, out := f.ctx(f.alice, f.aliceKey, false) | |
| 108 | if code := runRunnerNext(c, nil); code != protocol.ExitOK || !strings.Contains(out.String(), "alice/app") { | |
| 109 | t.Fatalf("exit %d: %s", code, out.String()) | |
| 110 | } | |
| 111 | if b, _ := f.st.BuildByNumber(f.evil.ID, f.evilBuild); b.Status != "pending" { | |
| 112 | t.Fatalf("mallory's build was touched: %s", b.Status) | |
| 113 | } | |
| 114 | c, out = f.ctx(f.alice, f.aliceKey, false) | |
| 115 | if code := runRunnerNext(c, []string{"mallory/evil"}); code != protocol.ExitDenied { | |
| 116 | t.Fatalf("naming an unattached repo: exit %d, want %d: %s", code, protocol.ExitDenied, out.String()) | |
| 117 | } | |
| 118 | } | |
| 119 | ||
| 120 | // The heartbeat is recorded against the key, and admin runners shows it | |
| 121 | // with its fingerprint and attachments. The column is the attachments even | |
| 122 | // when the key polled with a narrower -repos, and none when it has no | |
| 123 | // attachment at all. | |
| 124 | func TestAdminRunnersShowsKeyAndAttachments(t *testing.T) { | |
| 125 | f := newAttachFixture(t) | |
| 126 | for _, id := range []int64{f.app.ID, f.evil.ID} { | |
| 127 | if err := f.st.AttachRunner(f.aliceKey.ID, id); err != nil { | |
| 128 | t.Fatal(err) | |
| 129 | } | |
| 130 | } | |
| 131 | c, _ := f.ctx(f.alice, f.aliceKey, false) | |
| 132 | runRunnerNext(c, []string{"alice/app"}) | |
| 133 | c, _ = f.ctx(f.mallory, f.malloryKey, false) | |
| 134 | runRunnerNext(c, nil) | |
| 135 | admin, out := f.ctx(f.alice, f.aliceKey, true) | |
| 136 | admin.Scope = "full" | |
| 137 | if code := runAdminRunners(admin, nil); code != protocol.ExitOK { | |
| 138 | t.Fatalf("admin runners: exit %d: %s", code, out.String()) | |
| 139 | } | |
| 140 | if !strings.Contains(out.String(), "alice\tSHA256:alice\t") || | |
| 141 | !strings.Contains(out.String(), "\talice/app,mallory/evil\t") { | |
| 142 | t.Fatalf("row lacks fingerprint or attachments:\n%s", out.String()) | |
| 143 | } | |
| 144 | if !strings.Contains(out.String(), "\tnone\t") { | |
| 145 | t.Fatalf("mallory's unattached runner key is not none:\n%s", out.String()) | |
| 146 | } | |
| 147 | } | |
| 148 | ||
| 149 | // The instance-admin bypass is the key, not the account: a runner-scoped | |
| 150 | // key on an admin account claims only what it is attached to. | |
| 151 | func TestRunnerNextAdminAccountRunnerKeyIsConfined(t *testing.T) { | |
| 152 | f := newAttachFixture(t) | |
| 153 | c, out := f.ctx(f.alice, f.aliceKey, true) | |
| 154 | if code := runRunnerNext(c, nil); code != protocol.ExitOK || !strings.Contains(out.String(), "no pending builds") { | |
| 155 | t.Fatalf("exit %d: %s", code, out.String()) | |
| 156 | } | |
| 157 | for _, b := range []struct { | |
| 158 | repo store.Repo | |
| 159 | number int64 | |
| 160 | }{{f.app, f.appBuild}, {f.evil, f.evilBuild}} { | |
| 161 | if got, _ := f.st.BuildByNumber(b.repo.ID, b.number); got.Status != "pending" { | |
| 162 | t.Fatalf("%s claimed by an unattached runner key: %s", b.repo.Path(), got.Status) | |
| 163 | } | |
| 164 | } | |
| 165 | if err := f.st.AttachRunner(f.aliceKey.ID, f.app.ID); err != nil { | |
| 166 | t.Fatal(err) | |
| 167 | } | |
| 168 | c, out = f.ctx(f.alice, f.aliceKey, true) | |
| 169 | if code := runRunnerNext(c, nil); code != protocol.ExitOK || !strings.Contains(out.String(), "alice/app") { | |
| 170 | t.Fatalf("attached claim: exit %d: %s", code, out.String()) | |
| 171 | } | |
| 172 | if got, _ := f.st.BuildByNumber(f.evil.ID, f.evilBuild); got.Status != "pending" { | |
| 173 | t.Fatalf("mallory's build was claimed: %s", got.Status) | |
| 174 | } | |
| 175 | } | |
| 176 | ||
| 177 | // Untrusted builds are skipped unless the runner asks. | |
| 178 | func TestRunnerNextUntrustedFlag(t *testing.T) { | |
| 179 | f := newAttachFixture(t) | |
| 180 | if err := f.st.AttachRunner(f.aliceKey.ID, f.app.ID); err != nil { | |
| 181 | t.Fatal(err) | |
| 182 | } | |
| 183 | c, _ := f.ctx(f.alice, f.aliceKey, false) | |
| 184 | runRunnerNext(c, nil) // takes the trusted build | |
| 185 | fork, err := f.st.CreateBuild(f.app.ID, "unit", "ccc333", "refs/merge-requests/1/head", "[]", "", "", false) | |
| 186 | if err != nil { | |
| 187 | t.Fatal(err) | |
| 188 | } | |
| 189 | c, out := f.ctx(f.alice, f.aliceKey, false) | |
| 190 | runRunnerNext(c, nil) | |
| 191 | if !strings.Contains(out.String(), "no pending builds") { | |
| 192 | t.Fatalf("fork head claimed without --untrusted: %s", out.String()) | |
| 193 | } | |
| 194 | c, out = f.ctx(f.alice, f.aliceKey, false) | |
| 195 | if code := runRunnerNext(c, []string{"--untrusted"}); code != protocol.ExitOK || !strings.Contains(out.String(), "alice/app") { | |
| 196 | t.Fatalf("--untrusted did not claim the fork head: exit %d %s", code, out.String()) | |
| 197 | } | |
| 198 | if b, _ := f.st.BuildByNumber(f.app.ID, fork); b.Status != "running" { | |
| 199 | t.Fatalf("fork build is %s, want running", b.Status) | |
| 200 | } | |
| 201 | } | |
| 202 | ||
| 203 | // runner done and runner log on a build whose repository is not attached | |
| 204 | // to the key are refused. | |
| 205 | func TestRunnerDoneRefusedForUnattachedBuild(t *testing.T) { | |
| 206 | f := newAttachFixture(t) | |
| 207 | if err := f.st.AttachRunner(f.malloryKey.ID, f.evil.ID); err != nil { | |
| 208 | t.Fatal(err) | |
| 209 | } | |
| 210 | c, _ := f.ctx(f.mallory, f.malloryKey, false) | |
| 211 | runRunnerNext(c, nil) // mallory holds her own build | |
| 212 | evil, _ := f.st.BuildByNumber(f.evil.ID, f.evilBuild) | |
| 213 | c, out := f.ctx(f.alice, f.aliceKey, false) | |
| 214 | id := strconv.FormatInt(evil.ID, 10) | |
| 215 | if code := runRunnerDone(c, []string{id, "success"}); code != protocol.ExitDenied { | |
| 216 | t.Fatalf("done on an unattached build: exit %d, want %d: %s", code, protocol.ExitDenied, out.String()) | |
| 217 | } | |
| 218 | c, out = f.ctx(f.alice, f.aliceKey, false) | |
| 219 | if code := runRunnerLog(c, []string{id}); code != protocol.ExitDenied { | |
| 220 | t.Fatalf("log on an unattached build: exit %d, want %d: %s", code, protocol.ExitDenied, out.String()) | |
| 221 | } | |
| 222 | if b, _ := f.st.BuildByNumber(f.evil.ID, f.evilBuild); b.Status != "running" { | |
| 223 | t.Fatalf("build was finished by a foreign key: %s", b.Status) | |
| 224 | } | |
| 225 | } | |
internal/control/runnernext_test.go +6 −2
| @@ -14,12 +14,16 @@ import ( | ||
| 14 | 14 | ) |
| 15 | 15 | |
| 16 | 16 | // runnerCtx builds a Ctx good enough to run runRunnerNext directly: an |
| 17 | // admin user (requireRunner accepts admin as well as scope "runner"), a | |
| 17 | // admin user (runnerSession accepts admin as well as scope "runner"), a | |
| 18 | 18 | // server root that matches where the test's bare repo lives. |
| 19 | 19 | func runnerCtx(st *store.Store, uid int64, root string) (*Ctx, *bytes.Buffer) { |
| 20 | 20 | var out bytes.Buffer |
| 21 | fp := fmt.Sprintf("SHA256:runner-%d", uid) | |
| 22 | st.AddSSHKey(uid, fp, "ssh-ed25519", []byte(fp), "full") // ErrDuplicateKey on reuse is fine | |
| 21 | 23 | c := &Ctx{ |
| 22 | 24 | User: store.User{ID: uid, Username: "ci", IsAdmin: true}, |
| 25 | Scope: "full", | |
| 26 | Source: fp, | |
| 23 | 27 | Store: st, |
| 24 | 28 | Cfg: config.Config{Server: config.Server{Root: root, SiteURL: "https://x.test"}}, |
| 25 | 29 | Stdin: strings.NewReader(""), |
| @@ -224,7 +228,7 @@ func TestRunnerLogMarksStreamClosed(t *testing.T) { | ||
| 224 | 228 | if _, err := st.CreateBuild(repo.ID, "unit", strings.Repeat("a", 40), "main", "[]", "", "", true); err != nil { |
| 225 | 229 | t.Fatal(err) |
| 226 | 230 | } |
| 227 | b, ok, err := st.ClaimBuild([]int64{repo.ID}) | |
| 231 | b, ok, err := st.ClaimBuild([]int64{repo.ID}, false) | |
| 228 | 232 | if err != nil || !ok { |
| 229 | 233 | t.Fatalf("claim: %v", err) |
| 230 | 234 | } |
internal/control/runnerrepo.go added +143
| @@ -0,0 +1,143 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "errors" | |
| 5 | "fmt" | |
| 6 | "io" | |
| 7 | ||
| 8 | "golang.org/x/crypto/ssh" | |
| 9 | ||
| 10 | "gitbay.org/gitbay/internal/policy" | |
| 11 | "gitbay.org/gitbay/internal/protocol" | |
| 12 | "gitbay.org/gitbay/internal/store" | |
| 13 | ) | |
| 14 | ||
| 15 | // Runners attached to a repository (#184). A runner key claims builds only | |
| 16 | // for the repositories it is attached to; a repository admin attaches it | |
| 17 | // by pasting the runner's public key. The key lands on the admin's own | |
| 18 | // account with scope runner, which confines it to the runner protocol and | |
| 19 | // read-only git. | |
| 20 | func init() { | |
| 21 | register(Command{Path: []string{"repo", "runner", "add"}, | |
| 22 | Summary: "attach a runner's public key to a repository", | |
| 23 | Usage: "repo runner add <owner/name> < key.pub", | |
| 24 | ReadsStdin: true, Run: runRepoRunnerAdd}) | |
| 25 | register(Command{Path: []string{"repo", "runner", "list"}, | |
| 26 | Summary: "list the runners attached to a repository", | |
| 27 | Usage: "repo runner list <owner/name>", ReadOnly: true, Run: runRepoRunnerList}) | |
| 28 | register(Command{Path: []string{"repo", "runner", "remove"}, | |
| 29 | Summary: "detach a runner from a repository", | |
| 30 | Usage: "repo runner remove <owner/name> <fingerprint>", Run: runRepoRunnerRemove}) | |
| 31 | } | |
| 32 | ||
| 33 | func runRepoRunnerAdd(c *Ctx, args []string) int { | |
| 34 | f, err := parseFlags(args, flagSpec{MaxPos: 1, Usage: "repo runner add <owner/name> < key.pub"}) | |
| 35 | if err != nil || len(f.Pos) != 1 { | |
| 36 | return c.fail(protocol.ExitUsage, "usage: repo runner add <owner/name> < key.pub") | |
| 37 | } | |
| 38 | repo, code := resolveRepo(c, f.Pos[0], policy.CanAdmin) | |
| 39 | if code >= 0 { | |
| 40 | return code | |
| 41 | } | |
| 42 | raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10)) | |
| 43 | if err != nil { | |
| 44 | return c.fail(protocol.ExitFailure, "reading key: %v", err) | |
| 45 | } | |
| 46 | pub, _, _, _, err := ssh.ParseAuthorizedKey(raw) | |
| 47 | if err != nil { | |
| 48 | return c.fail(protocol.ExitUsage, "not a valid public key in authorized_keys format: %v", err) | |
| 49 | } | |
| 50 | fp := ssh.FingerprintSHA256(pub) | |
| 51 | key, err := c.Store.SSHKeyByFingerprint(fp) | |
| 52 | switch { | |
| 53 | case errors.Is(err, store.ErrNotFound): | |
| 54 | if err := c.Store.AddSSHKey(c.User.ID, fp, pub.Type(), pub.Marshal(), "runner"); err != nil { | |
| 55 | return c.fail(protocol.ExitFailure, "adding key: %v", err) | |
| 56 | } | |
| 57 | if key, err = c.Store.SSHKeyByFingerprint(fp); err != nil { | |
| 58 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 59 | } | |
| 60 | case err != nil: | |
| 61 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 62 | case key.Scope != "runner": | |
| 63 | // A full key would let a build step administer the account; a | |
| 64 | // deploy key is bound elsewhere. A runner gets a key of its own. | |
| 65 | 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) | |
| 66 | case key.UserID != c.User.ID && !c.User.IsAdmin: | |
| 67 | return c.fail(protocol.ExitDenied, "%s belongs to another account", fp) | |
| 68 | } | |
| 69 | // The runner clones what it builds, so the key's account must be able | |
| 70 | // to read the repository. The caller's own key needs no check: they | |
| 71 | // hold admin on the repository to get here. | |
| 72 | if key.UserID != c.User.ID { | |
| 73 | owner, err := c.Store.UserByID(key.UserID) | |
| 74 | if err != nil { | |
| 75 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 76 | } | |
| 77 | grant, err := c.Store.AccessRole(repo.ID, owner.ID) | |
| 78 | if err != nil { | |
| 79 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 80 | } | |
| 81 | if !policy.CanRead(owner, repo, grant) { | |
| 82 | return c.fail(protocol.ExitDenied, "%s belongs to %s, who cannot read %s", fp, owner.Username, repo.Path()) | |
| 83 | } | |
| 84 | } | |
| 85 | if err := c.Store.AttachRunner(key.ID, repo.ID); err != nil { | |
| 86 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 87 | } | |
| 88 | c.Store.Audit(c.User.ID, "repo.runner.add", map[string]any{"repo": repo.Path(), "fingerprint": fp}) | |
| 89 | d := map[string]string{"fingerprint": fp, "repo": repo.Path()} | |
| 90 | return c.emit(d, func(w io.Writer) { | |
| 91 | fmt.Fprintf(w, "runner %s attached to %s\n", fp, repo.Path()) | |
| 92 | }) | |
| 93 | } | |
| 94 | ||
| 95 | func runRepoRunnerList(c *Ctx, args []string) int { | |
| 96 | if len(args) != 1 { | |
| 97 | return c.fail(protocol.ExitUsage, "usage: repo runner list <owner/name>") | |
| 98 | } | |
| 99 | repo, code := resolveRepo(c, args[0], policy.CanAdmin) | |
| 100 | if code >= 0 { | |
| 101 | return code | |
| 102 | } | |
| 103 | runners, err := c.Store.ListRepoRunners(repo.ID) | |
| 104 | if err != nil { | |
| 105 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 106 | } | |
| 107 | if runners == nil { | |
| 108 | runners = []store.RepoRunner{} | |
| 109 | } | |
| 110 | return c.emit(runners, func(w io.Writer) { | |
| 111 | for _, r := range runners { | |
| 112 | seen := r.LastSeen | |
| 113 | if seen == "" { | |
| 114 | seen = "never" | |
| 115 | } | |
| 116 | held := "idle" | |
| 117 | if r.BuildNumber != 0 { | |
| 118 | held = fmt.Sprintf("%s #%d %s since %s", r.BuildRepo, r.BuildNumber, r.BuildJob, r.StartedAt) | |
| 119 | } | |
| 120 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Fingerprint, r.Algo, r.Username, seen, held) | |
| 121 | } | |
| 122 | }) | |
| 123 | } | |
| 124 | ||
| 125 | func runRepoRunnerRemove(c *Ctx, args []string) int { | |
| 126 | if len(args) != 2 { | |
| 127 | return c.fail(protocol.ExitUsage, "usage: repo runner remove <owner/name> <fingerprint>") | |
| 128 | } | |
| 129 | repo, code := resolveRepo(c, args[0], policy.CanAdmin) | |
| 130 | if code >= 0 { | |
| 131 | return code | |
| 132 | } | |
| 133 | if err := c.Store.DetachRunner(repo.ID, args[1]); err != nil { | |
| 134 | if errors.Is(err, store.ErrNotFound) { | |
| 135 | return c.fail(protocol.ExitNotFound, "no runner %s on %s", args[1], repo.Path()) | |
| 136 | } | |
| 137 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 138 | } | |
| 139 | c.Store.Audit(c.User.ID, "repo.runner.remove", map[string]any{"repo": repo.Path(), "fingerprint": args[1]}) | |
| 140 | return c.emit(map[string]string{"removed": args[1]}, func(w io.Writer) { | |
| 141 | fmt.Fprintf(w, "runner %s detached from %s\n", args[1], repo.Path()) | |
| 142 | }) | |
| 143 | } | |
internal/control/runnerrepo_test.go added +124
| @@ -0,0 +1,124 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "strings" | |
| 6 | "testing" | |
| 7 | ||
| 8 | "gitbay.org/gitbay/internal/config" | |
| 9 | "gitbay.org/gitbay/internal/protocol" | |
| 10 | "gitbay.org/gitbay/internal/store" | |
| 11 | ) | |
| 12 | ||
| 13 | // Generated once with ssh-keygen -t ed25519; a valid authorized_keys line. | |
| 14 | const testRunnerPub = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILAr2r82jFsCJwsEyrEf2wgKy9Dv45xYYici6Ii7NyCS runner@test\n" | |
| 15 | ||
| 16 | func repoRunnerCtx(t *testing.T, st *store.Store, uid int64, admin bool, stdin string) (*Ctx, *bytes.Buffer) { | |
| 17 | t.Helper() | |
| 18 | var out bytes.Buffer | |
| 19 | return &Ctx{ | |
| 20 | User: store.User{ID: uid, Username: "alice", IsAdmin: admin}, | |
| 21 | Scope: "full", | |
| 22 | Source: "SHA256:session", | |
| 23 | Store: st, | |
| 24 | Cfg: config.Config{Server: config.Server{SiteURL: "https://x.test"}}, | |
| 25 | Stdin: strings.NewReader(stdin), | |
| 26 | Stdout: &out, | |
| 27 | Stderr: &out, | |
| 28 | JSON: true, | |
| 29 | }, &out | |
| 30 | } | |
| 31 | ||
| 32 | // A fresh key is registered on the caller's account with scope runner and | |
| 33 | // attached; a second add is a no-op; list shows it; remove detaches and | |
| 34 | // leaves the key on the account. | |
| 35 | func TestRepoRunnerAddListRemove(t *testing.T) { | |
| 36 | st, repo, uid := newQueueTestRepo(t) | |
| 37 | c, out := repoRunnerCtx(t, st, uid, false, testRunnerPub) | |
| 38 | if code := runRepoRunnerAdd(c, []string{repo.Path()}); code != protocol.ExitOK { | |
| 39 | t.Fatalf("add: exit %d %s", code, out.String()) | |
| 40 | } | |
| 41 | if !strings.Contains(out.String(), `"fingerprint":"SHA256:`) { | |
| 42 | t.Fatalf("add output: %s", out.String()) | |
| 43 | } | |
| 44 | keys, _ := st.ListSSHKeys(uid) | |
| 45 | if len(keys) != 1 || keys[0].Scope != "runner" { | |
| 46 | t.Fatalf("key not registered as runner: %+v", keys) | |
| 47 | } | |
| 48 | fp := keys[0].Fingerprint | |
| 49 | c, out = repoRunnerCtx(t, st, uid, false, testRunnerPub) | |
| 50 | if code := runRepoRunnerAdd(c, []string{repo.Path()}); code != protocol.ExitOK { | |
| 51 | t.Fatalf("second add: exit %d %s", code, out.String()) | |
| 52 | } | |
| 53 | c, out = repoRunnerCtx(t, st, uid, false, "") | |
| 54 | if code := runRepoRunnerList(c, []string{repo.Path()}); code != protocol.ExitOK || strings.Count(out.String(), fp) != 1 { | |
| 55 | t.Fatalf("list: exit %d %s", code, out.String()) | |
| 56 | } | |
| 57 | c, out = repoRunnerCtx(t, st, uid, false, "") | |
| 58 | if code := runRepoRunnerRemove(c, []string{repo.Path(), fp}); code != protocol.ExitOK { | |
| 59 | t.Fatalf("remove: exit %d %s", code, out.String()) | |
| 60 | } | |
| 61 | if ok, _ := st.RunnerAttached(keys[0].ID, repo.ID); ok { | |
| 62 | t.Fatal("still attached after remove") | |
| 63 | } | |
| 64 | if keys, _ = st.ListSSHKeys(uid); len(keys) != 1 { | |
| 65 | t.Fatal("remove dropped the key from the account") | |
| 66 | } | |
| 67 | c, out = repoRunnerCtx(t, st, uid, false, "") | |
| 68 | if code := runRepoRunnerRemove(c, []string{repo.Path(), fp}); code != protocol.ExitNotFound { | |
| 69 | t.Fatalf("remove twice: exit %d, want %d", code, protocol.ExitNotFound) | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| 73 | // A key that already exists with another scope is never promoted, and | |
| 74 | // another account's runner key is refused unless the caller is an admin. | |
| 75 | func TestRepoRunnerAddRefusesWrongKeys(t *testing.T) { | |
| 76 | st, repo, uid := newQueueTestRepo(t) | |
| 77 | c, _ := repoRunnerCtx(t, st, uid, false, testRunnerPub) | |
| 78 | // Register the same key as a full key first. | |
| 79 | if code := runKeysAdd(c, nil); code != protocol.ExitOK { | |
| 80 | t.Fatal("keys add failed") | |
| 81 | } | |
| 82 | c, out := repoRunnerCtx(t, st, uid, false, testRunnerPub) | |
| 83 | if code := runRepoRunnerAdd(c, []string{repo.Path()}); code != protocol.ExitDenied { | |
| 84 | t.Fatalf("full key accepted as runner: exit %d %s", code, out.String()) | |
| 85 | } | |
| 86 | keys, _ := st.ListSSHKeys(uid) | |
| 87 | if keys[0].Scope != "full" { | |
| 88 | t.Fatalf("scope changed to %s", keys[0].Scope) | |
| 89 | } | |
| 90 | // Someone else's runner key. | |
| 91 | bob, _ := st.CreateUser("bob", false) | |
| 92 | if err := st.AddSSHKey(bob, "SHA256:bobrunner", "ssh-ed25519", []byte("x"), "runner"); err != nil { | |
| 93 | t.Fatal(err) | |
| 94 | } | |
| 95 | st.RemoveSSHKey(uid, keys[0].Fingerprint) | |
| 96 | if err := st.AddSSHKey(bob, keys[0].Fingerprint, "ssh-ed25519", keys[0].Blob, "runner"); err != nil { | |
| 97 | t.Fatal(err) | |
| 98 | } | |
| 99 | c, out = repoRunnerCtx(t, st, uid, false, testRunnerPub) | |
| 100 | if code := runRepoRunnerAdd(c, []string{repo.Path()}); code != protocol.ExitDenied { | |
| 101 | t.Fatalf("another account's key attached by a non-admin: exit %d %s", code, out.String()) | |
| 102 | } | |
| 103 | c, out = repoRunnerCtx(t, st, uid, true, testRunnerPub) | |
| 104 | if code := runRepoRunnerAdd(c, []string{repo.Path()}); code != protocol.ExitOK { | |
| 105 | t.Fatalf("admin could not attach another account's runner key: exit %d %s", code, out.String()) | |
| 106 | } | |
| 107 | // The runner clones what it builds: bob cannot read alice's private | |
| 108 | // repository, so not even an admin may attach his key to it. | |
| 109 | secretID, err := st.CreateRepo("user", uid, "secret", "private") | |
| 110 | if err != nil { | |
| 111 | t.Fatal(err) | |
| 112 | } | |
| 113 | secret, err := st.RepoByID(secretID) | |
| 114 | if err != nil { | |
| 115 | t.Fatal(err) | |
| 116 | } | |
| 117 | c, out = repoRunnerCtx(t, st, uid, true, testRunnerPub) | |
| 118 | if code := runRepoRunnerAdd(c, []string{secret.Path()}); code != protocol.ExitDenied { | |
| 119 | t.Fatalf("key attached to a repo its account cannot read: exit %d %s", code, out.String()) | |
| 120 | } | |
| 121 | if runners, _ := st.ListRepoRunners(secret.ID); len(runners) != 0 { | |
| 122 | t.Fatalf("attached anyway: %+v", runners) | |
| 123 | } | |
| 124 | } | |
internal/hookd/pushshapes_test.go +2 −2
| @@ -174,7 +174,7 @@ func (f *shapeFixture) push(branch, old, sha string) { | ||
| 174 | 174 | // commit included. |
| 175 | 175 | func (f *shapeFixture) finish(status string) { |
| 176 | 176 | for { |
| 177 | b, ok, err := f.st.ClaimBuild([]int64{f.repo.ID}) | |
| 177 | b, ok, err := f.st.ClaimBuild([]int64{f.repo.ID}, true) | |
| 178 | 178 | if err != nil { |
| 179 | 179 | f.t.Fatal(err) |
| 180 | 180 | } |
| @@ -368,7 +368,7 @@ var pushShapes = []pushShape{ | ||
| 368 | 368 | c1 := f.appCommit("more") |
| 369 | 369 | f.push("feat", zeroSHA40, c1) |
| 370 | 370 | for i := 0; i < 2; i++ { |
| 371 | if _, ok, err := f.st.ClaimBuild(nil); err != nil || !ok { | |
| 371 | if _, ok, err := f.st.ClaimBuild(nil, true); err != nil || !ok { | |
| 372 | 372 | f.t.Fatalf("claim: %v ok=%v", err, ok) |
| 373 | 373 | } |
| 374 | 374 | } |
internal/httpd/settings.go +19 −1
| @@ -21,6 +21,7 @@ type settingsPage struct { | ||
| 21 | 21 | Branches []gitutil.Ref |
| 22 | 22 | DepsEnabled bool |
| 23 | 23 | Deps control.DepsOut |
| 24 | Runners []store.RepoRunner | |
| 24 | 25 | Notice string |
| 25 | 26 | } |
| 26 | 27 | |
| @@ -41,10 +42,13 @@ func (s *Server) settingsForm(w http.ResponseWriter, r *http.Request, u store.Us | ||
| 41 | 42 | // prints (#164). |
| 42 | 43 | var deps control.DepsOut |
| 43 | 44 | s.runControlInto(u, []string{"repo", "deps", "status", repo.Path()}, &deps) |
| 45 | var runners []store.RepoRunner | |
| 46 | s.runControlInto(u, []string{"repo", "runner", "list", repo.Path()}, &runners) | |
| 44 | 47 | s.render(w, "settings.html", settingsPage{ |
| 45 | 48 | repoPage: p, Topics: topics, Branches: branches, |
| 46 | 49 | DepsEnabled: deps.Enabled, Deps: deps, |
| 47 | Notice: s.takeFlash(w, r), | |
| 50 | Runners: runners, | |
| 51 | Notice: s.takeFlash(w, r), | |
| 48 | 52 | }) |
| 49 | 53 | } |
| 50 | 54 | |
| @@ -113,6 +117,20 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store. | ||
| 113 | 117 | s.settingsRedirect(w, r, "name at least one topic") |
| 114 | 118 | return |
| 115 | 119 | } |
| 120 | case "runner-add": | |
| 121 | body := v("key") | |
| 122 | if body == "" { | |
| 123 | s.settingsRedirect(w, r, "paste the runner's public key") | |
| 124 | return | |
| 125 | } | |
| 126 | msg, ok := s.runControlStdin(u, []string{"repo", "runner", "add", repo}, body+"\n") | |
| 127 | if ok { | |
| 128 | msg = "" | |
| 129 | } | |
| 130 | s.settingsRedirect(w, r, msg) | |
| 131 | return | |
| 132 | case "runner-remove": | |
| 133 | argv = []string{"repo", "runner", "remove", repo, v("fingerprint")} | |
| 116 | 134 | default: |
| 117 | 135 | s.settingsRedirect(w, r, "unknown setting") |
| 118 | 136 | return |
internal/store/builds.go +12 −9
| @@ -77,27 +77,30 @@ func scanBuild(row interface{ Scan(...any) error }) (Build, error) { | ||
| 77 | 77 | return b, err |
| 78 | 78 | } |
| 79 | 79 | |
| 80 | // ClaimBuild atomically hands the oldest pending build to a runner. | |
| 81 | // ClaimBuild takes the oldest pending build and marks it running. A | |
| 82 | // non-empty repoIDs restricts the claim to those repositories, which is how | |
| 83 | // a runner on a machine that should not execute every repository's steps | |
| 84 | // limits what it picks up. | |
| 85 | func (s *Store) ClaimBuild(repoIDs []int64) (Build, bool, error) { | |
| 80 | // ClaimBuild atomically hands the oldest pending build to a runner and | |
| 81 | // marks it running. A non-empty repoIDs restricts the claim to those | |
| 82 | // repositories. Untrusted builds — merge request heads from another | |
| 83 | // repository — are skipped unless untrusted is set: they run a stranger's | |
| 84 | // code, which only a runner that isolates should take. | |
| 85 | func (s *Store) ClaimBuild(repoIDs []int64, untrusted bool) (Build, bool, error) { | |
| 86 | 86 | tx, err := s.DB.Begin() |
| 87 | 87 | if err != nil { |
| 88 | 88 | return Build{}, false, err |
| 89 | 89 | } |
| 90 | 90 | defer tx.Rollback() |
| 91 | query := "SELECT id FROM builds WHERE status = 'pending' ORDER BY id LIMIT 1" | |
| 91 | query := "SELECT id FROM builds WHERE status = 'pending'" | |
| 92 | 92 | args := []any{} |
| 93 | if !untrusted { | |
| 94 | query += " AND trusted = 1" | |
| 95 | } | |
| 93 | 96 | if len(repoIDs) > 0 { |
| 94 | 97 | marks := strings.TrimSuffix(strings.Repeat("?,", len(repoIDs)), ",") |
| 95 | query = "SELECT id FROM builds WHERE status = 'pending' AND repo_id IN (" + | |
| 96 | marks + ") ORDER BY id LIMIT 1" | |
| 98 | query += " AND repo_id IN (" + marks + ")" | |
| 97 | 99 | for _, id := range repoIDs { |
| 98 | 100 | args = append(args, id) |
| 99 | 101 | } |
| 100 | 102 | } |
| 103 | query += " ORDER BY id LIMIT 1" | |
| 101 | 104 | var id int64 |
| 102 | 105 | err = tx.QueryRow(query, args...).Scan(&id) |
| 103 | 106 | if errors.Is(err, sql.ErrNoRows) { |
internal/store/builds_test.go +45 −7
| @@ -32,7 +32,7 @@ func TestReapStaleBuilds(t *testing.T) { | ||
| 32 | 32 | |
| 33 | 33 | // Claim both, then age only the first past the deadline. |
| 34 | 34 | for range 2 { |
| 35 | if _, ok, err := s.ClaimBuild(nil); err != nil || !ok { | |
| 35 | if _, ok, err := s.ClaimBuild(nil, false); err != nil || !ok { | |
| 36 | 36 | t.Fatalf("claim: %v ok=%v", err, ok) |
| 37 | 37 | } |
| 38 | 38 | } |
| @@ -148,7 +148,7 @@ func TestClaimBuildScopedToRepos(t *testing.T) { | ||
| 148 | 148 | t.Fatal(err) |
| 149 | 149 | } |
| 150 | 150 | |
| 151 | b, ok, err := s.ClaimBuild([]int64{mine}) | |
| 151 | b, ok, err := s.ClaimBuild([]int64{mine}, false) | |
| 152 | 152 | if err != nil || !ok { |
| 153 | 153 | t.Fatalf("claim: %v ok=%v", err, ok) |
| 154 | 154 | } |
| @@ -158,11 +158,11 @@ func TestClaimBuildScopedToRepos(t *testing.T) { | ||
| 158 | 158 | } |
| 159 | 159 | |
| 160 | 160 | // Nothing left for that scope, even though another repo's build is pending. |
| 161 | if _, ok, err := s.ClaimBuild([]int64{mine}); err != nil || ok { | |
| 161 | if _, ok, err := s.ClaimBuild([]int64{mine}, false); err != nil || ok { | |
| 162 | 162 | t.Fatalf("second scoped claim: err=%v ok=%v, want no build", err, ok) |
| 163 | 163 | } |
| 164 | 164 | // An unscoped runner still takes it. |
| 165 | if b, ok, err := s.ClaimBuild(nil); err != nil || !ok || b.RepoID != theirs { | |
| 165 | if b, ok, err := s.ClaimBuild(nil, false); err != nil || !ok || b.RepoID != theirs { | |
| 166 | 166 | t.Fatalf("unscoped claim: err=%v ok=%v repo=%d", err, ok, b.RepoID) |
| 167 | 167 | } |
| 168 | 168 | } |
| @@ -232,7 +232,7 @@ func TestSuccessBuildForTree(t *testing.T) { | ||
| 232 | 232 | t.Fatal(err) |
| 233 | 233 | } |
| 234 | 234 | b, _ := s.BuildsForCommit(repoID, "aaa") |
| 235 | if _, ok, err := s.ClaimBuild([]int64{repoID}); err != nil || !ok { | |
| 235 | if _, ok, err := s.ClaimBuild([]int64{repoID}, false); err != nil || !ok { | |
| 236 | 236 | t.Fatalf("claim: ok=%v err=%v", ok, err) |
| 237 | 237 | } |
| 238 | 238 | if err := s.FinishBuild(b["unit"].ID, "success"); err != nil { |
| @@ -262,7 +262,7 @@ func TestReapStaleBuildsAfterLogClosed(t *testing.T) { | ||
| 262 | 262 | if _, err := s.CreateBuild(repoID, job, "abc", "main", `["true"]`, "", "", true); err != nil { |
| 263 | 263 | t.Fatal(err) |
| 264 | 264 | } |
| 265 | if _, ok, err := s.ClaimBuild([]int64{repoID}); err != nil || !ok { | |
| 265 | if _, ok, err := s.ClaimBuild([]int64{repoID}, false); err != nil || !ok { | |
| 266 | 266 | t.Fatalf("claim %s: %v", job, err) |
| 267 | 267 | } |
| 268 | 268 | } |
| @@ -315,7 +315,7 @@ func TestQueueStatsFractionalAverage(t *testing.T) { | ||
| 315 | 315 | if _, err := s.CreateBuild(1, "test", "abc123", "main", `["true"]`, "", "", true); err != nil { |
| 316 | 316 | t.Fatal(err) |
| 317 | 317 | } |
| 318 | if _, ok, err := s.ClaimBuild(nil); err != nil || !ok { | |
| 318 | if _, ok, err := s.ClaimBuild(nil, false); err != nil || !ok { | |
| 319 | 319 | t.Fatalf("claim: %v ok=%v", err, ok) |
| 320 | 320 | } |
| 321 | 321 | } |
| @@ -331,3 +331,41 @@ func TestQueueStatsFractionalAverage(t *testing.T) { | ||
| 331 | 331 | t.Fatalf("stats: %+v", q) |
| 332 | 332 | } |
| 333 | 333 | } |
| 334 | ||
| 335 | // A merge request head from a fork is untrusted. A claim skips it unless | |
| 336 | // the runner asked for untrusted builds, so a runner on someone's laptop | |
| 337 | // never executes a stranger's branch by default. | |
| 338 | func TestClaimBuildSkipsUntrustedUnlessAsked(t *testing.T) { | |
| 339 | s := open(t) | |
| 340 | if err := s.MigrateUp(); err != nil { | |
| 341 | t.Fatal(err) | |
| 342 | } | |
| 343 | uid, err := s.CreateUser("cmc", true) | |
| 344 | if err != nil { | |
| 345 | t.Fatal(err) | |
| 346 | } | |
| 347 | repo, err := s.CreateRepo("user", uid, "app", "public") | |
| 348 | if err != nil { | |
| 349 | t.Fatal(err) | |
| 350 | } | |
| 351 | // Queued first, so an unfiltered claim would take it. | |
| 352 | forkBuild, err := s.CreateBuild(repo, "unit", "abc123", "refs/merge-requests/1/head", `["true"]`, "", "", false) | |
| 353 | if err != nil { | |
| 354 | t.Fatal(err) | |
| 355 | } | |
| 356 | own, err := s.CreateBuild(repo, "unit", "def456", "main", `["true"]`, "", "", true) | |
| 357 | if err != nil { | |
| 358 | t.Fatal(err) | |
| 359 | } | |
| 360 | b, ok, err := s.ClaimBuild(nil, false) | |
| 361 | if err != nil || !ok || b.Number != own { | |
| 362 | t.Fatalf("trusted-only claim: err=%v ok=%v number=%d, want %d", err, ok, b.Number, own) | |
| 363 | } | |
| 364 | if _, ok, _ := s.ClaimBuild(nil, false); ok { | |
| 365 | t.Fatal("trusted-only claim took the fork build") | |
| 366 | } | |
| 367 | b, ok, err = s.ClaimBuild(nil, true) | |
| 368 | if err != nil || !ok || b.Number != forkBuild { | |
| 369 | t.Fatalf("untrusted claim: err=%v ok=%v number=%d, want %d", err, ok, b.Number, forkBuild) | |
| 370 | } | |
| 371 | } | |
internal/store/migrations/0050_runner_repos.down.sql added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | DROP TABLE runner_repos; | |
| 2 | DROP TABLE runner_seen; | |
| 3 | CREATE TABLE runner_seen ( | |
| 4 | user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, | |
| 5 | last_seen TEXT NOT NULL, | |
| 6 | scope TEXT NOT NULL DEFAULT '', | |
| 7 | build_id INTEGER REFERENCES builds(id) ON DELETE SET NULL | |
| 8 | ); | |
internal/store/migrations/0050_runner_repos.up.sql added +19
| @@ -0,0 +1,19 @@ | ||
| 1 | -- A runner key is attached to the repositories it may claim builds for | |
| 2 | -- (#184). runner_seen is rekeyed by key so two runners on one account | |
| 3 | -- are two rows; what it held were heartbeats, so the rows are dropped. | |
| 4 | CREATE TABLE runner_repos ( | |
| 5 | key_id INTEGER NOT NULL REFERENCES ssh_keys(id) ON DELETE CASCADE, | |
| 6 | repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 7 | added_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 8 | PRIMARY KEY (key_id, repo_id) | |
| 9 | ); | |
| 10 | CREATE INDEX runner_repos_repo ON runner_repos(repo_id); | |
| 11 | ||
| 12 | DROP TABLE runner_seen; | |
| 13 | CREATE TABLE runner_seen ( | |
| 14 | key_id INTEGER PRIMARY KEY REFERENCES ssh_keys(id) ON DELETE CASCADE, | |
| 15 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 16 | last_seen TEXT NOT NULL, | |
| 17 | scope TEXT NOT NULL DEFAULT '', | |
| 18 | build_id INTEGER REFERENCES builds(id) ON DELETE SET NULL | |
| 19 | ); | |
internal/store/queues_test.go +1 −1
| @@ -24,7 +24,7 @@ func TestQueuesListsPendingBuilds(t *testing.T) { | ||
| 24 | 24 | if err != nil { |
| 25 | 25 | t.Fatal(err) |
| 26 | 26 | } |
| 27 | if _, ok, err := s.ClaimBuild(nil); err != nil || !ok { | |
| 27 | if _, ok, err := s.ClaimBuild(nil, false); err != nil || !ok { | |
| 28 | 28 | t.Fatalf("claim: %v ok=%v", err, ok) |
| 29 | 29 | } |
| 30 | 30 | |
internal/store/runners.go +141 −18
| @@ -1,10 +1,17 @@ | ||
| 1 | 1 | package store |
| 2 | 2 | |
| 3 | // Runner is one runner account as the instance admin sees it. | |
| 3 | import "sort" | |
| 4 | ||
| 5 | // Runner is one runner key as the instance admin sees it. | |
| 4 | 6 | type Runner struct { |
| 5 | Username string `json:"username"` | |
| 6 | LastSeen string `json:"last_seen"` | |
| 7 | Scope string `json:"scope,omitempty"` // comma-joined owner/name, "" for any | |
| 7 | Username string `json:"username"` | |
| 8 | Fingerprint string `json:"fingerprint"` | |
| 9 | KeyID int64 `json:"-"` | |
| 10 | LastSeen string `json:"last_seen"` | |
| 11 | // Scope is what the runner asked for: comma-joined owner/name, "" | |
| 12 | // for any. admin runners replaces it with the attachments for a | |
| 13 | // runner key. | |
| 14 | Scope string `json:"scope,omitempty"` | |
| 8 | 15 | // The build it holds, if any. |
| 9 | 16 | BuildRepo string `json:"build_repo,omitempty"` |
| 10 | 17 | BuildNumber int64 `json:"build_number,omitempty"` |
| @@ -12,32 +19,147 @@ type Runner struct { | ||
| 12 | 19 | StartedAt string `json:"started_at,omitempty"` |
| 13 | 20 | } |
| 14 | 21 | |
| 15 | // TouchRunner records a poll: the time, the scope the runner asked for, | |
| 16 | // and the build it just claimed (0 for none). | |
| 17 | func (s *Store) TouchRunner(userID int64, scope string, buildID int64) error { | |
| 18 | _, err := s.DB.Exec(`INSERT INTO runner_seen (user_id, last_seen, scope, build_id) | |
| 19 | VALUES (?1, strftime('%Y-%m-%dT%H:%M:%fZ','now'), ?2, NULLIF(?3, 0)) | |
| 20 | ON CONFLICT (user_id) DO UPDATE SET | |
| 22 | // RepoRunner is one key attached to a repository, as repo runner list | |
| 23 | // shows it. | |
| 24 | type RepoRunner struct { | |
| 25 | Fingerprint string `json:"fingerprint"` | |
| 26 | Algo string `json:"algo"` | |
| 27 | Username string `json:"username"` | |
| 28 | AddedAt string `json:"added_at"` | |
| 29 | LastSeen string `json:"last_seen,omitempty"` | |
| 30 | BuildRepo string `json:"build_repo,omitempty"` | |
| 31 | BuildNumber int64 `json:"build_number,omitempty"` | |
| 32 | BuildJob string `json:"build_job,omitempty"` | |
| 33 | StartedAt string `json:"started_at,omitempty"` | |
| 34 | } | |
| 35 | ||
| 36 | // AttachRunner lets a key claim a repository's builds. Attaching twice is | |
| 37 | // one row. | |
| 38 | func (s *Store) AttachRunner(keyID, repoID int64) error { | |
| 39 | _, err := s.DB.Exec("INSERT OR IGNORE INTO runner_repos (key_id, repo_id) VALUES (?, ?)", keyID, repoID) | |
| 40 | return err | |
| 41 | } | |
| 42 | ||
| 43 | // DetachRunner removes one attachment by fingerprint. The key itself stays. | |
| 44 | func (s *Store) DetachRunner(repoID int64, fingerprint string) error { | |
| 45 | res, err := s.DB.Exec(`DELETE FROM runner_repos WHERE repo_id = ? | |
| 46 | AND key_id = (SELECT id FROM ssh_keys WHERE fingerprint = ?)`, repoID, fingerprint) | |
| 47 | if err != nil { | |
| 48 | return err | |
| 49 | } | |
| 50 | if n, _ := res.RowsAffected(); n == 0 { | |
| 51 | return ErrNotFound | |
| 52 | } | |
| 53 | return nil | |
| 54 | } | |
| 55 | ||
| 56 | // RunnerRepoIDs is every repository a key is attached to. | |
| 57 | func (s *Store) RunnerRepoIDs(keyID int64) ([]int64, error) { | |
| 58 | rows, err := s.DB.Query("SELECT repo_id FROM runner_repos WHERE key_id = ? ORDER BY repo_id", keyID) | |
| 59 | if err != nil { | |
| 60 | return nil, err | |
| 61 | } | |
| 62 | defer rows.Close() | |
| 63 | var ids []int64 | |
| 64 | for rows.Next() { | |
| 65 | var id int64 | |
| 66 | if err := rows.Scan(&id); err != nil { | |
| 67 | return nil, err | |
| 68 | } | |
| 69 | ids = append(ids, id) | |
| 70 | } | |
| 71 | return ids, rows.Err() | |
| 72 | } | |
| 73 | ||
| 74 | // RunnerRepoPaths is RunnerRepoIDs as owner/name, sorted. | |
| 75 | func (s *Store) RunnerRepoPaths(keyID int64) ([]string, error) { | |
| 76 | rows, err := s.DB.Query(`SELECT COALESCE(u.username, o.name) || '/' || r.name | |
| 77 | FROM runner_repos rr JOIN repos r ON r.id = rr.repo_id | |
| 78 | LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id | |
| 79 | LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id | |
| 80 | WHERE rr.key_id = ?`, keyID) | |
| 81 | if err != nil { | |
| 82 | return nil, err | |
| 83 | } | |
| 84 | defer rows.Close() | |
| 85 | var paths []string | |
| 86 | for rows.Next() { | |
| 87 | var p string | |
| 88 | if err := rows.Scan(&p); err != nil { | |
| 89 | return nil, err | |
| 90 | } | |
| 91 | paths = append(paths, p) | |
| 92 | } | |
| 93 | sort.Strings(paths) | |
| 94 | return paths, rows.Err() | |
| 95 | } | |
| 96 | ||
| 97 | // RunnerAttached reports whether a key may claim a repository's builds. | |
| 98 | func (s *Store) RunnerAttached(keyID, repoID int64) (bool, error) { | |
| 99 | var n int | |
| 100 | err := s.DB.QueryRow("SELECT count(*) FROM runner_repos WHERE key_id = ? AND repo_id = ?", keyID, repoID).Scan(&n) | |
| 101 | return n > 0, err | |
| 102 | } | |
| 103 | ||
| 104 | // ListRepoRunners is every key attached to a repository with its last | |
| 105 | // poll and the build it holds, oldest attachment first. | |
| 106 | func (s *Store) ListRepoRunners(repoID int64) ([]RepoRunner, error) { | |
| 107 | rows, err := s.DB.Query(`SELECT k.fingerprint, k.algo, u.username, rr.added_at, | |
| 108 | COALESCE(rs.last_seen, ''), | |
| 109 | COALESCE(COALESCE(bu.username, bo.name) || '/' || br.name, ''), | |
| 110 | COALESCE(b.number, 0), COALESCE(b.job, ''), COALESCE(b.started_at, '') | |
| 111 | FROM runner_repos rr | |
| 112 | JOIN ssh_keys k ON k.id = rr.key_id | |
| 113 | JOIN users u ON u.id = k.user_id | |
| 114 | LEFT JOIN runner_seen rs ON rs.key_id = rr.key_id | |
| 115 | LEFT JOIN builds b ON b.id = rs.build_id AND b.status = 'running' | |
| 116 | LEFT JOIN repos br ON br.id = b.repo_id | |
| 117 | LEFT JOIN users bu ON br.owner_kind = 'user' AND bu.id = br.owner_id | |
| 118 | LEFT JOIN orgs bo ON br.owner_kind = 'org' AND bo.id = br.owner_id | |
| 119 | WHERE rr.repo_id = ? ORDER BY rr.added_at, k.id`, repoID) | |
| 120 | if err != nil { | |
| 121 | return nil, err | |
| 122 | } | |
| 123 | defer rows.Close() | |
| 124 | var out []RepoRunner | |
| 125 | for rows.Next() { | |
| 126 | var r RepoRunner | |
| 127 | if err := rows.Scan(&r.Fingerprint, &r.Algo, &r.Username, &r.AddedAt, &r.LastSeen, | |
| 128 | &r.BuildRepo, &r.BuildNumber, &r.BuildJob, &r.StartedAt); err != nil { | |
| 129 | return nil, err | |
| 130 | } | |
| 131 | out = append(out, r) | |
| 132 | } | |
| 133 | return out, rows.Err() | |
| 134 | } | |
| 135 | ||
| 136 | // TouchRunner records a poll by one key: the time, the scope the runner | |
| 137 | // asked for, and the build it just claimed (0 for none). | |
| 138 | func (s *Store) TouchRunner(keyID, userID int64, scope string, buildID int64) error { | |
| 139 | _, err := s.DB.Exec(`INSERT INTO runner_seen (key_id, user_id, last_seen, scope, build_id) | |
| 140 | VALUES (?1, ?2, strftime('%Y-%m-%dT%H:%M:%fZ','now'), ?3, NULLIF(?4, 0)) | |
| 141 | ON CONFLICT (key_id) DO UPDATE SET | |
| 21 | 142 | last_seen = excluded.last_seen, scope = excluded.scope, |
| 22 | 143 | build_id = COALESCE(excluded.build_id, runner_seen.build_id)`, |
| 23 | userID, scope, buildID) | |
| 144 | keyID, userID, scope, buildID) | |
| 24 | 145 | return err |
| 25 | 146 | } |
| 26 | 147 | |
| 27 | // RunnerDone records that the runner reported and holds nothing now. | |
| 28 | func (s *Store) RunnerDone(userID int64) error { | |
| 148 | // RunnerDone records that the key reported and holds nothing now. | |
| 149 | func (s *Store) RunnerDone(keyID int64) error { | |
| 29 | 150 | _, err := s.DB.Exec(`UPDATE runner_seen SET last_seen = strftime('%Y-%m-%dT%H:%M:%fZ','now'), |
| 30 | build_id = NULL WHERE user_id = ?`, userID) | |
| 151 | build_id = NULL WHERE key_id = ?`, keyID) | |
| 31 | 152 | return err |
| 32 | 153 | } |
| 33 | 154 | |
| 34 | // ListRunners lists every account that has ever polled as a runner, | |
| 35 | // most recently seen first. | |
| 155 | // ListRunners lists every key that has ever polled as a runner, most | |
| 156 | // recently seen first. | |
| 36 | 157 | func (s *Store) ListRunners() ([]Runner, error) { |
| 37 | rows, err := s.DB.Query(`SELECT u.username, r.last_seen, r.scope, | |
| 158 | rows, err := s.DB.Query(`SELECT u.username, k.fingerprint, k.id, r.last_seen, r.scope, | |
| 38 | 159 | COALESCE(COALESCE(bu.username, bo.name) || '/' || br.name, ''), |
| 39 | 160 | COALESCE(b.number, 0), COALESCE(b.job, ''), COALESCE(b.started_at, '') |
| 40 | 161 | FROM runner_seen r JOIN users u ON u.id = r.user_id |
| 162 | JOIN ssh_keys k ON k.id = r.key_id | |
| 41 | 163 | LEFT JOIN builds b ON b.id = r.build_id AND b.status = 'running' |
| 42 | 164 | LEFT JOIN repos br ON br.id = b.repo_id |
| 43 | 165 | LEFT JOIN users bu ON br.owner_kind = 'user' AND bu.id = br.owner_id |
| @@ -50,7 +172,8 @@ func (s *Store) ListRunners() ([]Runner, error) { | ||
| 50 | 172 | var out []Runner |
| 51 | 173 | for rows.Next() { |
| 52 | 174 | var r Runner |
| 53 | if err := rows.Scan(&r.Username, &r.LastSeen, &r.Scope, &r.BuildRepo, &r.BuildNumber, &r.BuildJob, &r.StartedAt); err != nil { | |
| 175 | if err := rows.Scan(&r.Username, &r.Fingerprint, &r.KeyID, &r.LastSeen, &r.Scope, | |
| 176 | &r.BuildRepo, &r.BuildNumber, &r.BuildJob, &r.StartedAt); err != nil { | |
| 54 | 177 | return nil, err |
| 55 | 178 | } |
| 56 | 179 | out = append(out, r) |
internal/store/runners_test.go added +148
| @@ -0,0 +1,148 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import ( | |
| 4 | "errors" | |
| 5 | "testing" | |
| 6 | ) | |
| 7 | ||
| 8 | // runnerFixture is one user with a runner key and two repositories. | |
| 9 | func runnerFixture(t *testing.T) (s *Store, uid, keyID, repoA, repoB int64) { | |
| 10 | t.Helper() | |
| 11 | s = open(t) | |
| 12 | if err := s.MigrateUp(); err != nil { | |
| 13 | t.Fatal(err) | |
| 14 | } | |
| 15 | uid, err := s.CreateUser("alice", false) | |
| 16 | if err != nil { | |
| 17 | t.Fatal(err) | |
| 18 | } | |
| 19 | if err := s.AddSSHKey(uid, "SHA256:runnerkey", "ssh-ed25519", []byte("blob"), "runner"); err != nil { | |
| 20 | t.Fatal(err) | |
| 21 | } | |
| 22 | k, err := s.SSHKeyByFingerprint("SHA256:runnerkey") | |
| 23 | if err != nil { | |
| 24 | t.Fatal(err) | |
| 25 | } | |
| 26 | repoA, err = s.CreateRepo("user", uid, "a", "public") | |
| 27 | if err != nil { | |
| 28 | t.Fatal(err) | |
| 29 | } | |
| 30 | repoB, err = s.CreateRepo("user", uid, "b", "public") | |
| 31 | if err != nil { | |
| 32 | t.Fatal(err) | |
| 33 | } | |
| 34 | return s, uid, k.ID, repoA, repoB | |
| 35 | } | |
| 36 | ||
| 37 | // Attaching twice is one row; detaching what is not attached is not found. | |
| 38 | func TestAttachRunnerIdempotentAndDetach(t *testing.T) { | |
| 39 | s, _, keyID, repoA, repoB := runnerFixture(t) | |
| 40 | for range 2 { | |
| 41 | if err := s.AttachRunner(keyID, repoA); err != nil { | |
| 42 | t.Fatal(err) | |
| 43 | } | |
| 44 | } | |
| 45 | ids, err := s.RunnerRepoIDs(keyID) | |
| 46 | if err != nil || len(ids) != 1 || ids[0] != repoA { | |
| 47 | t.Fatalf("attached repos %v err=%v, want [%d]", ids, err, repoA) | |
| 48 | } | |
| 49 | if ok, _ := s.RunnerAttached(keyID, repoB); ok { | |
| 50 | t.Fatal("attached to a repo it was never attached to") | |
| 51 | } | |
| 52 | if err := s.DetachRunner(repoB, "SHA256:runnerkey"); !errors.Is(err, ErrNotFound) { | |
| 53 | t.Fatalf("detach of an unattached repo: %v, want ErrNotFound", err) | |
| 54 | } | |
| 55 | if err := s.DetachRunner(repoA, "SHA256:runnerkey"); err != nil { | |
| 56 | t.Fatal(err) | |
| 57 | } | |
| 58 | if ok, _ := s.RunnerAttached(keyID, repoA); ok { | |
| 59 | t.Fatal("still attached after detach") | |
| 60 | } | |
| 61 | } | |
| 62 | ||
| 63 | // Removing the key or the repository removes the attachment with it. | |
| 64 | func TestRunnerAttachmentCascades(t *testing.T) { | |
| 65 | s, uid, keyID, repoA, repoB := runnerFixture(t) | |
| 66 | if err := s.AttachRunner(keyID, repoA); err != nil { | |
| 67 | t.Fatal(err) | |
| 68 | } | |
| 69 | if err := s.AttachRunner(keyID, repoB); err != nil { | |
| 70 | t.Fatal(err) | |
| 71 | } | |
| 72 | if _, err := s.DB.Exec("DELETE FROM repos WHERE id = ?", repoB); err != nil { | |
| 73 | t.Fatal(err) | |
| 74 | } | |
| 75 | if ids, _ := s.RunnerRepoIDs(keyID); len(ids) != 1 { | |
| 76 | t.Fatalf("after repo delete: %v, want one attachment", ids) | |
| 77 | } | |
| 78 | if err := s.RemoveSSHKey(uid, "SHA256:runnerkey"); err != nil { | |
| 79 | t.Fatal(err) | |
| 80 | } | |
| 81 | var n int | |
| 82 | if err := s.DB.QueryRow("SELECT count(*) FROM runner_repos").Scan(&n); err != nil || n != 0 { | |
| 83 | t.Fatalf("after key delete: %d rows err=%v, want 0", n, err) | |
| 84 | } | |
| 85 | } | |
| 86 | ||
| 87 | // The heartbeat is per key: two keys on one account are two rows, and a | |
| 88 | // repository's runner list shows each key's last poll and the build it holds. | |
| 89 | func TestRunnerSeenPerKeyAndRepoList(t *testing.T) { | |
| 90 | s, uid, keyID, repoA, _ := runnerFixture(t) | |
| 91 | if err := s.AddSSHKey(uid, "SHA256:second", "ssh-ed25519", []byte("blob2"), "runner"); err != nil { | |
| 92 | t.Fatal(err) | |
| 93 | } | |
| 94 | k2, _ := s.SSHKeyByFingerprint("SHA256:second") | |
| 95 | for _, id := range []int64{keyID, k2.ID} { | |
| 96 | if err := s.AttachRunner(id, repoA); err != nil { | |
| 97 | t.Fatal(err) | |
| 98 | } | |
| 99 | } | |
| 100 | if _, err := s.CreateBuild(repoA, "unit", "abc123", "main", `["true"]`, "", "", true); err != nil { | |
| 101 | t.Fatal(err) | |
| 102 | } | |
| 103 | b, ok, err := s.ClaimBuild(nil, false) | |
| 104 | if err != nil || !ok { | |
| 105 | t.Fatalf("claim: %v ok=%v", err, ok) | |
| 106 | } | |
| 107 | if err := s.TouchRunner(keyID, uid, "", b.ID); err != nil { | |
| 108 | t.Fatal(err) | |
| 109 | } | |
| 110 | if err := s.TouchRunner(k2.ID, uid, "", 0); err != nil { | |
| 111 | t.Fatal(err) | |
| 112 | } | |
| 113 | runners, err := s.ListRunners() | |
| 114 | if err != nil || len(runners) != 2 { | |
| 115 | t.Fatalf("ListRunners: %v err=%v, want two rows", runners, err) | |
| 116 | } | |
| 117 | list, err := s.ListRepoRunners(repoA) | |
| 118 | if err != nil || len(list) != 2 { | |
| 119 | t.Fatalf("ListRepoRunners: %v err=%v, want two rows", list, err) | |
| 120 | } | |
| 121 | var held, idle int | |
| 122 | for _, r := range list { | |
| 123 | if r.Username != "alice" || r.LastSeen == "" || r.AddedAt == "" { | |
| 124 | t.Fatalf("row %+v lacks username, last_seen or added_at", r) | |
| 125 | } | |
| 126 | if r.BuildNumber == b.Number && r.BuildJob == "unit" && r.BuildRepo == "alice/a" { | |
| 127 | held++ | |
| 128 | } else if r.BuildNumber == 0 { | |
| 129 | idle++ | |
| 130 | } | |
| 131 | } | |
| 132 | if held != 1 || idle != 1 { | |
| 133 | t.Fatalf("held=%d idle=%d, want 1 and 1: %+v", held, idle, list) | |
| 134 | } | |
| 135 | if err := s.RunnerDone(keyID); err != nil { | |
| 136 | t.Fatal(err) | |
| 137 | } | |
| 138 | list, _ = s.ListRepoRunners(repoA) | |
| 139 | for _, r := range list { | |
| 140 | if r.BuildNumber != 0 { | |
| 141 | t.Fatalf("build still held after RunnerDone: %+v", r) | |
| 142 | } | |
| 143 | } | |
| 144 | paths, err := s.RunnerRepoPaths(keyID) | |
| 145 | if err != nil || len(paths) != 1 || paths[0] != "alice/a" { | |
| 146 | t.Fatalf("RunnerRepoPaths: %v err=%v", paths, err) | |
| 147 | } | |
| 148 | } | |
internal/web/templates/settings.html +20
| @@ -157,6 +157,26 @@ depends on.</p> | ||
| 157 | 157 | {{else}}<p class="none">Nothing behind{{if not .Deps.LastCheck}} — the first check has not run yet{{end}}.</p>{{end}} |
| 158 | 158 | {{end}} |
| 159 | 159 | |
| 160 | <h2>Runners</h2> | |
| 161 | {{if .Runners}} | |
| 162 | <ul class="protlist"> | |
| 163 | {{range .Runners}}<li><code>{{.Fingerprint}}</code> <span class="meta">{{.Username}}{{if .LastSeen}}, last poll {{.LastSeen}}{{else}}, never polled{{end}}{{if .BuildNumber}}, running {{.BuildRepo}} #{{.BuildNumber}} {{.BuildJob}}{{end}}</span> | |
| 164 | <form method="post" action="{{$base}}" class="inline"> | |
| 165 | <input type="hidden" name="field" value="runner-remove"> | |
| 166 | <input type="hidden" name="fingerprint" value="{{.Fingerprint}}"> | |
| 167 | <button type="submit" class="linklike">Detach</button> | |
| 168 | </form></li> | |
| 169 | {{end}} | |
| 170 | </ul> | |
| 171 | {{else}}<p class="meta">No runners attached. Builds for this repository run on the runners attached here; a repository with none queues builds nothing claims.</p>{{end}} | |
| 172 | <form method="post" action="{{$base}}" class="setform"> | |
| 173 | <input type="hidden" name="field" value="runner-add"> | |
| 174 | <label for="runner-key">Attach a runner</label> | |
| 175 | <textarea id="runner-key" name="key" rows="3" placeholder="ssh-ed25519 AAAA… (from gitbay-runner init)"></textarea> | |
| 176 | <button type="submit">Attach</button> | |
| 177 | </form> | |
| 178 | <p class="meta">Install <code>gitbay-runner</code>, run <code>gitbay-runner init</code>, and paste the key it prints. The runner builds your commits with the repository's secrets; merge requests from forks wait unless it runs with <code>-untrusted</code>.</p> | |
| 179 | ||
| 160 | 180 | <h2>Lifecycle</h2> |
| 161 | 181 | <form method="post" action="{{$base}}" class="setform"> |
| 162 | 182 | <input type="hidden" name="field" value="archive"> |