Commit 972f8f6350
Verified · cmc
cmd/gitbay-runner/config.go added +97
| @@ -0,0 +1,97 @@ | ||
| 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 | } | |
| 95 | ||
| 96 | // runInit is a stub; Task 7 replaces it. | |
| 97 | func 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 @@ | ||
| 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/main.go +46 −15
| @@ -62,25 +62,42 @@ type runner struct { | ||
| 62 | 62 | // means any, which is what a runner on the server itself wants; a runner |
| 63 | 63 | // somewhere that should not execute every repository's steps names them. |
| 64 | 64 | repos []string |
| 65 | // untrusted also claims merge request heads from forks. | |
| 66 | untrusted bool | |
| 65 | 67 | } |
| 66 | 68 | |
| 67 | 69 | func main() { |
| 70 | if len(os.Args) > 1 && os.Args[1] == "init" { | |
| 71 | os.Exit(runInit(os.Args[2:])) | |
| 72 | } | |
| 68 | 73 | 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") | |
| 83 | 91 | ) |
| 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 | } | |
| 84 | 101 | flag.Parse() |
| 85 | 102 | if *version { |
| 86 | 103 | fmt.Println(buildinfo.String()) |
| @@ -127,6 +144,13 @@ func main() { | ||
| 127 | 144 | if *sshOpts != "" { |
| 128 | 145 | r.sshOpts = strings.Fields(*sshOpts) |
| 129 | 146 | } |
| 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 | |
| 130 | 154 | for _, name := range strings.Split(*repos, ",") { |
| 131 | 155 | if name = strings.TrimSpace(name); name != "" { |
| 132 | 156 | r.repos = append(r.repos, name) |
| @@ -218,7 +242,12 @@ func (r *runner) serve(n int, once bool, poll time.Duration, stop <-chan struct{ | ||
| 218 | 242 | // step claims and executes at most one build. ran reports whether there was |
| 219 | 243 | // one, so the caller knows when to idle. |
| 220 | 244 | func (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...) | |
| 222 | 251 | if err != nil { |
| 223 | 252 | return false, fmt.Errorf("claiming build: %w (%s)", err, out) |
| 224 | 253 | } |
| @@ -468,6 +497,8 @@ func (r *runner) ssh(stdin io.Reader, args ...string) (string, error) { | ||
| 468 | 497 | return out.String(), nil |
| 469 | 498 | } |
| 470 | 499 | |
| 500 | func fileExists(p string) bool { _, err := os.Stat(p); return err == nil } | |
| 501 | ||
| 471 | 502 | // defaultWorkdir picks a build workspace that another local user cannot |
| 472 | 503 | // have created first. |
| 473 | 504 | // |
e2e/ci_test.go +5 −3
| @@ -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,12 +31,14 @@ 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()) | |
| 39 | "-workdir", t.TempDir()} | |
| 40 | args = append(args, extra...) | |
| 41 | cmd := exec.Command(i.runner, args...) | |
| 40 | 42 | cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null") |
| 41 | 43 | out, err := cmd.CombinedOutput() |
| 42 | 44 | if err != nil { |
e2e/mrbuilds_test.go +1 −1
| @@ -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 | } |