diff --git a/modules/mesh-build-go/cmd/mesh-build-go/level.go b/modules/mesh-build-go/cmd/mesh-build-go/level.go index 30f4299a..520962dc 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/level.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/level.go @@ -68,6 +68,7 @@ func (m *Module) integrationShutOff(ctx context.Context, workspace string, packa } iso := m.isolation(workspace, false) iso.ReadOnly = append(iso.ReadOnly, exe) // the program alone, not the directory it lies in + iso.ReadOnly = append(iso.ReadOnly, m.nodeToolchains()...) r := buildstep.Isolated(m.Run, iso)(ctx, nil, exe, levelArg, string(spec)) var answer levelAnswer line := lastLine(r.Stdout) @@ -80,6 +81,25 @@ func (m *Module) integrationShutOff(ctx context.Context, workspace string, packa return answer.Result, nil } +// NodeToolchains is the Node build module's toolchains directory on this node: its cache directory (module.json +// resource `cache`, mode 0711) joined with toolchain, where it installs typescript, esbuild, @novox/mesh-sdk and nats +// from its own lock (ADR 0533 §1, §2; issue 1891). +const NodeToolchains = "/var/lib/mesh-build-node-cache/toolchain" + +// nodeToolchains is what the integration level is given of the Node build module's toolchains: their directory, +// read-only, when it is there and this account may enter it, and nothing on a node where the Node build module has +// installed none (ADR 0533 §1). The unit level is never given it. +func (m *Module) nodeToolchains() []string { + dir := m.NodeToolchains + if dir == "" { + dir = NodeToolchains + } + if _, err := os.ReadDir(dir); err != nil { + return nil + } + return []string{dir} +} + func lastLine(s string) string { lines := strings.Split(strings.TrimSpace(s), "\n") return strings.TrimSpace(lines[len(lines)-1]) diff --git a/modules/mesh-build-go/cmd/mesh-build-go/module.go b/modules/mesh-build-go/cmd/mesh-build-go/module.go index ee3cc99c..92148e95 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/module.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/module.go @@ -62,6 +62,9 @@ type Module struct { Push func(ctx context.Context, registry, repository, digest string, body []byte) error // Executable is this program's path, which the integration level starts again shut off; os.Executable when empty. Executable string + // NodeToolchains is where the Node build module keeps its installed toolchains, bound read-only into the + // integration level (ADR 0533 §1); NodeToolchains when empty. A test points it elsewhere. + NodeToolchains string // StartBus starts the throwaway bus of a run in dir and answers its address and what stops it (the module's own // program, StartBus in bus.go, when nil). StartBus func(ctx context.Context, dir string) (url string, stop func(), err error) diff --git a/modules/mesh-build-go/cmd/mesh-build-go/node_toolchains_test.go b/modules/mesh-build-go/cmd/mesh-build-go/node_toolchains_test.go new file mode 100644 index 00000000..0c48ac73 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/node_toolchains_test.go @@ -0,0 +1,128 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "slices" + "strconv" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +// integrationShutOffWith runs the integration level through the fake bubblewrap and answers what its one shut-off +// process was given. +func integrationShutOffWith(t *testing.T, toolchains string) shutOff { + t.Helper() + m, states, ws, _ := workspaceFor(t, "failing") + m.Executable = "/opt/bundle/mesh-build-go" + m.NodeToolchains = toolchains + want := TestResult{Level: LevelIntegration, Passed: true, Tests: 1, Packages: []PackageResult{}, Store: "postgres 18"} + var seen []shutOff + m.Run = throughBubblewrap(t, func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { + switch { + case name == "postgres" && len(args) == 1 && args[0] == "--version": + return forgekey.Ran{Stdout: "postgres (PostgreSQL) 18.6\n"} + case name == m.Executable && len(args) == 2 && args[0] == levelArg: + raw, _ := json.Marshal(levelAnswer{Result: &want}) + return forgekey.Ran{Stdout: string(raw) + "\n"} + } + return forgekey.Ran{Status: 1, Err: "not expected here"} + }, &seen) + run := stepTool(t, m, "build_go_test") + if _, err := run(map[string]any{"workspace": ws, "level": LevelIntegration}); err != nil { + t.Fatal(err) + } + if st := states.waitEnd(t); st.State != delivery.StepPassed { + t.Fatalf("the step ended %s: %s", st.State, st.Reason) + } + if len(seen) != 1 { + t.Fatalf("shut off %d processes, want one: %v", len(seen), seen) + } + checkShutOff(t, m, seen[0], false) + return seen[0] +} + +// 1891 (ADR 0533 §1): the integration level is given the Node build module's installed toolchains, read-only, so the +// Node build module's own integration tests build with the real tsc and esbuild and reach no registry. +func TestTheIntegrationLevelIsGivenTheNodeToolchainsReadOnly(t *testing.T) { + dir := t.TempDir() + s := integrationShutOffWith(t, dir) + if !slices.Contains(s.roBinds, dir) { + t.Errorf("the node toolchains %s are not bound read-only: %v", dir, s.roBinds) + } + if slices.Contains(s.binds, dir) || slices.Contains(s.overlays, dir) { + t.Errorf("the node toolchains %s may be written: binds %v, overlays %v", dir, s.binds, s.overlays) + } + if slices.Contains(s.roBinds, filepath.Dir(dir)) { + t.Errorf("the whole cache %s is bound, not its toolchains alone: %v", filepath.Dir(dir), s.roBinds) + } +} + +// 1891 (ADR 0533 §1): a node where the Node build module has installed no toolchain runs the level as before, with +// nothing more bound (bubblewrap binds only what is there). +func TestANodeWithoutNodeToolchainsRunsTheLevelWithNothingMoreBound(t *testing.T) { + missing := filepath.Join(t.TempDir(), "none") + s := integrationShutOffWith(t, missing) + if slices.Contains(s.roBinds, missing) { + t.Errorf("a missing %s is bound: %v", missing, s.roBinds) + } +} + +// 1891 (ADR 0533 §1): the unit level is not given them. +func TestTheUnitLevelIsNotGivenTheNodeToolchains(t *testing.T) { + m, states, ws, _ := workspaceFor(t, "failing") + dir := t.TempDir() + m.NodeToolchains = dir + var seen []shutOff + m.Run = throughBubblewrap(t, m.Run, &seen) + if _, st := runUnit(t, m, states, ws); st.State != delivery.StepPassed { + t.Fatalf("the step ended %s: %s", st.State, st.Reason) + } + for _, s := range seen { + if slices.Contains(s.roBinds, dir) || slices.Contains(s.binds, dir) { + t.Errorf("%s was given the node toolchains", s.command) + } + } +} + +// 1891 (ADR 0533 §1, §2): the place bound is the Node build module's own: its manifest's cache directory joined with +// toolchain, and that cache directory lets other accounts enter it without listing it (0711). +func TestTheNodeToolchainsAreTheNodeBuildModulesCache(t *testing.T) { + if _, err := catalogue.ReadTreeManifest(catalogueRoot, filepath.Join(catalogueRoot, "modules", "mesh-build-node")); err != nil { + t.Fatalf("mesh-sdk's validator refuses mesh-build-node's manifest: %v", err) + } + raw, err := os.ReadFile(filepath.Join(catalogueRoot, "modules", "mesh-build-node", "module.json")) + if err != nil { + t.Fatal(err) + } + var man struct { + Resources []struct { + ID, Path, Mode string + } `json:"resources"` + } + if err := json.Unmarshal(raw, &man); err != nil { + t.Fatal(err) + } + found := false + for _, r := range man.Resources { + if r.ID != "cache" { + continue + } + found = true + if want := filepath.Join(r.Path, "toolchain"); NodeToolchains != want { + t.Errorf("NodeToolchains = %s, want %s (mesh-build-node's cache)", NodeToolchains, want) + } + mode, err := strconv.ParseUint(r.Mode, 8, 32) + if err != nil || mode&0o001 == 0 || mode&0o004 != 0 { + t.Errorf("mesh-build-node's cache has mode %q; want others to enter it and not list it (0711)", r.Mode) + } + } + if !found { + t.Fatal("mesh-build-node's manifest declares no cache directory") + } +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/build_integration_test.go b/modules/mesh-build-node/cmd/mesh-build-node/build_integration_test.go index e4c9e5b8..85809ed2 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/build_integration_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/build_integration_test.go @@ -3,7 +3,10 @@ package main import ( + "bytes" "context" + "fmt" + "io/fs" "os" "os/exec" "path/filepath" @@ -13,8 +16,11 @@ import ( "git.novox.be/novox/mesh-sdk/go/forgekey" ) -// realMachine installs the real toolchain from the module's own lock (npm ci --ignore-scripts, from the mesh's -// registry for @novox, which reads without a credential) and makes two workspaces holding one fixture bundle. +// realMachine gives the tests the real toolchain of the module's own lock and makes two workspaces holding one fixture +// bundle. The toolchain is the one this node's installed Node build module keeps (ADR 0533 §1): the integration level +// is given its toolchains read-only and no network, so it is copied into the test's own cache from there. Only where +// no toolchain is installed (a desk) is it installed with `npm ci --ignore-scripts` from the mesh's registry, as the +// module does, which needs the network. func realMachine(t *testing.T) (*Module, string) { t.Helper() for _, p := range []string{"node", "npm", "bwrap"} { // bwrap: tsc, esbuild and the scripts run shut off (1309) @@ -40,8 +46,11 @@ func realMachine(t *testing.T) (*Module, string) { return Registry{URL: "https://git.novox.be/api/packages/novox/npm/"}, nil }}} withStore(t, m) + if from := installedToolchain(t, filepath.Join(CacheDir, "toolchain"), filepath.Join(home, "toolchain", "package-lock.json")); from != "" { + copyToolchain(t, from, m.Toolchain.Paths.Dir(m.Recipe.Version())) + } if err := m.Toolchain.Ensure(context.Background(), m.Recipe.Version()); err != nil { - t.Fatal(err) + t.Fatalf("no toolchain of this lock is installed under %s, and installing it from the registry failed: %v", filepath.Join(CacheDir, "toolchain"), err) } for _, id := range []string{"w1", "w2"} { for p, body := range map[string]string{ @@ -115,3 +124,61 @@ func TestAPackageWhoseTestsImportItsBuildPassesTheUnitLevel(t *testing.T) { t.Errorf("result = %+v", r) } } + +// installedToolchain is the toolchain directory under toolchains that this node's Node build module installed from +// the lock at lockPath, byte for byte; "" when there is none (or the directory cannot be read: no Node build module +// here). A toolchain of another lock is never taken: the tests prove the lock they carry. +func installedToolchain(t *testing.T, toolchains, lockPath string) string { + t.Helper() + want, err := os.ReadFile(lockPath) + if err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(toolchains) + if err != nil { + return "" + } + for _, e := range entries { + dir := filepath.Join(toolchains, e.Name()) + if _, err := os.Stat(filepath.Join(dir, installedMark)); err != nil { + continue + } + if got, err := os.ReadFile(filepath.Join(dir, "package-lock.json")); err == nil && bytes.Equal(got, want) { + t.Logf("the toolchain is this node's installed %s", dir) + return dir + } + } + return "" +} + +// copyToolchain copies the installed toolchain from into to whole, files and folders with their modes, so the test's +// steps run behind a toolchain of their own and the installed one is only read. +func copyToolchain(t *testing.T, from, to string) { + t.Helper() + err := filepath.WalkDir(from, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(from, p) + dst := filepath.Join(to, rel) + info, err := d.Info() + if err != nil { + return err + } + switch { + case d.IsDir(): + return os.MkdirAll(dst, 0o755) + case info.Mode().IsRegular(): + raw, err := os.ReadFile(p) + if err != nil { + return err + } + return os.WriteFile(dst, raw, info.Mode().Perm()|0o600) + default: + return fmt.Errorf("%s is neither a file nor a folder", p) + } + }) + if err != nil { + t.Fatalf("copying the installed toolchain %s: %v", from, err) + } +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/toolchain.go b/modules/mesh-build-node/cmd/mesh-build-node/toolchain.go index 2feaac95..93901cf2 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/toolchain.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/toolchain.go @@ -10,6 +10,7 @@ package main import ( "context" "fmt" + "io/fs" "os" "path/filepath" "strings" @@ -53,14 +54,17 @@ func (t Toolchain) Present(version string) bool { // are copied in, and `npm ci --ignore-scripts` runs once, the mesh's registry named for the @novox scope. A directory // an earlier install left unfinished is removed first. func (t Toolchain) Ensure(ctx context.Context, version string) error { - if t.Present(version) { - return nil - } dir := t.Paths.Dir(version) + if t.Present(version) { + if opened(dir) { + return nil + } + return t.open(dir) + } if err := os.RemoveAll(dir); err != nil { return err } - if err := os.MkdirAll(dir, 0o700); err != nil { + if err := os.MkdirAll(dir, 0o755); err != nil { return err } for _, name := range []string{"package.json", "package-lock.json"} { @@ -87,7 +91,55 @@ func (t Toolchain) Ensure(ctx context.Context, version string) error { return fmt.Errorf("npm ci --ignore-scripts for the toolchain %s failed (status %d %s): %s", version, ran.Status, ran.Err, tail(ran.Stderr)) } - return os.WriteFile(filepath.Join(dir, installedMark), []byte(version+"\n"), 0o600) + if err := os.WriteFile(filepath.Join(dir, installedMark), []byte(version+"\n"), 0o600); err != nil { + return err + } + return t.open(dir) +} + +// opened says whether the toolchain directory dir was opened to other accounts: its installed mark, opened last, is +// readable by others. +func opened(dir string) bool { + st, err := os.Stat(filepath.Join(dir, installedMark)) + return err == nil && st.Mode().Perm()&0o004 != 0 +} + +// open lets every account read the toolchain directory dir and nobody but its owner write it (ADR 0533 §2): the +// toolchains folder and every folder beneath dir 0755, every file 0644 and every program 0755, whatever umask npm ran +// under; the installed mark last, so a toolchain opened only in part is opened again. The other build module's +// integration level reads it through the cache directory, which others may enter and not list (0711); the npm cache +// beside it stays the owner's. +func (t Toolchain) open(dir string) error { + if err := os.Chmod(filepath.Join(t.Paths.Cache, "toolchain"), 0o755); err != nil { + return err + } + mark := filepath.Join(dir, installedMark) + err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if p == mark { + return nil + } + info, err := d.Info() + if err != nil { + return err + } + switch mode := info.Mode(); { + case mode.IsDir(): + return os.Chmod(p, 0o755) + case mode.IsRegular() && mode.Perm()&0o100 != 0: + return os.Chmod(p, 0o755) + case mode.IsRegular(): + return os.Chmod(p, 0o644) + default: + return fmt.Errorf("%s is neither a file nor a folder, and a toolchain holds only those", p) + } + }) + if err != nil { + return fmt.Errorf("the toolchain %s cannot be opened to the integration level: %w", dir, err) + } + return os.Chmod(mark, 0o644) } func tail(s string) string { diff --git a/modules/mesh-build-node/cmd/mesh-build-node/toolchain_open_test.go b/modules/mesh-build-node/cmd/mesh-build-node/toolchain_open_test.go new file mode 100644 index 00000000..a64ed321 --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/toolchain_open_test.go @@ -0,0 +1,100 @@ +package main + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "testing" + + "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +// npmAsUmask077 installs as npm does under a umask of 077: node_modules with a package whose folders are 0700, its +// file 0600 and its program 0700. +func npmAsUmask077(f *fakeNpm) forgekey.Runner { + return func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { + ran := f.run(ctx, env, name, args...) + for i, a := range args { + if a == "--prefix" { + pkg := filepath.Join(args[i+1], "node_modules", "typescript", "bin") + os.MkdirAll(pkg, 0o700) + os.WriteFile(filepath.Join(args[i+1], "node_modules", "typescript", "package.json"), []byte("{}"), 0o600) + os.WriteFile(filepath.Join(pkg, "tsc"), []byte("#!/bin/sh\n"), 0o700) + } + } + return ran + } +} + +// checkOpen fails unless every folder from the cache's toolchain down lets others enter and read it and every file lets +// others read it, a program others run, and nothing lets anyone but the owner write. +func checkOpen(t *testing.T, tc Toolchain) { + t.Helper() + root := filepath.Join(tc.Paths.Cache, "toolchain") + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + info, err := d.Info() + if err != nil { + return err + } + mode := info.Mode().Perm() + switch { + case mode&0o022 != 0: + t.Errorf("%s is %o: writable by others than its owner", p, mode) + case d.IsDir() && mode&0o005 != 0o005: + t.Errorf("%s is %o: others may not enter and read it", p, mode) + case !d.IsDir() && mode&0o004 == 0: + t.Errorf("%s is %o: others may not read it", p, mode) + case !d.IsDir() && mode&0o100 != 0 && mode&0o001 == 0: + t.Errorf("%s is %o: a program others may not run", p, mode) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +// 1891 (ADR 0533 §2): a toolchain the module installs can be read by the other build module's integration level: +// its folders 0755 and its files 0644 (programs 0755), whatever umask npm ran under, and writable by its owner alone. +func TestAnInstalledToolchainIsReadableByOthersAndWritableByItsOwnerAlone(t *testing.T) { + fake := &fakeNpm{} + tc := toolchainIn(t, fake) + tc.Run = npmAsUmask077(fake) + if err := tc.Ensure(context.Background(), "v1"); err != nil { + t.Fatal(err) + } + checkOpen(t, tc) +} + +// 1891 (ADR 0533 §2): a toolchain installed before this release, shut to its owner, is opened by the next Ensure +// without installing it again. +func TestAToolchainInstalledShutIsOpenedWithoutAnInstall(t *testing.T) { + fake := &fakeNpm{} + tc := toolchainIn(t, fake) + dir := tc.Paths.Dir("v1") + os.MkdirAll(filepath.Join(dir, "node_modules", "esbuild", "bin"), 0o700) + os.WriteFile(filepath.Join(dir, "node_modules", "esbuild", "bin", "esbuild"), []byte("x"), 0o700) + os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte("{}"), 0o600) + os.WriteFile(filepath.Join(dir, installedMark), []byte("v1\n"), 0o600) + os.Chmod(filepath.Join(tc.Paths.Cache, "toolchain"), 0o700) + if err := tc.Ensure(context.Background(), "v1"); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 0 { + t.Errorf("npm ran %d times for a toolchain already there", len(fake.calls)) + } + checkOpen(t, tc) +} + +// 1891 (ADR 0533 §2): the cache directory lets other accounts enter it and not list it, so the toolchains beneath it +// can be reached by their path and the npm cache beside them (0700) cannot. +func TestTheCacheDirectoryIsEnteredByOthersNotListed(t *testing.T) { + r := resource(t, readManifest(t), "cache") + if r["mode"] != "0711" { + t.Errorf("the cache directory's mode is %v, want 0711", r["mode"]) + } +} diff --git a/modules/mesh-build-node/module.json b/modules/mesh-build-node/module.json index c73aa9a1..21378546 100644 --- a/modules/mesh-build-node/module.json +++ b/modules/mesh-build-node/module.json @@ -87,7 +87,7 @@ "id": "cache", "type": "directory", "path": "/var/lib/mesh-build-node-cache", - "mode": "0700", + "mode": "0711", "owner": "mesh-build-node", "unknown": "program" },