Files
mesh-controller/internal/builder/packages.go
T
mesh-tools bfe7fe1530 Pin mesh-sdk at 14fd5cb6 (R4a's merge) and take the catalogue, overlay and validator from it, s to inventory (issue 1381, 4 of 6 import swaps)
go.mod and go.sum move to mesh-sdk 14fd5cb68fd1, the commit on its main that holds go/catalogue, go/overlay and go/validate (ADR 0453 clause 2); vendor/modules.txt follows in the last of the six commits.
2026-10-20 08:26:01 +00:00

234 lines
9.8 KiB
Go

package builder
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// Npmrc is what a build needs to resolve the mesh's own shared library — and any other package the
// mesh publishes — from the mesh's package registry rather than from a git URL (novox/hq ADR 0076,
// issue 053).
//
// It is a build-time credential, not a runtime one. A module compiled inside the toolchain image
// resolves the SDK there, once, when that image is built; the running container never speaks to the
// package registry. So this is given to the *builder*, the way the artifact store is
// (`whereToPublish`), and reaches a build as a secret rather than a layer — see Secret.
//
// The registry's exact URL shape is the provider's business, not the builder's: it arrives whole,
// either from the binding the mesh writes (a package-registry provider's `serves` facts) or from the
// environment when a person runs a build by hand. Nothing here knows gitea from verdaccio.
type Npmrc struct {
// Scope is the npm scope the registry answers for, e.g. "@novox". A build resolves only this
// scope from the mesh; everything else resolves the ordinary way, so a mesh with no internet
// still cannot pull the public registry's version of a name the mesh also publishes.
Scope string
// Registry is the full base URL a client uses for this scope, e.g.
// "https://<forge>/api/packages/<owner>/npm/". Trailing slash tolerated either way.
Registry string
// Token authenticates to the registry as a bearer token, when a provider mints one. Left empty
// when the mesh authenticates the ordinary way it authenticates everything — a generated
// password it applies and seals — for which see Username and Password.
Token string
// Username and Password authenticate by basic auth, which is what gitea and verdaccio both
// accept and what lets the credential be a mesh-generated password the provider's provisioner
// applies and the mesh seals to the consumer — the same shape a database password takes. The
// username is the consumer's mesh identity. Ignored when Token is set.
Username string
Password string
// From says where the build agent resolved this registry from (novox/hq ADR 0336 §4): its
// npm-package-registry binding, the genesis document the installer sealed to it, or the
// environment of a builder run by a person. Empty when it resolved none.
From string
// Machine is the machine the build agent runs on, named when a build that needs the registry is
// refused for having none (ADR 0336 §6).
Machine string
}
// Where a build agent resolves the mesh's package registry from (novox/hq ADR 0336 §3, §4).
const (
// FromBinding is the npm-package-registry binding the mesh wrote, with the credential it sealed.
FromBinding = "the npm-package-registry binding"
// FromGenesis is the genesis document the installer sealed at genesis's packages step, read only
// while no binding is present.
FromGenesis = "the genesis document"
// FromEnvironment is the environment of a builder run by a person.
FromEnvironment = "the environment"
)
// RegistryRequirement is the build agent's optional requirement the registry is bound through, and
// RegistryProvider the catalogue module that provides it (novox/hq ADR 0336 §1, §6): named when a
// build that needs the registry is refused, so the outcome says what to assign. The controller's
// catalogue test holds the provider to the catalogue's.
const (
RegistryRequirement = "npm-package-registry"
RegistryProvider = "gitea"
)
// Enabled reports whether there is a registry to resolve against at all. A bootstrap build that
// runs before any package registry exists has none, and must still build whatever needs no
// mesh-published dependency.
func (n Npmrc) Enabled() bool {
return strings.TrimSpace(n.Scope) != "" && strings.TrimSpace(n.Registry) != ""
}
// File renders the .npmrc a build mounts. Two lines: which registry answers for the scope, and the
// token to present to it. The auth line is keyed by the registry URL with its scheme removed, which
// is how npm matches a stored credential to a request.
//
// It returns an error rather than a malformed file, because an .npmrc that npm parses but points
// nowhere fails much later, inside a build, as a package that cannot be found.
func (n Npmrc) File() (string, error) {
scope := strings.TrimSpace(n.Scope)
if !strings.HasPrefix(scope, "@") {
return "", fmt.Errorf("a package-registry scope is written with its leading @, not %q", scope)
}
reg := strings.TrimSpace(n.Registry)
if !strings.HasPrefix(reg, "http://") && !strings.HasPrefix(reg, "https://") {
return "", fmt.Errorf("a package registry is reached over http(s), and %q is neither", reg)
}
if !strings.HasSuffix(reg, "/") {
// npm's per-scope registry key is matched by prefix, and the auth key below is derived from
// it; a missing trailing slash makes the two disagree and the token is never sent.
reg += "/"
}
parsed, err := url.Parse(reg)
if err != nil {
return "", fmt.Errorf("%q is not a usable registry URL: %w", reg, err)
}
// The auth key is the URL without its scheme, e.g. "//host/api/packages/owner/npm/".
authKey := "//" + parsed.Host + parsed.EscapedPath()
var auth string
switch {
case strings.TrimSpace(n.Token) != "":
auth = fmt.Sprintf("%s:_authToken=%s\n", authKey, strings.TrimSpace(n.Token))
case strings.TrimSpace(n.Username) != "" && n.Password != "":
// npm reads the password base64-encoded, and always-auth so it presents the credential to
// reads as well as writes — a private registry answers neither without it.
enc := base64.StdEncoding.EncodeToString([]byte(n.Password))
auth = fmt.Sprintf("%s:username=%s\n%s:_password=%s\n%s:always-auth=true\n",
authKey, strings.TrimSpace(n.Username), authKey, enc, authKey)
default:
return "", fmt.Errorf(
"the package registry at %s was given neither a token nor a username and password", reg)
}
return fmt.Sprintf("%s:registry=%s\n%s", scope, reg, auth), nil
}
// needsRegistry says why this module's build needs the mesh's package registry, or "" when it needs
// none (novox/hq ADR 0336 §5): a `package` artifact, an image whose Dockerfile names .npmrc, or a
// TypeScript artifact with an `@novox/` dependency. Every Go artifact, every image that does not name
// .npmrc, and every image pulled upstream builds without it.
func needsRegistry(manifest catalogue.Manifest, within string) (string, error) {
if manifest.Build == nil {
return "", nil
}
for _, a := range manifest.Build.Artifacts {
switch {
case a.Kind == catalogue.ArtifactPackage:
return fmt.Sprintf("%s is a package, published to the registry", a.Name), nil
case a.Kind == catalogue.ArtifactImage:
raw, err := os.ReadFile(filepath.Join(within, a.From))
if err == nil && strings.Contains(string(raw), ".npmrc") {
return fmt.Sprintf("%s's Dockerfile names .npmrc", a.Name), nil
}
case a.Language == "typescript":
scoped, err := scopedDependencies(within)
if err != nil {
return "", err
}
if len(scoped) > 0 {
return fmt.Sprintf("%s is TypeScript and depends on %s", a.Name, strings.Join(scoped, ", ")), nil
}
}
}
return "", nil
}
// scopedDependencies is every `@novox/` package the module's package.json depends on, the SDK
// included.
func scopedDependencies(tree string) ([]string, error) {
raw, err := os.ReadFile(filepath.Join(tree, "package.json"))
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var p struct {
Dependencies map[string]string `json:"dependencies"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("the module's package.json is not JSON: %w", err)
}
var names []string
for name := range p.Dependencies {
if strings.HasPrefix(name, "@novox/") {
names = append(names, name)
}
}
sort.Strings(names)
return names, nil
}
// unbound is the refusal of a build that needs the registry on a build agent that has none (novox/hq
// ADR 0336 §6): it names the machine, the requirement left unbound there and the module that provides
// it. Nothing is built, nothing is published, and the `@novox` scope is never resolved from the
// public registry instead.
func (n Npmrc) unbound(module, why string) error {
machine := n.Machine
if machine == "" {
machine = "this machine"
}
return fmt.Errorf("%s needs the mesh's package registry (%s), and %s is unbound on %s: the build "+
"agent there has neither its binding nor the genesis document. It is provided by the %s module; "+
"once %s is assigned the requirement binds (novox/hq ADR 0336 §6)",
module, why, RegistryRequirement, machine, RegistryProvider, RegistryProvider)
}
// packageRecipe is how a `package` artifact is built and published: on a PUBLIC base image, never
// the mesh toolchain, because the toolchain is built from the package this produces (the SDK). The
// script builds the module, then publishes it to the mesh's package registry unless that exact
// version is already there — so a re-run of genesis, which must be safe, does not fail on a version
// it published a moment ago.
type packageRecipe struct {
Base string
Script string
}
var packageRecipes = map[string]packageRecipe{
"typescript": {
Base: "node:22-bookworm-slim",
Script: `set -e
npm install --no-audit --no-fund
npm run build
name="$(node -p "require('./package.json').name")"
ver="$(node -p "require('./package.json').version")"
if npm view "$name@$ver" version >/dev/null 2>&1; then
echo "mesh-builder: $name@$ver is already published, leaving it"
else
npm publish
fi`,
},
}
// PackageLanguages is the languages a package artifact can be written in, for a manifest check that
// wants to refuse one it cannot build before a build starts.
func PackageLanguages() []string {
out := make([]string, 0, len(packageRecipes))
for l := range packageRecipes {
out = append(out, l)
}
return out
}