Files
mesh-catalog/modules/mesh-build-node/cmd/mesh-build-node/toolchain.go
T
jschoubben a2e5a20f52
mesh/delivery failed: its walk failed, carrying its own commit: a gate on a first machine (what it carried put back), a build, a machine
mesh/check failed: integration: meshbuildnode/cmd/mesh-build-node.TestAFixtureBundleBuiltTwiceHasOneDigest: build_integration_test.go:71: no toolchain…
mesh-build-node: write the install's registry credential in the account's home, never beneath the opened cache, and refuse to open a toolchain holding one (issue 1891 review, ADR 0533 §2)
2026-10-23 11:45:20 +02:00

188 lines
6.4 KiB
Go

package main
// The toolchain directory (novox/hq ADR 0405 §5, ADR 0469 §4; issue 1488): typescript, esbuild, @novox/mesh-sdk and
// nats by the versions the module's own lock names, installed by `npm ci --ignore-scripts` into
// <cache dir>/toolchain/<recipe version> when that directory is missing. No bin links are made (--no-bin-links): the
// toolchain holds no symlink, and tsc and esbuild are run by their real paths. A change of the lock, of the Node
// version or of the recipe moves the recipe version and so installs a fresh directory beside the old one; steps use the one named
// by the current recipe.
import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// installedMark is written last into a toolchain directory, so a directory an install did not finish is not taken
// for a toolchain.
const installedMark = ".installed"
// Paths are the module's places on its machine.
type Paths struct {
// Home is the account's home (the state directory): where the mesh writes toolchain/package.json and
// toolchain/package-lock.json (module.json resources) and the registry binding and secret.
Home string
// Cache is the cache directory, outside the home (ADR 0466 §1).
Cache string
}
// LockPath is the committed lock file the mesh writes into the home.
func (p Paths) LockPath() string { return filepath.Join(p.Home, "toolchain", "package-lock.json") }
// Dir is the toolchain directory of a recipe version.
func (p Paths) Dir(version string) string { return filepath.Join(p.Cache, "toolchain", version) }
// Toolchain installs the toolchain directories.
type Toolchain struct {
Paths Paths
Run forgekey.Runner
Registry func() (Registry, error)
}
// Present reports whether the recipe version's toolchain directory is installed.
func (t Toolchain) Present(version string) bool {
_, err := os.Stat(filepath.Join(t.Paths.Dir(version), installedMark))
return err == nil
}
// Ensure installs the recipe version's toolchain directory unless it is there: the module's package.json and lock
// 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 {
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, 0o755); err != nil {
return err
}
for _, name := range []string{"package.json", "package-lock.json"} {
raw, err := os.ReadFile(filepath.Join(t.Paths.Home, "toolchain", name))
if err != nil {
return fmt.Errorf("the toolchain's %s cannot be read: %w", name, err)
}
if err := os.WriteFile(filepath.Join(dir, name), raw, 0o600); err != nil {
return err
}
}
reg, err := t.Registry()
if err != nil {
return err
}
// The credential is written in the account's own home (0700), never beneath the cache, whose toolchains are
// opened to every account (ADR 0533 §2), and removed as soon as npm has read it.
f, err := os.CreateTemp(t.Paths.Home, ".npmrc-install-")
if err != nil {
return err
}
npmrc := f.Name()
defer os.Remove(npmrc)
_, err = f.WriteString(reg.Npmrc())
if cerr := f.Close(); err == nil {
err = cerr
}
if err != nil {
return err
}
ran := t.Run(ctx, []string{"PATH=" + pathOrDefault(), "HOME=" + t.Paths.Home, "LC_ALL=C", "npm_config_cache=" + filepath.Join(t.Paths.Cache, "npm")},
"npm", "ci", "--ignore-scripts", "--no-bin-links", "--no-audit", "--no-fund", "--prefix", dir, "--userconfig", npmrc)
os.Remove(npmrc)
if ran.Err != "" || ran.Status != 0 {
return fmt.Errorf("npm ci --ignore-scripts for the toolchain %s failed (status %d %s): %s",
version, ran.Status, ran.Err, tail(ran.Stderr))
}
if err := os.WriteFile(filepath.Join(dir, installedMark), []byte(version+"\n"), 0o600); err != nil {
return err
}
return t.open(dir)
}
// refuseNpmConfig refuses a toolchain folder that holds an install's npm config file (one a dead install of an older release left with
// the registry credential in it), naming it, before anything of the folder is opened.
func refuseNpmConfig(dir string) error {
return filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && strings.HasPrefix(d.Name(), ".npmrc-install") {
return fmt.Errorf("the toolchain %s holds the npm config file %s, which may hold the registry credential, "+
"so it is not opened to other accounts; remove it", dir, p)
}
return nil
})
}
// 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 := refuseNpmConfig(dir); err != nil {
return err
}
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 {
s = strings.TrimSpace(s)
if len(s) > 400 {
s = s[len(s)-400:]
}
return s
}
func (t *Toolchain) homeOrEmpty() string {
if t == nil {
return ""
}
return t.Paths.Home
}