package buildstep import ( "context" "fmt" "os" "path" "path/filepath" "strings" "git.novox.be/novox/mesh-sdk/go/forgekey" ) // Bubblewrap is the program a run step runs in (novox/hq ADR 0448): a build module declares its package, which needs // unprivileged user namespaces (issue 1308). const Bubblewrap = "bwrap" // Isolation is how a run step's command is shut off (novox/hq ADR 0448; issue 1309, the lead's decision L2 of R4e: one // runner in the SDK for every build module). The fetch step holds the forge key and the network and runs none of the // change's code; the run step runs the change's build and tests in bubblewrap with: // // - no network: its own network namespace, with only a loopback, so a test may start a bus or a store of its own but // resolves no name and reaches no socket of the machine; // - none of the account: not its home, not the forge key, not a bus login, not the runtime directory (/run), since // none of them is bound; HOME is a directory in the step's own /tmp unless Env names one; // - an environment of exactly Env and the runner's env: nothing of the build module's own passes; // - the system's programs and libraries read-only, and of /etc only what a program needs to run (users, libraries, // certificates, time zone), with a hosts file naming only the loopback; // - the Workspace and Writable directories read-write, the ReadOnly ones (the toolchain, the modules the fetch step // downloaded) read-only; // - the shared warm Caches read-only under a throwaway writable layer (S18), or read-write for a trunk build alone // (WriteCaches), so a candidate's code never changes what a later build reads. type Isolation struct { Workspace string Writable []string ReadOnly []string Caches []string WriteCaches bool Env []string // Hosts is the file bound as /etc/hosts; Isolated writes one naming only the loopback. Hosts string } // etcNeeded is what of /etc a program in the step may read: who the users are, where the libraries are, the // certificates, the time zone. Not resolv.conf, not hosts: the step resolves no name. var etcNeeded = []string{"/etc/passwd", "/etc/group", "/etc/nsswitch.conf", "/etc/ld.so.cache", "/etc/ld.so.conf", "/etc/ld.so.conf.d", "/etc/ssl", "/etc/ca-certificates", "/etc/pki", "/etc/localtime", "/etc/os-release", "/etc/alternatives"} // systemArgs binds the system's programs and libraries read-only: /usr, and /bin, /sbin, /lib, /lib64 as the symlinks // they are on a merged-usr system, or bound when they are directories. A variable so a unit test fixes it. var systemArgs = func() []string { args := []string{"--ro-bind", "/usr", "/usr"} for _, d := range []string{"/bin", "/sbin", "/lib", "/lib64"} { info, err := os.Lstat(d) switch { case err != nil: case info.Mode()&os.ModeSymlink != 0: if target, err := os.Readlink(d); err == nil { args = append(args, "--symlink", target, d) } case info.IsDir(): args = append(args, "--ro-bind", d, d) } } for _, e := range etcNeeded { args = append(args, "--ro-bind-try", e, e) } return args } // stepHome is the HOME of a step that is given none: inside its own /tmp, gone with it. const stepHome = "/tmp/home" func checkDir(what, dir string) error { if dir == "" || !strings.HasPrefix(dir, "/") || path.Clean(dir) != dir || dir == "/" { return fmt.Errorf("buildstep: the %s %q is not an absolute, clean directory other than the root", what, dir) } return nil } // Command is the bubblewrap command line that runs name with args shut off as iso says, env added to iso.Env. It // refuses a directory that is not absolute and clean, or is the root, and an environment word without "=". func (iso Isolation) Command(name string, args ...string) ([]string, error) { return iso.command(nil, name, args...) } func (iso Isolation) command(env []string, name string, args ...string) ([]string, error) { if err := checkDir("workspace", iso.Workspace); err != nil { return nil, err } for _, group := range []struct { what string dirs []string }{{"writable directory", iso.Writable}, {"read-only directory", iso.ReadOnly}, {"cache", iso.Caches}} { for _, d := range group.dirs { if err := checkDir(group.what, d); err != nil { return nil, err } } } words := append(append([]string{}, iso.Env...), env...) home := false for _, w := range words { k, _, ok := strings.Cut(w, "=") if !ok || k == "" { return nil, fmt.Errorf("buildstep: the environment word %q is not NAME=value", w) } home = home || k == "HOME" } argv := []string{"--unshare-all", "--die-with-parent", "--new-session", "--clearenv", "--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--dir", stepHome} argv = append(argv, systemArgs()...) for _, d := range iso.ReadOnly { argv = append(argv, "--ro-bind", d, d) } argv = append(argv, cacheArgs(iso.Caches, iso.WriteCaches)...) argv = append(argv, "--bind", iso.Workspace, iso.Workspace) for _, d := range iso.Writable { argv = append(argv, "--bind", d, d) } if iso.Hosts != "" { argv = append(argv, "--ro-bind", iso.Hosts, "/etc/hosts") } for _, w := range words { k, v, _ := strings.Cut(w, "=") argv = append(argv, "--setenv", k, v) } if !home { argv = append(argv, "--setenv", "HOME", stepHome) } argv = append(argv, "--chdir", iso.Workspace, "--", name) return append(argv, args...), nil } // cacheArgs binds the shared warm caches (S18): for a trunk build read-write, so the next build is warm; for any other // read-only beneath an overlay whose writes go to a tmpfs of the step's own, so the step writes as a build does and // nothing it wrote outlives it (bubblewrap's --tmp-overlay, an overlay mount in the step's user namespace). func cacheArgs(caches []string, write bool) []string { var argv []string for _, c := range caches { if write { argv = append(argv, "--bind", c, c) continue } argv = append(argv, "--overlay-src", c, "--tmp-overlay", c) } return argv } // loopbackHosts is the hosts file of a step: the loopback and nothing else. const loopbackHosts = "127.0.0.1 localhost\n::1 localhost\n" // Isolated is run with every command shut off as iso says (Command), run's env going inside the step: the build modules' // run steps call their test, build and digest commands through it, never run itself. Each command gets a hosts file of // its own, naming only the loopback, removed when it ends. func Isolated(run forgekey.Runner, iso Isolation) forgekey.Runner { return func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { dir, err := os.MkdirTemp("", "step-hosts-") if err != nil { return forgekey.Ran{Status: 1, Err: "the step's hosts file: " + err.Error()} } defer os.RemoveAll(dir) hosts := filepath.Join(dir, "hosts") if err := os.WriteFile(hosts, []byte(loopbackHosts), 0o644); err != nil { return forgekey.Ran{Status: 1, Err: "the step's hosts file: " + err.Error()} } iso := iso iso.Hosts = hosts argv, err := iso.command(env, name, args...) if err != nil { return forgekey.Ran{Status: 1, Err: err.Error()} } return run(ctx, nil, Bubblewrap, argv...) } }