Files
mesh-catalog/modules/mesh-build-node/cmd/mesh-build-node/package.go
T
jschoubben b9cdca78b0 mesh-build-node: narrow the fetch to the named paths, build members against the SDK their lock names, and build, pack and publish a package (issues 1758, 1587 S15-S19; ADR 0499 §4, ADR 0498 §5, ADR 0500 §4-§6; lead decision L10)
build_node_fetch takes buildstep.FetchRequest (dir and paths narrow the workspace) and an
optional package {repository, commit, ref}: the group's package member, built and packed
shut off and published nowhere. Each member's lock decides its SDK: the group's packed
file when the version and integrity are the lock's, else the registry's (integrity
checked); a version the registry lacks fails waits for: @novox/mesh-sdk@<version>. A
package artifact is built by its own script and packed with npm pack, shut off; only a
trunk build publishes it, from this process with the write credential (never in a
workspace): absent is published, the same file is done, another is version taken:. The
registry configuration file now lies outside every workspace.
2026-10-22 18:38:22 +02:00

528 lines
18 KiB
Go

package main
// A TypeScript package (novox/hq ADR 0498 §5, ADR 0500 §4-§6; issue 1587, the lead's decision L10): built from its own
// lock's devDependencies (the toolchain's SDK is never used to build the SDK itself), packed with npm pack, and, for a
// group, served to its TypeScript members' fetch in place of the registry's version. A member is built against the SDK
// version its own lock names: the fetch places that version in the member's node_modules, from the group's packed
// package when the group carries that version, else from the registry; a version neither holds waits for its
// publication (`waits for:`). The install of a package's devDependencies is the fetch's (it holds the network and runs
// no script); its build and pack run shut off like every run step.
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"crypto/sha512"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// maxPackage is the largest packed package read: far above the SDK's.
const maxPackage = 64 << 20
// packed is a package built and packed: its name and version from its package.json, the packed file's bytes, its npm
// integrity (sha512, as a lock names it) and its digest (sha256, as a done key names it).
type packed struct {
Name, Version, Integrity, Digest string
tgz []byte
}
// integrityOf is a packed file's integrity as npm writes it in a lock: sha512- and the base64 of its sha512.
func integrityOf(b []byte) string {
sum := sha512.Sum512(b)
return "sha512-" + base64.StdEncoding.EncodeToString(sum[:])
}
func digestOf(b []byte) string {
sum := sha256.Sum256(b)
return "sha256:" + hex.EncodeToString(sum[:])
}
// packageJSON is the part of a package.json the package steps read.
type packageJSON struct {
Name string `json:"name"`
Version string `json:"version"`
Scripts map[string]string `json:"scripts"`
}
func readPackageJSON(dir string) (packageJSON, error) {
var p packageJSON
raw, err := os.ReadFile(filepath.Join(dir, "package.json"))
if err != nil {
return p, fmt.Errorf("the package has no package.json: %w", err)
}
if err := json.Unmarshal(raw, &p); err != nil {
return p, fmt.Errorf("the package.json is not JSON: %w", err)
}
if p.Name == "" || p.Version == "" {
return p, fmt.Errorf("the package.json names no name or no version")
}
return p, nil
}
// installDev installs a package's own dependencies, its devDependencies included, from its lock (the fetch: network,
// no script, no bin link), and writes a shim for each program they carry into <root>/bin-<name>, files and not links,
// so its build script finds them by name.
func (m *Module) installDev(ctx context.Context, root, dir, rel string) error {
if _, err := os.Stat(filepath.Join(dir, "package-lock.json")); err != nil {
return fmt.Errorf("the package in %s has no package-lock.json: commit one", rel)
}
npmrc, err := m.npmrcIn(root)
if err != nil {
return err
}
defer os.Remove(npmrc)
env := append(m.baseEnv(), "npm_config_cache="+m.npmCache())
r := m.Run(ctx, env, "npm", "ci", "--ignore-scripts", "--no-bin-links", "--no-audit", "--no-fund", "--prefix", dir, "--userconfig", npmrc)
if why := ranFailed(r); why != "" {
return fmt.Errorf("installing the dependencies of the package in %s: %s", rel, why)
}
return writeShims(filepath.Join(dir, "node_modules"), shimDir(root, rel))
}
// npmrcIn writes the npm configuration for the mesh's registry, with its credential, for one command of this process;
// the caller removes it. It lies in this process's temporary directory, never in a workspace: a run step is given the
// workspace and a /tmp of its own, so no step's mounts hold the credential (ADR 0498 §5, ADR 0500 §4).
func (m *Module) npmrcIn(root string) (string, error) {
if m.Toolchain == nil || m.Toolchain.Registry == nil {
return "", fmt.Errorf("the module has no package registry")
}
reg, err := m.Toolchain.Registry()
if err != nil {
return "", err
}
f, err := os.CreateTemp("", ".npmrc-")
if err != nil {
return "", err
}
defer f.Close()
if _, err := f.WriteString(reg.Npmrc()); err != nil {
os.Remove(f.Name())
return "", err
}
return f.Name(), nil
}
// shimDir is where the shims of the package in rel are written.
func shimDir(root, rel string) string {
return filepath.Join(root, "bin-"+strings.NewReplacer("/", "-", ".", "root").Replace(rel))
}
// writeShims writes, for every program a top-level package of nm names in its package.json `bin`, a shell script that
// runs it by its real path.
func writeShims(nm, into string) error {
if err := os.MkdirAll(into, 0o755); err != nil {
return err
}
var dirs []string
entries, _ := os.ReadDir(nm)
for _, e := range entries {
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
continue
}
if strings.HasPrefix(e.Name(), "@") {
scoped, _ := os.ReadDir(filepath.Join(nm, e.Name()))
for _, s := range scoped {
if s.IsDir() {
dirs = append(dirs, filepath.Join(nm, e.Name(), s.Name()))
}
}
continue
}
dirs = append(dirs, filepath.Join(nm, e.Name()))
}
sort.Strings(dirs)
for _, d := range dirs {
raw, err := os.ReadFile(filepath.Join(d, "package.json"))
if err != nil {
continue
}
var p struct {
Name string `json:"name"`
Bin json.RawMessage `json:"bin"`
}
if json.Unmarshal(raw, &p) != nil || len(p.Bin) == 0 {
continue
}
bins := map[string]string{}
var one string
if json.Unmarshal(p.Bin, &one) == nil {
bins[path.Base(p.Name)] = one
} else if json.Unmarshal(p.Bin, &bins) != nil {
continue
}
for name, target := range bins {
if name == "" || strings.ContainsAny(name, "/\\") {
continue
}
body := "#!/bin/sh\nexec node '" + filepath.Join(d, filepath.FromSlash(target)) + "' \"$@\"\n"
p := filepath.Join(into, name)
if err := os.WriteFile(p, []byte(body), 0o755); err != nil {
return err
}
if err := os.Chmod(p, 0o755); err != nil {
return err
}
}
}
return nil
}
// buildAndPack builds the package in dir with its own build script, if it has one, and packs it with npm pack, both
// shut off (write: a trunk build's, which alone writes the shared cache). The packed file is read and removed: it never
// stays on the node.
func (m *Module) buildAndPack(ctx context.Context, root, dir, rel string, write bool) (packed, error) {
p, err := readPackageJSON(dir)
if err != nil {
return packed{}, err
}
env := []string{"PATH=" + shimDir(root, rel) + ":" + pathOrDefault(), "npm_config_cache=" + m.npmCache()}
if p.Scripts["build"] != "" {
if why := ranFailed(m.runIn(ctx, root, write, dir, env, "npm", "run", "build")); why != "" {
return packed{}, fmt.Errorf("the build of the package %s: %s", p.Name, why)
}
}
out, err := os.MkdirTemp(root, "packed-")
if err != nil {
return packed{}, err
}
defer os.RemoveAll(out)
if why := ranFailed(m.runIn(ctx, root, write, dir, env, "npm", "pack", "--ignore-scripts", "--pack-destination", out)); why != "" {
return packed{}, fmt.Errorf("packing the package %s: %s", p.Name, why)
}
files, _ := filepath.Glob(filepath.Join(out, "*.tgz"))
if len(files) != 1 {
return packed{}, fmt.Errorf("npm pack of %s left %d packed files, want one", p.Name, len(files))
}
tgz, err := os.ReadFile(files[0])
if err != nil {
return packed{}, err
}
return packed{Name: p.Name, Version: p.Version, Integrity: integrityOf(tgz), Digest: digestOf(tgz), tgz: tgz}, nil
}
// groupPackage is the fetch's `package` argument: the group's package member (ADR 0500 §5), which the fetch packs and
// serves to its TypeScript members in place of the registry's version.
type groupPackage struct{ Repository, Commit, Ref string }
func groupPackageArg(v any) (*groupPackage, error) {
if v == nil {
return nil, nil
}
o, ok := v.(map[string]any)
if !ok {
return nil, fmt.Errorf("package is {repository, commit, ref}")
}
g := &groupPackage{}
g.Repository, _ = o["repository"].(string)
g.Commit, _ = o["commit"].(string)
g.Ref, _ = o["ref"].(string)
if !repositoryOK.MatchString(g.Repository) || strings.Contains(g.Repository, "..") {
return nil, fmt.Errorf("package: %q is not a repository, <owner>/<name>", g.Repository)
}
if !commitOK.MatchString(g.Commit) {
return nil, fmt.Errorf("package: %q is not a full commit (40 hex digits)", g.Commit)
}
if g.Ref != "" && !strings.HasPrefix(g.Ref, "refs/pull/") {
return nil, fmt.Errorf("package: %q is not a pull request's head", g.Ref)
}
return g, nil
}
// packGroup fetches the group's package member into the workspace, installs its dependencies, builds and packs it,
// publishing nothing (ADR 0500 §4).
func (m *Module) packGroup(ctx context.Context, root string, run forgekey.Runner, g *groupPackage) (packed, error) {
dir := filepath.Join(root, "package", "tree")
if err := os.MkdirAll(dir, 0o700); err != nil {
return packed{}, err
}
if err := fetchCommit(ctx, run, dir, m.remote(g.Repository), g.Commit, g.Ref, m); err != nil {
return packed{}, fmt.Errorf("the group's package %s: %w", g.Repository, err)
}
if err := m.installDev(ctx, root, dir, "package"); err != nil {
return packed{}, err
}
return m.buildAndPack(ctx, root, dir, "package", false)
}
// sdkLocked is what a module's lock names of the SDK: its version, its integrity and where it was resolved from; ok is
// false when the lock names none (the toolchain's SDK is then the module's, as before).
func sdkLocked(dir string) (version, integrity string, ok bool, err error) {
raw, err := os.ReadFile(filepath.Join(dir, "package-lock.json"))
if os.IsNotExist(err) {
return "", "", false, nil
}
if err != nil {
return "", "", false, err
}
var lock struct {
Packages map[string]struct {
Version string `json:"version"`
Integrity string `json:"integrity"`
} `json:"packages"`
}
if err := json.Unmarshal(raw, &lock); err != nil {
return "", "", false, fmt.Errorf("the package-lock.json is not JSON: %w", err)
}
p, ok := lock.Packages["node_modules/"+sdkPackage]
if !ok || p.Version == "" {
return "", "", false, nil
}
return p.Version, p.Integrity, true, nil
}
// placeSDK places in the module's node_modules the SDK version its lock names (the lead's decision L10): the group's
// packed package when it is that version (its integrity must be the lock's), else the registry's; a version neither
// holds is a wait (`waits for:`) when no group package was given, and a failure naming both when one was.
func (m *Module) placeSDK(ctx context.Context, dir, rel string, group *packed) error {
version, integrity, ok, err := sdkLocked(dir)
if err != nil || !ok {
return err
}
want := sdkPackage + "@" + version
var tgz []byte
switch {
case group != nil && group.Name == sdkPackage && group.Version == version:
if integrity != "" && integrity != group.Integrity {
return fmt.Errorf("the lock of %s names %s with the integrity %s, and the group's package packs to %s", rel, want, integrity, group.Integrity)
}
tgz = group.tgz
default:
got, held, err := m.registryPackage(ctx, sdkPackage, version)
if err != nil {
return err
}
if !held {
if group != nil {
return fmt.Errorf("the lock of %s names %s, which neither the registry nor the group (%s@%s) holds", rel, want, group.Name, group.Version)
}
return fmt.Errorf("%s%s", delivery.WaitsFor, want)
}
if integrity != "" && integrityOf(got) != integrity {
return fmt.Errorf("the registry's %s is %s, and the lock of %s names %s", want, integrityOf(got), rel, integrity)
}
tgz = got
}
into := filepath.Join(dir, "node_modules", filepath.FromSlash(sdkPackage))
if err := os.RemoveAll(into); err != nil {
return err
}
return unpackPackage(tgz, into)
}
// httpClient is the client of the registry's reads and publishes: HTTP when a test set it.
func (m *Module) httpClient() *http.Client {
if m.HTTP != nil {
return m.HTTP
}
return &http.Client{Timeout: 5 * time.Minute}
}
// packument is the part of the registry's document of a package the steps read: each version's packed file.
type packument struct {
Versions map[string]struct {
Dist struct {
Tarball string `json:"tarball"`
Integrity string `json:"integrity"`
} `json:"dist"`
} `json:"versions"`
}
// registryVersion reads what the registry holds of name@version: its packed file's address and integrity, and whether
// it holds it at all.
func (m *Module) registryVersion(ctx context.Context, name, version string) (tarball, integrity string, held bool, err error) {
reg, err := m.Toolchain.Registry()
if err != nil {
return "", "", false, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reg.URL+url.PathEscape(name), nil)
if err != nil {
return "", "", false, err
}
reg.authorize(req)
resp, err := m.httpClient().Do(req)
if err != nil {
return "", "", false, fmt.Errorf("asking the registry for %s: %w", name, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return "", "", false, nil
}
if resp.StatusCode != http.StatusOK {
said, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return "", "", false, fmt.Errorf("the registry answered %s for %s: %s", resp.Status, name, strings.TrimSpace(string(said)))
}
var p packument
if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<20)).Decode(&p); err != nil {
return "", "", false, fmt.Errorf("the registry's document of %s: %w", name, err)
}
v, ok := p.Versions[version]
if !ok {
return "", "", false, nil
}
return v.Dist.Tarball, v.Dist.Integrity, true, nil
}
// registryPackage downloads name@version's packed file from the registry; held is false when the registry has none.
func (m *Module) registryPackage(ctx context.Context, name, version string) ([]byte, bool, error) {
tarball, _, held, err := m.registryVersion(ctx, name, version)
if err != nil || !held {
return nil, held, err
}
reg, err := m.Toolchain.Registry()
if err != nil {
return nil, false, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, tarball, nil)
if err != nil {
return nil, false, err
}
reg.authorize(req)
resp, err := m.httpClient().Do(req)
if err != nil {
return nil, false, fmt.Errorf("downloading %s@%s: %w", name, version, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, false, fmt.Errorf("the registry answered %s for %s@%s's packed file", resp.Status, name, version)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxPackage+1))
if err != nil {
return nil, false, err
}
if len(body) > maxPackage {
return nil, false, fmt.Errorf("%s@%s is larger than %d bytes", name, version, maxPackage)
}
return body, true, nil
}
// unpackPackage unpacks a packed npm package (a gzipped tar whose entries lie under package/) into dir: files and
// directories only, nothing outside.
func unpackPackage(tgz []byte, dir string) error {
zr, err := gzip.NewReader(bytes.NewReader(tgz))
if err != nil {
return fmt.Errorf("the packed package is not gzip: %w", err)
}
tr := tar.NewReader(zr)
for {
h, err := tr.Next()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
name := path.Clean(strings.TrimPrefix(h.Name, "./"))
rel, ok := strings.CutPrefix(name, "package/")
if !ok || rel == "" || path.IsAbs(rel) || rel == ".." || strings.HasPrefix(rel, "../") {
if name == "package" {
continue
}
return fmt.Errorf("the packed package holds %s, outside package/", h.Name)
}
target := filepath.Join(dir, filepath.FromSlash(rel))
switch h.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
body, err := io.ReadAll(io.LimitReader(tr, maxPackage))
if err != nil {
return err
}
if err := os.WriteFile(target, body, os.FileMode(h.Mode).Perm()|0o600); err != nil {
return err
}
default:
return fmt.Errorf("the packed package holds %s, neither a file nor a directory", h.Name)
}
}
}
// fetchCommit fetches one commit into dir as the fetch step fetches the module's own (buildstep.FetchCommit).
func fetchCommit(ctx context.Context, run forgekey.Runner, dir, remote, commit, ref string, m *Module) error {
return buildstep.FetchCommit(ctx, run, dir, remote, commit, ref, m.FetchOptions...)
}
// buildPackage builds and packs a package artifact (ADR 0498 §5): its digest is the packed file's. Only a trunk build
// (publish) publishes it, from this module's own process with the registry's write credential, which no run step is
// given: a version the registry lacks is published; the same packed file already there is done without a publish;
// another packed file under that version fails `version taken:`. The reference is <name>@<version>, answered only when
// the registry holds this build's file; a candidate answers its digest alone.
func (m *Module) buildPackage(ctx context.Context, workspace, module, root, dir string, a artifact, publish bool) (any, error) {
rel := module
if rel == "" {
rel = "."
}
p, err := m.buildAndPack(ctx, root, dir, rel, publish)
if err != nil {
return nil, err
}
res := BuildResult{Workspace: workspace, Module: module, Artifact: a.Name, Digest: p.Digest, Bytes: len(p.tgz)}
if publish {
if res.Reference, err = m.publish(ctx, root, p); err != nil {
return nil, err
}
}
return res, nil
}
// publish puts a packed package into the registry unless it holds that version, and answers <name>@<version>.
func (m *Module) publish(ctx context.Context, root string, p packed) (string, error) {
ref := p.Name + "@" + p.Version
_, integrity, held, err := m.registryVersion(ctx, p.Name, p.Version)
if err != nil {
return "", err
}
if held {
if integrity == p.Integrity {
return ref, nil // this very file, published before: done without a publish
}
return "", fmt.Errorf("%s%s: the registry holds it as %s, and this build packs %s", delivery.VersionTaken, ref, integrity, p.Integrity)
}
file, err := os.CreateTemp(root, "publish-*.tgz")
if err != nil {
return "", err
}
defer os.Remove(file.Name()) // the packed file leaves the node once its publish answered
if _, err := file.Write(p.tgz); err != nil {
file.Close()
return "", err
}
if err := file.Close(); err != nil {
return "", err
}
npmrc, err := m.npmrcIn(root)
if err != nil {
return "", err
}
defer os.Remove(npmrc)
env := append(m.baseEnv(), "npm_config_cache="+m.npmCache())
r := m.Run(ctx, env, "npm", "publish", file.Name(), "--ignore-scripts", "--userconfig", npmrc)
if why := ranFailed(r); why != "" {
return "", fmt.Errorf("publishing %s: %s", ref, why)
}
return ref, nil
}