The builder resolves the SDK from the mesh registry, and can publish packages
A new 'package' artifact kind builds a module's own code on a public base image and publishes it to the mesh's package registry by version (hq ADR 0076) — the SDK above all, which the toolchain is built from and so cannot be built in the toolchain. The credential a build needs to resolve or publish packages is rendered as an .npmrc (basic auth, hq ADR 0048) and given to an image build as a buildkit secret, never a layer, so a token is not baked into the toolchain image. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user