Files
mesh-controller/internal/builder/packages.go
T
jochen a641ed7d15
mesh/merge-gate head a641ed7d15c7 merged into trunk b12c4b82e820 — pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every …
mesh/repo-check head a641ed7d15c7 merged into trunk b12c4b82e820 — pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group fix/121-genesis-package-registry delivered: every member is delivered
Let the build agent run on the genesis document until its registry binds (hq issue 121, ADR 0336)
With npm-package-registry optional, the binding file the agent's environment
names may be absent, and mesh-builder failed every build on reading it. It now
reads the genesis document the installer seals at genesis's packages step when
no binding is present, prefers the binding when both are, and each build's log
says which it resolved from (§4). A build that needs the registry - a package,
an image naming .npmrc, TypeScript with an @novox/ dependency - on an agent with
neither is refused before anything is built or published, naming the machine,
the unbound requirement and gitea (§6); it never falls back to the public
registry. The captured catalogue moves to the mesh-catalog change it is judged
with.
2026-10-13 02:53:41 +02:00

234 lines
9.8 KiB
Go

package builder
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"github.com/novox/mesh-controller/internal/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
}