cmd/gitbay-runner/env_test.go

bd49b87fce895e9f0a7588152548fb6e1821ac7d
gitbay/cmd/gitbay-runner/env_test.go history · blame · raw

246 lines · 9069 bytes

  1package main
  2
  3import (
  4	"os"
  5	"strings"
  6	"testing"
  7	"time"
  8)
  9
 10// A step's environment is constructed, not inherited: repository content
 11// must not see what the operator set on the runner service (#144).
 12func TestStepEnvDoesNotInherit(t *testing.T) {
 13	t.Setenv("GITBAY_RUNNER_TOKEN", "a-secret-the-service-was-given")
 14	t.Setenv("AWS_SECRET_ACCESS_KEY", "also-not-for-builds")
 15
 16	env := stepEnv(job{Repo: "alice/app", SHA: "abc", Ref: "main", Job: "test"}, "/tmp/buildhome", "git@x.test")
 17
 18	for _, e := range env {
 19		if strings.HasPrefix(e, "GITBAY_RUNNER_TOKEN=") || strings.HasPrefix(e, "AWS_SECRET_ACCESS_KEY=") {
 20			t.Errorf("the runner's own environment reached a build step: %q", e)
 21		}
 22	}
 23	want := map[string]string{
 24		"CI": "true", "GITBAY_REPO": "alice/app", "GITBAY_SHA": "abc",
 25		"GITBAY_REF": "main", "GITBAY_JOB": "test",
 26		// HOME is the shared build home, not the runner's own, so a
 27		// build cannot read the dotfiles where tools keep credentials —
 28		// and not the workspace, which is deleted after every build,
 29		// taking every tool cache with it.
 30		"HOME": "/tmp/buildhome",
 31	}
 32	got := map[string]string{}
 33	for _, e := range env {
 34		k, v, _ := strings.Cut(e, "=")
 35		got[k] = v
 36	}
 37	for k, v := range want {
 38		if got[k] != v {
 39			t.Errorf("%s = %q, want %q", k, got[k], v)
 40		}
 41	}
 42	if got["PATH"] == "" {
 43		t.Error("PATH is empty; a step could not find any tool")
 44	}
 45}
 46
 47// Secrets reach a trusted build's steps and never an untrusted one's,
 48// whatever the claim carried: the trust flag decides, not whether any
 49// secrets arrived (#255).
 50func TestStepEnvCarriesSecrets(t *testing.T) {
 51	secrets := map[string]string{"TOKEN": "s3cret"}
 52	env := stepEnv(job{Trusted: true, Secrets: secrets}, "/tmp/buildhome", "git@x.test")
 53	if !containsEnv(env, "TOKEN=s3cret") {
 54		t.Error("a trusted build's secret did not reach the step")
 55	}
 56	for _, j := range []job{{}, {Secrets: secrets}} {
 57		for _, e := range stepEnv(j, "/tmp/buildhome", "git@x.test") {
 58			if strings.HasPrefix(e, "TOKEN=") {
 59				t.Errorf("a secret reached an untrusted build: %q", e)
 60			}
 61		}
 62	}
 63}
 64
 65// PATH falls back rather than leaving a step unable to find anything.
 66func TestStepEnvPathFallback(t *testing.T) {
 67	old := os.Getenv("PATH")
 68	os.Unsetenv("PATH")
 69	defer os.Setenv("PATH", old)
 70	if env := stepEnv(job{}, "/tmp/buildhome", "git@x.test"); !containsEnv(env, "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin") {
 71		t.Errorf("no PATH fallback: %v", env)
 72	}
 73}
 74
 75func containsEnv(env []string, want string) bool {
 76	for _, e := range env {
 77		if e == want {
 78			return true
 79		}
 80	}
 81	return false
 82}
 83
 84// The build home must outlive a build. It was briefly the workspace,
 85// which run() removes when the build ends, so every build re-downloaded
 86// the Go module cache and the ~50MB sonar scanner.
 87func TestStepEnvHomeIsNotTheWorkspace(t *testing.T) {
 88	env := stepEnv(job{ID: 7}, "/var/lib/gitbay-runner/work/home", "git@x.test")
 89	for _, e := range env {
 90		if strings.HasPrefix(e, "HOME=") && strings.Contains(e, "build-7") {
 91			t.Errorf("HOME is the per-build workspace, which is deleted after the build: %q", e)
 92		}
 93	}
 94}
 95
 96// podman runs from a system service, where the systemd cgroup manager
 97// has no user slice to work in. Every invocation must say so, or crun
 98// fails creating the container's scope (#144).
 99func TestPodmanUsesCgroupfs(t *testing.T) {
100	r := &runner{}
101	got := r.podmanGlobal()
102	found := false
103	for _, f := range got {
104		if f == "--cgroup-manager=cgroupfs" {
105			found = true
106		}
107	}
108	if !found {
109		t.Errorf("podmanGlobal() = %v, missing the cgroupfs manager", got)
110	}
111}
112
113// The build home is where caches live, so the container must see it at
114// the path HOME names; otherwise every containerised build starts cold.
115func TestEnvHomeFindsHome(t *testing.T) {
116	if got := envHome([]string{"PATH=/bin", "HOME=/var/lib/gitbay-runner/work/home", "CI=true"}); got != "/var/lib/gitbay-runner/work/home" {
117		t.Errorf("envHome = %q", got)
118	}
119	if got := envHome([]string{"PATH=/bin"}); got != "" {
120		t.Errorf("envHome with no HOME = %q, want empty", got)
121	}
122}
123
124// Closing stop drains: the build in flight finishes and is reported, and
125// no further build is claimed (#179).
126func TestServeDrainsOnStop(t *testing.T) {
127	stop := make(chan struct{})
128	started := make(chan struct{})
129	release := make(chan struct{})
130	calls := 0
131	r := &runner{stepFn: func() (bool, error) {
132		calls++
133		if calls == 1 {
134			close(started)
135			<-release // the build is in flight while stop closes
136		}
137		return true, nil
138	}}
139	done := make(chan struct{})
140	go func() { r.serve(1, false, time.Millisecond, stop); close(done) }()
141	<-started
142	close(stop)
143	close(release)
144	select {
145	case <-done:
146	case <-time.After(2 * time.Second):
147		t.Fatal("serve did not return after the in-flight build finished")
148	}
149	if calls != 1 {
150		t.Errorf("claimed %d builds after stop, want the one already in flight", calls-1)
151	}
152}
153
154// An idle worker leaves promptly on stop rather than sleeping out a poll.
155func TestServeStopsWhileIdle(t *testing.T) {
156	stop := make(chan struct{})
157	r := &runner{stepFn: func() (bool, error) { return false, nil }}
158	done := make(chan struct{})
159	go func() { r.serve(1, false, time.Hour, stop); close(done) }()
160	time.Sleep(20 * time.Millisecond)
161	close(stop)
162	select {
163	case <-done:
164	case <-time.After(2 * time.Second):
165		t.Fatal("idle worker did not stop")
166	}
167}
168
169// A private key is a secret with newlines. An env file cannot carry one,
170// so such values reach the container through podman's own environment,
171// named on the command line and never valued there.
172func TestSplitEnvKeepsMultilineOutOfTheFile(t *testing.T) {
173	env := []string{"PATH=/bin", "KEY=-----BEGIN\nabc\n-----END", "TOKEN=s3cret", "CR=a\rb"}
174	file, inherit := splitEnv(env)
175	if len(file) != 2 || file[0] != "PATH=/bin" || file[1] != "TOKEN=s3cret" {
176		t.Fatalf("file env = %q", file)
177	}
178	if len(inherit) != 2 || inherit[0] != "KEY=-----BEGIN\nabc\n-----END" || inherit[1] != "CR=a\rb" {
179		t.Fatalf("inherited env = %q", inherit)
180	}
181	args := inheritArgs(inherit)
182	want := []string{"--env", "KEY", "--env", "CR"}
183	if strings.Join(args, " ") != strings.Join(want, " ") {
184		t.Fatalf("podman args = %q, want %q", args, want)
185	}
186	for _, a := range args {
187		if strings.Contains(a, "BEGIN") || strings.Contains(a, "a\rb") {
188			t.Fatalf("a secret's value reached argv: %q", a)
189		}
190	}
191}
192
193// A build that talks back to the instance needs an address that works
194// from where it runs. Under pasta a podman build holds the host's public
195// address, so a runner polling over loopback gives its podman builds
196// 169.254.1.2, pasta's address for the host, with the claim's port when
197// it is not 22, and never the loopback address it polls. Any other runner
198// passes on its own remote (#260).
199func TestStepEnvCarriesInstanceAddress(t *testing.T) {
200	env := stepEnv(job{}, "/tmp/buildhome", "git@gitbay.org")
201	if !containsEnv(env, "GITBAY_SSH=git@gitbay.org") {
202		t.Errorf("GITBAY_SSH missing: %q", env)
203	}
204	for _, tc := range []struct{ remote, isolation, public, want string }{
205		{"git@127.0.0.1", isolationPodman, "git@gitbay.org", "git@169.254.1.2"},
206		{"git@127.0.0.1", isolationPodman, "git@gitbay.test:22", "git@169.254.1.2"},
207		{"git@127.0.0.1", isolationPodman, "git@gitbay.test:2022", "git@169.254.1.2:2022"},
208		{"git@127.0.0.1", isolationPodman, "git@[2001:db8::1]:2022", "git@169.254.1.2:2022"},
209		{"git@127.0.0.1", isolationPodman, "git@2001:db8::1", "git@169.254.1.2"},
210		{"git@localhost", isolationPodman, "git@gitbay.org", "git@169.254.1.2"},
211		{"forge@::1", isolationPodman, "git@gitbay.org", "forge@169.254.1.2"},
212		{"127.0.0.1", isolationPodman, "git@gitbay.org", "git@169.254.1.2"},
213		{"git@127.0.0.1", isolationPodman, "", "git@169.254.1.2"},
214		{"git@127.0.0.1", isolationNone, "git@gitbay.org", "git@127.0.0.1"},
215		{"git@127.0.0.1", isolationNone, "", "git@127.0.0.1"},
216		{"git@gitbay.org", isolationPodman, "git@other.test", "git@gitbay.org"},
217		{"git@gitbay.org", isolationPodman, "", "git@gitbay.org"},
218		{"gitbay.org", isolationPodman, "", "gitbay.org"},
219		{"ci@forge.internal", isolationNone, "git@gitbay.org", "ci@forge.internal"},
220	} {
221		r := &runner{remote: tc.remote, isolation: tc.isolation}
222		if got := r.buildSSH(tc.public); got != tc.want {
223			t.Errorf("remote %s under %s, public %q: got %s want %s", tc.remote, tc.isolation, tc.public, got, tc.want)
224		}
225	}
226}
227
228// Only a runner that polls over loopback shares an address a build could
229// connect from, so only its builds state --no-map-gw rather than rely on
230// podman's default (#260).
231func TestBuildNetworkKeepsLoopbackRunnersBuildsOff(t *testing.T) {
232	for _, tc := range []struct {
233		remote string
234		want   []string
235	}{
236		{"git@127.0.0.1", []string{"--network", "pasta:--no-map-gw"}},
237		{"localhost", []string{"--network", "pasta:--no-map-gw"}},
238		{"git@::1", []string{"--network", "pasta:--no-map-gw"}},
239		{"git@gitbay.org", nil},
240	} {
241		r := &runner{remote: tc.remote, isolation: isolationPodman}
242		if got := r.buildNetwork(); strings.Join(got, " ") != strings.Join(tc.want, " ") {
243			t.Errorf("remote %s: %q, want %q", tc.remote, got, tc.want)
244		}
245	}
246}