Files
mesh-catalog/modules/mesh-build-node/cmd/mesh-build-node/workspace.go
T
jschoubben 98c66fb1e4 Build modules: a failed command's reason says its error, not its last progress line (issue 1703)
buildah writes STEP lines on stdout and its error on stderr, so the last line of both hid every image build's cause.
mesh-build-image's reason is now stderr's last lines (600 bytes) with the exit status, and the journal gets both tails;
mesh-build-node keeps stderr's end and stdout's start apart; mesh-build-go's cause skips go build's '# package' line.
2026-10-26 15:21:01 +01:00

208 lines
7.3 KiB
Go

package main
// A workspace and the packages a step runs against (novox/hq ADR 0469 §2 and §5; issues 1490, 1491). A workspace is
// <work dir>/<step id>/tree, made by the fetch step. The toolchain's packages are copied to <work dir>/<step id>/
// node_modules, which lies above the tree: node, tsc and esbuild resolve an import by walking up from the importing
// file, so @novox/mesh-sdk and the types are found there while the tree itself, which a bundle is packed from, stays
// what the commit holds. A copy and not a link, because the mesh makes no symlink.
import (
"context"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
var workspaceOK = regexp.MustCompile(`^[a-z0-9]+$`)
// notCopied are the toolchain's own tools: a bundle imports none of them, and they are tens of megabytes.
var notCopied = map[string]bool{"typescript": true, "esbuild": true, "@esbuild": true}
// moduleDirOf answers the directory of a module in a workspace and the workspace's root (the step's directory),
// refusing an id or a directory that is not what a fetch made.
func (m *Module) moduleDirOf(workspace, module string) (root, dir string, err error) {
if !workspaceOK.MatchString(workspace) {
return "", "", fmt.Errorf("%q is not a workspace id", workspace)
}
if module == "" {
module = "."
}
clean := filepath.Clean(filepath.FromSlash(module))
if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", "", fmt.Errorf("the module directory %q is not inside the workspace", module)
}
root = filepath.Join(m.Work, workspace)
dir = filepath.Join(root, "tree", clean)
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
return "", "", fmt.Errorf("workspace %s holds no directory %q", workspace, module)
}
return root, dir, nil
}
// toolchainDir is the installed toolchain directory of the recipe in force.
func (m *Module) toolchainDir() string {
return m.Toolchain.Paths.Dir(m.recipe().Version())
}
// tsc and esbuild are the toolchain's own programs, run by their real paths.
func (m *Module) tsc() string {
return filepath.Join(m.toolchainDir(), "node_modules", "typescript", "bin", "tsc")
}
func (m *Module) esbuild() string {
return filepath.Join(m.toolchainDir(), "node_modules", "esbuild", "bin", "esbuild")
}
// provide copies the toolchain's packages to <root>/node_modules unless they are there.
func (m *Module) provide(root string) error {
if err := m.writeBin(filepath.Join(root, "bin")); err != nil {
return err
}
dest := filepath.Join(root, "node_modules")
if _, err := os.Stat(dest); err == nil {
return nil
}
return copyPackages(filepath.Join(m.toolchainDir(), "node_modules"), dest)
}
// binDir is the workspace's bin directory: small shell scripts, files and not links, that call the toolchain's tsc and
// esbuild, so a package's scripts (`tsc`, `esbuild` by name) find them. npm ci runs with --no-bin-links, which leaves
// the toolchain no .bin directory of its own.
func binDir(root string) string { return filepath.Join(root, "bin") }
func (m *Module) writeBin(dir string) error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
for name, body := range map[string]string{
"tsc": "exec node '" + m.tsc() + "' \"$@\"\n",
"esbuild": "exec '" + m.esbuild() + "' \"$@\"\n",
} {
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body), 0o755); err != nil {
return err
}
if err := os.Chmod(path, 0o755); err != nil {
return err
}
}
return nil
}
// copyTree copies a directory of regular files and directories, keeping permission bits, except a build's output and
// the repository's own .git. Anything else in it (a link) is an error: the mesh makes no symlink.
func copyTree(from, to string) error {
return filepath.WalkDir(from, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, _ := filepath.Rel(from, path)
if d.IsDir() && (rel == ".mesh-build" || rel == ".git") {
return filepath.SkipDir
}
switch {
case d.IsDir():
return os.MkdirAll(filepath.Join(to, rel), 0o755)
case d.Type().IsRegular():
return copyFile(path, filepath.Join(to, rel))
default:
return fmt.Errorf("%s is neither a file nor a directory, and the build copies only those", path)
}
})
}
// copyPackages copies the packages of a node_modules directory except the toolchain's own tools. It copies regular
// files and directories only and keeps their permission bits; anything else in the source is an error.
func copyPackages(from, to string) error {
if err := os.MkdirAll(to, 0o755); err != nil {
return err
}
return filepath.WalkDir(from, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, _ := filepath.Rel(from, path)
if rel == "." {
return nil
}
if top := strings.Split(rel, string(filepath.Separator))[0]; notCopied[top] || top == ".package-lock.json" || top == ".bin" {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
switch {
case d.IsDir():
return os.MkdirAll(filepath.Join(to, rel), 0o755)
case d.Type().IsRegular():
return copyFile(path, filepath.Join(to, rel))
default:
return fmt.Errorf("%s is neither a file nor a directory, and the packages copied carry only those", path)
}
})
}
func copyFile(from, to string) error {
in, err := os.Open(from)
if err != nil {
return err
}
defer in.Close()
info, err := in.Stat()
if err != nil {
return err
}
out, err := os.OpenFile(to, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm())
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
return out.Close()
}
// 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 (issue 1703): its exit status, the end of what it wrote
// on stderr (npm's error is its last lines) and the start of what it wrote on stdout (tsc writes its errors there,
// first first), each bounded, so a long stdout never pushes stderr's error out of the reason.
func ranFailed(r forgekey.Ran) string {
if r.Status == 0 && r.Err == "" {
return ""
}
why := fmt.Sprintf("status %d %s", r.Status, r.Err)
if e := strings.TrimSpace(r.Stderr); e != "" {
if len(e) > failedBytes/2 {
e = "... " + e[len(e)-failedBytes/2:]
}
why += ": stderr: " + e
}
if o := strings.TrimSpace(r.Stdout); o != "" {
if len(o) > failedBytes/2 {
o = o[:failedBytes/2] + " ..."
}
why += ": stdout: " + o
}
return why
}
// failedBytes bounds what ranFailed carries of a command's output, stderr and stdout half each.
const failedBytes = 1500