diff --git a/modules/mesh-build-node/cmd/mesh-build-node/build.go b/modules/mesh-build-node/cmd/mesh-build-node/build.go index a5f9519a..65b0ea83 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/build.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/build.go @@ -63,6 +63,7 @@ func (m *Module) buildTool() StepTool { "workspace": map[string]any{"type": "string", "description": "the workspace id a fetch step answered"}, "module": map[string]any{"type": "string", "description": "the module's directory in the workspace, '.' when it is the root"}, "artifact": map[string]any{"type": "string", "description": "the name of the bundle in the module's module.json"}, + "publish": map[string]any{"type": "boolean", "description": "true for a merged trunk commit alone: the build writes the shared cache; without it (a candidate) the cache is read beneath a throwaway layer"}, }, "required": []string{"workspace", "artifact"}, }, @@ -75,6 +76,7 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) { workspace, _ := args["workspace"].(string) module, _ := args["module"].(string) name, _ := args["artifact"].(string) + publish, _ := args["publish"].(bool) root, dir, err := m.moduleDirOf(workspace, module) if err != nil { return nil, err @@ -112,14 +114,14 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) { tsc := []string{m.tsc(), "--module", "NodeNext", "--moduleResolution", "NodeNext", "--target", "ES2022", "--rootDir", ".", "--outDir", out} tsc = append(tsc, sourcesFor(a.Entrypoints, out, ".ts")...) - if why := ranFailed(m.runIn(ctx, dir, nil, "node", tsc...)); why != "" { + if why := ranFailed(m.runIn(ctx, root, publish, dir, nil, "node", tsc...)); why != "" { return nil, fmt.Errorf("tsc for %s: %s", a.Name, why) } launchers, err := writeLaunchers(filepath.Join(dir, out), a) if err != nil { return nil, err } - if err := m.bundle(ctx, dir, a, launchers); err != nil { + if err := m.bundle(ctx, root, publish, dir, a, launchers); err != nil { return nil, err } bundleDir := filepath.Join(dir, final) @@ -150,7 +152,7 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) { // bundle makes every entrypoint and every launcher ONE file, with esbuild (builder.go:1971-2039): an entrypoint is // bundled in place under its own name, a launcher keeps its name with the extension .mjs. -func (m *Module) bundle(ctx context.Context, dir string, a artifact, launchers map[string]string) error { +func (m *Module) bundle(ctx context.Context, root string, publish bool, dir string, a artifact, launchers map[string]string) error { out, final := Out(a.Name), Out(a.Name)+".bundled" if err := os.RemoveAll(filepath.Join(dir, final)); err != nil { return err @@ -177,7 +179,7 @@ func (m *Module) bundle(ctx context.Context, dir string, a artifact, launchers m return nil } args := append(append(append([]string{}, entries...), common...), extra...) - if why := ranFailed(m.runIn(ctx, dir, nil, m.esbuild(), args...)); why != "" { + if why := ranFailed(m.runIn(ctx, root, publish, dir, nil, m.esbuild(), args...)); why != "" { return fmt.Errorf("esbuild for %s: %s", a.Name, why) } return nil diff --git a/modules/mesh-build-node/cmd/mesh-build-node/check.go b/modules/mesh-build-node/cmd/mesh-build-node/check.go index 672a3539..02463848 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/check.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/check.go @@ -139,7 +139,7 @@ func (m *Module) test(ctx context.Context, args map[string]any) (any, error) { } if len(sources) > 0 { start := m.now() - r := m.runIn(ctx, dir, nil, "node", m.typeCheck(dir, sources)...) + r := m.runIn(ctx, root, false, dir, nil, "node", m.typeCheck(dir, sources)...) check = m.now().Sub(start) if err := ran("type-check", r); err != nil { return nil, err @@ -149,7 +149,7 @@ func (m *Module) test(ctx context.Context, args map[string]any) (any, error) { if hasScript && len(failed) == 0 { start := m.now() env := []string{"PATH=" + binDir(root) + ":" + pathOrDefault(), "npm_config_cache=" + filepath.Join(m.Toolchain.Paths.Cache, "npm")} - r := m.runIn(ctx, dir, env, "npm", scriptArgs(script)...) + r := m.runIn(ctx, root, false, dir, env, "npm", scriptArgs(script)...) run = m.now().Sub(start) if err := ran(script, r); err != nil { return nil, err diff --git a/modules/mesh-build-node/cmd/mesh-build-node/check_test.go b/modules/mesh-build-node/cmd/mesh-build-node/check_test.go index dc3e79fa..5a95a94a 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/check_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/check_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "slices" "strings" "testing" "time" @@ -122,8 +123,9 @@ func TestTheStepsCommandsGetAnEnvironmentOfTheirOwn(t *testing.T) { t.Errorf("%s was given %s", joined(c), e) } } - if len(c.env) < 3 || !strings.HasPrefix(c.env[0], "PATH=") || !strings.HasPrefix(c.env[1], "HOME=") { - t.Errorf("%s env = %v, want PATH, HOME and the locale", c.name, c.env) + // 1309: the HOME of a run step is its own, inside its /tmp, never the account's. + if len(c.env) < 3 || !strings.HasPrefix(c.env[0], "PATH=") || !slices.Contains(c.env, "HOME=/tmp/home") || !slices.Contains(c.env, "LC_ALL=C") { + t.Errorf("%s env = %v, want PATH, the step's own HOME and the locale", c.name, c.env) } } } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/harness_test.go b/modules/mesh-build-node/cmd/mesh-build-node/harness_test.go index da417017..b991462d 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/harness_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/harness_test.go @@ -27,7 +27,9 @@ type call struct { type fakeMachine struct { mu sync.Mutex calls []call - now time.Time + // shut are the commands that ran in bubblewrap, read back (isolate_test.go). + shut []shutOff + now time.Time // cost is how long a program takes, by the base name of its path; answer is what it prints and its status; // effect runs the program's effects on the disk. cost map[string]time.Duration @@ -74,7 +76,9 @@ func newMachine(t *testing.T) (*Module, *fakeMachine, string) { Toolchain: &Toolchain{Paths: Paths{Home: home, Cache: cache}, Registry: func() (Registry, error) { return Registry{URL: "https://git.novox.be/api/packages/novox/npm/"}, nil }}, - Work: work, Run: f.run, Clock: f.clock, Ctx: context.Background()} + Work: work, Clock: f.clock, Ctx: context.Background()} + // A run step's commands run in bubblewrap (1309); the fake reads each back and runs its command. + m.Run = throughBubblewrap(t, f.run, &f.shut) // The toolchain directory as npm ci leaves it, with no bin links. nm := filepath.Join(m.toolchainDir(), "node_modules") for _, p := range []string{"typescript/bin/tsc", "esbuild/bin/esbuild", "@esbuild/linux-x64/bin/esbuild", diff --git a/modules/mesh-build-node/cmd/mesh-build-node/isolate.go b/modules/mesh-build-node/cmd/mesh-build-node/isolate.go new file mode 100644 index 00000000..b6222da9 --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/isolate.go @@ -0,0 +1,110 @@ +package main + +// The run steps shut off (novox/hq ADR 0448; issue 1309, lead decision L2 of R4e): the type-check, the package's test +// script, tsc and esbuild run the change's code, so each runs through mesh-sdk's buildstep.Isolated, in bubblewrap: no +// network, none of the account (its home, its ssh key, its registry secret, its bus login, the runtime directory), the +// workspace read-write, the toolchain read-only, the shared npm cache read-only beneath a throwaway writable layer, or +// read-write for a trunk build alone (publish). The fetch step (git, npm ci of the module's own packages) holds the key +// and the network and runs none of the change's code, so it runs as before. + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/buildstep" + "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +// BubblewrapLeast is the oldest bubblewrap a run step is shut off with: --overlay-src and --tmp-overlay, which keep the +// shared cache unchanged by a candidate, came in 0.10. +const BubblewrapLeast = "0.10" + +// npmCache is the shared npm cache a package's test script reads. +func (m *Module) npmCache() string { return filepath.Join(m.Toolchain.Paths.Cache, "npm") } + +// isolation is how a run step in the workspace root is shut off. +func (m *Module) isolation(root string, write bool) buildstep.Isolation { + return buildstep.Isolation{ + Workspace: root, + ReadOnly: []string{m.toolchainDir()}, + Caches: []string{m.npmCache()}, + WriteCaches: write, + Env: []string{"PATH=" + pathOrDefault(), "LC_ALL=C"}, + } +} + +// stepRunner is the runner of a run step in the workspace root: buildstep.Isolated over m.Run, every command shut off; +// write is a trunk build's alone. +func (m *Module) stepRunner(root string, write bool) (forgekey.Runner, error) { + // bubblewrap binds only a directory that is there. + if err := os.MkdirAll(m.npmCache(), 0o700); err != nil { + return nil, fmt.Errorf("the shared cache %s: %w", m.npmCache(), err) + } + return buildstep.Isolated(m.Run, m.isolation(root, write)), nil +} + +// ProbeBubblewrap reads the installed bubblewrap's version (`bwrap --version`: "bubblewrap 0.13.0"). +func ProbeBubblewrap(ctx context.Context) (string, error) { + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + r := forgekey.ExecRunnerClean(0)(ctx, []string{"PATH=" + defaultPath}, buildstep.Bubblewrap, "--version") + if r.Status != 0 || r.Err != "" { + return "", fmt.Errorf("`%s --version` failed: %s", buildstep.Bubblewrap, firstLine(r.Err+"\n"+r.Stderr)) + } + return parseBubblewrapVersion(r.Stdout) +} + +var bubblewrapVersion = regexp.MustCompile(`^bubblewrap (\d+)\.(\d+)(\.\d+)?\s*$`) + +func parseBubblewrapVersion(out string) (string, error) { + f := bubblewrapVersion.FindStringSubmatch(strings.TrimSpace(out)) + if f == nil { + return "", fmt.Errorf("`%s --version` answered %q", buildstep.Bubblewrap, strings.TrimSpace(out)) + } + return f[1] + "." + f[2] + f[3], nil +} + +// bubblewrapProblem says why the installed bubblewrap cannot shut a run step off, or "". +func bubblewrapProblem(version string, err error) string { + if err != nil { + return "bubblewrap cannot be run, and every build and test step runs in it (ADR 0448): " + err.Error() + } + if !atLeast(version, BubblewrapLeast) { + return fmt.Sprintf("bubblewrap %s installed, and a build or test step needs %s or later (ADR 0448)", version, BubblewrapLeast) + } + return "" +} + +// atLeast says whether the dotted version v is least or later. +func atLeast(v, least string) bool { + a, b := strings.Split(v, "."), strings.Split(least, ".") + for i := range b { + var x int + if i < len(a) { + x, _ = strconv.Atoi(a[i]) + } + y, _ := strconv.Atoi(b[i]) + if x != y { + return x > y + } + } + return true +} + +// checkBubblewrap refuses a step when bubblewrap cannot shut it off; nil Bubblewrap probes nothing. +func (m *Module) checkBubblewrap(ctx context.Context) error { + if m.Bubblewrap == nil { + return nil + } + v, err := m.Bubblewrap(ctx) + if why := bubblewrapProblem(v, err); why != "" { + return &buildstep.Refusal{Text: why, Want: BubblewrapLeast + " or later", Have: v} + } + return nil +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/isolate_test.go b/modules/mesh-build-node/cmd/mesh-build-node/isolate_test.go new file mode 100644 index 00000000..7f3f7391 --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/isolate_test.go @@ -0,0 +1,178 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "slices" + "strings" + "sync" + "testing" + + "git.novox.be/novox/mesh-sdk/go/buildstep" + "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +// shutOff is one command a step ran in bubblewrap: what it bound read-write, read-only and beneath a throwaway layer, +// its bubblewrap flags, and the command. +type shutOff struct { + flags []string + binds, roBinds, overlays []string + env []string + command string +} + +// bwrapArity is how many words each bubblewrap option the SDK writes takes. +var bwrapArity = map[string]int{"--unshare-all": 0, "--die-with-parent": 0, "--new-session": 0, "--clearenv": 0, + "--proc": 1, "--dev": 1, "--tmpfs": 1, "--dir": 1, "--chdir": 1, "--overlay-src": 1, "--tmp-overlay": 1, + "--ro-bind": 2, "--ro-bind-try": 2, "--bind": 2, "--symlink": 2, "--setenv": 2} + +// throughBubblewrap reads a bubblewrap command back (recorded in seen) and hands its command to run with the environment +// bubblewrap sets (a name set twice keeps its last value, as bubblewrap's --setenv does); any other command goes to run +// as it is. +func throughBubblewrap(t *testing.T, run forgekey.Runner, seen *[]shutOff) forgekey.Runner { + t.Helper() + var mu sync.Mutex + return func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { + if name != buildstep.Bubblewrap { + return run(ctx, env, name, args...) + } + var s shutOff + var names []string + values := map[string]string{} + i := 0 + for ; i < len(args) && args[i] != "--"; i++ { + n, ok := bwrapArity[args[i]] + if !ok || i+n >= len(args) { + t.Errorf("bubblewrap was given %q, which the test does not know: %v", args[i], args) + return forgekey.Ran{Status: 1, Err: "unknown bubblewrap option"} + } + s.flags = append(s.flags, args[i]) + switch args[i] { + case "--bind": + s.binds = append(s.binds, args[i+1]) + case "--ro-bind", "--ro-bind-try": + s.roBinds = append(s.roBinds, args[i+1]) + case "--overlay-src": + s.overlays = append(s.overlays, args[i+1]) + case "--setenv": + if _, set := values[args[i+1]]; !set { + names = append(names, args[i+1]) + } + values[args[i+1]] = args[i+2] + } + i += n + } + if i+1 >= len(args) { + t.Errorf("bubblewrap was given no command: %v", args) + return forgekey.Ran{Status: 1, Err: "no command"} + } + for _, n := range names { + s.env = append(s.env, n+"="+values[n]) + } + s.command = args[i+1] + " " + strings.Join(args[i+2:], " ") + mu.Lock() + *seen = append(*seen, s) + mu.Unlock() + return run(ctx, s.env, args[i+1], args[i+2:]...) + } +} + +// checkShutOff fails the test unless s ran with no network and none of the account, the toolchain read-only, and the +// shared cache beneath a throwaway layer unless write. +func checkShutOff(t *testing.T, m *Module, root string, s shutOff, write bool) { + t.Helper() + for _, f := range []string{"--unshare-all", "--clearenv", "--die-with-parent"} { + if !slices.Contains(s.flags, f) { + t.Errorf("%s ran without %s", s.command, f) + } + } + home := m.Toolchain.Paths.Home + for _, b := range append(append([]string{}, s.binds...), s.roBinds...) { + if b == home || strings.HasPrefix(b, home+"/") || strings.HasPrefix(b, "/run") { + t.Errorf("%s was given %s: the account's home or the runtime directory", s.command, b) + } + } + for _, e := range s.env { + if e == "HOME="+home { + t.Errorf("%s was told the account's home", s.command) + } + } + if !slices.Contains(s.roBinds, m.toolchainDir()) || slices.Contains(s.binds, m.toolchainDir()) { + t.Errorf("%s was not given the toolchain read-only: %v %v", s.command, s.roBinds, s.binds) + } + c := m.npmCache() + bound, layered := slices.Contains(s.binds, c), slices.Contains(s.overlays, c) + if write != bound || write == layered { + t.Errorf("%s: the shared cache bound %v, layered %v; a trunk build (%v) alone writes it", s.command, bound, layered, write) + } + if !slices.Contains(s.binds, root) { + t.Errorf("%s was not given its workspace %s read-write: %v", s.command, root, s.binds) + } +} + +// 1309-S7: the type-check and the test script run shut off, the cache beneath a throwaway layer. +func TestTheTestStepRunsItsCommandsShutOff(t *testing.T) { + m, f, work := newMachine(t) + workspaceWith(t, work, map[string]string{"index.ts": `export const a = 1;`, "package.json": `{"scripts":{"test":"x"}}`}) + if _, err := m.test(context.Background(), testArgs("unit")); err != nil { + t.Fatal(err) + } + if len(f.shut) != 2 || len(f.calls) != 2 { + t.Fatalf("shut off %d of %d commands, want the type-check and the script: %v", len(f.shut), len(f.calls), f.calls) + } + for _, s := range f.shut { + checkShutOff(t, m, work+"/w1", s, false) + } +} + +// 1309-S8: tsc and esbuild of a candidate's build run shut off with the cache beneath a throwaway layer; a trunk build +// (publish) writes it. +func TestTheBuildStepRunsShutOffAndOnlyATrunkBuildWritesTheCache(t *testing.T) { + for _, publish := range []bool{false, true} { + m, f, work := builtMachine(t, "") + ws := "w1" + args := buildArgs() + if publish { + args["publish"] = true + } + if _, err := m.build(context.Background(), args); err != nil { + t.Fatalf("publish %v: %v", publish, err) + } + if len(f.shut) == 0 || len(f.shut) != len(f.calls) { + t.Fatalf("publish %v: shut off %d of %d commands", publish, len(f.shut), len(f.calls)) + } + for _, s := range f.shut { + checkShutOff(t, m, work+"/"+ws, s, publish) + } + } +} + +// 1309-S1: a step is refused, and the module is unhealthy, while bubblewrap is missing or older than 0.10. +func TestAnOldOrMissingBubblewrapRefusesTheStepsAndIsUnhealthy(t *testing.T) { + for _, c := range []struct { + version string + err error + ok bool + }{{"0.13.0", nil, true}, {"0.10", nil, true}, {"0.9.0", nil, false}, {"", errors.New("bwrap: not found"), false}} { + m, _, _ := newMachine(t) + m.Probe = func(context.Context) (string, error) { return "v22.20.0", nil } + m.Toolchains = func(string, any) (uint64, error) { return 1, nil } + m.Bubblewrap = func(context.Context) (string, error) { return c.version, c.err } + err := m.Check(context.Background()) + raw, _ := json.Marshal(m.Health(context.Background())) + if c.ok { + if err != nil || strings.Contains(string(raw), "unhealthy") { + t.Errorf("bubblewrap %q: refused %v, health %s", c.version, err, raw) + } + continue + } + var ref *buildstep.Refusal + if !errors.As(err, &ref) || !strings.Contains(err.Error(), "bubblewrap") { + t.Errorf("bubblewrap %q %v: Check answered %v", c.version, c.err, err) + } + if !strings.Contains(string(raw), "unhealthy") || !strings.Contains(string(raw), "bubblewrap") { + t.Errorf("bubblewrap %q %v: health %s", c.version, c.err, raw) + } + } +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/isolation_integration_test.go b/modules/mesh-build-node/cmd/mesh-build-node/isolation_integration_test.go new file mode 100644 index 00000000..095f16cf --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/isolation_integration_test.go @@ -0,0 +1,91 @@ +//go:build integration + +package main + +import ( + "context" + "encoding/json" + "net" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" +) + +// probeScript is a package's test script that tries what a change's code must not reach and writes what it saw. +const probeScript = `import fs from "node:fs"; +import dns from "node:dns/promises"; +import net from "node:net"; +const [key, login, port, sock, cache] = process.argv.slice(2); +const seen = {}; +const read = (p) => { try { fs.readFileSync(p); return true; } catch { return false; } }; +seen.key = read(key); +seen.login = read(login); +try { await dns.lookup("git.novox.be"); seen.resolved = true; } catch { seen.resolved = false; } +const reach = (opts) => new Promise((ok) => { const c = net.connect(opts); c.on("connect", () => { c.destroy(); ok(true); }); c.on("error", () => ok(false)); }); +seen.tcp = await reach({ host: "127.0.0.1", port: Number(port) }); +seen.socket = await reach({ path: sock }); +try { fs.writeFileSync(cache + "/written-by-a-candidate", "x"); seen.wroteCache = true; } catch { seen.wroteCache = false; } +fs.writeFileSync("seen.json", JSON.stringify(seen)); +` + +// 1309-S9 (ADR 0448's check): a package's test script, run by the test step, reads no key and no login of the account, +// resolves no name, reaches no socket of the machine (a TCP port on its loopback, a unix socket), and leaves the +// shared cache unchanged. +func TestARunStepReachesNoKeyLoginNameOrSocketAndLeavesTheCacheUnchanged(t *testing.T) { + if _, err := exec.LookPath("bwrap"); err != nil { + t.Skip("NOT RUN: no bubblewrap installed where this check runs; every build node has it (the module declares it)") + } + m, work := realMachine(t) + home := m.Toolchain.Paths.Home + key, login := filepath.Join(home, ".ssh", "mesh_ed25519"), filepath.Join(home, "broker") + write(t, key, "a key", 0o600) + write(t, login, "a login", 0o600) + tcp, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer tcp.Close() + sockDir, err := os.MkdirTemp("", "sock") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(sockDir) + sock := filepath.Join(sockDir, "s") + unix, err := net.Listen("unix", sock) + if err != nil { + t.Fatal(err) + } + defer unix.Close() + if err := os.MkdirAll(m.npmCache(), 0o700); err != nil { + t.Fatal(err) + } + port := strconv.Itoa(tcp.Addr().(*net.TCPAddr).Port) + dir := filepath.Join(work, "w1", "tree", "modules", "m") + write(t, filepath.Join(dir, "probe.mjs"), probeScript, 0o644) + write(t, filepath.Join(dir, "package.json"), `{"name":"m","type":"module","scripts":{"test":"node probe.mjs `+key+` `+login+` `+port+` `+sock+` `+m.npmCache()+`"}}`, 0o644) + out, err := m.test(context.Background(), map[string]any{"workspace": "w1", "module": "modules/m", "level": "unit"}) + if err != nil { + t.Fatal(err) + } + if r := out.(*TestResult); !r.Passed { + t.Fatalf("the probe did not run: %+v", r) + } + raw, err := os.ReadFile(filepath.Join(dir, "seen.json")) + if err != nil { + t.Fatalf("the probe wrote nothing: %v", err) + } + var seen map[string]bool + if err := json.Unmarshal(raw, &seen); err != nil { + t.Fatal(err) + } + for what, reached := range seen { + if reached && what != "wroteCache" { + t.Errorf("the run step reached %s: %s", what, raw) + } + } + if _, err := os.Stat(filepath.Join(m.npmCache(), "written-by-a-candidate")); err == nil { + t.Errorf("a candidate's step changed the shared cache") + } +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/main.go b/modules/mesh-build-node/cmd/mesh-build-node/main.go index 1d334cac..fcca5746 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/main.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/main.go @@ -51,6 +51,7 @@ func main() { Run: forgekey.ExecRunnerClean(0), Ctx: ctx, Say: logf, + Bubblewrap: ProbeBubblewrap, } // Outside a launch by the runtime — a build, a check — there is no node and no state to write. if m.Node != "" { diff --git a/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go b/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go index efc359e9..d523c627 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go @@ -66,7 +66,7 @@ func TestManifestIsValidAndDeclaresTheAccountTheCapabilityAndThePackages(t *test } } slices.Sort(packages) - if want := []string{"git", "openssh"}; !slices.Equal(packages, want) { + if want := []string{"bubblewrap", "git", "openssh"}; !slices.Equal(packages, want) { t.Errorf("packages = %v, want %v", packages, want) } } @@ -194,3 +194,15 @@ func TestTheToolchainFilesAreAnArchiveUnpackedIntoTheHome(t *testing.T) { t.Errorf("the module carries no toolchain/package-lock.json: %v", err) } } + +// 1309-S1: every run step runs in bubblewrap, so the module declares its package (the lead's decision: each build module +// that runs a step through it declares it), beside the user-namespaces capability bubblewrap needs. +func TestTheModuleDeclaresBubblewrapAndTheCapabilityItNeeds(t *testing.T) { + m := readManifest(t) + if r := resource(t, m, "bubblewrap"); r["type"] != "package" || r["package"] != "bubblewrap" { + t.Errorf("the bubblewrap resource is %v, want the package bubblewrap", r) + } + if !slices.Contains(m.Capabilities, "user-namespaces") { + t.Errorf("capabilities %v lack user-namespaces", m.Capabilities) + } +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/module.go b/modules/mesh-build-node/cmd/mesh-build-node/module.go index 1f9e60d0..f277560b 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/module.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/module.go @@ -48,6 +48,9 @@ type Module struct { // Ctx is the process's context, which a step runs under (the tool call's is cut at 30 s, the step's is not). Ctx context.Context Say func(format string, a ...any) + // Bubblewrap reads the installed bubblewrap's version (ProbeBubblewrap): a step is refused, and the module is + // unhealthy, while it is missing or older than BubblewrapLeast. Nil probes nothing. + Bubblewrap func(ctx context.Context) (string, error) mu sync.Mutex // guards Recipe install sync.Mutex // one install at a time @@ -87,7 +90,10 @@ func (m *Module) check(ctx context.Context) (string, error) { m.mu.Unlock() } installed, err := m.Probe(ctx) - return installed, m.guard(m.recipe(), installed, err).Check(ctx) + if err := m.guard(m.recipe(), installed, err).Check(ctx); err != nil { + return installed, err + } + return installed, m.checkBubblewrap(ctx) } // Prepare is Check and then the toolchain directory of the recipe in force: what every step runs behind. diff --git a/modules/mesh-build-node/cmd/mesh-build-node/workspace.go b/modules/mesh-build-node/cmd/mesh-build-node/workspace.go index 001dec70..c49f6cc7 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/workspace.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/workspace.go @@ -168,10 +168,16 @@ func copyFile(from, to string) error { return out.Close() } -// runIn runs a command with dir as its working directory (`env -C`), so it is the plain line the build agent runs from -// the module's root, with the same relative paths. The command's environment is baseEnv and extra. -func (m *Module) runIn(ctx context.Context, dir string, extra []string, name string, args ...string) forgekey.Ran { - return m.Run(ctx, append(m.baseEnv(), extra...), "env", append([]string{"-C", dir, name}, args...)...) +// runIn runs a command of a run step shut off (stepRunner) with dir as its working directory (`env -C`), so it is the +// plain line the build agent runs from the module's root, with the same relative paths. The command's environment is +// a PATH, a locale and extra: never the account's HOME (the step's is its own) nor this process's environment. root is +// the workspace; write is a trunk build's (publish), which alone writes the shared cache. +func (m *Module) runIn(ctx context.Context, root string, write bool, dir string, extra []string, name string, args ...string) forgekey.Ran { + run, err := m.stepRunner(root, write) + if err != nil { + return forgekey.Ran{Status: 1, Err: err.Error()} + } + return run(ctx, extra, "env", append([]string{"-C", dir, name}, args...)...) } // ranFailed says why a command did not pass, or "" when it did. diff --git a/modules/mesh-build-node/module.json b/modules/mesh-build-node/module.json index b92212aa..f0b078d1 100644 --- a/modules/mesh-build-node/module.json +++ b/modules/mesh-build-node/module.json @@ -139,6 +139,11 @@ "id": "openssh", "type": "package", "package": "openssh" + }, + { + "id": "bubblewrap", + "type": "package", + "package": "bubblewrap" } ], "build": {