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:
@@ -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.
|
||||
|
||||
@@ -139,6 +139,11 @@
|
||||
"id": "openssh",
|
||||
"type": "package",
|
||||
"package": "openssh"
|
||||
},
|
||||
{
|
||||
"id": "bubblewrap",
|
||||
"type": "package",
|
||||
"package": "bubblewrap"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
|
||||
Reference in New Issue
Block a user