Files
mesh-catalog/modules/mesh-build-node/cmd/mesh-build-node/workspace.go
T

188 lines
6.4 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 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...)...)
}
// ranFailed says why a command did not pass, or "" when it did.
func ranFailed(r forgekey.Ran) string {
if r.Status == 0 && r.Err == "" {
return ""
}
out := strings.TrimSpace(r.Stdout + "\n" + r.Stderr)
if len(out) > 1500 {
out = out[:1500] + " ..."
}
return fmt.Sprintf("status %d %s: %s", r.Status, r.Err, out)
}