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:///api/packages//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 }