cmd/gitbay-runner/isolate.go
168 lines · 6164 bytes
1package main
2
3import (
4 "fmt"
5 "io"
6 "log"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strings"
11 "time"
12
13 "gitbay.org/gitbay/internal/toolpath"
14)
15
16// Isolation modes. podman runs a job's steps in a container; none runs
17// them on the host as the runner's user, which is what the runner did
18// before #144 and what a private instance may still choose.
19const (
20 isolationPodman = "podman"
21 isolationNone = "none"
22)
23
24// defaultImage is used when neither the job nor -image names one. Chosen
25// for being small and having a shell; anything a build actually needs it
26// declares with `image:`.
27const defaultImage = "docker.io/library/debian:stable-slim"
28
29// checkIsolation fails the runner at start-up rather than at the first
30// build, and refuses anything it does not recognise. There is no silent
31// fallback from podman to the host: dropping isolation without saying so
32// is the failure mode this whole change exists to prevent (#144).
33func (r *runner) checkIsolation() error {
34 switch r.isolation {
35 case isolationNone:
36 log.Printf("WARNING: -isolation none: build steps run on this host as %s, "+
37 "with no container. Only do this where every repository is trusted.", currentUser())
38 return nil
39 case isolationPodman:
40 bin := toolpath.Look("podman")
41 out, err := exec.Command(bin, "info", "--format", "{{.Host.Security.Rootless}}").CombinedOutput()
42 if err != nil {
43 return fmt.Errorf("podman is required by -isolation podman but does not work here: %v\n%s\n"+
44 "prepare the host with deploy/runner-podman-setup.sh, or pass -isolation none "+
45 "if every repository on this instance is trusted", err, strings.TrimSpace(string(out)))
46 }
47 if r.image == "" {
48 r.image = defaultImage
49 }
50 log.Printf("isolation: podman (rootless=%s), default image %s",
51 strings.TrimSpace(string(out)), r.image)
52 return nil
53 default:
54 return fmt.Errorf("unknown -isolation %q: podman or none", r.isolation)
55 }
56}
57
58// runSteps executes a job's steps and reports whether all succeeded. The
59// clone has already happened, outside any container and with the runner's
60// key: the container never sees GIT_SSH_COMMAND, the key, or the runner's
61// environment — it gets the workspace and nothing else.
62type stepRunner func(cmd *exec.Cmd, deadline time.Time) (bool, string)
63
64func (r *runner) runSteps(j job, dir string, env []string, sink io.Writer, deadline time.Time, runStep stepRunner) bool {
65 if r.isolation == isolationNone {
66 for _, step := range j.Steps {
67 fmt.Fprintf(sink, "$ %s\n", step)
68 cmd := exec.Command(toolpath.Look("sh"), "-c", step)
69 cmd.Dir, cmd.Env = dir, env
70 cmd.Stdout, cmd.Stderr = sink, sink
71 if ok, why := runStep(cmd, deadline); !ok {
72 fmt.Fprintf(sink, "%s\n", why)
73 return false
74 }
75 }
76 return true
77 }
78 return r.runStepsPodman(j, dir, env, sink, deadline, runStep)
79}
80
81// runStepsPodman starts one container for the whole job and runs each
82// step in it with `podman exec`. One container per job, not per step,
83// because steps share state — a build step writes what a test step reads
84// — and per-step containers would break that.
85func (r *runner) runStepsPodman(j job, dir string, env []string, sink io.Writer, deadline time.Time, runStep stepRunner) bool {
86 podman := toolpath.Look("podman")
87 image := j.Image
88 if image == "" {
89 image = r.image
90 }
91
92 // Secrets must not reach argv: /proc is world-readable, and this
93 // codebase keeps them on stdin or in files everywhere else. An env
94 // file outside the workspace holds them instead — outside because the
95 // workspace is bind mounted, and a file of secrets sitting in the
96 // checkout is one `cat` from a build's own log.
97 envFile := filepath.Join(r.workdir, fmt.Sprintf("env-%d", j.ID))
98 if err := writeEnvFile(envFile, env); err != nil {
99 fmt.Fprintf(sink, "preparing the build environment: %v\n", err)
100 return false
101 }
102 defer os.Remove(envFile)
103
104 name := fmt.Sprintf("gitbay-build-%d", j.ID)
105 // --rm so a container cannot outlive its build; the explicit rm below
106 // covers the case where the daemon-less run itself fails.
107 start := exec.Command(podman, "run", "--detach", "--rm",
108 "--name", name,
109 "--env-file", envFile,
110 "--volume", dir+":/workspace:rw",
111 "--workdir", "/workspace",
112 "--entrypoint", "sh",
113 image, "-c", "sleep infinity")
114 start.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + r.podmanHome()}
115 if out, err := start.CombinedOutput(); err != nil {
116 // A pull failure lands here. Fail the build with what podman
117 // said; do not retry and do not fall back to another image.
118 fmt.Fprintf(sink, "starting the build container from %s failed:\n%s\n", image, strings.TrimSpace(string(out)))
119 return false
120 }
121 defer exec.Command(podman, "rm", "--force", name).Run()
122
123 for _, step := range j.Steps {
124 fmt.Fprintf(sink, "$ %s\n", step)
125 cmd := exec.Command(podman, "exec", "--workdir", "/workspace", name, "sh", "-c", step)
126 cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + r.podmanHome()}
127 cmd.Stdout, cmd.Stderr = sink, sink
128 if ok, why := runStep(cmd, deadline); !ok {
129 fmt.Fprintf(sink, "%s\n", why)
130 return false
131 }
132 }
133 return true
134}
135
136// podmanHome is where podman keeps its own storage: the runner's home,
137// not a build's. The container store is the runner's business, and a
138// build never sees this path.
139func (r *runner) podmanHome() string {
140 if h, err := os.UserHomeDir(); err == nil && h != "" {
141 return h
142 }
143 return "/var/lib/gitbay-runner"
144}
145
146// writeEnvFile writes KEY=VALUE lines for podman --env-file, readable
147// only by this user. Values containing a newline are refused rather than
148// silently truncated: the format has no escape for one, and a secret that
149// half-arrives is worse than a failed build.
150func writeEnvFile(path string, env []string) error {
151 var b strings.Builder
152 for _, e := range env {
153 if strings.ContainsAny(e, "\n\r") {
154 name, _, _ := strings.Cut(e, "=")
155 return fmt.Errorf("%s contains a newline, which an env file cannot carry", name)
156 }
157 b.WriteString(e)
158 b.WriteByte('\n')
159 }
160 return os.WriteFile(path, []byte(b.String()), 0o600)
161}
162
163func currentUser() string {
164 if u := os.Getenv("USER"); u != "" {
165 return u
166 }
167 return fmt.Sprintf("uid %d", os.Getuid())
168}