Commit 82df8f9045

82df8f90454aad2cbefad57752369c3214ed367e

parent: d5e1f3119a

Verified · cmc

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

runner: init generates the key and config and prints the attach step

Ref #184
cmd/gitbay-runner/config.go −3
@@ -92,6 +92,3 @@ func identityOpts(path string) []string {
9292 }
9393 return []string{"-i", path, "-o", "IdentitiesOnly=yes"}
9494}
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/init.go added +89
@@ -0,0 +1,89 @@
1package main
2
3import (
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.
16var 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.
21func 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 @@
1package main
2
3import (
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.
14func 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.
65func 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}