mesh-build-node: run the type-check, the test script, tsc and esbuild in bubblewrap, so a change's code reaches no key, login or network (issue 1309, S1 S7 S8 S9, ADR 0448)

Each run step goes through mesh-sdk's buildstep.Isolated: no network, none of the account,
the workspace read-write, the toolchain read-only, the npm cache beneath a throwaway layer
unless the build step is told publish (a trunk build). The module declares the bubblewrap
package and refuses a step, unhealthy, while bwrap is missing or older than 0.10. The proof
test runs a probe script through the real test step.
This commit is contained in:
2026-10-22 18:08:12 +02:00
parent 075b61901c
commit 56f842c3af
12 changed files with 433 additions and 16 deletions
@@ -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
@@ -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
@@ -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)
}
}
}
@@ -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",
@@ -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
}
@@ -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)
}
}
}
@@ -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")
}
}
@@ -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 != "" {
@@ -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)
}
}
@@ -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.
@@ -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.
+5
View File
@@ -139,6 +139,11 @@
"id": "openssh",
"type": "package",
"package": "openssh"
},
{
"id": "bubblewrap",
"type": "package",
"package": "bubblewrap"
}
],
"build": {