cmd/gitbay-runner/isolate.go

76668f2f381dd69422df050a9921de7189b6eb70
gitbay/cmd/gitbay-runner/isolate.go history · blame · raw

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}