Commit 972f8f6350

972f8f635099f72e6d75d3da08a1d16d15d2d332

parent: 5bce95a7da

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-09 03:05 UTC

runner: config.toml, -identity, -untrusted

The e2e runner helper takes extra flags so the fork-head test runs with
-untrusted.

Ref #184
cmd/gitbay-runner/config.go added +97
@@ -0,0 +1,97 @@
1package main
2
3import (
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
18func 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
25func 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.
29func 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.
48var 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.
55func 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.
74func 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.
89func identityOpts(path string) []string {
90 if path == "" {
91 return nil
92 }
93 return []string{"-i", path, "-o", "IdentitiesOnly=yes"}
94}
95
96// runInit is a stub; Task 7 replaces it.
97func runInit(args []string) int { fmt.Fprintln(os.Stderr, "init: not implemented"); return 2 }
cmd/gitbay-runner/config_test.go added +84
@@ -0,0 +1,84 @@
1package main
2
3import (
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.
11func 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
47func 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
64func 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
76func 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/main.go +46 −15
@@ -62,25 +62,42 @@ type runner struct {
6262 // means any, which is what a runner on the server itself wants; a runner
6363 // somewhere that should not execute every repository's steps names them.
6464 repos []string
65 // untrusted also claims merge request heads from forks.
66 untrusted bool
6567}
6668
6769func main() {
70 if len(os.Args) > 1 && os.Args[1] == "init" {
71 os.Exit(runInit(os.Args[2:]))
72 }
6873 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")
74 configPath = flag.String("config", defaultConfigPath(), "config file; keys are these flag names, flags override it")
75 identity = flag.String("identity", "", "ssh private key to poll and clone with (default: the key gitbay-runner init generated, if present)")
76 untrusted = flag.Bool("untrusted", false, "also claim untrusted builds: merge request heads from forks (needs -isolation podman to be safe)")
77 remote = flag.String("remote", "git@gitbay.org", "ssh destination of the gitbay server")
78 sshOpts = flag.String("ssh-opts", "", "extra ssh options, space-separated (also used for git clone)")
79 cloneBase = flag.String("clone-base", "", "clone URL prefix (default ssh://<remote>)")
80 workdir = flag.String("workdir", defaultWorkdir(), "build workspace root")
81 poll = flag.Duration("poll", 5*time.Second, "idle poll interval")
82 timeout = flag.Duration("timeout", 30*time.Minute, "per-build time limit")
83 repos = flag.String("repos", "", "only claim builds for these repositories, comma-separated owner/name (default: any)")
84 once = flag.Bool("once", false, "process at most one build, then exit")
85 jobs = flag.Int("jobs", 1, "builds to run at once")
86 image = flag.String("image", "", "default container image for jobs that name none")
87 isolation = flag.String("isolation", "podman", "how steps run: podman, or none for no container")
88 memory = flag.String("memory", "", "memory limit per build, e.g. 4g (podman only, needs a delegated cgroup; default unlimited)")
89 cpus = flag.String("cpus", "", "CPU limit per build, e.g. 2 (podman only, needs a delegated cgroup; default unlimited)")
90 version = flag.Bool("version", false, "print the commit this binary was built from, then exit")
8391 )
92 path := configPathFromArgs(os.Args[1:], *configPath)
93 if values, found, err := loadConfig(path); err != nil {
94 log.Fatal(err)
95 } else if found {
96 if err := applyConfig(flag.CommandLine, values); err != nil {
97 log.Fatal(err)
98 }
99 log.Printf("config: %s", path)
100 }
84101 flag.Parse()
85102 if *version {
86103 fmt.Println(buildinfo.String())
@@ -127,6 +144,13 @@ func main() {
127144 if *sshOpts != "" {
128145 r.sshOpts = strings.Fields(*sshOpts)
129146 }
147 if *identity == "" {
148 if p := filepath.Join(configDir(), "id_ed25519"); fileExists(p) {
149 *identity = p
150 }
151 }
152 r.sshOpts = append(identityOpts(*identity), r.sshOpts...)
153 r.untrusted = *untrusted
130154 for _, name := range strings.Split(*repos, ",") {
131155 if name = strings.TrimSpace(name); name != "" {
132156 r.repos = append(r.repos, name)
@@ -218,7 +242,12 @@ func (r *runner) serve(n int, once bool, poll time.Duration, stop <-chan struct{
218242// step claims and executes at most one build. ran reports whether there was
219243// one, so the caller knows when to idle.
220244func (r *runner) step() (bool, error) {
221 out, err := r.ssh(nil, append([]string{"runner", "next"}, append(r.repos, "--json")...)...)
245 args := []string{"runner", "next"}
246 if r.untrusted {
247 args = append(args, "--untrusted")
248 }
249 args = append(append(args, r.repos...), "--json")
250 out, err := r.ssh(nil, args...)
222251 if err != nil {
223252 return false, fmt.Errorf("claiming build: %w (%s)", err, out)
224253 }
@@ -468,6 +497,8 @@ func (r *runner) ssh(stdin io.Reader, args ...string) (string, error) {
468497 return out.String(), nil
469498}
470499
500func fileExists(p string) bool { _, err := os.Stat(p); return err == nil }
501
471502// defaultWorkdir picks a build workspace that another local user cannot
472503// have created first.
473504//
e2e/ci_test.go +5 −3
@@ -23,7 +23,7 @@ func buildRunner(t *testing.T) string {
2323}
2424
2525// runnerOnce processes at most one pending build with the given key.
26func (i *instance) runnerOnce(t *testing.T, key string) string {
26func (i *instance) runnerOnce(t *testing.T, key string, extra ...string) string {
2727 t.Helper()
2828 opts := fmt.Sprintf("-p %d -i %s -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=%s -o BatchMode=yes",
2929 i.port, key, filepath.Join(i.sshDir, "known_hosts"))
@@ -31,12 +31,14 @@ func (i *instance) runnerOnce(t *testing.T, key string) string {
3131 // cancellation, not the sandbox, and the suite must run on a machine
3232 // without podman. The isolation tests are in isolation_podman_test.go
3333 // and skip visibly when it is absent (#144).
34 cmd := exec.Command(i.runner, "-once",
34 args := []string{"-once",
3535 "-remote", "git@127.0.0.1",
3636 "-ssh-opts", opts,
3737 "-isolation", "none",
3838 "-clone-base", fmt.Sprintf("ssh://git@127.0.0.1:%d", i.port),
39 "-workdir", t.TempDir())
39 "-workdir", t.TempDir()}
40 args = append(args, extra...)
41 cmd := exec.Command(i.runner, args...)
4042 cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null")
4143 out, err := cmd.CombinedOutput()
4244 if err != nil {
e2e/mrbuilds_test.go +1 −1
@@ -112,7 +112,7 @@ func TestForkMRHeadIsBuilt(t *testing.T) {
112112 }
113113
114114 // 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")
116116 if !strings.Contains(log, "two") {
117117 t.Fatalf("runner did not run the second job:\n%s", log)
118118 }